@nanobpm/nano-workforce 0.57.0 → 0.58.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 +7 -0
- package/AGENTS.md +83 -1
- package/CHANGELOG.md +7 -0
- package/README.md +1 -1
- package/SPEC.md +21 -22
- package/app/agentCompletion.test.ts +337 -0
- package/app/agentCompletion.ts +219 -0
- package/app/answer-escalation.test.ts +106 -0
- package/app/answerEscalation.test.ts +67 -0
- package/app/baseGuard.test.ts +9 -1
- package/app/baseGuard.ts +11 -0
- package/app/escalationSla.test.ts +39 -0
- package/app/escalationSla.ts +28 -0
- package/app/escalationTaxonomy.test.ts +115 -0
- package/app/escalationTaxonomy.ts +115 -0
- package/app/feature.test.ts +161 -0
- package/app/feature.ts +173 -0
- package/app/mergeProtocol.test.ts +15 -0
- package/app/mergeProtocol.ts +10 -0
- package/app/persist-escalation.test.ts +34 -36
- package/app/plan.test.ts +0 -294
- package/app/plan.ts +26 -216
- package/app/reviewWait.ts +12 -4
- package/app/roundResultDefault.test.ts +111 -2
- package/app/roundResultDefault.ts +35 -0
- package/app/service.test.ts +6 -7
- package/app/service.ts +52 -35
- package/db/migrations/026_agent_completion.sql +36 -0
- package/db/migrations/027_retire_escalation_subsystem.sql +43 -0
- package/db/migrations/028_feature_runs.sql +28 -0
- package/e2e/agent-answerable.e2e.ts +185 -0
- package/e2e/convergence-escalation.e2e.ts +180 -0
- package/e2e/convergence-loop.e2e.ts +1 -1
- package/e2e/feature-run.e2e.ts +231 -0
- package/e2e/plan-fanout-sla.e2e.ts +238 -0
- package/e2e/plan-fanout.e2e.ts +303 -0
- package/e2e/retire-escalation-subsystem.e2e.ts +223 -0
- package/e2e/support/github-admit.ts +99 -0
- package/e2e/user-task-spine.e2e.ts +155 -0
- package/nano.app.json +37 -11
- package/openapi.yaml +181 -73
- package/operations/agentCompleteEscalation.ts +53 -0
- package/operations/listActivePrs.test.ts +39 -6
- package/operations/postMessage.ts +10 -41
- package/operations/revertEscalationCompletion.ts +44 -0
- package/operations/startAndMessage.test.ts +0 -58
- package/operations/startFeature.ts +127 -0
- package/package.json +4 -1
- package/pages/cockpit.page.json +1 -0
- package/pages/epic-detail.page.json +11 -37
- package/pages/epic.page.json +1 -1
- package/pages/feature.page.json +82 -0
- package/pages/home.page.json +6 -18
- package/resources/agent-guide.md +52 -24
- package/resources/forms/feature-escalation.form +27 -0
- package/resources/forms/plan-review-decision.form +27 -0
- package/resources/forms/pr-escalation.form +23 -0
- package/resources/forms/spine-demo.form +15 -0
- package/resources/forms/trial-merge-decision.form +25 -0
- package/resources/processes/convergence-loop.bpmn +127 -75
- package/resources/processes/feature.bpmn +240 -0
- package/resources/processes/plan-fanout.bpmn +304 -223
- package/resources/processes/spine-demo.bpmn +72 -0
- package/scripts/check-migrations.ts +68 -0
- package/workers/answer-escalation/worker.ts +78 -0
- package/workers/converge-feature/worker.ts +51 -0
- package/workers/finalize/worker.ts +0 -2
- package/workers/mark-merged/worker.ts +0 -2
- package/workers/merge/worker.ts +6 -5
- package/workers/persist-escalation/worker.ts +28 -32
- package/workers/record-feature/worker.ts +61 -0
- package/workers/record-plan-review/worker.test.ts +9 -10
- package/workers/record-plan-review/worker.ts +15 -5
- package/workers/resolve-trial-attention/worker.test.ts +77 -0
- package/workers/resolve-trial-attention/worker.ts +43 -0
- package/operations/answerFeatureEscalation.test.ts +0 -112
- package/operations/answerFeatureEscalation.ts +0 -58
- package/operations/answerPlanEscalation.test.ts +0 -115
- package/operations/answerPlanEscalation.ts +0 -41
- package/workers/persist-plan-escalation/worker.test.ts +0 -80
- package/workers/persist-plan-escalation/worker.ts +0 -73
- package/workers/persist-task-escalation/worker.ts +0 -120
package/app/plan.test.ts
CHANGED
|
@@ -127,94 +127,6 @@ test("re-plan of a finished issue clears stale plan_reviews rows", async () => {
|
|
|
127
127
|
assertEquals(stores.plan_tasks.rows.length, 0);
|
|
128
128
|
});
|
|
129
129
|
|
|
130
|
-
// Red/green regression for re-plan clearing stale open escalations (issue #25).
|
|
131
|
-
//
|
|
132
|
-
// `plan_escalations` is written by the implementation-phase escalation loop and denormalised onto
|
|
133
|
-
// the plan row (`open_task_*`). When `startPlan` re-plans a finished issue it deletes the prior
|
|
134
|
-
// `plan_tasks`, so any still-"open" escalation from that run points at a task that no longer
|
|
135
|
-
// exists. If those rows (and the plan's denormalised pointer) survive the re-plan,
|
|
136
|
-
// `refreshOpenTaskEscalation` re-surfaces a dead question in the answer form — the same
|
|
137
|
-
// stale-row class as `plan_reviews` above. This drives `startPlan` against the in-memory data
|
|
138
|
-
// layer and asserts both the escalation rows and the denormalised pointer are cleared.
|
|
139
|
-
test("re-plan of a finished issue clears stale open escalations and the denormalised open_task_* pointer", async () => {
|
|
140
|
-
const PLAN_KEY = "owner/repo#8";
|
|
141
|
-
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
142
|
-
plans: {
|
|
143
|
-
rows: [{
|
|
144
|
-
plan_key: PLAN_KEY,
|
|
145
|
-
status: "done",
|
|
146
|
-
task_count: 1,
|
|
147
|
-
open_task_escalation_id: 5,
|
|
148
|
-
open_task_question: "stale question from prior run?",
|
|
149
|
-
open_task_corr_key: `${PLAN_KEY}:task-1`,
|
|
150
|
-
open_task_id: "task-1",
|
|
151
|
-
}],
|
|
152
|
-
key: "plan_key",
|
|
153
|
-
},
|
|
154
|
-
plan_tasks: { rows: [{ id: 1, plan_key: PLAN_KEY, task_id: "task-1" }], key: "id" },
|
|
155
|
-
plan_reviews: { rows: [], key: "plan_key" },
|
|
156
|
-
plan_escalations: {
|
|
157
|
-
rows: [{
|
|
158
|
-
id: 5,
|
|
159
|
-
plan_key: PLAN_KEY,
|
|
160
|
-
task_id: "task-1",
|
|
161
|
-
corr_key: `${PLAN_KEY}:task-1`,
|
|
162
|
-
question: "stale question from prior run?",
|
|
163
|
-
status: "open",
|
|
164
|
-
}],
|
|
165
|
-
key: "id",
|
|
166
|
-
},
|
|
167
|
-
plan_task_deps: { rows: [], key: "plan_key" },
|
|
168
|
-
};
|
|
169
|
-
const data = {
|
|
170
|
-
table: (name: string, key: string) =>
|
|
171
|
-
memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
172
|
-
} as any;
|
|
173
|
-
const engine = {
|
|
174
|
-
createInstance: () => Promise.resolve({ processInstanceKey: "PI-1" }),
|
|
175
|
-
} as any;
|
|
176
|
-
|
|
177
|
-
await startPlan(data, engine, {
|
|
178
|
-
repo: "owner/repo",
|
|
179
|
-
number: 8,
|
|
180
|
-
url: "https://github.com/owner/repo/issues/8",
|
|
181
|
-
planKey: PLAN_KEY,
|
|
182
|
-
}, "epic/agent-protocol");
|
|
183
|
-
|
|
184
|
-
// Stale escalation rows from the prior run must not survive a re-plan …
|
|
185
|
-
assertEquals(stores.plan_escalations.rows.length, 0);
|
|
186
|
-
// … and the plan's denormalised "surfaced escalation" pointer must be reset,
|
|
187
|
-
// so `refreshOpenTaskEscalation` can't re-surface a question for a deleted task.
|
|
188
|
-
const plan = stores.plans.rows[0] as Record<string, unknown>;
|
|
189
|
-
assertEquals(plan.open_task_escalation_id, null);
|
|
190
|
-
assertEquals(plan.open_task_question, null);
|
|
191
|
-
assertEquals(plan.open_task_corr_key, null);
|
|
192
|
-
assertEquals(plan.open_task_id, null);
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
// Red/green coverage for the implementation-phase escalation lifecycle (issue #25).
|
|
196
|
-
//
|
|
197
|
-
// `refreshOpenTaskEscalation` and `answerTaskEscalation` (issue #25) drive new stateful
|
|
198
|
-
// behaviour — denormalising the plan's "surfaced" escalation, mirroring the answer onto the
|
|
199
|
-
// task row, and publishing the correlated resume message — that had no unit coverage. These
|
|
200
|
-
// drive both against the in-memory data layer above and assert the oldest-first surfacing,
|
|
201
|
-
// the answer mirroring, and the published message.
|
|
202
|
-
import {
|
|
203
|
-
answerPlanEscalation,
|
|
204
|
-
answerTaskEscalation,
|
|
205
|
-
currentPlanReviewEpoch,
|
|
206
|
-
PLAN_ESCALATION_MESSAGE,
|
|
207
|
-
refreshOpenTaskEscalation,
|
|
208
|
-
} from "./plan.ts";
|
|
209
|
-
|
|
210
|
-
function escalationStores(rows: unknown[]): Record<string, { rows: unknown[]; key: string }> {
|
|
211
|
-
return {
|
|
212
|
-
plans: { rows: [{ plan_key: "owner/repo#9" }], key: "plan_key" },
|
|
213
|
-
plan_escalations: { rows, key: "id" },
|
|
214
|
-
plan_tasks: { rows: [], key: "id" },
|
|
215
|
-
};
|
|
216
|
-
}
|
|
217
|
-
|
|
218
130
|
function memData(stores: Record<string, { rows: any[]; key: string }>) {
|
|
219
131
|
return {
|
|
220
132
|
table: (name: string, key: string) =>
|
|
@@ -222,212 +134,6 @@ function memData(stores: Record<string, { rows: any[]; key: string }>) {
|
|
|
222
134
|
} as any;
|
|
223
135
|
}
|
|
224
136
|
|
|
225
|
-
test("refreshOpenTaskEscalation surfaces the OLDEST open escalation, then clears when none remain", async () => {
|
|
226
|
-
const stores = escalationStores([
|
|
227
|
-
{ id: 2, plan_key: "owner/repo#9", task_id: "b", corr_key: "owner/repo#9:b", question: "Q-b", status: "open" },
|
|
228
|
-
{ id: 1, plan_key: "owner/repo#9", task_id: "a", corr_key: "owner/repo#9:a", question: "Q-a", status: "open" },
|
|
229
|
-
]);
|
|
230
|
-
const data = memData(stores);
|
|
231
|
-
|
|
232
|
-
await refreshOpenTaskEscalation(data, "owner/repo#9");
|
|
233
|
-
let plan = stores.plans.rows[0] as any;
|
|
234
|
-
assertEquals(plan.open_task_escalation_id, 1);
|
|
235
|
-
assertEquals(plan.open_task_question, "Q-a");
|
|
236
|
-
assertEquals(plan.open_task_corr_key, "owner/repo#9:a");
|
|
237
|
-
assertEquals(plan.open_task_id, "a");
|
|
238
|
-
|
|
239
|
-
// Once the oldest is answered, the next-oldest is surfaced.
|
|
240
|
-
(stores.plan_escalations.rows.find((r: any) => r.id === 1) as any).status = "answered";
|
|
241
|
-
await refreshOpenTaskEscalation(data, "owner/repo#9");
|
|
242
|
-
plan = stores.plans.rows[0] as any;
|
|
243
|
-
assertEquals(plan.open_task_escalation_id, 2);
|
|
244
|
-
assertEquals(plan.open_task_id, "b");
|
|
245
|
-
|
|
246
|
-
// With nothing open the denormalised fields clear.
|
|
247
|
-
(stores.plan_escalations.rows.find((r: any) => r.id === 2) as any).status = "answered";
|
|
248
|
-
await refreshOpenTaskEscalation(data, "owner/repo#9");
|
|
249
|
-
plan = stores.plans.rows[0] as any;
|
|
250
|
-
assertEquals(plan.open_task_escalation_id, null);
|
|
251
|
-
assertEquals(plan.open_task_question, null);
|
|
252
|
-
assertEquals(plan.open_task_corr_key, null);
|
|
253
|
-
assertEquals(plan.open_task_id, null);
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
test("answerTaskEscalation records the answer, mirrors it onto the task, publishes the resume message, and re-surfaces the next escalation", async () => {
|
|
257
|
-
const stores = escalationStores([
|
|
258
|
-
{ id: 1, plan_key: "owner/repo#9", task_id: "a", corr_key: "owner/repo#9:a", question: "Q-a", status: "open", answer: null },
|
|
259
|
-
{ id: 2, plan_key: "owner/repo#9", task_id: "b", corr_key: "owner/repo#9:b", question: "Q-b", status: "open", answer: null },
|
|
260
|
-
]);
|
|
261
|
-
stores.plan_tasks.rows.push({ id: 10, plan_key: "owner/repo#9", task_id: "a", answer: null });
|
|
262
|
-
const data = memData(stores);
|
|
263
|
-
|
|
264
|
-
const published: any[] = [];
|
|
265
|
-
const engine = {
|
|
266
|
-
publishMessage: (m: any) => {
|
|
267
|
-
published.push(m);
|
|
268
|
-
return Promise.resolve();
|
|
269
|
-
},
|
|
270
|
-
} as any;
|
|
271
|
-
|
|
272
|
-
const r = await answerTaskEscalation(data, engine, "owner/repo#9:a", "do it");
|
|
273
|
-
assertEquals(r.ok, true);
|
|
274
|
-
assertEquals(r.escalationId, 1);
|
|
275
|
-
assertEquals(r.planKey, "owner/repo#9");
|
|
276
|
-
assertEquals(r.taskId, "a");
|
|
277
|
-
|
|
278
|
-
// Escalation row marked answered with the recorded answer.
|
|
279
|
-
const esc = stores.plan_escalations.rows.find((x: any) => x.id === 1) as any;
|
|
280
|
-
assertEquals(esc.status, "answered");
|
|
281
|
-
assertEquals(esc.answer, "do it");
|
|
282
|
-
|
|
283
|
-
// Answer mirrored onto the task row.
|
|
284
|
-
assertEquals((stores.plan_tasks.rows[0] as any).answer, "do it");
|
|
285
|
-
|
|
286
|
-
// Correlated resume message published on the shared constant channel.
|
|
287
|
-
assertEquals(published.length, 1);
|
|
288
|
-
assertEquals(published[0].name, "feature-escalation-answered");
|
|
289
|
-
assertEquals(published[0].correlationKey, "owner/repo#9:a");
|
|
290
|
-
assertEquals(published[0].variables.answer, "do it");
|
|
291
|
-
|
|
292
|
-
// Next-oldest open escalation re-surfaced on the plan row.
|
|
293
|
-
assertEquals((stores.plans.rows[0] as any).open_task_escalation_id, 2);
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
test("answerTaskEscalation is a no-op when no open escalation matches the correlation key", async () => {
|
|
297
|
-
const stores = escalationStores([]);
|
|
298
|
-
const data = memData(stores);
|
|
299
|
-
const engine = {
|
|
300
|
-
publishMessage: () => Promise.reject(new Error("should not publish")),
|
|
301
|
-
} as any;
|
|
302
|
-
const r = await answerTaskEscalation(data, engine, "owner/repo#9:missing", "x");
|
|
303
|
-
assertEquals(r.ok, false);
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
// Red/green regression (PR #131 suppressed advisory, app/plan.ts:455).
|
|
307
|
-
//
|
|
308
|
-
// Clearing a trial-merge wave's "Needs attention" row (`resolveTrialMergeAttention`)
|
|
309
|
-
// is a best-effort cosmetic cleanup, but it must be RETRIABLE: if it ran only AFTER
|
|
310
|
-
// the escalation was committed as `answered` and the resume message was published,
|
|
311
|
-
// a transient DB error there would 500 the whole answer flow while the escalation is
|
|
312
|
-
// already answered/resumed — a retry then 404s (no open escalation) and the red row
|
|
313
|
-
// is pinned forever (the very failure the insert-first ordering elsewhere avoids).
|
|
314
|
-
// The fix runs the idempotent resolution BEFORE the commit/publish, so a failure
|
|
315
|
-
// leaves the escalation OPEN and nothing is orphaned — the caller can safely retry.
|
|
316
|
-
test("answerTaskEscalation stays retriable (escalation open, no orphaned resume) when clearing 'Needs attention' fails", async () => {
|
|
317
|
-
const stores = escalationStores([
|
|
318
|
-
{
|
|
319
|
-
id: 1,
|
|
320
|
-
plan_key: "owner/repo#9",
|
|
321
|
-
task_id: "trial-merge-wave-0",
|
|
322
|
-
corr_key: "owner/repo#9:trial-merge-wave-0",
|
|
323
|
-
question: "Q",
|
|
324
|
-
status: "open",
|
|
325
|
-
answer: null,
|
|
326
|
-
},
|
|
327
|
-
]);
|
|
328
|
-
stores.plan_trial_merges = {
|
|
329
|
-
rows: [{ id: 100, plan_key: "owner/repo#9", wave: 0, resolved: 0 }],
|
|
330
|
-
key: "id",
|
|
331
|
-
};
|
|
332
|
-
const base = memData(stores);
|
|
333
|
-
// Inject a transient failure in the trial-merge audit table's `update` only.
|
|
334
|
-
const data = {
|
|
335
|
-
table: (name: string, key: string) => {
|
|
336
|
-
const t = base.table(name, key);
|
|
337
|
-
if (name === "plan_trial_merges") {
|
|
338
|
-
return { ...t, update: () => Promise.reject(new Error("transient DB error")) };
|
|
339
|
-
}
|
|
340
|
-
return t;
|
|
341
|
-
},
|
|
342
|
-
} as any;
|
|
343
|
-
|
|
344
|
-
const published: any[] = [];
|
|
345
|
-
const engine = {
|
|
346
|
-
publishMessage: (m: any) => {
|
|
347
|
-
published.push(m);
|
|
348
|
-
return Promise.resolve();
|
|
349
|
-
},
|
|
350
|
-
} as any;
|
|
351
|
-
|
|
352
|
-
await assertRejects(() =>
|
|
353
|
-
answerTaskEscalation(data, engine, "owner/repo#9:trial-merge-wave-0", "proceed")
|
|
354
|
-
);
|
|
355
|
-
|
|
356
|
-
// Escalation must remain OPEN so a retry can recover (never committed as answered).
|
|
357
|
-
const esc = stores.plan_escalations.rows.find((x: any) => x.id === 1) as any;
|
|
358
|
-
assertEquals(esc.status, "open");
|
|
359
|
-
assertEquals(esc.answer, null);
|
|
360
|
-
// No orphaned resume message was published.
|
|
361
|
-
assertEquals(published.length, 0);
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
test("currentPlanReviewEpoch counts answered plan-review escalations only", async () => {
|
|
365
|
-
const stores = {
|
|
366
|
-
plan_review_escalations: {
|
|
367
|
-
rows: [
|
|
368
|
-
{ id: 1, plan_key: "owner/repo#10", status: "answered" },
|
|
369
|
-
{ id: 2, plan_key: "owner/repo#10", status: "open" },
|
|
370
|
-
{ id: 3, plan_key: "owner/repo#other", status: "answered" },
|
|
371
|
-
],
|
|
372
|
-
key: "id",
|
|
373
|
-
},
|
|
374
|
-
};
|
|
375
|
-
assertEquals(await currentPlanReviewEpoch(memData(stores), "owner/repo#10"), 1);
|
|
376
|
-
});
|
|
377
|
-
|
|
378
|
-
test("answerPlanEscalation records directive, clears the plan pointer, and publishes the resume message", async () => {
|
|
379
|
-
const stores = {
|
|
380
|
-
plans: {
|
|
381
|
-
rows: [{
|
|
382
|
-
plan_key: "owner/repo#11",
|
|
383
|
-
open_plan_escalation_id: 7,
|
|
384
|
-
open_plan_findings: "reviewer findings",
|
|
385
|
-
open_plan_round: 2,
|
|
386
|
-
}],
|
|
387
|
-
key: "plan_key",
|
|
388
|
-
},
|
|
389
|
-
plan_review_escalations: {
|
|
390
|
-
rows: [{
|
|
391
|
-
id: 7,
|
|
392
|
-
plan_key: "owner/repo#11",
|
|
393
|
-
epoch: 0,
|
|
394
|
-
round: 2,
|
|
395
|
-
findings: "reviewer findings",
|
|
396
|
-
status: "open",
|
|
397
|
-
directive: null,
|
|
398
|
-
note: null,
|
|
399
|
-
}],
|
|
400
|
-
key: "id",
|
|
401
|
-
},
|
|
402
|
-
};
|
|
403
|
-
const published: any[] = [];
|
|
404
|
-
const engine = {
|
|
405
|
-
publishMessage: (m: any) => {
|
|
406
|
-
published.push(m);
|
|
407
|
-
return Promise.resolve();
|
|
408
|
-
},
|
|
409
|
-
} as any;
|
|
410
|
-
|
|
411
|
-
const r = await answerPlanEscalation(memData(stores), engine, "owner/repo#11", "revise", "Use issue-1 as seam.");
|
|
412
|
-
assertEquals(r.ok, true);
|
|
413
|
-
assertEquals(r.directive, "revise");
|
|
414
|
-
const esc = stores.plan_review_escalations.rows[0] as any;
|
|
415
|
-
assertEquals(esc.status, "answered");
|
|
416
|
-
assertEquals(esc.directive, "revise");
|
|
417
|
-
assertEquals(esc.note, "Use issue-1 as seam.");
|
|
418
|
-
const plan = stores.plans.rows[0] as any;
|
|
419
|
-
assertEquals(plan.open_plan_escalation_id, null);
|
|
420
|
-
assertEquals(plan.open_plan_findings, null);
|
|
421
|
-
assertEquals(plan.open_plan_round, null);
|
|
422
|
-
assertEquals(published[0].name, PLAN_ESCALATION_MESSAGE);
|
|
423
|
-
assertEquals(published[0].correlationKey, "owner/repo#11");
|
|
424
|
-
assertEquals(published[0].variables.planEscalationDirective, "revise");
|
|
425
|
-
assertEquals(
|
|
426
|
-
String(published[0].variables.planFindings).includes("Use issue-1 as seam."),
|
|
427
|
-
true,
|
|
428
|
-
);
|
|
429
|
-
});
|
|
430
|
-
|
|
431
137
|
// Coverage for the epic base-branch control (issue nano-ide #124 / 019_plan_base_branch.sql; ADR 0003).
|
|
432
138
|
//
|
|
433
139
|
// Every plan must pin a base branch so the fleet branches off — and opens every PR against — a
|
package/app/plan.ts
CHANGED
|
@@ -11,14 +11,23 @@
|
|
|
11
11
|
// hand-written SQL — matching app/service.ts.
|
|
12
12
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
13
13
|
import { blackboardUrl, mintBlackboardToken, renderCoordinationBrief } from "./blackboard.ts";
|
|
14
|
+
import { DEFAULT_ESCALATION_SLA_TIMEOUT, escalationSlaTimeout } from "./escalationSla.ts";
|
|
14
15
|
import { ensureBaseBranch, fetchDefaultBranch } from "./github.ts";
|
|
15
16
|
import { clearExclusions } from "./mergeExclusion.ts";
|
|
16
17
|
import { clearTaskDeltas } from "./taskDelta.ts";
|
|
17
|
-
import { resolveTrialMergeAttention, trialMergeWaveFromTaskId } from "./trialMerge.ts";
|
|
18
18
|
|
|
19
19
|
/** The BPMN process this module drives (resources/processes/plan-fanout.bpmn). */
|
|
20
20
|
export const PLAN_PROCESS_ID = "plan-fanout";
|
|
21
21
|
|
|
22
|
+
/** The fleet-wide escalation SLA (ISO-8601 duration) seeded onto every plan-fanout instance as the
|
|
23
|
+
* `escalationSlaTimeout` process variable and evaluated by each escalation user task's interrupting
|
|
24
|
+
* timer boundary. An operator sets `NANO_ESCALATION_SLA_TIMEOUT`; a malformed value falls back to
|
|
25
|
+
* {@link DEFAULT_ESCALATION_SLA_TIMEOUT} so a bad env can never deploy an uninterpretable timer. */
|
|
26
|
+
export const ESCALATION_SLA_TIMEOUT = escalationSlaTimeout(
|
|
27
|
+
process.env.NANO_ESCALATION_SLA_TIMEOUT,
|
|
28
|
+
DEFAULT_ESCALATION_SLA_TIMEOUT,
|
|
29
|
+
);
|
|
30
|
+
|
|
22
31
|
const now = () => new Date().toISOString();
|
|
23
32
|
|
|
24
33
|
// Agent prompts are no longer read by the host. The `senior:plan`, `senior:plan-review`, and
|
|
@@ -38,21 +47,6 @@ export interface Plan {
|
|
|
38
47
|
task_count: number;
|
|
39
48
|
process_key: string | null;
|
|
40
49
|
outcome: string | null;
|
|
41
|
-
// Denormalised "oldest open task escalation" pointer (issue #25): the plans page
|
|
42
|
-
// detail has a single answer form per row, so the oldest still-open per-task
|
|
43
|
-
// escalation is surfaced here; answering re-points these at the next one (or
|
|
44
|
-
// clears them). See refreshOpenTaskEscalation.
|
|
45
|
-
open_task_escalation_id: number | null;
|
|
46
|
-
open_task_question: string | null;
|
|
47
|
-
open_task_corr_key: string | null;
|
|
48
|
-
open_task_id: string | null;
|
|
49
|
-
// Denormalised "open plan-review escalation" pointer (# plan-review escalation): when the
|
|
50
|
-
// adversarial plan-review cap is reached without approval, the process parks for a human
|
|
51
|
-
// proceed/revise directive. These fields surface the newest open plan-level escalation on the
|
|
52
|
-
// plans page without overloading the implementation-phase `plan_escalations` table.
|
|
53
|
-
open_plan_escalation_id: number | null;
|
|
54
|
-
open_plan_findings: string | null;
|
|
55
|
-
open_plan_round: number | null;
|
|
56
50
|
// Wave-merge barrier (007_wave_gate.sql): the wave index whose PRs the plan is currently
|
|
57
51
|
// waiting to see MERGED before dispatching the next wave, or null when not parked at the barrier.
|
|
58
52
|
gate_wave: number | null;
|
|
@@ -114,36 +108,8 @@ export const PLAN_TASK_STATUSES = [
|
|
|
114
108
|
] as const;
|
|
115
109
|
export type PlanTaskStatus = typeof PLAN_TASK_STATUSES[number];
|
|
116
110
|
|
|
117
|
-
/** One implementation-phase escalation (issue #25) — the per-task analogue of the
|
|
118
|
-
* review loop's `escalations` row. `status` is open | answered. */
|
|
119
|
-
export interface PlanEscalation {
|
|
120
|
-
id: number;
|
|
121
|
-
plan_key: string;
|
|
122
|
-
task_id: string;
|
|
123
|
-
corr_key: string;
|
|
124
|
-
question: string;
|
|
125
|
-
answer: string | null;
|
|
126
|
-
draft_pr_key: string | null;
|
|
127
|
-
status: string;
|
|
128
|
-
asked_at: string;
|
|
129
|
-
answered_at: string | null;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
111
|
export const plans = (data: DataLayer) => data.table<Plan>("plans", "plan_key");
|
|
133
112
|
export const planTasks = (data: DataLayer) => data.table<PlanTask>("plan_tasks", "id");
|
|
134
|
-
export const planEscalations = (data: DataLayer) =>
|
|
135
|
-
data.table<PlanEscalation>("plan_escalations", "id");
|
|
136
|
-
|
|
137
|
-
/** The message the plan-fanout process catches to resume an escalated task; its
|
|
138
|
-
* subscription correlates on `<plan_key>:<task_id>` (see plan-fanout.bpmn). */
|
|
139
|
-
export const FEATURE_ESCALATION_MESSAGE = "feature-escalation-answered";
|
|
140
|
-
|
|
141
|
-
/** The message the plan-fanout process catches to resume a plan-review escalation; its
|
|
142
|
-
* subscription correlates on `<plan_key>` (see plan-fanout.bpmn). */
|
|
143
|
-
export const PLAN_ESCALATION_MESSAGE = "plan-escalation-answered";
|
|
144
|
-
|
|
145
|
-
/** Build the per-task message correlation key the process parks on. */
|
|
146
|
-
export const featureCorrKey = (planKey: string, taskId: string) => `${planKey}:${taskId}`;
|
|
147
113
|
|
|
148
114
|
/** One dependency edge in the plan DAG (issue #20): `task_id` waits for `depends_on_task_id`.
|
|
149
115
|
* Keyed on `plan_key` so a single delete clears a plan's whole edge set (as pr_dependencies). */
|
|
@@ -172,31 +138,6 @@ export interface PlanReview {
|
|
|
172
138
|
}
|
|
173
139
|
export const planReviews = (data: DataLayer) => data.table<PlanReview>("plan_reviews", "plan_key");
|
|
174
140
|
|
|
175
|
-
export type PlanEscalationDirective = "proceed" | "revise";
|
|
176
|
-
|
|
177
|
-
export function parsePlanEscalationDirective(input: unknown): PlanEscalationDirective | null {
|
|
178
|
-
const s = typeof input === "string" ? input.trim().toLowerCase() : "";
|
|
179
|
-
return s === "proceed" || s === "revise" ? s : null;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/** One plan-review cap escalation. Kept in a dedicated table rather than overloading
|
|
183
|
-
* `plan_escalations`: the latter is task-scoped (`task_id`/`corr_key` are NOT NULL and mirrored
|
|
184
|
-
* onto `plan_tasks`), while this row is plan-scoped and drives the review epoch reset. */
|
|
185
|
-
export interface PlanReviewEscalation {
|
|
186
|
-
id: number;
|
|
187
|
-
plan_key: string;
|
|
188
|
-
epoch: number;
|
|
189
|
-
round: number;
|
|
190
|
-
findings: string | null;
|
|
191
|
-
status: string;
|
|
192
|
-
directive: PlanEscalationDirective | null;
|
|
193
|
-
note: string | null;
|
|
194
|
-
asked_at: string;
|
|
195
|
-
answered_at: string | null;
|
|
196
|
-
}
|
|
197
|
-
export const planReviewEscalations = (data: DataLayer) =>
|
|
198
|
-
data.table<PlanReviewEscalation>("plan_review_escalations", "id");
|
|
199
|
-
|
|
200
141
|
/** Read a positive-integer env override, falling back when unset/blank/invalid. A bad value
|
|
201
142
|
* (e.g. "", "abc", "0", "2.5") must NOT silently become `NaN`/`0` — that would make the round
|
|
202
143
|
* cap `round + 1 >= cap` always false and allow an unbounded revise loop. */
|
|
@@ -212,11 +153,9 @@ export function positiveIntEnv(name: string, fallback: number): number {
|
|
|
212
153
|
* #86). A human `revise` answer starts a fresh epoch, so the next plan gets a full new budget. */
|
|
213
154
|
export const MAX_PLAN_REVIEW_ROUNDS = positiveIntEnv("NANO_PLAN_REVIEW_ROUNDS", 3);
|
|
214
155
|
|
|
215
|
-
/** The current review epoch is
|
|
216
|
-
* plan-review
|
|
217
|
-
|
|
218
|
-
return await planReviewEscalations(data).count({ plan_key: planKey, status: "answered" });
|
|
219
|
-
}
|
|
156
|
+
/** The current review epoch is a durable process variable (`planReviewEpoch`) bumped by the
|
|
157
|
+
* `plan-review-decision` user task each time a human answers a plan-review escalation. It is read
|
|
158
|
+
* back by `record-plan-review` to reset the round budget — there is no derived counter here. */
|
|
220
159
|
|
|
221
160
|
/** A plan is "done" in exactly these states; everything else (planning, dispatched)
|
|
222
161
|
* is in flight. The cancel guard and the active view key off this. */
|
|
@@ -467,18 +406,6 @@ export async function startPlan(
|
|
|
467
406
|
// Clear them here — the table is keyed on `plan_key`, so one delete drops the
|
|
468
407
|
// whole set (mirrors how record-plan clears `plan_task_deps`).
|
|
469
408
|
await planReviews(data).delete(parsed.planKey);
|
|
470
|
-
// Same class of stale-row bug for escalation state: task escalations are keyed on `id` (not
|
|
471
|
-
// `plan_key`), so drop the prior run's rows one-by-one. Otherwise a still-"open" escalation
|
|
472
|
-
// from the previous run survives the re-plan and `refreshOpenTaskEscalation` re-surfaces a
|
|
473
|
-
// question for a `task_id` we just deleted from `plan_tasks`.
|
|
474
|
-
for (const e of await planEscalations(data).find({ plan_key: parsed.planKey })) {
|
|
475
|
-
await planEscalations(data).delete(e.id);
|
|
476
|
-
}
|
|
477
|
-
// Plan-review escalations are also keyed on `id` because they are an audit trail; clear them
|
|
478
|
-
// on a fresh submission so the epoch derived from answered escalations resets to 0.
|
|
479
|
-
for (const e of await planReviewEscalations(data).find({ plan_key: parsed.planKey })) {
|
|
480
|
-
await planReviewEscalations(data).delete(e.id);
|
|
481
|
-
}
|
|
482
409
|
// Same for the structured impl-change deltas (D5, #55): keyed on `id`, so drop the prior run's
|
|
483
410
|
// rows one-by-one, otherwise a stale delta lingers in the epic report for a task we just deleted.
|
|
484
411
|
await clearTaskDeltas(data, parsed.planKey);
|
|
@@ -489,15 +416,6 @@ export async function startPlan(
|
|
|
489
416
|
task_count: 0,
|
|
490
417
|
issue_url: parsed.url,
|
|
491
418
|
outcome: null,
|
|
492
|
-
// Reset the denormalised "surfaced escalation" pointer so nothing from the
|
|
493
|
-
// prior run lingers on the plan row (would otherwise show a dead answer form).
|
|
494
|
-
open_task_escalation_id: null,
|
|
495
|
-
open_task_question: null,
|
|
496
|
-
open_task_corr_key: null,
|
|
497
|
-
open_task_id: null,
|
|
498
|
-
open_plan_escalation_id: null,
|
|
499
|
-
open_plan_findings: null,
|
|
500
|
-
open_plan_round: null,
|
|
501
419
|
blackboard_token: token,
|
|
502
420
|
base_branch: base,
|
|
503
421
|
updated_at: ts,
|
|
@@ -525,6 +443,19 @@ export async function startPlan(
|
|
|
525
443
|
issueNumber: parsed.number,
|
|
526
444
|
issueUrl: parsed.url,
|
|
527
445
|
planFindings: null,
|
|
446
|
+
// The plan-review epoch is a durable process variable, bumped by the `plan-review-decision`
|
|
447
|
+
// user task each time a human answers a plan-review escalation. `record-plan-review` reads it
|
|
448
|
+
// to reset the per-epoch round budget; it starts at 0 for the first review round.
|
|
449
|
+
planReviewEpoch: 0,
|
|
450
|
+
// Escalation-of-the-escalation SLA (U5, #156): the validated ISO-8601 duration seeded onto the
|
|
451
|
+
// instance and read by each escalation user task's interrupting timer boundary
|
|
452
|
+
// (`<bpmn:timeDuration>=escalationSlaTimeout`). If a human never answers, the boundary fires and
|
|
453
|
+
// the process auto-proceeds down the gateway's safe-default arm — durable in-process liveness,
|
|
454
|
+
// not a poller-side watchdog. `escalationAssignee` is the optional named assignee the escalation
|
|
455
|
+
// user tasks' `zeebe:assignmentDefinition` resolves (null = unassigned, routed via the
|
|
456
|
+
// `operators` candidate group); an operator/agent can claim/reassign via the task inbox.
|
|
457
|
+
escalationSlaTimeout: ESCALATION_SLA_TIMEOUT,
|
|
458
|
+
escalationAssignee: null,
|
|
528
459
|
// Coordination blackboard (#51): the capability URL + the protocol brief that each
|
|
529
460
|
// implementer agent gets appended to its prompt (composed into `appendPrompt` in
|
|
530
461
|
// plan-fanout.bpmn's implement-task). Advisory shared state, delivered in-band, used
|
|
@@ -546,124 +477,3 @@ export async function startPlan(
|
|
|
546
477
|
return { planKey: parsed.planKey, processKey };
|
|
547
478
|
}
|
|
548
479
|
|
|
549
|
-
/** Re-point a plan's denormalised "open task escalation" fields at its OLDEST
|
|
550
|
-
* still-open `plan_escalations` row (or clear them when none remain). The page
|
|
551
|
-
* runtime binds a single answer form per plan row, so parallel escalations are
|
|
552
|
-
* surfaced one at a time, oldest-first; this is called after opening an
|
|
553
|
-
* escalation and after answering one. */
|
|
554
|
-
export async function refreshOpenTaskEscalation(data: DataLayer, planKey: string) {
|
|
555
|
-
const open = (await planEscalations(data).find({ plan_key: planKey, status: "open" }))
|
|
556
|
-
.sort((a, b) => a.id - b.id)[0];
|
|
557
|
-
await plans(data).update(planKey, {
|
|
558
|
-
open_task_escalation_id: open ? open.id : null,
|
|
559
|
-
open_task_question: open ? open.question : null,
|
|
560
|
-
open_task_corr_key: open ? open.corr_key : null,
|
|
561
|
-
open_task_id: open ? open.task_id : null,
|
|
562
|
-
updated_at: now(),
|
|
563
|
-
});
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
/** Answer an open implementation-phase escalation → record it, resume the parked
|
|
567
|
-
* task via the correlated `feature-escalation-answered` message, and re-surface
|
|
568
|
-
* the next-oldest open escalation (if any). Keyed by the correlation key
|
|
569
|
-
* (`<plan_key>:<task_id>`) so an external webhook and the page share one path.
|
|
570
|
-
* Idempotent-ish: a corr_key with no open escalation is a 404-style no-op. */
|
|
571
|
-
export async function answerTaskEscalation(
|
|
572
|
-
data: DataLayer,
|
|
573
|
-
engine: EngineClient,
|
|
574
|
-
corrKey: string,
|
|
575
|
-
answer: string,
|
|
576
|
-
) {
|
|
577
|
-
const open = (await planEscalations(data).find({ corr_key: corrKey, status: "open" }))
|
|
578
|
-
.sort((a, b) => b.id - a.id)[0];
|
|
579
|
-
if (!open) return { ok: false, reason: "no open escalation" };
|
|
580
|
-
const ts = now();
|
|
581
|
-
// A trial-merge escalation (task_id `trial-merge-wave-<wave>`) leaves an
|
|
582
|
-
// append-only red audit row in `plan_trial_merges`. Answering it clears that
|
|
583
|
-
// row from the page's "Needs attention" tab — including a "proceed" override
|
|
584
|
-
// that records no re-run row (a re-run would supersede it, but a proceed would
|
|
585
|
-
// not, pinning the red row forever).
|
|
586
|
-
//
|
|
587
|
-
// Resolve it FIRST, before the escalation is committed as answered and the
|
|
588
|
-
// resume message is published. `resolveTrialMergeAttention` is idempotent, so
|
|
589
|
-
// if this throws (e.g. a transient DB error) the escalation is still open and
|
|
590
|
-
// the whole operation retries cleanly. Running it AFTER the commit/publish
|
|
591
|
-
// would make a failure here unrecoverable: the escalation is already answered,
|
|
592
|
-
// a retry 404s (no open escalation), and the red row is pinned forever.
|
|
593
|
-
const trialWave = trialMergeWaveFromTaskId(open.task_id);
|
|
594
|
-
if (trialWave != null) await resolveTrialMergeAttention(data, open.plan_key, trialWave);
|
|
595
|
-
await planEscalations(data).update(open.id, { answer, status: "answered", answered_at: ts });
|
|
596
|
-
// Mirror onto the task row so a re-dispatched agent (and the UI) sees the answer.
|
|
597
|
-
for (const t of await planTasks(data).find({ plan_key: open.plan_key, task_id: open.task_id })) {
|
|
598
|
-
await planTasks(data).update(t.id, { answer, updated_at: ts });
|
|
599
|
-
}
|
|
600
|
-
// Resume the parked child: the process merges `answer` into the child scope and
|
|
601
|
-
// loops back to re-dispatch the SAME task on its existing branch.
|
|
602
|
-
await engine.publishMessage({
|
|
603
|
-
name: FEATURE_ESCALATION_MESSAGE,
|
|
604
|
-
correlationKey: corrKey,
|
|
605
|
-
variables: { answer },
|
|
606
|
-
});
|
|
607
|
-
await refreshOpenTaskEscalation(data, open.plan_key);
|
|
608
|
-
return { ok: true, escalationId: open.id, planKey: open.plan_key, taskId: open.task_id };
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
export function normalizePlanEscalationDirective(input: unknown): PlanEscalationDirective {
|
|
612
|
-
return parsePlanEscalationDirective(input) ?? "revise";
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
function renderPlanEscalationFindings(open: PlanReviewEscalation, note: string): string {
|
|
616
|
-
const parts = [
|
|
617
|
-
`Plan review reached its round budget at epoch ${open.epoch}, round ${open.round}.`,
|
|
618
|
-
"",
|
|
619
|
-
"Reviewer findings:",
|
|
620
|
-
(open.findings ?? "").trim() || "(no reviewer findings were provided.)",
|
|
621
|
-
];
|
|
622
|
-
if (note) {
|
|
623
|
-
parts.push("", "Human guidance:", note);
|
|
624
|
-
} else {
|
|
625
|
-
parts.push("", "Human directive: revise the plan within the allowed task boundaries.");
|
|
626
|
-
}
|
|
627
|
-
return parts.join("\n");
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
/** Answer the newest open plan-review escalation. `proceed` is an explicit human override that lets
|
|
631
|
-
* the current (unapproved) plan continue to wave dispatch; `revise` (the default) folds the human
|
|
632
|
-
* note into `planFindings` and starts a fresh review epoch on the next planner pass. */
|
|
633
|
-
export async function answerPlanEscalation(
|
|
634
|
-
data: DataLayer,
|
|
635
|
-
engine: EngineClient,
|
|
636
|
-
planKey: string,
|
|
637
|
-
directiveInput: unknown,
|
|
638
|
-
noteInput: unknown,
|
|
639
|
-
) {
|
|
640
|
-
const open = (await planReviewEscalations(data).find({ plan_key: planKey, status: "open" }))
|
|
641
|
-
.sort((a, b) => b.id - a.id)[0];
|
|
642
|
-
if (!open) return { ok: false, reason: "no open plan escalation" };
|
|
643
|
-
|
|
644
|
-
const directive = normalizePlanEscalationDirective(directiveInput);
|
|
645
|
-
const note = typeof noteInput === "string" ? noteInput.trim() : "";
|
|
646
|
-
const ts = now();
|
|
647
|
-
await planReviewEscalations(data).update(open.id, {
|
|
648
|
-
directive,
|
|
649
|
-
note: note || null,
|
|
650
|
-
status: "answered",
|
|
651
|
-
answered_at: ts,
|
|
652
|
-
});
|
|
653
|
-
await plans(data).update(planKey, {
|
|
654
|
-
open_plan_escalation_id: null,
|
|
655
|
-
open_plan_findings: null,
|
|
656
|
-
open_plan_round: null,
|
|
657
|
-
updated_at: ts,
|
|
658
|
-
});
|
|
659
|
-
|
|
660
|
-
await engine.publishMessage({
|
|
661
|
-
name: PLAN_ESCALATION_MESSAGE,
|
|
662
|
-
correlationKey: planKey,
|
|
663
|
-
variables: {
|
|
664
|
-
planEscalationDirective: directive,
|
|
665
|
-
planFindings: directive === "revise" ? renderPlanEscalationFindings(open, note) : "",
|
|
666
|
-
},
|
|
667
|
-
});
|
|
668
|
-
return { ok: true, escalationId: open.id, planKey, directive };
|
|
669
|
-
}
|
package/app/reviewWait.ts
CHANGED
|
@@ -20,15 +20,23 @@ export const DEFAULT_REVIEW_WAIT_TIMEOUT = "PT20M";
|
|
|
20
20
|
// would fail to interpret; not a full grammar (we don't need fractional seconds here).
|
|
21
21
|
const ISO_DURATION = /^P(?!$)(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+S)?)?$/;
|
|
22
22
|
|
|
23
|
+
/** Validate an ISO-8601 duration string for a BPMN timer's `<bpmn:timeDuration>`, falling back to
|
|
24
|
+
* `def` when the value is absent, blank, or malformed — a bad env value must never deploy an
|
|
25
|
+
* uninterpretable timer expression into a process. Normalises to upper case (`pt20m` → `PT20M`).
|
|
26
|
+
* This is the single canonical duration validator; per-timer policies (review-wait, escalation
|
|
27
|
+
* SLA) derive their env-driven value from it rather than re-implementing the grammar. */
|
|
28
|
+
export function isoDuration(raw: string | undefined, def: string): string {
|
|
29
|
+
const s = (raw ?? "").trim().toUpperCase();
|
|
30
|
+
return s !== "" && ISO_DURATION.test(s) ? s : def;
|
|
31
|
+
}
|
|
32
|
+
|
|
23
33
|
/** Validate an ISO-8601 duration for the review-wait timer, falling back to `def` when the value
|
|
24
|
-
* is absent, blank, or malformed
|
|
25
|
-
* expression into the process. Normalises to upper case (`pt20m` → `PT20M`). */
|
|
34
|
+
* is absent, blank, or malformed. Thin wrapper over {@link isoDuration}. */
|
|
26
35
|
export function reviewWaitTimeout(
|
|
27
36
|
raw: string | undefined,
|
|
28
37
|
def: string = DEFAULT_REVIEW_WAIT_TIMEOUT,
|
|
29
38
|
): string {
|
|
30
|
-
|
|
31
|
-
return s !== "" && ISO_DURATION.test(s) ? s : def;
|
|
39
|
+
return isoDuration(raw, def);
|
|
32
40
|
}
|
|
33
41
|
|
|
34
42
|
/** Default cooldown (minutes) between automatic Copilot re-request nudges for one waiting PR.
|