@nanobpm/nano-workforce 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +60 -0
- package/.github/workflows/release.yml +58 -0
- package/.releaserc.json +17 -0
- package/AGENTS.md +168 -0
- package/CHANGELOG.md +231 -0
- package/LICENSE +202 -0
- package/README.md +303 -0
- package/SPEC.md +492 -0
- package/actions/abandon.test.ts +93 -0
- package/actions/abandon.ts +23 -0
- package/actions/blackboard.test.ts +195 -0
- package/actions/blackboard.ts +76 -0
- package/actions/cancel.ts +29 -0
- package/actions/feature-answer-hook.ts +44 -0
- package/actions/message.ts +49 -0
- package/actions/plan-hook.ts +19 -0
- package/actions/plan-start.ts +17 -0
- package/actions/start.ts +19 -0
- package/actions/status.ts +22 -0
- package/actions/webhook-submit.ts +21 -0
- package/app/abandon.test.ts +97 -0
- package/app/abandon.ts +105 -0
- package/app/baseGuard.test.ts +35 -0
- package/app/baseGuard.ts +62 -0
- package/app/blackboard.test.ts +295 -0
- package/app/blackboard.ts +301 -0
- package/app/github.test.ts +59 -0
- package/app/github.ts +647 -0
- package/app/mergeExclusion.test.ts +168 -0
- package/app/mergeExclusion.ts +211 -0
- package/app/mergeProtocol.test.ts +124 -0
- package/app/mergeProtocol.ts +193 -0
- package/app/mergeRebaseArm.test.ts +72 -0
- package/app/mergeTrain.test.ts +91 -0
- package/app/mergeTrain.ts +117 -0
- package/app/persist-escalation.test.ts +119 -0
- package/app/persist-round.test.ts +65 -0
- package/app/plan.test.ts +317 -0
- package/app/plan.ts +321 -0
- package/app/record-plan-review.test.ts +38 -0
- package/app/reviewWait.test.ts +70 -0
- package/app/reviewWait.ts +59 -0
- package/app/rounds.test.ts +74 -0
- package/app/rounds.ts +48 -0
- package/app/service.test.ts +101 -0
- package/app/service.ts +895 -0
- package/app/taskDelta.test.ts +144 -0
- package/app/taskDelta.ts +175 -0
- package/app/trialMerge.test.ts +15 -0
- package/app/trialMerge.ts +102 -0
- package/app/waves.test.ts +128 -0
- package/app/waves.ts +116 -0
- package/assets/icon.svg +13 -0
- package/components/review-round.json +69 -0
- package/db/migrations/001_init.sql +46 -0
- package/db/migrations/002_transcript.sql +7 -0
- package/db/migrations/003_open_escalation.sql +8 -0
- package/db/migrations/004_merge.sql +36 -0
- package/db/migrations/004_planning.sql +37 -0
- package/db/migrations/005_job_activation.sql +15 -0
- package/db/migrations/005_plan_deps.sql +20 -0
- package/db/migrations/006_plan_review.sql +22 -0
- package/db/migrations/006_task_escalation.sql +52 -0
- package/db/migrations/007_plan_review_job_key.sql +14 -0
- package/db/migrations/007_wave_gate.sql +16 -0
- package/db/migrations/008_review_nudge.sql +9 -0
- package/db/migrations/009_plan_blackboard.sql +46 -0
- package/db/migrations/010_plan_task_deltas.sql +27 -0
- package/db/migrations/011_plan_merge_exclusions.sql +26 -0
- package/db/migrations/012_merge_protocol_attempt.sql +4 -0
- package/db/migrations/013_merge_train_waiting_lane.sql +6 -0
- package/db/migrations/014_plan_trial_merges.sql +21 -0
- package/db/migrations/015_pr_abandon_token.sql +9 -0
- package/deno.json +24 -0
- package/deno.lock +1776 -0
- package/main.ts +71 -0
- package/nano-ide.ext.json +7 -0
- package/nano.app.json +138 -0
- package/nanobpm.project.json +20 -0
- package/package.json +56 -0
- package/pages/epic.page.json +195 -0
- package/pages/home.page.json +296 -0
- package/prompts/feature.md +132 -0
- package/prompts/fix-ci.md +65 -0
- package/prompts/plan-review.md +69 -0
- package/prompts/plan.md +183 -0
- package/prompts/rebase.md +82 -0
- package/prompts/review-round.md +171 -0
- package/prompts/trial-merge.md +43 -0
- package/renovate.json +21 -0
- package/resources/processes/convergence-loop.bpmn +399 -0
- package/resources/processes/merge-loop.bpmn +585 -0
- package/resources/processes/plan-fanout.bpmn +546 -0
- package/scripts/check-agent-prompts.test.ts +84 -0
- package/scripts/check-agent-prompts.ts +143 -0
- package/scripts/layout-bpmn.ts +99 -0
- package/scripts/purge-db.ts +57 -0
- package/scripts/upgrade-from-pack.ts +334 -0
- package/tsconfig.json +51 -0
- package/workers/arm-merge/worker.ts +18 -0
- package/workers/finalize/worker.ts +89 -0
- package/workers/mark-merged/worker.ts +21 -0
- package/workers/merge/worker.ts +119 -0
- package/workers/persist-escalation/worker.ts +107 -0
- package/workers/persist-round/worker.ts +52 -0
- package/workers/persist-task-escalation/worker.ts +112 -0
- package/workers/record-plan/worker.ts +135 -0
- package/workers/record-plan-review/worker.ts +92 -0
- package/workers/record-results/worker.ts +30 -0
- package/workers/record-trial-merge/worker.test.ts +104 -0
- package/workers/record-trial-merge/worker.ts +88 -0
- package/workers/record-wave/worker.test.ts +221 -0
- package/workers/record-wave/worker.ts +308 -0
- package/workers/select-wave/worker.test.ts +130 -0
- package/workers/select-wave/worker.ts +84 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Unit tests for the structured scope/impl-change report (D5, issue #55 / #49).
|
|
2
|
+
import { assert, assertEquals } from "jsr:@std/assert@1";
|
|
3
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
4
|
+
import {
|
|
5
|
+
aggregateEpicDeltas,
|
|
6
|
+
clearTaskDeltas,
|
|
7
|
+
parseTaskDelta,
|
|
8
|
+
readTaskDeltas,
|
|
9
|
+
recordTaskDelta,
|
|
10
|
+
} from "./taskDelta.ts";
|
|
11
|
+
|
|
12
|
+
// A tiny in-memory stand-in for the record gateway (insert/find/findOne/update/delete), mirroring
|
|
13
|
+
// the fake-app style used across the app tests (see app/blackboard.test.ts).
|
|
14
|
+
// deno-lint-ignore no-explicit-any
|
|
15
|
+
function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
16
|
+
// deno-lint-ignore no-explicit-any
|
|
17
|
+
const stores: Record<string, any[]> = {};
|
|
18
|
+
const seq: Record<string, number> = {};
|
|
19
|
+
function tbl(name: string, pk = "id") {
|
|
20
|
+
// deno-lint-ignore no-explicit-any
|
|
21
|
+
const rows = (stores[name] ??= [] as any[]);
|
|
22
|
+
// deno-lint-ignore no-explicit-any
|
|
23
|
+
const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
|
|
24
|
+
return {
|
|
25
|
+
// deno-lint-ignore no-explicit-any require-await
|
|
26
|
+
async insert(row: any) {
|
|
27
|
+
const id = (seq[name] = (seq[name] ?? 0) + 1);
|
|
28
|
+
rows.push(pk === "id" ? { id, ...row } : { ...row });
|
|
29
|
+
return pk === "id" ? id : row[pk];
|
|
30
|
+
},
|
|
31
|
+
// deno-lint-ignore no-explicit-any require-await
|
|
32
|
+
async find(where: any = {}) {
|
|
33
|
+
return rows.filter((r) => match(r, where));
|
|
34
|
+
},
|
|
35
|
+
// deno-lint-ignore no-explicit-any require-await
|
|
36
|
+
async findOne(where: any = {}) {
|
|
37
|
+
return rows.find((r) => match(r, where));
|
|
38
|
+
},
|
|
39
|
+
// deno-lint-ignore no-explicit-any require-await
|
|
40
|
+
async update(id: any, patch: any) {
|
|
41
|
+
const r = rows.find((row) => row[pk] === id);
|
|
42
|
+
if (r) Object.assign(r, patch);
|
|
43
|
+
},
|
|
44
|
+
// deno-lint-ignore no-explicit-any require-await
|
|
45
|
+
async delete(id: any) {
|
|
46
|
+
const i = rows.findIndex((row) => row[pk] === id);
|
|
47
|
+
if (i >= 0) rows.splice(i, 1);
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
// deno-lint-ignore no-explicit-any
|
|
52
|
+
const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
|
|
53
|
+
return { data, stores };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
Deno.test("parseTaskDelta: trims, dedupes arrays, and drops empties", () => {
|
|
57
|
+
const d = parseTaskDelta({
|
|
58
|
+
contractChange: " new signature ",
|
|
59
|
+
newlyTouches: ["a.rs", " a.rs ", "", "b.rs"],
|
|
60
|
+
affectsTasks: ["gap-8", "gap-8"],
|
|
61
|
+
constraint: "",
|
|
62
|
+
});
|
|
63
|
+
assert(d);
|
|
64
|
+
assertEquals(d.contractChange, "new signature");
|
|
65
|
+
assertEquals(d.newlyTouches, ["a.rs", "b.rs"], "trimmed + de-duplicated");
|
|
66
|
+
assertEquals(d.affectsTasks, ["gap-8"]);
|
|
67
|
+
assertEquals(d.constraint, undefined, "a blank constraint is dropped");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
Deno.test("parseTaskDelta: a delta with nothing actionable is null", () => {
|
|
71
|
+
assertEquals(parseTaskDelta(undefined), null);
|
|
72
|
+
assertEquals(parseTaskDelta({}), null);
|
|
73
|
+
assertEquals(parseTaskDelta({ newlyTouches: [], affectsTasks: [], contractChange: " " }), null);
|
|
74
|
+
assertEquals(parseTaskDelta("not an object"), null);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
Deno.test("recordTaskDelta: upserts per (plan, task) — a resume overwrites, not duplicates", async () => {
|
|
78
|
+
const { data, stores } = memData();
|
|
79
|
+
const first = await recordTaskDelta(data, "o/r#1", "gap-2", {
|
|
80
|
+
newlyTouches: ["a.rs"],
|
|
81
|
+
affectsTasks: [],
|
|
82
|
+
contractChange: "v1",
|
|
83
|
+
}, { wave: 0 });
|
|
84
|
+
assertEquals(first.inserted, true);
|
|
85
|
+
|
|
86
|
+
const second = await recordTaskDelta(data, "o/r#1", "gap-2", {
|
|
87
|
+
newlyTouches: ["a.rs", "b.rs"],
|
|
88
|
+
affectsTasks: ["gap-9"],
|
|
89
|
+
constraint: "seeded",
|
|
90
|
+
}, { wave: 1 });
|
|
91
|
+
assertEquals(second.inserted, false, "same (plan, task) → update in place");
|
|
92
|
+
assertEquals(second.id, first.id);
|
|
93
|
+
assertEquals(stores["plan_task_deltas"].length, 1, "exactly one row");
|
|
94
|
+
|
|
95
|
+
const [entry] = await readTaskDeltas(data, "o/r#1");
|
|
96
|
+
assertEquals(entry.newlyTouches, ["a.rs", "b.rs"], "latest report wins");
|
|
97
|
+
assertEquals(entry.constraint, "seeded");
|
|
98
|
+
assertEquals(entry.contractChange, undefined, "cleared when the new report omits it");
|
|
99
|
+
assertEquals(entry.wave, 1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
Deno.test("readTaskDeltas: scoped to a plan, in write order, arrays decoded", async () => {
|
|
103
|
+
const { data } = memData();
|
|
104
|
+
await recordTaskDelta(data, "o/r#1", "gap-2", { newlyTouches: ["a.rs"], affectsTasks: [] });
|
|
105
|
+
await recordTaskDelta(data, "o/r#1", "gap-8", { newlyTouches: [], affectsTasks: ["gap-2"], constraint: "x" });
|
|
106
|
+
await recordTaskDelta(data, "o/r#2", "gap-1", { newlyTouches: ["z.rs"], affectsTasks: [] }); // other plan
|
|
107
|
+
|
|
108
|
+
const entries = await readTaskDeltas(data, "o/r#1");
|
|
109
|
+
assertEquals(entries.map((e) => e.taskId), ["gap-2", "gap-8"], "write order, scoped to plan");
|
|
110
|
+
assertEquals(entries[0].newlyTouches, ["a.rs"]);
|
|
111
|
+
assertEquals(entries[1].affectsTasks, ["gap-2"]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
Deno.test("aggregateEpicDeltas: unions touched files + affected tasks, lists changes/constraints", async () => {
|
|
115
|
+
const { data } = memData();
|
|
116
|
+
await recordTaskDelta(data, "p", "gap-2", {
|
|
117
|
+
newlyTouches: ["engine/state.rs"],
|
|
118
|
+
affectsTasks: ["gap-8"],
|
|
119
|
+
contractChange: "restructured complete_adhoc_tool",
|
|
120
|
+
});
|
|
121
|
+
await recordTaskDelta(data, "p", "gap-8", {
|
|
122
|
+
newlyTouches: ["engine/state.rs", "engine/tests.rs"],
|
|
123
|
+
affectsTasks: ["gap-2", "gap-4"],
|
|
124
|
+
constraint: "results:[] seed",
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const report = await aggregateEpicDeltas(data, "p");
|
|
128
|
+
assertEquals(report.touchedFiles, ["engine/state.rs", "engine/tests.rs"], "union, sorted, de-duplicated");
|
|
129
|
+
assertEquals(report.affectedTasks, ["gap-2", "gap-4", "gap-8"]);
|
|
130
|
+
assertEquals(report.contractChanges, [{ taskId: "gap-2", change: "restructured complete_adhoc_tool" }]);
|
|
131
|
+
assertEquals(report.constraints, [{ taskId: "gap-8", constraint: "results:[] seed" }]);
|
|
132
|
+
assertEquals(report.deltas.length, 2);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
Deno.test("clearTaskDeltas: drops a plan's whole set (re-plan cleanup), leaving other plans intact", async () => {
|
|
136
|
+
const { data, stores } = memData();
|
|
137
|
+
await recordTaskDelta(data, "p", "gap-2", { newlyTouches: ["a.rs"], affectsTasks: [] });
|
|
138
|
+
await recordTaskDelta(data, "p", "gap-8", { newlyTouches: ["b.rs"], affectsTasks: [] });
|
|
139
|
+
await recordTaskDelta(data, "other", "gap-1", { newlyTouches: ["z.rs"], affectsTasks: [] });
|
|
140
|
+
|
|
141
|
+
await clearTaskDeltas(data, "p");
|
|
142
|
+
assertEquals((await readTaskDeltas(data, "p")).length, 0);
|
|
143
|
+
assertEquals(stores["plan_task_deltas"].length, 1, "the other plan's delta survives");
|
|
144
|
+
});
|
package/app/taskDelta.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// nano-workforce — structured scope/impl-change report from implementers (D5, issue #55 / #49).
|
|
2
|
+
//
|
|
3
|
+
// The implementer result contract (prompts/feature.md) can carry an optional `delta`: a machine-
|
|
4
|
+
// readable record of how a slice's implementation diverged from its brief — a changed contract, a
|
|
5
|
+
// discovered constraint, files it now touches beyond its slice, or other tasks it affects. Before
|
|
6
|
+
// this, that information lived only in PR prose: invisible to the planner, to sibling agents, and
|
|
7
|
+
// to any later merge-planning (D6). Here we parse it, persist one row per (plan, task), aggregate
|
|
8
|
+
// a plan into a single epic-level report, and (in record-wave) auto-broadcast the file/constraint
|
|
9
|
+
// facts onto the D4 coordination blackboard.
|
|
10
|
+
//
|
|
11
|
+
// Data access goes through the record gateway (`data.table`), never hand-written SQL — matching
|
|
12
|
+
// app/plan.ts and app/blackboard.ts.
|
|
13
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
14
|
+
|
|
15
|
+
const now = () => new Date().toISOString();
|
|
16
|
+
|
|
17
|
+
/** The stored row shape. `newly_touches`/`affects_tasks` are JSON-encoded string arrays or NULL. */
|
|
18
|
+
export interface TaskDeltaRow {
|
|
19
|
+
id: number;
|
|
20
|
+
plan_key: string;
|
|
21
|
+
task_id: string;
|
|
22
|
+
wave: number | null;
|
|
23
|
+
contract_change: string | null;
|
|
24
|
+
newly_touches: string | null;
|
|
25
|
+
affects_tasks: string | null;
|
|
26
|
+
constraint_note: string | null;
|
|
27
|
+
created_at: string;
|
|
28
|
+
updated_at: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The parsed, structured delta an implementer reports (all fields optional). */
|
|
32
|
+
export interface TaskDelta {
|
|
33
|
+
contractChange?: string;
|
|
34
|
+
newlyTouches: string[];
|
|
35
|
+
affectsTasks: string[];
|
|
36
|
+
constraint?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The agent-facing view of a persisted delta (arrays decoded, keyed to its task). */
|
|
40
|
+
export interface TaskDeltaEntry extends TaskDelta {
|
|
41
|
+
taskId: string;
|
|
42
|
+
wave: number | null;
|
|
43
|
+
updatedAt: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function trimStr(v: unknown): string | undefined {
|
|
47
|
+
return typeof v === "string" && v.trim() !== "" ? v.trim() : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function strArray(v: unknown): string[] {
|
|
51
|
+
if (!Array.isArray(v)) return [];
|
|
52
|
+
const seen = new Set<string>();
|
|
53
|
+
for (const raw of v) {
|
|
54
|
+
const s = typeof raw === "string" ? raw.trim() : "";
|
|
55
|
+
if (s !== "") seen.add(s);
|
|
56
|
+
}
|
|
57
|
+
return [...seen];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function decodeArray(raw: string | null): string[] {
|
|
61
|
+
if (!raw) return [];
|
|
62
|
+
try {
|
|
63
|
+
const v = JSON.parse(raw);
|
|
64
|
+
return Array.isArray(v) ? v.map(String) : [];
|
|
65
|
+
} catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Coerce an arbitrary result-`delta` payload into a {@link TaskDelta}, or `null` when it carries
|
|
71
|
+
* nothing actionable (so callers persist/broadcast only real deltas, never empty noise). */
|
|
72
|
+
export function parseTaskDelta(raw: unknown): TaskDelta | null {
|
|
73
|
+
if (!raw || typeof raw !== "object") return null;
|
|
74
|
+
const o = raw as Record<string, unknown>;
|
|
75
|
+
const delta: TaskDelta = {
|
|
76
|
+
contractChange: trimStr(o.contractChange),
|
|
77
|
+
newlyTouches: strArray(o.newlyTouches),
|
|
78
|
+
affectsTasks: strArray(o.affectsTasks),
|
|
79
|
+
constraint: trimStr(o.constraint),
|
|
80
|
+
};
|
|
81
|
+
const empty = !delta.contractChange && !delta.constraint &&
|
|
82
|
+
delta.newlyTouches.length === 0 && delta.affectsTasks.length === 0;
|
|
83
|
+
return empty ? null : delta;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const deltaTable = (data: DataLayer) => data.table<TaskDeltaRow>("plan_task_deltas", "id");
|
|
87
|
+
|
|
88
|
+
function toEntry(r: TaskDeltaRow): TaskDeltaEntry {
|
|
89
|
+
return {
|
|
90
|
+
taskId: r.task_id,
|
|
91
|
+
wave: r.wave,
|
|
92
|
+
contractChange: r.contract_change ?? undefined,
|
|
93
|
+
newlyTouches: decodeArray(r.newly_touches),
|
|
94
|
+
affectsTasks: decodeArray(r.affects_tasks),
|
|
95
|
+
constraint: r.constraint_note ?? undefined,
|
|
96
|
+
updatedAt: r.updated_at,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Upsert the delta for one (plan, task). A worker retry or a post-escalation resume overwrites the
|
|
101
|
+
* prior report in place (idempotent), rather than appending a duplicate. */
|
|
102
|
+
export async function recordTaskDelta(
|
|
103
|
+
data: DataLayer,
|
|
104
|
+
planKey: string,
|
|
105
|
+
taskId: string,
|
|
106
|
+
delta: TaskDelta,
|
|
107
|
+
opts: { wave?: number | null } = {},
|
|
108
|
+
): Promise<{ inserted: boolean; id: number | bigint }> {
|
|
109
|
+
const table = deltaTable(data);
|
|
110
|
+
const ts = now();
|
|
111
|
+
const fields = {
|
|
112
|
+
wave: opts.wave ?? null,
|
|
113
|
+
contract_change: delta.contractChange ?? null,
|
|
114
|
+
newly_touches: delta.newlyTouches.length ? JSON.stringify(delta.newlyTouches) : null,
|
|
115
|
+
affects_tasks: delta.affectsTasks.length ? JSON.stringify(delta.affectsTasks) : null,
|
|
116
|
+
constraint_note: delta.constraint ?? null,
|
|
117
|
+
updated_at: ts,
|
|
118
|
+
};
|
|
119
|
+
const existing = await table.findOne({ plan_key: planKey, task_id: taskId });
|
|
120
|
+
if (existing) {
|
|
121
|
+
await table.update(existing.id, fields);
|
|
122
|
+
return { inserted: false, id: existing.id };
|
|
123
|
+
}
|
|
124
|
+
const id = await table.insert({ plan_key: planKey, task_id: taskId, created_at: ts, ...fields });
|
|
125
|
+
return { inserted: true, id };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A plan's per-task deltas in write order. */
|
|
129
|
+
export async function readTaskDeltas(data: DataLayer, planKey: string): Promise<TaskDeltaEntry[]> {
|
|
130
|
+
const rows = await deltaTable(data).find({ plan_key: planKey });
|
|
131
|
+
return rows.slice().sort((a, b) => a.id - b.id).map(toEntry);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Delete a plan's whole delta set (called on re-plan, alongside the other plan-scoped tables). */
|
|
135
|
+
export async function clearTaskDeltas(data: DataLayer, planKey: string): Promise<void> {
|
|
136
|
+
for (const r of await deltaTable(data).find({ plan_key: planKey })) {
|
|
137
|
+
await deltaTable(data).delete(r.id);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The single epic-level report: every per-task delta plus cross-task rollups (the union of
|
|
142
|
+
* newly-touched files and affected tasks, and the list of contract changes / constraints). The
|
|
143
|
+
* rollups are what a coordinator (D10) and merge-planning (D6) read; `touchedFiles`/`affectedTasks`
|
|
144
|
+
* are the seed for D2's conflict graph. */
|
|
145
|
+
export interface EpicDeltaReport {
|
|
146
|
+
deltas: TaskDeltaEntry[];
|
|
147
|
+
touchedFiles: string[];
|
|
148
|
+
affectedTasks: string[];
|
|
149
|
+
contractChanges: { taskId: string; change: string }[];
|
|
150
|
+
constraints: { taskId: string; constraint: string }[];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function aggregateEpicDeltas(
|
|
154
|
+
data: DataLayer,
|
|
155
|
+
planKey: string,
|
|
156
|
+
): Promise<EpicDeltaReport> {
|
|
157
|
+
const deltas = await readTaskDeltas(data, planKey);
|
|
158
|
+
const files = new Set<string>();
|
|
159
|
+
const affected = new Set<string>();
|
|
160
|
+
const contractChanges: { taskId: string; change: string }[] = [];
|
|
161
|
+
const constraints: { taskId: string; constraint: string }[] = [];
|
|
162
|
+
for (const d of deltas) {
|
|
163
|
+
for (const f of d.newlyTouches) files.add(f);
|
|
164
|
+
for (const t of d.affectsTasks) affected.add(t);
|
|
165
|
+
if (d.contractChange) contractChanges.push({ taskId: d.taskId, change: d.contractChange });
|
|
166
|
+
if (d.constraint) constraints.push({ taskId: d.taskId, constraint: d.constraint });
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
deltas,
|
|
170
|
+
touchedFiles: [...files].sort(),
|
|
171
|
+
affectedTasks: [...affected].sort(),
|
|
172
|
+
contractChanges,
|
|
173
|
+
constraints,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { assertEquals } from "jsr:@std/assert@1";
|
|
2
|
+
import { shouldRunTrialMerge, trialMergeDecision } from "./trialMerge.ts";
|
|
3
|
+
|
|
4
|
+
Deno.test("trialMergeDecision only escalates clean-merge suite failures", () => {
|
|
5
|
+
assertEquals(trialMergeDecision("clean"), "proceed");
|
|
6
|
+
assertEquals(trialMergeDecision("merge-conflict"), "proceed");
|
|
7
|
+
assertEquals(trialMergeDecision("suite-failed"), "escalate");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
Deno.test("shouldRunTrialMerge skips lone heads and mergify queues", () => {
|
|
11
|
+
assertEquals(shouldRunTrialMerge(0, { land: { method: "gh-merge" } }), false);
|
|
12
|
+
assertEquals(shouldRunTrialMerge(1, { land: { method: "gh-merge" } }), false);
|
|
13
|
+
assertEquals(shouldRunTrialMerge(2, { land: { method: "mergify-queue" } }), false);
|
|
14
|
+
assertEquals(shouldRunTrialMerge(2, { land: { method: "gh-merge" } }), true);
|
|
15
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Trial-merge integration gate (D3, issue #69).
|
|
2
|
+
//
|
|
3
|
+
// D3 catches semantic conflicts between concurrently-open PR heads: if they merge cleanly but the
|
|
4
|
+
// target repo's combined suite fails, a human/agent design decision is required. Textual merge
|
|
5
|
+
// conflicts are explicitly pass-through because D2/D6 own merge-exclusion and merge-train ordering.
|
|
6
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
7
|
+
import type { MergeProtocol } from "./mergeProtocol.ts";
|
|
8
|
+
|
|
9
|
+
const now = () => new Date().toISOString();
|
|
10
|
+
|
|
11
|
+
export type TrialMergeResult = "clean" | "merge-conflict" | "suite-failed";
|
|
12
|
+
export type TrialMergeDecision = "proceed" | "escalate";
|
|
13
|
+
|
|
14
|
+
export interface TrialMergeHead {
|
|
15
|
+
repo: string;
|
|
16
|
+
prNumber: number | string;
|
|
17
|
+
headRef?: string;
|
|
18
|
+
headSha?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface TrialMergeAuditRow {
|
|
22
|
+
id: number;
|
|
23
|
+
plan_key: string;
|
|
24
|
+
wave: number;
|
|
25
|
+
result: TrialMergeResult;
|
|
26
|
+
heads: string | null;
|
|
27
|
+
conflicts: string | null;
|
|
28
|
+
failing: string | null;
|
|
29
|
+
summary: string | null;
|
|
30
|
+
job_key: string | null;
|
|
31
|
+
created_at: string;
|
|
32
|
+
updated_at: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const TRIAL_MERGE_TASK_PREFIX = "trial-merge-wave-";
|
|
36
|
+
|
|
37
|
+
export function trialMergeDecision(result: TrialMergeResult): TrialMergeDecision {
|
|
38
|
+
return result === "suite-failed" ? "escalate" : "proceed";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function shouldRunTrialMerge(headCount: number, protocol: Pick<MergeProtocol, "land">): boolean {
|
|
42
|
+
return headCount >= 2 && protocol.land.method !== "mergify-queue";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function trialMergeTaskId(wave: number): string {
|
|
46
|
+
return `${TRIAL_MERGE_TASK_PREFIX}${Math.max(0, Math.trunc(wave))}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const auditTable = (data: DataLayer) => data.table<TrialMergeAuditRow>("plan_trial_merges", "id");
|
|
50
|
+
|
|
51
|
+
function jsonOrNull(v: unknown): string | null {
|
|
52
|
+
if (v == null) return null;
|
|
53
|
+
try {
|
|
54
|
+
return JSON.stringify(v);
|
|
55
|
+
} catch {
|
|
56
|
+
return JSON.stringify(String(v));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function recordTrialMergeAudit(
|
|
61
|
+
data: DataLayer,
|
|
62
|
+
row: {
|
|
63
|
+
planKey: string;
|
|
64
|
+
wave: number;
|
|
65
|
+
result: TrialMergeResult;
|
|
66
|
+
heads?: unknown;
|
|
67
|
+
conflicts?: unknown;
|
|
68
|
+
failing?: unknown;
|
|
69
|
+
summary?: string | null;
|
|
70
|
+
jobKey?: string | null;
|
|
71
|
+
},
|
|
72
|
+
): Promise<number> {
|
|
73
|
+
const ts = now();
|
|
74
|
+
const table = auditTable(data);
|
|
75
|
+
const jobKey = row.jobKey ?? null;
|
|
76
|
+
if (jobKey) {
|
|
77
|
+
const existing = (await table.find({ plan_key: row.planKey, job_key: jobKey })).sort((a, b) => b.id - a.id)[0];
|
|
78
|
+
if (existing) {
|
|
79
|
+
await table.update(existing.id, {
|
|
80
|
+
result: row.result,
|
|
81
|
+
heads: jsonOrNull(row.heads),
|
|
82
|
+
conflicts: jsonOrNull(row.conflicts),
|
|
83
|
+
failing: jsonOrNull(row.failing),
|
|
84
|
+
summary: row.summary ?? null,
|
|
85
|
+
updated_at: ts,
|
|
86
|
+
});
|
|
87
|
+
return existing.id;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return Number(await table.insert({
|
|
91
|
+
plan_key: row.planKey,
|
|
92
|
+
wave: row.wave,
|
|
93
|
+
result: row.result,
|
|
94
|
+
heads: jsonOrNull(row.heads),
|
|
95
|
+
conflicts: jsonOrNull(row.conflicts),
|
|
96
|
+
failing: jsonOrNull(row.failing),
|
|
97
|
+
summary: row.summary ?? null,
|
|
98
|
+
job_key: jobKey,
|
|
99
|
+
created_at: ts,
|
|
100
|
+
updated_at: ts,
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Red/green regression for the plan levelizer (issue #20). Run with `deno test`.
|
|
2
|
+
//
|
|
3
|
+
// One `Deno.test` = one named property of computeWaves. These encode the wave
|
|
4
|
+
// contract: independent tasks share wave 0 (all-parallel), a chain steps 0,1,2…
|
|
5
|
+
// (all-sequential), a diamond re-converges, and a malformed graph is rejected
|
|
6
|
+
// rather than silently mis-levelized.
|
|
7
|
+
import { assertEquals, assertThrows } from "jsr:@std/assert@1";
|
|
8
|
+
import { computeWaves, WaveError, type WaveGateTask, type WaveTask, waveMergeTargets } from "./waves.ts";
|
|
9
|
+
|
|
10
|
+
Deno.test("no dependencies → every task in wave 0 (fully parallel)", () => {
|
|
11
|
+
const tasks: WaveTask[] = [{ id: "a" }, { id: "b" }, { id: "c" }];
|
|
12
|
+
const { waves, waveCount } = computeWaves(tasks);
|
|
13
|
+
assertEquals(waveCount, 1);
|
|
14
|
+
assertEquals(waves, [["a", "b", "c"]]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
Deno.test("linear chain → one task per wave (fully sequential)", () => {
|
|
18
|
+
const tasks: WaveTask[] = [
|
|
19
|
+
{ id: "a" },
|
|
20
|
+
{ id: "b", dependsOn: ["a"] },
|
|
21
|
+
{ id: "c", dependsOn: ["b"] },
|
|
22
|
+
];
|
|
23
|
+
const { waves, waveCount, waveOf } = computeWaves(tasks);
|
|
24
|
+
assertEquals(waveCount, 3);
|
|
25
|
+
assertEquals(waves, [["a"], ["b"], ["c"]]);
|
|
26
|
+
assertEquals([waveOf.get("a"), waveOf.get("b"), waveOf.get("c")], [0, 1, 2]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
Deno.test("diamond → longest-path level; join waits for both arms", () => {
|
|
30
|
+
const tasks: WaveTask[] = [
|
|
31
|
+
{ id: "a" },
|
|
32
|
+
{ id: "b", dependsOn: ["a"] },
|
|
33
|
+
{ id: "c", dependsOn: ["a"] },
|
|
34
|
+
{ id: "d", dependsOn: ["b", "c"] },
|
|
35
|
+
];
|
|
36
|
+
const { waves, waveCount } = computeWaves(tasks);
|
|
37
|
+
assertEquals(waveCount, 3);
|
|
38
|
+
assertEquals(waves, [["a"], ["b", "c"], ["d"]]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
Deno.test("mixed graph → level is 1 + max(dep level), not 1 + min", () => {
|
|
42
|
+
// e depends on a (wave 0) and d (wave 2) → must land in wave 3, behind the deeper dep.
|
|
43
|
+
const tasks: WaveTask[] = [
|
|
44
|
+
{ id: "a" },
|
|
45
|
+
{ id: "b", dependsOn: ["a"] },
|
|
46
|
+
{ id: "c", dependsOn: ["b"] },
|
|
47
|
+
{ id: "d", dependsOn: ["c"] },
|
|
48
|
+
{ id: "e", dependsOn: ["a", "d"] },
|
|
49
|
+
];
|
|
50
|
+
const { waveOf, waveCount } = computeWaves(tasks);
|
|
51
|
+
assertEquals(waveCount, 5);
|
|
52
|
+
assertEquals(waveOf.get("e"), 4);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
Deno.test("empty plan → zero waves", () => {
|
|
56
|
+
const { waves, waveCount } = computeWaves([]);
|
|
57
|
+
assertEquals(waveCount, 0);
|
|
58
|
+
assertEquals(waves, []);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
Deno.test("blank / whitespace dependsOn entries are ignored", () => {
|
|
62
|
+
const tasks: WaveTask[] = [{ id: "a", dependsOn: ["", " "] }];
|
|
63
|
+
const { waves, waveCount } = computeWaves(tasks);
|
|
64
|
+
assertEquals(waveCount, 1);
|
|
65
|
+
assertEquals(waves, [["a"]]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
Deno.test("dependency cycle → WaveError", () => {
|
|
69
|
+
const tasks: WaveTask[] = [
|
|
70
|
+
{ id: "a", dependsOn: ["b"] },
|
|
71
|
+
{ id: "b", dependsOn: ["a"] },
|
|
72
|
+
];
|
|
73
|
+
assertThrows(() => computeWaves(tasks), WaveError, "cycle");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
Deno.test("self-dependency → WaveError", () => {
|
|
77
|
+
assertThrows(() => computeWaves([{ id: "a", dependsOn: ["a"] }]), WaveError, "itself");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
Deno.test("unknown dependency id → WaveError", () => {
|
|
81
|
+
assertThrows(
|
|
82
|
+
() => computeWaves([{ id: "a", dependsOn: ["ghost"] }]),
|
|
83
|
+
WaveError,
|
|
84
|
+
"unknown task",
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
Deno.test("duplicate task id → WaveError", () => {
|
|
89
|
+
assertThrows(
|
|
90
|
+
() => computeWaves([{ id: "a" }, { id: "a" }]),
|
|
91
|
+
WaveError,
|
|
92
|
+
"duplicate",
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// --- Wave-merge barrier: which PRs must merge for a wave to clear? ---
|
|
97
|
+
// `waveMergeTargets` drives the poller's `wave-merged` gate. It must select exactly the
|
|
98
|
+
// opened/waiting-with-a-PR tasks of the gate wave, ignore other waves, and treat blocked/skipped
|
|
99
|
+
// and keyless tasks as nothing-to-wait-on so no non-mergeable PR state can wedge the barrier.
|
|
100
|
+
|
|
101
|
+
Deno.test("waveMergeTargets → only opened PRs of the gate wave", () => {
|
|
102
|
+
const tasks: WaveGateTask[] = [
|
|
103
|
+
{ wave: 0, status: "opened", pr_key: "o/r#1" },
|
|
104
|
+
{ wave: 0, status: "opened", pr_key: "o/r#2" },
|
|
105
|
+
{ wave: 1, status: "opened", pr_key: "o/r#9" }, // a later wave — not this gate
|
|
106
|
+
];
|
|
107
|
+
assertEquals(waveMergeTargets(tasks, 0), ["o/r#1", "o/r#2"]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
Deno.test("waveMergeTargets → mergeable waiting tasks are waited on; failed/keyless tasks are not", () => {
|
|
111
|
+
const tasks: WaveGateTask[] = [
|
|
112
|
+
{ wave: 0, status: "opened", pr_key: "o/r#1" },
|
|
113
|
+
{ wave: 0, status: "blocked", pr_key: null },
|
|
114
|
+
{ wave: 0, status: "skipped", pr_key: null },
|
|
115
|
+
{ wave: 0, status: "waiting-for-lane", pr_key: "o/r#2" },
|
|
116
|
+
{ wave: 0, status: "waiting-for-lane", pr_key: null },
|
|
117
|
+
{ wave: 0, status: "opened", pr_key: null }, // opened but no PR key → nothing to merge
|
|
118
|
+
];
|
|
119
|
+
assertEquals(waveMergeTargets(tasks, 0), ["o/r#1", "o/r#2"]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
Deno.test("waveMergeTargets → a wave with no opened PRs clears vacuously (empty)", () => {
|
|
123
|
+
const tasks: WaveGateTask[] = [
|
|
124
|
+
{ wave: 0, status: "blocked", pr_key: null },
|
|
125
|
+
{ wave: 0, status: "skipped", pr_key: null },
|
|
126
|
+
];
|
|
127
|
+
assertEquals(waveMergeTargets(tasks, 0), []);
|
|
128
|
+
});
|
package/app/waves.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// nano-workforce — plan levelizer (issue #20).
|
|
2
|
+
//
|
|
3
|
+
// The planner may emit tasks with an optional `dependsOn: [taskId, ...]` DAG.
|
|
4
|
+
// This module turns that DAG into ordered **waves**: `wave(t) = 0` if the task has
|
|
5
|
+
// no dependencies, else `1 + max(wave(dep))` (the longest-path level). The
|
|
6
|
+
// `plan-fanout` process then runs its parallel multi-instance `implement` activity
|
|
7
|
+
// once per wave, in order — so independent tasks in the same wave run in parallel,
|
|
8
|
+
// while a dependent task waits for the wave containing all its dependencies.
|
|
9
|
+
//
|
|
10
|
+
// Pure and side-effect free: it does no I/O and is the red/green regression target
|
|
11
|
+
// (app/waves.test.ts). The workers (record-plan) call it to assign `plan_tasks.wave`.
|
|
12
|
+
|
|
13
|
+
/** A task as seen by the levelizer: a stable id and its (optional) dependency ids. */
|
|
14
|
+
export interface WaveTask {
|
|
15
|
+
id: string;
|
|
16
|
+
dependsOn?: readonly string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface WaveResult {
|
|
20
|
+
/** 0-based wave index per task id (longest-path level). */
|
|
21
|
+
waveOf: Map<string, number>;
|
|
22
|
+
/** Task ids grouped by wave, in wave order; input order preserved within a wave. */
|
|
23
|
+
waves: string[][];
|
|
24
|
+
/** Number of waves (0 for an empty plan). */
|
|
25
|
+
waveCount: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Thrown when the task graph is not a valid DAG (self-loop, cycle, dangling dep, or
|
|
29
|
+
* a duplicate task id) — the plan cannot be levelized and must be rejected. */
|
|
30
|
+
export class WaveError extends Error {
|
|
31
|
+
constructor(message: string) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.name = "WaveError";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Levelize a task DAG into ordered waves. Throws {@link WaveError} on a duplicate
|
|
39
|
+
* task id, an unknown dependency id, a self-dependency, or a dependency cycle.
|
|
40
|
+
*/
|
|
41
|
+
export function computeWaves(tasks: readonly WaveTask[]): WaveResult {
|
|
42
|
+
const ids = new Set<string>();
|
|
43
|
+
for (const t of tasks) {
|
|
44
|
+
if (ids.has(t.id)) throw new WaveError(`duplicate task id "${t.id}"`);
|
|
45
|
+
ids.add(t.id);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const depsOf = new Map<string, string[]>();
|
|
49
|
+
for (const t of tasks) {
|
|
50
|
+
const deps: string[] = [];
|
|
51
|
+
for (const raw of t.dependsOn ?? []) {
|
|
52
|
+
const d = raw.trim();
|
|
53
|
+
if (d === "") continue;
|
|
54
|
+
if (d === t.id) throw new WaveError(`task "${t.id}" depends on itself`);
|
|
55
|
+
if (!ids.has(d)) throw new WaveError(`task "${t.id}" depends on unknown task "${d}"`);
|
|
56
|
+
deps.push(d);
|
|
57
|
+
}
|
|
58
|
+
depsOf.set(t.id, deps);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const waveOf = new Map<string, number>();
|
|
62
|
+
const visiting = new Set<string>();
|
|
63
|
+
const level = (id: string): number => {
|
|
64
|
+
const cached = waveOf.get(id);
|
|
65
|
+
if (cached !== undefined) return cached;
|
|
66
|
+
if (visiting.has(id)) throw new WaveError(`dependency cycle detected at task "${id}"`);
|
|
67
|
+
visiting.add(id);
|
|
68
|
+
let lvl = 0;
|
|
69
|
+
for (const dep of depsOf.get(id) ?? []) lvl = Math.max(lvl, level(dep) + 1);
|
|
70
|
+
visiting.delete(id);
|
|
71
|
+
waveOf.set(id, lvl);
|
|
72
|
+
return lvl;
|
|
73
|
+
};
|
|
74
|
+
for (const t of tasks) level(t.id);
|
|
75
|
+
|
|
76
|
+
let waveCount = 0;
|
|
77
|
+
for (const lvl of waveOf.values()) waveCount = Math.max(waveCount, lvl + 1);
|
|
78
|
+
|
|
79
|
+
const waves: string[][] = [];
|
|
80
|
+
for (let i = 0; i < waveCount; i++) waves.push([]);
|
|
81
|
+
for (const t of tasks) {
|
|
82
|
+
const w = waveOf.get(t.id) ?? 0;
|
|
83
|
+
waves[w].push(t.id);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { waveOf, waves, waveCount };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A `plan_tasks` row as seen by the wave-merge barrier: its levelized wave, its dispatch
|
|
90
|
+
* status, and the PR it produced (if any). Structurally a subset of `PlanTask` (app/plan.ts). */
|
|
91
|
+
export interface WaveGateTask {
|
|
92
|
+
wave: number | null;
|
|
93
|
+
status: string;
|
|
94
|
+
pr_key: string | null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The PR keys that must MERGE for `gateWave` to clear the wave-merge barrier: the PRs opened by
|
|
98
|
+
* that wave's tasks. `opened` tasks and `waiting-for-lane` tasks with PR keys produced a PR to
|
|
99
|
+
* wait on — `blocked`/`skipped` tasks can never merge and must not wedge the barrier, and keyless
|
|
100
|
+
* tasks are treated as having nothing to wait on.
|
|
101
|
+
*
|
|
102
|
+
* Pure and side-effect free (like {@link computeWaves}) so the poller's gate decision is a
|
|
103
|
+
* red/green regression target: the poller releases the next wave iff every key returned here has
|
|
104
|
+
* merged. An empty result means the wave clears vacuously (nothing to merge). */
|
|
105
|
+
export function waveMergeTargets(
|
|
106
|
+
tasks: readonly WaveGateTask[],
|
|
107
|
+
gateWave: number,
|
|
108
|
+
): string[] {
|
|
109
|
+
const keys: string[] = [];
|
|
110
|
+
for (const t of tasks) {
|
|
111
|
+
if (t.wave !== gateWave) continue;
|
|
112
|
+
if ((t.status !== "opened" && t.status !== "waiting-for-lane") || !t.pr_key) continue;
|
|
113
|
+
keys.push(t.pr_key);
|
|
114
|
+
}
|
|
115
|
+
return keys;
|
|
116
|
+
}
|