@nanobpm/nano-workforce 0.46.0 → 0.46.2
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.test.ts +59 -1
- package/app/plan.ts +15 -0
- package/app/trialMerge.test.ts +94 -2
- package/app/trialMerge.ts +67 -12
- package/db/migrations/021_trial_merge_resolved.sql +44 -0
- package/package.json +1 -1
- package/pages/epic.page.json +8 -2
- package/prompts/fix-ci.md +45 -0
- package/prompts/rebase.md +45 -0
- package/prompts/review-round.md +1 -1
- package/resources/processes/plan-fanout.bpmn +1 -1
- package/scripts/check-agent-prompts.test.ts +28 -3
- package/scripts/check-agent-prompts.ts +42 -0
- package/workers/persist-task-escalation/worker.ts +9 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
## [0.46.2](https://github.com/nanobpm/nano-workforce/compare/v0.46.1...v0.46.2) (2026-08-12)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **prompts:** make merge-phase agents emit a machine-readable result ([#133](https://github.com/nanobpm/nano-workforce/issues/133)) ([6584092](https://github.com/nanobpm/nano-workforce/commit/6584092b3ef8f15159c024c080b312ead7484076)), closes [Magikcraft/nano-bpm#746](https://github.com/Magikcraft/nano-bpm/issues/746)
|
|
7
|
+
|
|
8
|
+
## [0.46.1](https://github.com/nanobpm/nano-workforce/compare/v0.46.0...v0.46.1) (2026-08-12)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **trial-merge:** durable "needs attention" resolution + robust escalation key ([#131](https://github.com/nanobpm/nano-workforce/issues/131)) ([71200e8](https://github.com/nanobpm/nano-workforce/commit/71200e814a8c035a79f1eec37303781c70b6e6c0))
|
|
14
|
+
|
|
1
15
|
# [0.46.0](https://github.com/nanobpm/nano-workforce/compare/v0.45.0...v0.46.0) (2026-08-12)
|
|
2
16
|
|
|
3
17
|
|
package/app/plan.test.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// planner could revise forever. `positiveIntEnv` must fall back to the default on any value that
|
|
6
6
|
// is not a positive integer, so the loop is always bounded.
|
|
7
7
|
import { test } from "node:test";
|
|
8
|
-
import { assertEquals, assertThrows } from "#test-assert";
|
|
8
|
+
import { assertEquals, assertRejects, assertThrows } from "#test-assert";
|
|
9
9
|
import { positiveIntEnv } from "./plan.ts";
|
|
10
10
|
|
|
11
11
|
const KEY = "NANO_PLAN_REVIEW_ROUNDS_TEST";
|
|
@@ -303,6 +303,64 @@ test("answerTaskEscalation is a no-op when no open escalation matches the correl
|
|
|
303
303
|
assertEquals(r.ok, false);
|
|
304
304
|
});
|
|
305
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
|
+
|
|
306
364
|
test("currentPlanReviewEpoch counts answered plan-review escalations only", async () => {
|
|
307
365
|
const stores = {
|
|
308
366
|
plan_review_escalations: {
|
package/app/plan.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
|
13
13
|
import { blackboardUrl, mintBlackboardToken, renderCoordinationBrief } from "./blackboard.ts";
|
|
14
14
|
import { clearExclusions } from "./mergeExclusion.ts";
|
|
15
15
|
import { clearTaskDeltas } from "./taskDelta.ts";
|
|
16
|
+
import { resolveTrialMergeAttention, trialMergeWaveFromTaskId } from "./trialMerge.ts";
|
|
16
17
|
|
|
17
18
|
/** The BPMN process this module drives (resources/processes/plan-fanout.bpmn). */
|
|
18
19
|
export const PLAN_PROCESS_ID = "plan-fanout";
|
|
@@ -433,6 +434,20 @@ export async function answerTaskEscalation(
|
|
|
433
434
|
.sort((a, b) => b.id - a.id)[0];
|
|
434
435
|
if (!open) return { ok: false, reason: "no open escalation" };
|
|
435
436
|
const ts = now();
|
|
437
|
+
// A trial-merge escalation (task_id `trial-merge-wave-<wave>`) leaves an
|
|
438
|
+
// append-only red audit row in `plan_trial_merges`. Answering it clears that
|
|
439
|
+
// row from the page's "Needs attention" tab — including a "proceed" override
|
|
440
|
+
// that records no re-run row (a re-run would supersede it, but a proceed would
|
|
441
|
+
// not, pinning the red row forever).
|
|
442
|
+
//
|
|
443
|
+
// Resolve it FIRST, before the escalation is committed as answered and the
|
|
444
|
+
// resume message is published. `resolveTrialMergeAttention` is idempotent, so
|
|
445
|
+
// if this throws (e.g. a transient DB error) the escalation is still open and
|
|
446
|
+
// the whole operation retries cleanly. Running it AFTER the commit/publish
|
|
447
|
+
// would make a failure here unrecoverable: the escalation is already answered,
|
|
448
|
+
// a retry 404s (no open escalation), and the red row is pinned forever.
|
|
449
|
+
const trialWave = trialMergeWaveFromTaskId(open.task_id);
|
|
450
|
+
if (trialWave != null) await resolveTrialMergeAttention(data, open.plan_key, trialWave);
|
|
436
451
|
await planEscalations(data).update(open.id, { answer, status: "answered", answered_at: ts });
|
|
437
452
|
// Mirror onto the task row so a re-dispatched agent (and the UI) sees the answer.
|
|
438
453
|
for (const t of await planTasks(data).find({ plan_key: open.plan_key, task_id: open.task_id })) {
|
package/app/trialMerge.test.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
|
-
import { assertEquals } from "#test-assert";
|
|
3
|
-
import {
|
|
2
|
+
import { assertEquals, assertRejects } from "#test-assert";
|
|
3
|
+
import {
|
|
4
|
+
recordTrialMergeAudit,
|
|
5
|
+
resolveTrialMergeAttention,
|
|
6
|
+
shouldRunTrialMerge,
|
|
7
|
+
trialMergeDecision,
|
|
8
|
+
trialMergeWaveFromTaskId,
|
|
9
|
+
} from "./trialMerge.ts";
|
|
4
10
|
|
|
5
11
|
test("trialMergeDecision only escalates clean-merge suite failures", () => {
|
|
6
12
|
assertEquals(trialMergeDecision("clean"), "proceed");
|
|
@@ -14,3 +20,89 @@ test("shouldRunTrialMerge skips lone heads and mergify queues", () => {
|
|
|
14
20
|
assertEquals(shouldRunTrialMerge(2, { land: { method: "mergify-queue" } }), false);
|
|
15
21
|
assertEquals(shouldRunTrialMerge(2, { land: { method: "gh-merge" } }), true);
|
|
16
22
|
});
|
|
23
|
+
|
|
24
|
+
// In-memory `plan_trial_merges` table backing the audit-resolution tests.
|
|
25
|
+
function memData() {
|
|
26
|
+
const rows: any[] = [];
|
|
27
|
+
let nextId = 1;
|
|
28
|
+
const table = {
|
|
29
|
+
async insert(row: any) {
|
|
30
|
+
const r = { id: nextId++, ...row };
|
|
31
|
+
rows.push(r);
|
|
32
|
+
return r.id;
|
|
33
|
+
},
|
|
34
|
+
async find(where: any = {}) {
|
|
35
|
+
return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v)).map((r) => ({ ...r }));
|
|
36
|
+
},
|
|
37
|
+
async update(id: any, patch: any) {
|
|
38
|
+
const r = rows.find((x) => x.id === id);
|
|
39
|
+
if (r) Object.assign(r, patch);
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
const data = { table: () => table } as any;
|
|
43
|
+
return { data, rows, table };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
test("recordTrialMergeAudit supersedes prior rows for the same wave", async () => {
|
|
47
|
+
const { data, rows } = memData();
|
|
48
|
+
// Wave 1 fails, then re-runs clean; wave 2 is independent.
|
|
49
|
+
const first = await recordTrialMergeAudit(data, { planKey: "o/r#1", wave: 1, result: "suite-failed" });
|
|
50
|
+
await recordTrialMergeAudit(data, { planKey: "o/r#1", wave: 2, result: "suite-failed" });
|
|
51
|
+
const rerun = await recordTrialMergeAudit(data, { planKey: "o/r#1", wave: 1, result: "clean" });
|
|
52
|
+
|
|
53
|
+
const byId = (id: number) => rows.find((r) => r.id === id);
|
|
54
|
+
assertEquals(byId(first).resolved, 1, "the superseded wave-1 red row is resolved");
|
|
55
|
+
assertEquals(byId(rerun).resolved, 0, "the fresh wave-1 row stays unresolved");
|
|
56
|
+
// The unrelated wave-2 row is untouched (still needs attention).
|
|
57
|
+
assertEquals(rows.filter((r) => r.wave === 2)[0].resolved, 0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("recordTrialMergeAudit updates a re-reporting job in place without superseding", async () => {
|
|
61
|
+
const { data, rows } = memData();
|
|
62
|
+
const id = await recordTrialMergeAudit(data, { planKey: "o/r#1", wave: 1, result: "suite-failed", jobKey: "j1" });
|
|
63
|
+
const again = await recordTrialMergeAudit(data, { planKey: "o/r#1", wave: 1, result: "clean", jobKey: "j1" });
|
|
64
|
+
assertEquals(again, id, "the same job_key updates its row in place");
|
|
65
|
+
assertEquals(rows.length, 1, "no duplicate/supersede row is created");
|
|
66
|
+
assertEquals(rows[0].result, "clean");
|
|
67
|
+
assertEquals(rows[0].resolved, 0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("recordTrialMergeAudit keeps the wave flagged if the superseding insert fails", async () => {
|
|
71
|
+
const { data, rows, table } = memData();
|
|
72
|
+
await recordTrialMergeAudit(data, { planKey: "o/r#1", wave: 1, result: "suite-failed" });
|
|
73
|
+
// Simulate a crash/failure on the superseding insert. The new row must be
|
|
74
|
+
// inserted BEFORE prior rows are resolved, so a failure here must not leave
|
|
75
|
+
// the wave with zero unresolved rows (which would silently clear "Needs
|
|
76
|
+
// attention").
|
|
77
|
+
table.insert = async () => {
|
|
78
|
+
throw new Error("insert failed");
|
|
79
|
+
};
|
|
80
|
+
await assertRejects(() => recordTrialMergeAudit(data, { planKey: "o/r#1", wave: 1, result: "clean" }));
|
|
81
|
+
const unresolved = rows.filter((r) => r.wave === 1 && r.resolved !== 1);
|
|
82
|
+
assertEquals(unresolved.length, 1, "the wave still has an unresolved row after the failed insert");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("resolveTrialMergeAttention clears every unresolved row for the wave", async () => {
|
|
86
|
+
const { data, rows } = memData();
|
|
87
|
+
await recordTrialMergeAudit(data, { planKey: "o/r#1", wave: 1, result: "suite-failed" });
|
|
88
|
+
await recordTrialMergeAudit(data, { planKey: "o/r#1", wave: 3, result: "suite-failed" });
|
|
89
|
+
const cleared = await resolveTrialMergeAttention(data, "o/r#1", 1);
|
|
90
|
+
assertEquals(cleared, 1);
|
|
91
|
+
assertEquals(rows.filter((r) => r.wave === 1)[0].resolved, 1);
|
|
92
|
+
assertEquals(rows.filter((r) => r.wave === 3)[0].resolved, 0, "another wave is untouched");
|
|
93
|
+
// Idempotent: a second call resolves nothing new.
|
|
94
|
+
assertEquals(await resolveTrialMergeAttention(data, "o/r#1", 1), 0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("trialMergeWaveFromTaskId parses only trial-merge task ids", () => {
|
|
98
|
+
assertEquals(trialMergeWaveFromTaskId("trial-merge-wave-2"), 2);
|
|
99
|
+
assertEquals(trialMergeWaveFromTaskId("trial-merge-wave-0"), 0);
|
|
100
|
+
assertEquals(trialMergeWaveFromTaskId("some-feature-task"), null);
|
|
101
|
+
assertEquals(trialMergeWaveFromTaskId("trial-merge-wave-x"), null);
|
|
102
|
+
// Empty suffix must not silently map to wave 0 (Number("") === 0).
|
|
103
|
+
assertEquals(trialMergeWaveFromTaskId("trial-merge-wave-"), null);
|
|
104
|
+
// Non-integer / signed / whitespace suffixes are rejected.
|
|
105
|
+
assertEquals(trialMergeWaveFromTaskId("trial-merge-wave-1.5"), null);
|
|
106
|
+
assertEquals(trialMergeWaveFromTaskId("trial-merge-wave-+2"), null);
|
|
107
|
+
assertEquals(trialMergeWaveFromTaskId("trial-merge-wave-12"), 12);
|
|
108
|
+
});
|
package/app/trialMerge.ts
CHANGED
|
@@ -28,6 +28,7 @@ export interface TrialMergeAuditRow {
|
|
|
28
28
|
failing: string | null;
|
|
29
29
|
summary: string | null;
|
|
30
30
|
job_key: string | null;
|
|
31
|
+
resolved: number;
|
|
31
32
|
created_at: string;
|
|
32
33
|
updated_at: string;
|
|
33
34
|
}
|
|
@@ -46,6 +47,17 @@ export function trialMergeTaskId(wave: number): string {
|
|
|
46
47
|
return `${TRIAL_MERGE_TASK_PREFIX}${Math.max(0, Math.trunc(wave))}`;
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
/** Inverse of {@link trialMergeTaskId}: the wave a trial-merge escalation
|
|
51
|
+
* `task_id` refers to, or `null` when `taskId` is not a trial-merge escalation
|
|
52
|
+
* (e.g. an ordinary feature escalation). */
|
|
53
|
+
export function trialMergeWaveFromTaskId(taskId: string): number | null {
|
|
54
|
+
if (!taskId.startsWith(TRIAL_MERGE_TASK_PREFIX)) return null;
|
|
55
|
+
const suffix = taskId.slice(TRIAL_MERGE_TASK_PREFIX.length);
|
|
56
|
+
if (!/^\d+$/.test(suffix)) return null;
|
|
57
|
+
const wave = Number(suffix);
|
|
58
|
+
return Number.isInteger(wave) && wave >= 0 ? wave : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
49
61
|
const auditTable = (data: DataLayer) => data.table<TrialMergeAuditRow>("plan_trial_merges", "id");
|
|
50
62
|
|
|
51
63
|
function jsonOrNull(v: unknown): string | null {
|
|
@@ -76,6 +88,8 @@ export async function recordTrialMergeAudit(
|
|
|
76
88
|
if (jobKey) {
|
|
77
89
|
const existing = (await table.find({ plan_key: row.planKey, job_key: jobKey })).sort((a, b) => b.id - a.id)[0];
|
|
78
90
|
if (existing) {
|
|
91
|
+
// Same job re-reporting (a retry before its wait subscription opened):
|
|
92
|
+
// update in place — it is the same logical attempt, not a supersede.
|
|
79
93
|
await table.update(existing.id, {
|
|
80
94
|
result: row.result,
|
|
81
95
|
heads: jsonOrNull(row.heads),
|
|
@@ -87,16 +101,57 @@ export async function recordTrialMergeAudit(
|
|
|
87
101
|
return existing.id;
|
|
88
102
|
}
|
|
89
103
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
104
|
+
// A fresh audit row for this wave supersedes every prior row for the same
|
|
105
|
+
// (plan_key, wave): those are now history, so mark them resolved. Without this
|
|
106
|
+
// the append-only log leaves an old red row in the page's "Needs attention"
|
|
107
|
+
// tab forever, even after the wave was re-run clean (issue: the tab never
|
|
108
|
+
// cleared). The newly-inserted row defaults `resolved = 0`, so a still-red
|
|
109
|
+
// latest attempt keeps showing until it too is superseded or answered.
|
|
110
|
+
//
|
|
111
|
+
// Insert the new (unresolved) row FIRST, then resolve the older rows — never
|
|
112
|
+
// the reverse. Resolving priors before the insert would leave the wave with
|
|
113
|
+
// zero unresolved rows if the insert (or the process) failed in between,
|
|
114
|
+
// silently clearing the "Needs attention" tab. Insert-first guarantees the
|
|
115
|
+
// wave always has at least one unresolved row through the transition.
|
|
116
|
+
const id = Number(
|
|
117
|
+
await table.insert({
|
|
118
|
+
plan_key: row.planKey,
|
|
119
|
+
wave: row.wave,
|
|
120
|
+
result: row.result,
|
|
121
|
+
heads: jsonOrNull(row.heads),
|
|
122
|
+
conflicts: jsonOrNull(row.conflicts),
|
|
123
|
+
failing: jsonOrNull(row.failing),
|
|
124
|
+
summary: row.summary ?? null,
|
|
125
|
+
job_key: jobKey,
|
|
126
|
+
resolved: 0,
|
|
127
|
+
created_at: ts,
|
|
128
|
+
updated_at: ts,
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
for (const prior of await table.find({ plan_key: row.planKey, wave: row.wave })) {
|
|
132
|
+
if (prior.id !== id && prior.resolved !== 1) await table.update(prior.id, { resolved: 1, updated_at: ts });
|
|
133
|
+
}
|
|
134
|
+
return id;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Mark every trial-merge audit row for `(planKey, wave)` resolved, so the epic
|
|
138
|
+
* page's "Needs attention" tab stops surfacing it. Called when the wave's trial
|
|
139
|
+
* escalation is answered — including a "proceed" override that records no
|
|
140
|
+
* re-run row and so would otherwise leave the old red row pinned forever.
|
|
141
|
+
* Returns the number of rows newly resolved. */
|
|
142
|
+
export async function resolveTrialMergeAttention(
|
|
143
|
+
data: DataLayer,
|
|
144
|
+
planKey: string,
|
|
145
|
+
wave: number,
|
|
146
|
+
): Promise<number> {
|
|
147
|
+
const table = auditTable(data);
|
|
148
|
+
const ts = now();
|
|
149
|
+
let resolved = 0;
|
|
150
|
+
for (const r of await table.find({ plan_key: planKey, wave })) {
|
|
151
|
+
if (r.resolved !== 1) {
|
|
152
|
+
await table.update(r.id, { resolved: 1, updated_at: ts });
|
|
153
|
+
resolved++;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return resolved;
|
|
102
157
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
-- Durable "needs attention" resolution for the trial-merge audit log (issue: the
|
|
2
|
+
-- epic page's "Needs attention" tab never cleared).
|
|
3
|
+
--
|
|
4
|
+
-- `plan_trial_merges` is an append-only audit trail: a re-run after a suite
|
|
5
|
+
-- failure INSERTs a fresh row but never supersedes the old red one, so a
|
|
6
|
+
-- `merge-conflict`/`suite-failed` row stayed in "Needs attention" forever even
|
|
7
|
+
-- after the escalation was answered and the wave re-run clean. Add an explicit
|
|
8
|
+
-- `resolved` flag so the page can hide history, and backfill it for existing
|
|
9
|
+
-- rows. Going forward `recordTrialMergeAudit` marks prior same-(plan,wave) rows
|
|
10
|
+
-- resolved on each new insert (supersede-on-insert).
|
|
11
|
+
--
|
|
12
|
+
-- NB: the migration runner wraps each file in its own transaction — this file
|
|
13
|
+
-- must NOT contain BEGIN/COMMIT.
|
|
14
|
+
|
|
15
|
+
ALTER TABLE plan_trial_merges ADD COLUMN resolved INTEGER NOT NULL DEFAULT 0;
|
|
16
|
+
|
|
17
|
+
-- Backfill 1 (supersede): any audit row that has a NEWER row (higher id) for the
|
|
18
|
+
-- same (plan_key, wave) is superseded history — resolve it. The newest row per
|
|
19
|
+
-- wave stays unresolved so a still-red latest attempt keeps showing.
|
|
20
|
+
UPDATE plan_trial_merges
|
|
21
|
+
SET resolved = 1
|
|
22
|
+
WHERE EXISTS (
|
|
23
|
+
SELECT 1 FROM plan_trial_merges AS newer
|
|
24
|
+
WHERE newer.plan_key = plan_trial_merges.plan_key
|
|
25
|
+
AND newer.wave = plan_trial_merges.wave
|
|
26
|
+
AND newer.id > plan_trial_merges.id
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
-- Backfill 2 (answered): a red (needs-attention) row whose trial escalation has
|
|
30
|
+
-- already been answered is resolved — even if no re-run row was ever recorded
|
|
31
|
+
-- (e.g. the operator answered "proceed"/override). The trial escalation's
|
|
32
|
+
-- task_id is 'trial-merge-wave-<wave>' (see app/trialMerge.ts trialMergeTaskId).
|
|
33
|
+
UPDATE plan_trial_merges
|
|
34
|
+
SET resolved = 1
|
|
35
|
+
WHERE result IN ('merge-conflict', 'suite-failed')
|
|
36
|
+
AND EXISTS (
|
|
37
|
+
SELECT 1 FROM plan_escalations AS e
|
|
38
|
+
WHERE e.plan_key = plan_trial_merges.plan_key
|
|
39
|
+
AND e.task_id = 'trial-merge-wave-' || plan_trial_merges.wave
|
|
40
|
+
AND e.status = 'answered'
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
CREATE INDEX IF NOT EXISTS idx_plan_trial_merges_attention
|
|
44
|
+
ON plan_trial_merges(plan_key, resolved, result);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.46.
|
|
3
|
+
"version": "0.46.2",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
package/pages/epic.page.json
CHANGED
|
@@ -273,12 +273,18 @@
|
|
|
273
273
|
"source": "app",
|
|
274
274
|
"table": "plan_trial_merges",
|
|
275
275
|
"orderBy": { "field": "created_at", "dir": "desc" },
|
|
276
|
-
"filter": [
|
|
276
|
+
"filter": [
|
|
277
|
+
{ "field": "result", "in": ["merge-conflict", "suite-failed"] },
|
|
278
|
+
{ "field": "resolved", "in": [0] }
|
|
279
|
+
]
|
|
277
280
|
},
|
|
278
281
|
"tabs": [
|
|
279
282
|
{
|
|
280
283
|
"label": "Needs attention",
|
|
281
|
-
"filter": [
|
|
284
|
+
"filter": [
|
|
285
|
+
{ "field": "result", "in": ["merge-conflict", "suite-failed"] },
|
|
286
|
+
{ "field": "resolved", "in": [0] }
|
|
287
|
+
]
|
|
282
288
|
},
|
|
283
289
|
{
|
|
284
290
|
"label": "Clean",
|
package/prompts/fix-ci.md
CHANGED
|
@@ -84,3 +84,48 @@ Return a structured result:
|
|
|
84
84
|
Never report `fixed` unless you actually pushed a change. If nothing was wrong on
|
|
85
85
|
the branch (the failure was transient infrastructure), say so in `summary` and
|
|
86
86
|
return `blocked` so a human can decide whether to just retry the merge.
|
|
87
|
+
|
|
88
|
+
### How to return it (the wire mechanism)
|
|
89
|
+
|
|
90
|
+
Your result variables only reach the process if you emit them through the harness's
|
|
91
|
+
result channel. Prose in your normal output is **not** parsed — if you only "say"
|
|
92
|
+
your status in the transcript, the process can't read it, falls back to its safe
|
|
93
|
+
default (a merge escalation a human must clear), and the merge stalls. So emit a
|
|
94
|
+
machine-readable result one of two ways:
|
|
95
|
+
|
|
96
|
+
1. **Write a JSON object to the file at `$AGENT_RESULT_FILE`** (an env var the
|
|
97
|
+
harness sets for you). The object's keys become process variables. Examples:
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
# pushed a fix you believe turns the failing checks green:
|
|
101
|
+
printf '%s' '{"status":"fixed","summary":"Fixed the flaky timeout in auth.test.ts and pushed"}' > "$AGENT_RESULT_FILE"
|
|
102
|
+
# ordering constraint — must wait for another PR to land first:
|
|
103
|
+
printf '%s' '{"status":"waiting-on-pr","summary":"Blocked by the linked-issue gate","dependsOn":"owner/repo#123"}' > "$AGENT_RESULT_FILE"
|
|
104
|
+
# genuinely stuck — a human must decide:
|
|
105
|
+
printf '%s' '{"status":"blocked","summary":"CI needs an NPM_TOKEN secret I cannot set","question":"Add the NPM_TOKEN repo secret, then answer to rerun."}' > "$AGENT_RESULT_FILE"
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Write this file **once**, at the very end, with your final result. Keep it a flat
|
|
109
|
+
JSON object of exactly the variables named in the return contract above.
|
|
110
|
+
|
|
111
|
+
2. **Fallback** (only if you truly cannot write the file): print a single line to
|
|
112
|
+
stdout of the form `::nano:result:: {json}` — e.g.
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
::nano:result:: {"status":"fixed","summary":"Corrected the type error in handler.ts and pushed"}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The harness reads the **last** such line. A trailing fenced JSON code block is also
|
|
119
|
+
accepted as a last resort.
|
|
120
|
+
|
|
121
|
+
Do not put the result file inside the repo checkout or `git add` it — it lives
|
|
122
|
+
outside your workspace. Exit `0` for every status (including `blocked`/`waiting-on-pr`);
|
|
123
|
+
a non-zero exit means a genuine crash and the job is retried.
|
|
124
|
+
|
|
125
|
+
**Emitting a machine-readable result is your mandatory final step — never exit
|
|
126
|
+
silently.** It is the last thing you do on every path out of this job (including after
|
|
127
|
+
a push, or when you conclude nothing can be fixed). If you are ever unsure which status
|
|
128
|
+
applies, return **`blocked`** with a `summary` and a concrete `question` rather than
|
|
129
|
+
leaving without a result — a missing result is treated as an unclassified merge
|
|
130
|
+
escalation that pulls in a human and stalls the merge, so relying on that default
|
|
131
|
+
wastes the attempt.
|
package/prompts/rebase.md
CHANGED
|
@@ -100,3 +100,48 @@ was needed, so the process simply re-attempts the merge; the rebase budget
|
|
|
100
100
|
bounds how many times a still-stuck PR can loop here before it escalates. Reserve
|
|
101
101
|
`blocked` for a genuine semantic conflict you cannot resolve mechanically (or a
|
|
102
102
|
branch that is un-rebaseable), so a human can decide.
|
|
103
|
+
|
|
104
|
+
### How to return it (the wire mechanism)
|
|
105
|
+
|
|
106
|
+
Your result variables only reach the process if you emit them through the harness's
|
|
107
|
+
result channel. Prose in your normal output is **not** parsed — if you only "say"
|
|
108
|
+
your status in the transcript, the process can't read it, falls back to its safe
|
|
109
|
+
default (a merge escalation a human must clear), and the merge stalls. So emit a
|
|
110
|
+
machine-readable result one of two ways:
|
|
111
|
+
|
|
112
|
+
1. **Write a JSON object to the file at `$AGENT_RESULT_FILE`** (an env var the
|
|
113
|
+
harness sets for you). The object's keys become process variables. Examples:
|
|
114
|
+
|
|
115
|
+
```sh
|
|
116
|
+
# branch tip now contains the latest base (you pushed a resolved rebase, or it was already up to date):
|
|
117
|
+
printf '%s' '{"status":"rebased","summary":"Rebased onto main, resolved 2 conflicts in router.ts, pushed"}' > "$AGENT_RESULT_FILE"
|
|
118
|
+
# ordering constraint — must wait for another PR to land first:
|
|
119
|
+
printf '%s' '{"status":"waiting-on-pr","summary":"Stacked on the base PR that has not merged","dependsOn":"owner/repo#123"}' > "$AGENT_RESULT_FILE"
|
|
120
|
+
# genuine semantic conflict — a human must decide which behaviour wins:
|
|
121
|
+
printf '%s' '{"status":"blocked","summary":"main and this branch both rewrote retry() incompatibly","question":"Should retries stay capped at 3 (main) or become unbounded (this PR)?"}' > "$AGENT_RESULT_FILE"
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Write this file **once**, at the very end, with your final result. Keep it a flat
|
|
125
|
+
JSON object of exactly the variables named in the return contract above.
|
|
126
|
+
|
|
127
|
+
2. **Fallback** (only if you truly cannot write the file): print a single line to
|
|
128
|
+
stdout of the form `::nano:result:: {json}` — e.g.
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
::nano:result:: {"status":"rebased","summary":"Already up to date; no push needed"}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The harness reads the **last** such line. A trailing fenced JSON code block is also
|
|
135
|
+
accepted as a last resort.
|
|
136
|
+
|
|
137
|
+
Do not put the result file inside the repo checkout or `git add` it — it lives
|
|
138
|
+
outside your workspace. Exit `0` for every status (including `blocked`/`waiting-on-pr`);
|
|
139
|
+
a non-zero exit means a genuine crash and the job is retried.
|
|
140
|
+
|
|
141
|
+
**Emitting a machine-readable result is your mandatory final step — never exit
|
|
142
|
+
silently.** It is the last thing you do on every path out of this job (including after
|
|
143
|
+
a force-push, or when the branch was already up to date). If you are ever unsure which
|
|
144
|
+
status applies and the branch tip contains the latest base, return **`rebased`** with a
|
|
145
|
+
`summary`; otherwise return **`blocked`** with a concrete `question` — never leave
|
|
146
|
+
without a result. A missing result is treated as an unclassified merge escalation that
|
|
147
|
+
pulls in a human and stalls the merge, so relying on that default wastes the attempt.
|
package/prompts/review-round.md
CHANGED
|
@@ -181,7 +181,7 @@ default, and you waste a round. So emit a machine-readable result one of two way
|
|
|
181
181
|
::nano:result:: {"status":"converged","summary":"No actionable comments left"}
|
|
182
182
|
```
|
|
183
183
|
|
|
184
|
-
The harness reads the **last** such line. A trailing
|
|
184
|
+
The harness reads the **last** such line. A trailing fenced JSON code block is also
|
|
185
185
|
accepted as a last resort.
|
|
186
186
|
|
|
187
187
|
Do not put the result file inside the repo checkout or `git add` it — it lives
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:zeebe="http://camunda.org/schema/zeebe/1.0" id="Definitions_nano_workforce_plan" targetNamespace="http://nanobpm.io/nano-workforce">
|
|
3
3
|
<bpmn:message id="Message_featureEscalationAnswered" name="feature-escalation-answered">
|
|
4
4
|
<bpmn:extensionElements>
|
|
5
|
-
<zeebe:subscription correlationKey="=
|
|
5
|
+
<zeebe:subscription correlationKey="=escalationCorrKey" />
|
|
6
6
|
</bpmn:extensionElements>
|
|
7
7
|
</bpmn:message>
|
|
8
8
|
<bpmn:message id="Message_planEscalationAnswered" name="plan-escalation-answered">
|
|
@@ -30,11 +30,11 @@ function fixture(files: Record<string, string>): string {
|
|
|
30
30
|
return root;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
test("passes when every {{token}} resolves to a non-blank template", async () => {
|
|
33
|
+
test("passes when every {{token}} resolves to a non-blank template that emits a result", async () => {
|
|
34
34
|
const root = await fixture({
|
|
35
35
|
"nano.app.json": MANIFEST,
|
|
36
36
|
"resources/processes/loop.bpmn": header("{{review-round}}"),
|
|
37
|
-
"prompts/review-round.md": "# Round\nDo the thing
|
|
37
|
+
"prompts/review-round.md": "# Round\nDo the thing, then write your result to `$AGENT_RESULT_FILE`.",
|
|
38
38
|
});
|
|
39
39
|
const res = checkAgentPrompts(root);
|
|
40
40
|
assertEquals(res.errors, []);
|
|
@@ -75,6 +75,31 @@ test("fails when a reserved agent-prompt header is blank", async () => {
|
|
|
75
75
|
assert(res.errors.some((e) => e.includes("is empty")));
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
+
test("fails when an agent-prompt template omits the machine-readable result mechanism", async () => {
|
|
79
|
+
// A prompt wired as an agent's base prompt must tell it to write $AGENT_RESULT_FILE (or use the
|
|
80
|
+
// ::nano:result:: fallback). Without it the agent finishes with prose only, `status` comes back
|
|
81
|
+
// blank, and the status gateway escalates/stalls — the fix-ci/rebase gap behind #746's stuck merge.
|
|
82
|
+
const root = await fixture({
|
|
83
|
+
"nano.app.json": MANIFEST,
|
|
84
|
+
"resources/processes/loop.bpmn": header("{{review-round}}"),
|
|
85
|
+
"prompts/review-round.md": "# Round\nReturn status: converged. (but never says how to emit it)",
|
|
86
|
+
});
|
|
87
|
+
const res = checkAgentPrompts(root);
|
|
88
|
+
assert(!res.ok);
|
|
89
|
+
assert(res.errors.some((e) => e.includes("{{review-round}}") && e.includes("AGENT_RESULT_FILE")));
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("passes when an agent-prompt template emits via the ::nano:result:: fallback", async () => {
|
|
93
|
+
const root = await fixture({
|
|
94
|
+
"nano.app.json": MANIFEST,
|
|
95
|
+
"resources/processes/loop.bpmn": header("{{review-round}}"),
|
|
96
|
+
"prompts/review-round.md": "# Round\nEmit `::nano:result:: {\"status\":\"converged\"}` at the end.",
|
|
97
|
+
});
|
|
98
|
+
const res = checkAgentPrompts(root);
|
|
99
|
+
assertEquals(res.errors, []);
|
|
100
|
+
assert(res.ok);
|
|
101
|
+
});
|
|
102
|
+
|
|
78
103
|
test("checks the real repo: all committed agent prompts resolve", () => {
|
|
79
104
|
// The guard must be green against the actual app it protects — this is the case CI relies on.
|
|
80
105
|
const repoRoot = decodeURIComponent(new URL("../", import.meta.url).pathname);
|
|
@@ -82,7 +107,7 @@ test("checks the real repo: all committed agent prompts resolve", () => {
|
|
|
82
107
|
assertEquals(res.errors, []);
|
|
83
108
|
assert(res.ok);
|
|
84
109
|
// Every senior:* agent prompt header in the three processes must have resolved.
|
|
85
|
-
for (const t of ["review-round", "fix-ci", "plan", "plan-review", "feature", "trial-merge"]) {
|
|
110
|
+
for (const t of ["review-round", "fix-ci", "plan", "plan-review", "feature", "trial-merge", "rebase", "retro"]) {
|
|
86
111
|
assert(res.resolved.includes(t), `expected template ${t} to resolve`);
|
|
87
112
|
}
|
|
88
113
|
});
|
|
@@ -72,6 +72,33 @@ function hasBlankAgentPromptHeader(bpmn: string): boolean {
|
|
|
72
72
|
return false;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
// The template tokens a model wires as an agent's base prompt, e.g. the `fix-ci` in
|
|
76
|
+
// `value="{{fix-ci}}"` on an `io.nanobpm.agentTask.task.prompt` header. These templates *drive an
|
|
77
|
+
// agent*, so each must teach it to emit a machine-readable result (see agentPromptEmitsResult).
|
|
78
|
+
function agentPromptTokens(bpmn: string): string[] {
|
|
79
|
+
const tokens: string[] = [];
|
|
80
|
+
const re = /<zeebe:header\s+key="([^"]*)"\s+value="([^"]*)"\s*\/?>/g;
|
|
81
|
+
let m = re.exec(bpmn);
|
|
82
|
+
while (m !== null) {
|
|
83
|
+
if (m[1] === AGENT_PROMPT_HEADER) {
|
|
84
|
+
const tok = /^\{\{\s*([^}]+?)\s*\}\}$/.exec(m[2].trim());
|
|
85
|
+
if (tok) tokens.push(tok[1]);
|
|
86
|
+
}
|
|
87
|
+
m = re.exec(bpmn);
|
|
88
|
+
}
|
|
89
|
+
return tokens;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// A prompt that drives an agent must tell it how to return a machine-readable result — the
|
|
93
|
+
// `$AGENT_RESULT_FILE` write (or the `::nano:result::` stdout fallback). Without it the agent can
|
|
94
|
+
// finish with prose only, its `status` variable comes back empty, the status gateway falls through
|
|
95
|
+
// to its default escalation arm, and the run parks a human escalation / stalls the merge (the
|
|
96
|
+
// fix-ci/rebase gap behind Magikcraft/nano-bpm#746's stuck merge). Prose is never parsed, so this
|
|
97
|
+
// instruction is load-bearing, not documentation.
|
|
98
|
+
function agentPromptEmitsResult(body: string): boolean {
|
|
99
|
+
return body.includes("AGENT_RESULT_FILE") || body.includes("::nano:result::");
|
|
100
|
+
}
|
|
101
|
+
|
|
75
102
|
export interface CheckResult {
|
|
76
103
|
ok: boolean;
|
|
77
104
|
errors: string[];
|
|
@@ -82,6 +109,7 @@ export interface CheckResult {
|
|
|
82
109
|
export function checkAgentPrompts(root: string): CheckResult {
|
|
83
110
|
const errors: string[] = [];
|
|
84
111
|
const resolved = new Set<string>();
|
|
112
|
+
const agentTokens = new Set<string>();
|
|
85
113
|
|
|
86
114
|
const manifestPath = join(root, "nano.app.json");
|
|
87
115
|
if (!existsSync(manifestPath)) {
|
|
@@ -127,6 +155,20 @@ export function checkAgentPrompts(root: string): CheckResult {
|
|
|
127
155
|
if (hasBlankAgentPromptHeader(content)) {
|
|
128
156
|
errors.push(`${rel}: a reserved "${AGENT_PROMPT_HEADER}" header is empty (agent would run prompt-less)`);
|
|
129
157
|
}
|
|
158
|
+
for (const tok of agentPromptTokens(content)) agentTokens.add(tok);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Every template wired as an agent's base prompt must teach the agent to emit a machine-readable
|
|
162
|
+
// result; a prose-only agent leaves `status` blank and the process escalates/stalls.
|
|
163
|
+
for (const tok of [...agentTokens].sort()) {
|
|
164
|
+
const body = templates[tok];
|
|
165
|
+
if (body != null && body.trim() !== "" && !agentPromptEmitsResult(body)) {
|
|
166
|
+
errors.push(
|
|
167
|
+
`template {{${tok}}} drives an agent but never tells it to write $AGENT_RESULT_FILE ` +
|
|
168
|
+
`(or the ::nano:result:: fallback) — the agent can finish with prose only, leaving its ` +
|
|
169
|
+
`status blank so the process escalates/stalls`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
130
172
|
}
|
|
131
173
|
|
|
132
174
|
return { ok: errors.length === 0, errors, resolved: [...resolved].sort() };
|
|
@@ -36,6 +36,7 @@ interface In extends Record<string, unknown> {
|
|
|
36
36
|
}
|
|
37
37
|
interface Out extends Record<string, unknown> {
|
|
38
38
|
escalationId: number;
|
|
39
|
+
escalationCorrKey: string;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
// A non-blank trimmed string, else undefined. A blank question/PR must not reach
|
|
@@ -106,7 +107,14 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
await refreshOpenTaskEscalation(app.data, planKey);
|
|
109
|
-
|
|
110
|
+
// Emit the correlation key as a single scalar so the downstream
|
|
111
|
+
// `feature-escalation-answered` catch subscribes on `=escalationCorrKey`
|
|
112
|
+
// (freshly set, in scope) rather than re-deriving `=planKey + ":" + task.id`.
|
|
113
|
+
// The concatenation form errored to an empty, unmatchable key whenever `task`
|
|
114
|
+
// was not a Map at subscription-open, parking the token forever (see the
|
|
115
|
+
// engine incident fix). The value is identical to `featureCorrKey(planKey,
|
|
116
|
+
// taskId)`, so the app's answer-publish path still matches.
|
|
117
|
+
return { escalationId, escalationCorrKey: corrKey };
|
|
110
118
|
};
|
|
111
119
|
|
|
112
120
|
export default handler;
|