@nanobpm/nano-workforce 0.48.2 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/app/plan.ts +12 -0
- package/db/migrations/022_plan_wave_progress.sql +24 -0
- package/package.json +2 -2
- package/pages/epic-detail.page.json +314 -0
- package/pages/epic.page.json +8 -235
- package/pages/home.page.json +219 -47
- package/scripts/pages-contract.test.ts +8 -5
- package/workers/record-plan/worker.test.ts +89 -0
- package/workers/record-plan/worker.ts +8 -0
- package/workers/record-wave/worker.test.ts +56 -0
- package/workers/record-wave/worker.ts +14 -0
- package/workers/select-wave/worker.test.ts +33 -2
- package/workers/select-wave/worker.ts +23 -1
package/pages/home.page.json
CHANGED
|
@@ -9,15 +9,25 @@
|
|
|
9
9
|
"variant": "bar",
|
|
10
10
|
"title": "Nano Workforce",
|
|
11
11
|
"items": [
|
|
12
|
-
{
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
{
|
|
13
|
+
"label": "Convergence",
|
|
14
|
+
"page": "home"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"label": "Epics",
|
|
18
|
+
"page": "epic"
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"sticky": true
|
|
15
22
|
}
|
|
16
23
|
},
|
|
17
24
|
{
|
|
18
25
|
"type": "text",
|
|
19
26
|
"id": "title",
|
|
20
|
-
"props": {
|
|
27
|
+
"props": {
|
|
28
|
+
"text": "PR Review Convergence",
|
|
29
|
+
"variant": "heading"
|
|
30
|
+
}
|
|
21
31
|
},
|
|
22
32
|
{
|
|
23
33
|
"type": "text",
|
|
@@ -31,13 +41,13 @@
|
|
|
31
41
|
"type": "button",
|
|
32
42
|
"id": "agent-instructions",
|
|
33
43
|
"props": {
|
|
34
|
-
"label": "
|
|
44
|
+
"label": "\ud83e\udd16 Agent Instructions",
|
|
35
45
|
"variant": "ghost",
|
|
36
46
|
"modal": {
|
|
37
47
|
"title": "Point your agent at Nano Workforce",
|
|
38
48
|
"description": "Copy this prompt and paste it into your coding agent (Copilot, Claude, etc.). It tells the agent to read this workforce's live operator guide, then help you drive and debug it.",
|
|
39
49
|
"copyLabel": "Copy prompt",
|
|
40
|
-
"copyText": "You are helping me operate a running Nano Workforce instance
|
|
50
|
+
"copyText": "You are helping me operate a running Nano Workforce instance \u2014 a durable orchestration app that drives pull requests to review convergence against an automated reviewer, merges them, and can take a whole issue and plan \u2192 implement \u2192 converge it across a fleet of coding agents.\n\nFirst, fetch and read its live operator guide. It tells you how to submit PRs and epics for convergence (including whether to go all the way to merge or stop at review consensus), how to find engine instances and relate them to PRs via the Nano/Camunda-8 REST API, how to inspect the models and prompts, and how to help me untangle escalations:\n\n curl -sS {{appBase}}app/api/agent\n\nIf this instance is secured with a shared secret (NANO_PR_WEBHOOK_SECRET), add its value as an x-hook-secret header, otherwise the request returns 401:\n\n curl -sS -H \"x-hook-secret: <secret>\" {{appBase}}app/api/agent\n\nThen follow that guide to help me drive and debug this workforce. If you find a bug or a stuck process, the guide explains how to raise an issue or a PR against nanobpm/nano-workforce."
|
|
41
51
|
}
|
|
42
52
|
}
|
|
43
53
|
},
|
|
@@ -47,11 +57,26 @@
|
|
|
47
57
|
"props": {
|
|
48
58
|
"title": "Submit a pull request",
|
|
49
59
|
"submitLabel": "Start review",
|
|
50
|
-
"action": {
|
|
60
|
+
"action": {
|
|
61
|
+
"path": "/app/api/actions/start/convergence-loop",
|
|
62
|
+
"body": "{{form}}"
|
|
63
|
+
},
|
|
51
64
|
"fields": [
|
|
52
|
-
{
|
|
53
|
-
|
|
54
|
-
|
|
65
|
+
{
|
|
66
|
+
"key": "pr",
|
|
67
|
+
"label": "owner/repo#123 or a GitHub PR URL",
|
|
68
|
+
"type": "text"
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"key": "maxRounds",
|
|
72
|
+
"label": "Max review rounds (blank = fleet default)",
|
|
73
|
+
"type": "number"
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"key": "convergeOnly",
|
|
77
|
+
"label": "Converge only \u2014 stop at review consensus, do not merge",
|
|
78
|
+
"type": "checkbox"
|
|
79
|
+
}
|
|
55
80
|
]
|
|
56
81
|
}
|
|
57
82
|
},
|
|
@@ -67,50 +92,140 @@
|
|
|
67
92
|
"kind": "datasource",
|
|
68
93
|
"source": "app",
|
|
69
94
|
"table": "pull_requests",
|
|
70
|
-
"orderBy": {
|
|
95
|
+
"orderBy": {
|
|
96
|
+
"field": "updated_at",
|
|
97
|
+
"dir": "desc"
|
|
98
|
+
},
|
|
71
99
|
"filter": [
|
|
72
|
-
{
|
|
100
|
+
{
|
|
101
|
+
"field": "status",
|
|
102
|
+
"in": [
|
|
103
|
+
"converging",
|
|
104
|
+
"waiting_review",
|
|
105
|
+
"escalated",
|
|
106
|
+
"waiting_deps",
|
|
107
|
+
"waiting_merge",
|
|
108
|
+
"queued",
|
|
109
|
+
"merging"
|
|
110
|
+
]
|
|
111
|
+
}
|
|
73
112
|
]
|
|
74
113
|
},
|
|
75
114
|
"tabs": [
|
|
76
115
|
{
|
|
77
116
|
"label": "Active",
|
|
78
117
|
"filter": [
|
|
79
|
-
{
|
|
118
|
+
{
|
|
119
|
+
"field": "status",
|
|
120
|
+
"in": [
|
|
121
|
+
"converging",
|
|
122
|
+
"waiting_review",
|
|
123
|
+
"escalated",
|
|
124
|
+
"waiting_deps",
|
|
125
|
+
"waiting_merge",
|
|
126
|
+
"queued",
|
|
127
|
+
"merging"
|
|
128
|
+
]
|
|
129
|
+
}
|
|
80
130
|
]
|
|
81
131
|
},
|
|
82
132
|
{
|
|
83
133
|
"label": "History",
|
|
84
|
-
"filter": [
|
|
134
|
+
"filter": [
|
|
135
|
+
{
|
|
136
|
+
"field": "status",
|
|
137
|
+
"in": [
|
|
138
|
+
"converged",
|
|
139
|
+
"merged",
|
|
140
|
+
"abandoned"
|
|
141
|
+
]
|
|
142
|
+
}
|
|
143
|
+
]
|
|
85
144
|
}
|
|
86
145
|
],
|
|
87
146
|
"columns": [
|
|
88
|
-
{
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
{
|
|
147
|
+
{
|
|
148
|
+
"field": "pr_key",
|
|
149
|
+
"header": "PR",
|
|
150
|
+
"linkField": "url"
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"field": "status",
|
|
154
|
+
"header": "Status",
|
|
155
|
+
"link": {
|
|
156
|
+
"kind": "processExplorer",
|
|
157
|
+
"keyField": "process_key"
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
"field": "incident_message",
|
|
162
|
+
"header": "Incident",
|
|
163
|
+
"badge": {
|
|
164
|
+
"tone": "danger",
|
|
165
|
+
"label": "1"
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
"field": "current_round",
|
|
170
|
+
"header": "Round"
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
"field": "active_worker",
|
|
174
|
+
"header": "Agent"
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
"field": "updated_at",
|
|
178
|
+
"header": "Updated"
|
|
179
|
+
}
|
|
94
180
|
],
|
|
95
181
|
"rowActions": [
|
|
96
182
|
{
|
|
97
183
|
"label": "Cancel",
|
|
98
184
|
"confirm": "Cancel this PR's review run?",
|
|
99
185
|
"showWhenField": "process_key",
|
|
100
|
-
"action": {
|
|
186
|
+
"action": {
|
|
187
|
+
"path": "/app/actions/cancel",
|
|
188
|
+
"body": {
|
|
189
|
+
"processInstanceKey": "{{row.process_key}}"
|
|
190
|
+
}
|
|
191
|
+
}
|
|
101
192
|
}
|
|
102
193
|
],
|
|
103
194
|
"detail": {
|
|
104
195
|
"linkField": "url",
|
|
105
196
|
"fields": [
|
|
106
|
-
{
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
{
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
197
|
+
{
|
|
198
|
+
"field": "repo",
|
|
199
|
+
"label": "Repository"
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
"field": "number",
|
|
203
|
+
"label": "PR number"
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
"field": "active_worker",
|
|
207
|
+
"label": "Agent (leasing worker)"
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
"field": "lease_until",
|
|
211
|
+
"label": "Activation lease until"
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
"field": "incident_message",
|
|
215
|
+
"label": "Incident"
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
"field": "incident_key",
|
|
219
|
+
"label": "Incident key"
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
"field": "merged_at",
|
|
223
|
+
"label": "Merged at"
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
"field": "outcome",
|
|
227
|
+
"label": "Outcome"
|
|
228
|
+
}
|
|
114
229
|
],
|
|
115
230
|
"children": [
|
|
116
231
|
{
|
|
@@ -119,14 +234,33 @@
|
|
|
119
234
|
"table": "rounds",
|
|
120
235
|
"parentField": "pr_key",
|
|
121
236
|
"childField": "pr_key",
|
|
122
|
-
"orderBy": {
|
|
237
|
+
"orderBy": {
|
|
238
|
+
"field": "round_no",
|
|
239
|
+
"dir": "asc"
|
|
240
|
+
},
|
|
123
241
|
"columns": [
|
|
124
|
-
{
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
242
|
+
{
|
|
243
|
+
"field": "round_no",
|
|
244
|
+
"header": "#"
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
"field": "status",
|
|
248
|
+
"header": "Result"
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
"field": "worker",
|
|
252
|
+
"header": "Worker"
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
"field": "summary",
|
|
256
|
+
"header": "Summary"
|
|
257
|
+
}
|
|
128
258
|
],
|
|
129
|
-
"lazyField": {
|
|
259
|
+
"lazyField": {
|
|
260
|
+
"field": "transcript",
|
|
261
|
+
"label": "Transcript",
|
|
262
|
+
"lazy": true
|
|
263
|
+
}
|
|
130
264
|
},
|
|
131
265
|
{
|
|
132
266
|
"title": "Escalations",
|
|
@@ -134,16 +268,41 @@
|
|
|
134
268
|
"table": "escalations",
|
|
135
269
|
"parentField": "pr_key",
|
|
136
270
|
"childField": "pr_key",
|
|
137
|
-
"orderBy": {
|
|
271
|
+
"orderBy": {
|
|
272
|
+
"field": "id",
|
|
273
|
+
"dir": "asc"
|
|
274
|
+
},
|
|
138
275
|
"columns": [
|
|
139
|
-
{
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
{
|
|
144
|
-
|
|
276
|
+
{
|
|
277
|
+
"field": "round_no",
|
|
278
|
+
"header": "#"
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
"field": "kind",
|
|
282
|
+
"header": "Kind"
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
"field": "worker",
|
|
286
|
+
"header": "Worker"
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
"field": "question",
|
|
290
|
+
"header": "Question"
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
"field": "status",
|
|
294
|
+
"header": "Status"
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
"field": "answer",
|
|
298
|
+
"header": "Answer"
|
|
299
|
+
}
|
|
145
300
|
],
|
|
146
|
-
"lazyField": {
|
|
301
|
+
"lazyField": {
|
|
302
|
+
"field": "transcript",
|
|
303
|
+
"label": "Agent response",
|
|
304
|
+
"lazy": true
|
|
305
|
+
}
|
|
147
306
|
},
|
|
148
307
|
{
|
|
149
308
|
"title": "Depends on",
|
|
@@ -151,10 +310,19 @@
|
|
|
151
310
|
"table": "pr_dependencies",
|
|
152
311
|
"parentField": "pr_key",
|
|
153
312
|
"childField": "pr_key",
|
|
154
|
-
"orderBy": {
|
|
313
|
+
"orderBy": {
|
|
314
|
+
"field": "created_at",
|
|
315
|
+
"dir": "asc"
|
|
316
|
+
},
|
|
155
317
|
"columns": [
|
|
156
|
-
{
|
|
157
|
-
|
|
318
|
+
{
|
|
319
|
+
"field": "depends_on_key",
|
|
320
|
+
"header": "PR that must merge first"
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
"field": "created_at",
|
|
324
|
+
"header": "Declared"
|
|
325
|
+
}
|
|
158
326
|
]
|
|
159
327
|
}
|
|
160
328
|
],
|
|
@@ -167,7 +335,11 @@
|
|
|
167
335
|
"submitLabel": "Send answer",
|
|
168
336
|
"action": {
|
|
169
337
|
"path": "/app/api/actions/message",
|
|
170
|
-
"body": {
|
|
338
|
+
"body": {
|
|
339
|
+
"name": "escalation-answered",
|
|
340
|
+
"correlationKey": "{{row.pr_key}}",
|
|
341
|
+
"variables": "{{form}}"
|
|
342
|
+
}
|
|
171
343
|
}
|
|
172
344
|
}
|
|
173
345
|
}
|
|
@@ -207,12 +207,15 @@ test("every page datasource column exists on its table", async () => {
|
|
|
207
207
|
}
|
|
208
208
|
});
|
|
209
209
|
|
|
210
|
-
test("issue #87: plan_reviews is surfaced on the epic
|
|
210
|
+
test("issue #87: plan_reviews is surfaced on the per-epic detail page", async () => {
|
|
211
211
|
const refs = await loadRefs();
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
212
|
+
// The operator-visibility redesign (issue #137) moved per-plan detail grids off the flat
|
|
213
|
+
// epic index onto the param-scoped per-epic page (#/epic-detail/<plan_key>), where the
|
|
214
|
+
// plan-review trace is filtered to a single epic. Assert the trace lives there.
|
|
215
|
+
const onEpicDetail = refs.some(
|
|
216
|
+
(r) => r.page === "epic-detail.page.json" && r.table === "plan_reviews",
|
|
217
|
+
);
|
|
218
|
+
assert(onEpicDetail, "epic-detail.page.json must bind a grid to plan_reviews (plan-review trace)");
|
|
216
219
|
|
|
217
220
|
// The trace is only useful with the verdict + critique columns, so pin them. Assert against the
|
|
218
221
|
// visibly displayed `columns` (not `fields`, which also holds binding refs like orderBy.field) so
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Regression coverage for record-plan's operator-visibility wave progress projection (issue #137).
|
|
2
|
+
//
|
|
3
|
+
// record-plan initializes plans.wave_count/current_wave/wave_label when a plan is dispatched. The
|
|
4
|
+
// three fields must stay consistent: a taskful plan gets wave_count N, current_wave 0, "1/N"; a
|
|
5
|
+
// taskless plan gets all three NULL (never wave_count 0 against NULL current_wave/wave_label, which
|
|
6
|
+
// would leak a misleading value to the epics-index — the documented contract is "NULL until
|
|
7
|
+
// dispatched with tasks").
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import { assertEquals } from "#test-assert";
|
|
10
|
+
import handler from "./worker.ts";
|
|
11
|
+
|
|
12
|
+
interface Row extends Record<string, unknown> {
|
|
13
|
+
id?: number;
|
|
14
|
+
plan_key: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function fakeApp() {
|
|
18
|
+
const planTasks: Row[] = [];
|
|
19
|
+
const planTaskDeps: Row[] = [];
|
|
20
|
+
const plans: Row[] = [{ plan_key: "owner/repo#137" }];
|
|
21
|
+
let nextId = 1;
|
|
22
|
+
const app = {
|
|
23
|
+
log: { error() {}, info() {}, warn() {} },
|
|
24
|
+
data: {
|
|
25
|
+
table(name: string, key: string) {
|
|
26
|
+
const store = name === "plan_tasks"
|
|
27
|
+
? planTasks
|
|
28
|
+
: name === "plan_task_deps"
|
|
29
|
+
? planTaskDeps
|
|
30
|
+
: plans;
|
|
31
|
+
return {
|
|
32
|
+
find: (q: Record<string, unknown>) =>
|
|
33
|
+
Promise.resolve(
|
|
34
|
+
store.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v)),
|
|
35
|
+
),
|
|
36
|
+
insert: (row: Row) => {
|
|
37
|
+
store.push({ ...row, id: row.id ?? nextId++ });
|
|
38
|
+
return Promise.resolve();
|
|
39
|
+
},
|
|
40
|
+
delete: (k: unknown) => {
|
|
41
|
+
for (let i = store.length - 1; i >= 0; i--) {
|
|
42
|
+
if (store[i][key] === k) store.splice(i, 1);
|
|
43
|
+
}
|
|
44
|
+
return Promise.resolve();
|
|
45
|
+
},
|
|
46
|
+
update: (k: unknown, patch: Record<string, unknown>) => {
|
|
47
|
+
const row = store.find((r) => r[key] === k);
|
|
48
|
+
if (row) Object.assign(row, patch);
|
|
49
|
+
return Promise.resolve(row);
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
} as any;
|
|
55
|
+
return { app, plans, planTasks };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
test("record-plan initializes wave progress fields for a taskful plan", async () => {
|
|
59
|
+
const { app, plans } = fakeApp();
|
|
60
|
+
await handler(
|
|
61
|
+
{
|
|
62
|
+
variables: {
|
|
63
|
+
planKey: "owner/repo#137",
|
|
64
|
+
tasks: [
|
|
65
|
+
{ id: "a", prompt: "do A" },
|
|
66
|
+
{ id: "b", prompt: "do B", dependsOn: ["a"] },
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
} as any,
|
|
70
|
+
app,
|
|
71
|
+
);
|
|
72
|
+
assertEquals(plans[0].status, "dispatched");
|
|
73
|
+
assertEquals(plans[0].wave_count, 2);
|
|
74
|
+
assertEquals(plans[0].current_wave, 0);
|
|
75
|
+
assertEquals(plans[0].wave_label, "1/2");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("record-plan leaves all three wave progress fields NULL for a taskless plan", async () => {
|
|
79
|
+
const { app, plans } = fakeApp();
|
|
80
|
+
await handler(
|
|
81
|
+
{ variables: { planKey: "owner/repo#137", tasks: [], note: "planner emitted no tasks" } } as any,
|
|
82
|
+
app,
|
|
83
|
+
);
|
|
84
|
+
assertEquals(plans[0].status, "done");
|
|
85
|
+
// No wave to implement => no misleading wave_count: 0 while current_wave/wave_label are NULL.
|
|
86
|
+
assertEquals(plans[0].wave_count, null);
|
|
87
|
+
assertEquals(plans[0].current_wave, null);
|
|
88
|
+
assertEquals(plans[0].wave_label, null);
|
|
89
|
+
});
|
|
@@ -123,6 +123,14 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
123
123
|
const patch: Record<string, unknown> = {
|
|
124
124
|
status: tasks.length > 0 ? "dispatched" : "done",
|
|
125
125
|
task_count: tasks.length,
|
|
126
|
+
// Operator-visibility progress projection (issue #137): total waves (N), and the wave the
|
|
127
|
+
// fleet is actively implementing (0 at dispatch). `select-wave` advances current_wave per
|
|
128
|
+
// wave; a taskless plan gets no wave (NULL) since there is nothing to implement. Display-only.
|
|
129
|
+
// All three fields stay NULL until dispatched with tasks — a taskless plan must not leak a
|
|
130
|
+
// misleading wave_count: 0 while current_wave/wave_label are NULL (inconsistent projection).
|
|
131
|
+
wave_count: tasks.length > 0 ? waveCount : null,
|
|
132
|
+
current_wave: tasks.length > 0 ? 0 : null,
|
|
133
|
+
wave_label: tasks.length > 0 ? `1/${waveCount}` : null,
|
|
126
134
|
updated_at: ts,
|
|
127
135
|
};
|
|
128
136
|
if (tasks.length === 0) patch.outcome = note ? str(note) : "planner emitted no tasks";
|
|
@@ -142,6 +142,62 @@ test("record-wave retries the same wave when a task is still pending", async ()
|
|
|
142
142
|
trialMergeSkipReason: "wave-still-pending",
|
|
143
143
|
});
|
|
144
144
|
assertEquals((planUpdates[0].patch as Record<string, unknown>).gate_wave, 1);
|
|
145
|
+
// Retry keeps the projection on the same (still-pending) wave.
|
|
146
|
+
assertEquals((planUpdates[0].patch as Record<string, unknown>).current_wave, 1);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("record-wave pins current_wave to the last index and clears gate_wave on the final wave", async () => {
|
|
150
|
+
const rows: Row[] = [{
|
|
151
|
+
id: 9,
|
|
152
|
+
plan_key: "owner/repo#63",
|
|
153
|
+
task_id: "z",
|
|
154
|
+
status: "opened",
|
|
155
|
+
wave: 2,
|
|
156
|
+
}];
|
|
157
|
+
const { app, planUpdates } = fakeApp(rows);
|
|
158
|
+
|
|
159
|
+
await handler(
|
|
160
|
+
{
|
|
161
|
+
variables: {
|
|
162
|
+
planKey: "owner/repo#63",
|
|
163
|
+
currentWave: 2,
|
|
164
|
+
waveCount: 3,
|
|
165
|
+
waveTasks: [],
|
|
166
|
+
waveResults: [],
|
|
167
|
+
},
|
|
168
|
+
} as any,
|
|
169
|
+
app,
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
// Final wave (2 of 3): no successor wave — gate cleared, projection pinned to N-1 so the
|
|
173
|
+
// epics-index reads 3/3 rather than the one-past-the-end nextWave (3).
|
|
174
|
+
assertEquals((planUpdates[0].patch as Record<string, unknown>).gate_wave, null);
|
|
175
|
+
assertEquals((planUpdates[0].patch as Record<string, unknown>).current_wave, 2);
|
|
176
|
+
assertEquals((planUpdates[0].patch as Record<string, unknown>).wave_label, "3/3");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("record-wave keeps all wave-progress fields NULL for a taskless plan (waveCount 0)", async () => {
|
|
180
|
+
// A taskless plan runs record-wave with waveCount 0 (the MI `implement` step completed
|
|
181
|
+
// immediately). All three progress fields must stay NULL together — never current_wave=0 against
|
|
182
|
+
// a NULL wave_label, which would clobber record-plan/select-wave's NULL projection.
|
|
183
|
+
const { app, planUpdates } = fakeApp([]);
|
|
184
|
+
|
|
185
|
+
await handler(
|
|
186
|
+
{
|
|
187
|
+
variables: {
|
|
188
|
+
planKey: "owner/repo#70",
|
|
189
|
+
currentWave: 0,
|
|
190
|
+
waveCount: 0,
|
|
191
|
+
waveTasks: [],
|
|
192
|
+
waveResults: [],
|
|
193
|
+
},
|
|
194
|
+
} as any,
|
|
195
|
+
app,
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const patch = planUpdates[0].patch as Record<string, unknown>;
|
|
199
|
+
assertEquals(patch.current_wave, null);
|
|
200
|
+
assertEquals(patch.wave_label, null);
|
|
145
201
|
});
|
|
146
202
|
|
|
147
203
|
test("record-wave skips trial merge for mergify-queue repos with 2+ heads", async () => {
|
|
@@ -278,6 +278,18 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
278
278
|
const nextWave = stillPendingCurrentWave ? currentWave : currentWave + 1;
|
|
279
279
|
const hasMoreWaves = stillPendingCurrentWave || nextWave < waveCount;
|
|
280
280
|
|
|
281
|
+
// Operator-visibility projection (issue #137): keep plans.current_wave tracking the wave the
|
|
282
|
+
// fleet is on. While more waves remain, point it at the wave about to run (select-wave re-writes
|
|
283
|
+
// the same value when it dispatches); on the final wave, pin it to the last index so a finished
|
|
284
|
+
// epic reads N/N (nextWave would be waveCount, one past the last band). Display-only.
|
|
285
|
+
const projectedCurrentWave = hasMoreWaves ? nextWave : Math.max(0, waveCount - 1);
|
|
286
|
+
// Keep the three progress fields consistent: a taskless plan (waveCount 0 — the MI `implement`
|
|
287
|
+
// step completed immediately with no waves) has no wave to be on, so current_wave and wave_label
|
|
288
|
+
// both stay NULL rather than writing current_wave=0 against a NULL label (and clobbering the NULL
|
|
289
|
+
// projection record-plan/select-wave already recorded).
|
|
290
|
+
const currentWaveProjection = waveCount > 0 ? projectedCurrentWave : null;
|
|
291
|
+
const waveLabel = waveCount > 0 ? `${projectedCurrentWave + 1}/${waveCount}` : null;
|
|
292
|
+
|
|
281
293
|
// Wave-merge barrier: when another wave follows, park the plan-fanout instance at the
|
|
282
294
|
// `wait-wave-merged` catch event until THIS wave's opened PRs have MERGED (not merely opened).
|
|
283
295
|
// `gate_wave` is that durable marker; the poller (`pollWaveGates`) clears it and publishes
|
|
@@ -288,6 +300,8 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
288
300
|
try {
|
|
289
301
|
await plans(app.data).update(planKey, {
|
|
290
302
|
gate_wave: hasMoreWaves ? currentWave : null,
|
|
303
|
+
current_wave: currentWaveProjection,
|
|
304
|
+
wave_label: waveLabel,
|
|
291
305
|
updated_at: ts,
|
|
292
306
|
});
|
|
293
307
|
} catch (err) {
|
|
@@ -25,11 +25,12 @@ interface DepRow {
|
|
|
25
25
|
depends_on_task_id: string;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
function fakeApp(rows: Row[], deps: DepRow[]) {
|
|
28
|
+
function fakeApp(rows: Row[], deps: DepRow[], plans: Record<string, unknown>[] = []) {
|
|
29
29
|
return {
|
|
30
|
+
log: { error() {}, info() {}, warn() {} },
|
|
30
31
|
data: {
|
|
31
32
|
table(name: string, key: string) {
|
|
32
|
-
const store = name === "plan_tasks" ? rows : deps;
|
|
33
|
+
const store = name === "plan_tasks" ? rows : name === "plans" ? plans : deps;
|
|
33
34
|
return {
|
|
34
35
|
find: (q: any) =>
|
|
35
36
|
Promise.resolve(
|
|
@@ -60,6 +61,36 @@ async function selectWave(rows: Row[], deps: DepRow[]) {
|
|
|
60
61
|
return out as { waveTasks: unknown[] };
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
test("select-wave projects the active wave onto plans.current_wave", async () => {
|
|
65
|
+
const rows: Row[] = [
|
|
66
|
+
{ id: 1, plan_key: "owner/repo#63", task_id: "a", title: "A", prompt: "do A", status: "pending", wave: 1 },
|
|
67
|
+
];
|
|
68
|
+
const plans: Record<string, unknown>[] = [{ plan_key: "owner/repo#63", current_wave: 0 }];
|
|
69
|
+
const out = await handler(
|
|
70
|
+
{ variables: { planKey: "owner/repo#63", currentWave: 1 } } as any,
|
|
71
|
+
fakeApp(rows, [], plans),
|
|
72
|
+
);
|
|
73
|
+
assertEquals((out as { waveTasks: unknown[] }).waveTasks.length, 1);
|
|
74
|
+
assertEquals(plans[0].current_wave, 1);
|
|
75
|
+
// wave_count is derived from the levelized rows (max wave + 1) and the 1-based "X/N" label
|
|
76
|
+
// is pre-formatted for the epics-index at-a-glance column.
|
|
77
|
+
assertEquals(plans[0].wave_count, 2);
|
|
78
|
+
assertEquals(plans[0].wave_label, "2/2");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("select-wave nulls all three progress fields when there are no levelized rows", async () => {
|
|
82
|
+
// No plan_tasks rows => waveCount 0. current_wave must be NULL too (not a stray index against a
|
|
83
|
+
// NULL wave_count/wave_label), matching the documented "NULL until dispatched with tasks".
|
|
84
|
+
const plans: Record<string, unknown>[] = [{ plan_key: "owner/repo#63", current_wave: 5 }];
|
|
85
|
+
await handler(
|
|
86
|
+
{ variables: { planKey: "owner/repo#63", currentWave: 0 } } as any,
|
|
87
|
+
fakeApp([], [], plans),
|
|
88
|
+
);
|
|
89
|
+
assertEquals(plans[0].current_wave, null);
|
|
90
|
+
assertEquals(plans[0].wave_count, null);
|
|
91
|
+
assertEquals(plans[0].wave_label, null);
|
|
92
|
+
});
|
|
93
|
+
|
|
63
94
|
test("select-wave leaves dependents pending behind a waiting-for-lane dependency", async () => {
|
|
64
95
|
const rows: Row[] = [
|
|
65
96
|
{
|