@nanobpm/nano-workforce 0.48.2 → 0.50.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 +14 -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/merge/worker.test.ts +112 -0
- package/workers/merge/worker.ts +23 -18
- 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
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// token transport and stubs `globalThis.fetch` so the single-PR GET reports `merged: true`.
|
|
7
7
|
import { test } from "node:test";
|
|
8
8
|
import { assertEquals } from "#test-assert";
|
|
9
|
+
import { _clearMergeProtocolCache } from "../../app/mergeProtocol.ts";
|
|
9
10
|
import { noopLog } from "../../test/log.ts";
|
|
10
11
|
import handler from "./worker.ts";
|
|
11
12
|
|
|
@@ -95,3 +96,114 @@ test("pr.merge short-circuits an already-merged PR without re-running the land p
|
|
|
95
96
|
assertEquals(calls.some((u) => /comments|merge$/.test(u)), false);
|
|
96
97
|
});
|
|
97
98
|
});
|
|
99
|
+
|
|
100
|
+
// The land protocol has three terminal outcomes, dispatched by the worker's exhaustive `matchTags`
|
|
101
|
+
// (worker.ts §"Exhaustive dispatch"). The already-merged short-circuit above never reaches that
|
|
102
|
+
// dispatch, so the two land branches below — `queued` (repo lands via an on-demand merge queue) and
|
|
103
|
+
// `blocked` (GitHub refuses the merge) — pin the behaviour of that critical terminal switch so a
|
|
104
|
+
// regression in any arm is caught. Both drive the token transport and route GitHub calls through a
|
|
105
|
+
// stubbed `globalThis.fetch`.
|
|
106
|
+
// Each recorded call keeps the HTTP method alongside the URL so tests can assert not just *which*
|
|
107
|
+
// endpoint the worker hit but *how* (e.g. enqueue via POST, merge via PUT) — a URL-only matcher
|
|
108
|
+
// would stay green if the verb regressed.
|
|
109
|
+
type GithubCall = { url: string; method: string };
|
|
110
|
+
|
|
111
|
+
function withGithub(
|
|
112
|
+
routes: (url: string, init: RequestInit | undefined) => Response | null,
|
|
113
|
+
run: (calls: GithubCall[]) => Promise<void>,
|
|
114
|
+
): Promise<void> {
|
|
115
|
+
const oldTransport = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
116
|
+
const oldToken = process.env["GITHUB_TOKEN"];
|
|
117
|
+
const oldFetch = globalThis.fetch;
|
|
118
|
+
const calls: GithubCall[] = [];
|
|
119
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
120
|
+
process.env["GITHUB_TOKEN"] = "test-token";
|
|
121
|
+
_clearMergeProtocolCache();
|
|
122
|
+
globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) => {
|
|
123
|
+
const url = String(input);
|
|
124
|
+
calls.push({ url, method: (init?.method ?? "GET").toUpperCase() });
|
|
125
|
+
const res = routes(url, init);
|
|
126
|
+
return Promise.resolve(res ?? new Response("not found", { status: 404 }));
|
|
127
|
+
}) as typeof fetch;
|
|
128
|
+
return run(calls).finally(() => {
|
|
129
|
+
if (oldTransport == null) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
130
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = oldTransport;
|
|
131
|
+
if (oldToken == null) delete process.env["GITHUB_TOKEN"];
|
|
132
|
+
else process.env["GITHUB_TOKEN"] = oldToken;
|
|
133
|
+
globalThis.fetch = oldFetch;
|
|
134
|
+
_clearMergeProtocolCache();
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
test("pr.merge routes a mergify-queue repo through the queued branch (enqueue comment, status=queued)", async () => {
|
|
139
|
+
// AGENTS.md publishes a mergify-queue land protocol, so the worker enqueues via a comment rather
|
|
140
|
+
// than issuing a direct merge; the queued arm of `matchTags` marks the PR `queued` and returns.
|
|
141
|
+
const protocol =
|
|
142
|
+
"# repo\n\n```merge-protocol\n{ \"land\": { \"method\": \"mergify-queue\", \"comment\": \"@mergifyio queue\" } }\n```\n";
|
|
143
|
+
await withGithub(
|
|
144
|
+
(url) => {
|
|
145
|
+
if (/\/contents\/AGENTS\.md$/.test(url)) return new Response(protocol);
|
|
146
|
+
if (/\/pulls\/\d+$/.test(url)) return new Response(JSON.stringify({ merged: false, mergeable_state: "clean" }));
|
|
147
|
+
if (/\/issues\/\d+\/comments$/.test(url)) return new Response(JSON.stringify({ id: 1 }), { status: 201 });
|
|
148
|
+
return null;
|
|
149
|
+
},
|
|
150
|
+
async (calls) => {
|
|
151
|
+
const { app, stores } = fakeApp();
|
|
152
|
+
const out = (await handler(
|
|
153
|
+
{ variables: { prKey: "acme/widgets#7", repo: "acme/widgets", prNumber: 7 } } as any,
|
|
154
|
+
app,
|
|
155
|
+
)) as Record<string, unknown>;
|
|
156
|
+
|
|
157
|
+
// Queued arm: waits for `merge-landed`, so it reports `queued` (not `merged`/`blocked`).
|
|
158
|
+
assertEquals(out, { mergeStatus: "queued" });
|
|
159
|
+
|
|
160
|
+
// Enqueued via the protocol's comment (POST), never a direct merge PUT.
|
|
161
|
+
assertEquals(
|
|
162
|
+
calls.some((c) => /\/issues\/\d+\/comments$/.test(c.url) && c.method === "POST"),
|
|
163
|
+
true,
|
|
164
|
+
);
|
|
165
|
+
assertEquals(calls.some((c) => /\/merge$/.test(c.url)), false);
|
|
166
|
+
|
|
167
|
+
// Audit row records the queue-comment land, and the PR row is flipped to `queued`.
|
|
168
|
+
assertEquals(stores.merges.length, 1);
|
|
169
|
+
assertEquals(stores.merges[0].outcome, "queued");
|
|
170
|
+
assertEquals(stores.merges[0].method, "queue-comment");
|
|
171
|
+
assertEquals(stores.pull_requests.find((r) => r.pr_key === "acme/widgets#7")?.status, "queued");
|
|
172
|
+
},
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("pr.merge routes a refused merge through the blocked branch (escalation payload)", async () => {
|
|
177
|
+
// Default (gh-merge) protocol; GitHub refuses the merge PUT, so `mergePr` reports `blocked` and the
|
|
178
|
+
// blocked arm of `matchTags` shapes the human-facing escalation question from the failure detail.
|
|
179
|
+
await withGithub(
|
|
180
|
+
(url) => {
|
|
181
|
+
if (/\/pulls\/\d+\/merge$/.test(url))
|
|
182
|
+
return new Response("Pull Request is not mergeable", { status: 405, statusText: "Method Not Allowed" });
|
|
183
|
+
if (/\/pulls\/\d+$/.test(url)) return new Response(JSON.stringify({ merged: false, mergeable_state: "dirty" }));
|
|
184
|
+
return null; // no AGENTS.md / merge-protocol.json → DEFAULT gh-merge protocol
|
|
185
|
+
},
|
|
186
|
+
async (calls) => {
|
|
187
|
+
const { app, stores } = fakeApp();
|
|
188
|
+
const out = (await handler(
|
|
189
|
+
{ variables: { prKey: "acme/widgets#9", repo: "acme/widgets", prNumber: 9 } } as any,
|
|
190
|
+
app,
|
|
191
|
+
)) as Record<string, unknown>;
|
|
192
|
+
|
|
193
|
+
// Blocked arm: surfaces both the loop-terminal `mergeStatus` and the escalation `status`.
|
|
194
|
+
assertEquals(out.mergeStatus, "blocked");
|
|
195
|
+
assertEquals(out.status, "blocked");
|
|
196
|
+
assertEquals(typeof out.question, "string");
|
|
197
|
+
assertEquals((out.question as string).startsWith("Automated merge was blocked:"), true);
|
|
198
|
+
|
|
199
|
+
// Attempted a real merge PUT (not an enqueue comment), and recorded a blocked audit row.
|
|
200
|
+
assertEquals(
|
|
201
|
+
calls.some((c) => /\/pulls\/\d+\/merge$/.test(c.url) && c.method === "PUT"),
|
|
202
|
+
true,
|
|
203
|
+
);
|
|
204
|
+
assertEquals(calls.some((c) => /\/comments$/.test(c.url)), false);
|
|
205
|
+
assertEquals(stores.merges.length, 1);
|
|
206
|
+
assertEquals(stores.merges[0].outcome, "blocked");
|
|
207
|
+
},
|
|
208
|
+
);
|
|
209
|
+
});
|
package/workers/merge/worker.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// calls live in app/github.ts; this worker records the attempt in the `merges` audit table and
|
|
10
10
|
// shapes the escalation payload on a block.
|
|
11
11
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
12
|
+
import { matchTags, tag } from "@nanobpm/urban/effect";
|
|
12
13
|
import { abandonTokenFromUrl } from "../../app/abandon.ts";
|
|
13
14
|
import { checkBaseTarget } from "../../app/baseGuard.ts";
|
|
14
15
|
import { enqueueViaComment, fetchPrState, mergePr } from "../../app/github.ts";
|
|
@@ -134,25 +135,29 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
134
135
|
at: now,
|
|
135
136
|
});
|
|
136
137
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
});
|
|
142
|
-
return { mergeStatus: "queued" };
|
|
143
|
-
}
|
|
144
|
-
if (outcome === "merged") {
|
|
145
|
-
return { mergeStatus: "merged" };
|
|
146
|
-
}
|
|
147
|
-
// blocked → hand the escalation machinery a concrete question.
|
|
138
|
+
// Exhaustive dispatch on the land outcome. Modelled as a tagged value so
|
|
139
|
+
// `matchTags` forces a handler for every case — adding a new outcome to the
|
|
140
|
+
// `"merged" | "queued" | "blocked"` union becomes a compile error here rather
|
|
141
|
+
// than silently falling through to the "blocked" branch.
|
|
148
142
|
const docHint = protocol?.doc ? ` See the repo's merge protocol (${protocol.doc}).` : "";
|
|
149
|
-
return {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
143
|
+
return await matchTags(tag(outcome, { detail }), {
|
|
144
|
+
queued: async () => {
|
|
145
|
+
await app.data.table("pull_requests", "pr_key").update(prKey, {
|
|
146
|
+
status: "queued",
|
|
147
|
+
updated_at: now,
|
|
148
|
+
});
|
|
149
|
+
return { mergeStatus: "queued" };
|
|
150
|
+
},
|
|
151
|
+
merged: async () => ({ mergeStatus: "merged" }),
|
|
152
|
+
// blocked → hand the escalation machinery a concrete question.
|
|
153
|
+
blocked: async (o) => ({
|
|
154
|
+
mergeStatus: "blocked",
|
|
155
|
+
status: "blocked",
|
|
156
|
+
question:
|
|
157
|
+
`Automated merge was blocked: ${o.detail}. ` +
|
|
158
|
+
`Resolve it on GitHub (rebase / fix a required check / grant merge rights), then reply to retry.${docHint}`,
|
|
159
|
+
}),
|
|
160
|
+
});
|
|
156
161
|
};
|
|
157
162
|
|
|
158
163
|
export default handler;
|
|
@@ -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
|
{
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// Emitting an empty `waveTasks` is fine: the MI activity over an empty collection completes
|
|
16
16
|
// immediately (the same 0-task path the flat fan-out already relied on).
|
|
17
17
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
18
|
-
import { planTaskDeps, planTasks } from "../../app/plan.ts";
|
|
18
|
+
import { plans, planTaskDeps, planTasks } from "../../app/plan.ts";
|
|
19
19
|
|
|
20
20
|
interface In extends Record<string, unknown> {
|
|
21
21
|
planKey: string;
|
|
@@ -47,6 +47,28 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
47
47
|
const statusById = new Map<string, string>();
|
|
48
48
|
for (const r of rows) statusById.set(r.task_id, r.status);
|
|
49
49
|
|
|
50
|
+
// Operator-visibility projection (issue #137): mark this as the wave the fleet is now
|
|
51
|
+
// implementing, so the epics-index can show wave X/N at a glance. wave_count is derivable from
|
|
52
|
+
// the levelized rows (max wave + 1), so the "X/N" label stays correct even if a re-levelize
|
|
53
|
+
// changed the total. Best-effort + idempotent (a retry re-writes the same value) and
|
|
54
|
+
// display-only — it must never gate control flow, which stays driven by the process
|
|
55
|
+
// `currentWave`/`waveCount`/`gate_wave` state.
|
|
56
|
+
const waveCount = rows.reduce((m, r) => Math.max(m, r.wave ?? 0), -1) + 1;
|
|
57
|
+
try {
|
|
58
|
+
await plans(app.data).update(planKey, {
|
|
59
|
+
// Keep the three progress fields consistent: with no levelized rows (waveCount 0) there is
|
|
60
|
+
// no wave to implement, so current_wave is NULL too — never a stray index against NULL N.
|
|
61
|
+
current_wave: waveCount > 0 ? currentWave : null,
|
|
62
|
+
wave_count: waveCount > 0 ? waveCount : null,
|
|
63
|
+
wave_label: waveCount > 0 ? `${currentWave + 1}/${waveCount}` : null,
|
|
64
|
+
updated_at: ts,
|
|
65
|
+
});
|
|
66
|
+
} catch (err) {
|
|
67
|
+
app.log.error(`select-wave: projecting current_wave failed for ${planKey}`, {
|
|
68
|
+
err: String(err),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
50
72
|
const deps = await planTaskDeps(app.data).find({ plan_key: planKey });
|
|
51
73
|
const depsByTask = new Map<string, string[]>();
|
|
52
74
|
for (const d of deps) {
|