@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.
- package/.github/workflows/ci.yml +60 -0
- package/.github/workflows/release.yml +58 -0
- package/.releaserc.json +17 -0
- package/AGENTS.md +168 -0
- package/CHANGELOG.md +231 -0
- package/LICENSE +202 -0
- package/README.md +303 -0
- package/SPEC.md +492 -0
- package/actions/abandon.test.ts +93 -0
- package/actions/abandon.ts +23 -0
- package/actions/blackboard.test.ts +195 -0
- package/actions/blackboard.ts +76 -0
- package/actions/cancel.ts +29 -0
- package/actions/feature-answer-hook.ts +44 -0
- package/actions/message.ts +49 -0
- package/actions/plan-hook.ts +19 -0
- package/actions/plan-start.ts +17 -0
- package/actions/start.ts +19 -0
- package/actions/status.ts +22 -0
- package/actions/webhook-submit.ts +21 -0
- package/app/abandon.test.ts +97 -0
- package/app/abandon.ts +105 -0
- package/app/baseGuard.test.ts +35 -0
- package/app/baseGuard.ts +62 -0
- package/app/blackboard.test.ts +295 -0
- package/app/blackboard.ts +301 -0
- package/app/github.test.ts +59 -0
- package/app/github.ts +647 -0
- package/app/mergeExclusion.test.ts +168 -0
- package/app/mergeExclusion.ts +211 -0
- package/app/mergeProtocol.test.ts +124 -0
- package/app/mergeProtocol.ts +193 -0
- package/app/mergeRebaseArm.test.ts +72 -0
- package/app/mergeTrain.test.ts +91 -0
- package/app/mergeTrain.ts +117 -0
- package/app/persist-escalation.test.ts +119 -0
- package/app/persist-round.test.ts +65 -0
- package/app/plan.test.ts +317 -0
- package/app/plan.ts +321 -0
- package/app/record-plan-review.test.ts +38 -0
- package/app/reviewWait.test.ts +70 -0
- package/app/reviewWait.ts +59 -0
- package/app/rounds.test.ts +74 -0
- package/app/rounds.ts +48 -0
- package/app/service.test.ts +101 -0
- package/app/service.ts +895 -0
- package/app/taskDelta.test.ts +144 -0
- package/app/taskDelta.ts +175 -0
- package/app/trialMerge.test.ts +15 -0
- package/app/trialMerge.ts +102 -0
- package/app/waves.test.ts +128 -0
- package/app/waves.ts +116 -0
- package/assets/icon.svg +13 -0
- package/components/review-round.json +69 -0
- package/db/migrations/001_init.sql +46 -0
- package/db/migrations/002_transcript.sql +7 -0
- package/db/migrations/003_open_escalation.sql +8 -0
- package/db/migrations/004_merge.sql +36 -0
- package/db/migrations/004_planning.sql +37 -0
- package/db/migrations/005_job_activation.sql +15 -0
- package/db/migrations/005_plan_deps.sql +20 -0
- package/db/migrations/006_plan_review.sql +22 -0
- package/db/migrations/006_task_escalation.sql +52 -0
- package/db/migrations/007_plan_review_job_key.sql +14 -0
- package/db/migrations/007_wave_gate.sql +16 -0
- package/db/migrations/008_review_nudge.sql +9 -0
- package/db/migrations/009_plan_blackboard.sql +46 -0
- package/db/migrations/010_plan_task_deltas.sql +27 -0
- package/db/migrations/011_plan_merge_exclusions.sql +26 -0
- package/db/migrations/012_merge_protocol_attempt.sql +4 -0
- package/db/migrations/013_merge_train_waiting_lane.sql +6 -0
- package/db/migrations/014_plan_trial_merges.sql +21 -0
- package/db/migrations/015_pr_abandon_token.sql +9 -0
- package/deno.json +24 -0
- package/deno.lock +1776 -0
- package/main.ts +71 -0
- package/nano-ide.ext.json +7 -0
- package/nano.app.json +138 -0
- package/nanobpm.project.json +20 -0
- package/package.json +56 -0
- package/pages/epic.page.json +195 -0
- package/pages/home.page.json +296 -0
- package/prompts/feature.md +132 -0
- package/prompts/fix-ci.md +65 -0
- package/prompts/plan-review.md +69 -0
- package/prompts/plan.md +183 -0
- package/prompts/rebase.md +82 -0
- package/prompts/review-round.md +171 -0
- package/prompts/trial-merge.md +43 -0
- package/renovate.json +21 -0
- package/resources/processes/convergence-loop.bpmn +399 -0
- package/resources/processes/merge-loop.bpmn +585 -0
- package/resources/processes/plan-fanout.bpmn +546 -0
- package/scripts/check-agent-prompts.test.ts +84 -0
- package/scripts/check-agent-prompts.ts +143 -0
- package/scripts/layout-bpmn.ts +99 -0
- package/scripts/purge-db.ts +57 -0
- package/scripts/upgrade-from-pack.ts +334 -0
- package/tsconfig.json +51 -0
- package/workers/arm-merge/worker.ts +18 -0
- package/workers/finalize/worker.ts +89 -0
- package/workers/mark-merged/worker.ts +21 -0
- package/workers/merge/worker.ts +119 -0
- package/workers/persist-escalation/worker.ts +107 -0
- package/workers/persist-round/worker.ts +52 -0
- package/workers/persist-task-escalation/worker.ts +112 -0
- package/workers/record-plan/worker.ts +135 -0
- package/workers/record-plan-review/worker.ts +92 -0
- package/workers/record-results/worker.ts +30 -0
- package/workers/record-trial-merge/worker.test.ts +104 -0
- package/workers/record-trial-merge/worker.ts +88 -0
- package/workers/record-wave/worker.test.ts +221 -0
- package/workers/record-wave/worker.ts +308 -0
- package/workers/select-wave/worker.test.ts +130 -0
- 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;
|