@nanobpm/nano-workforce 0.42.0 → 0.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/SPEC.md +14 -4
- package/app/retro.ts +6 -6
- package/nano.app.json +4 -0
- package/openapi.yaml +85 -14
- package/operations/answerFeatureEscalation.test.ts +12 -0
- package/operations/answerFeatureEscalation.ts +25 -7
- package/operations/appendBlackboard.ts +10 -1
- package/operations/blackboard.test.ts +2 -1
- package/operations/checkAbandon.test.ts +2 -1
- package/operations/checkAbandon.ts +4 -1
- package/operations/getAgentInstructions.test.ts +2 -1
- package/operations/getAgentInstructions.ts +2 -1
- package/operations/getVersion.test.ts +2 -1
- package/operations/getVersion.ts +2 -1
- package/operations/listActivePrs.test.ts +2 -1
- package/operations/listActivePrs.ts +1 -0
- package/operations/postMessage.ts +9 -1
- package/operations/readBlackboard.ts +4 -1
- package/operations/startAndMessage.test.ts +39 -7
- package/operations/startConvergenceLoop.ts +25 -13
- package/operations/startPlanFanout.ts +17 -4
- package/package.json +1 -1
- package/prompts/fix-ci.md +14 -3
- package/prompts/rebase.md +10 -0
- package/resources/processes/merge-loop.bpmn +73 -20
- package/test/log.ts +12 -0
- package/workers/finalize/worker.test.ts +2 -1
- package/workers/finalize/worker.ts +2 -2
- package/workers/merge/worker.test.ts +2 -1
- package/workers/record-dependency/worker.test.ts +116 -0
- package/workers/record-dependency/worker.ts +109 -0
- package/workers/record-plan/worker.ts +1 -1
- package/workers/record-plan-review/worker.test.ts +2 -1
- package/workers/record-plan-review/worker.ts +2 -2
- package/workers/record-results/worker.test.ts +2 -1
- package/workers/record-results/worker.ts +1 -1
- package/workers/record-trial-merge/worker.test.ts +2 -1
- package/workers/record-trial-merge/worker.ts +1 -1
- package/workers/record-wave/worker.test.ts +2 -1
- package/workers/record-wave/worker.ts +7 -7
- package/workers/retro-gather/worker.test.ts +3 -2
- package/workers/retro-gather/worker.ts +1 -1
- package/workers/retro-record/worker.test.ts +2 -1
- package/workers/retro-record/worker.ts +1 -1
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// pr.record-dependency — a merge-stage agent discovered that this PR cannot land until ANOTHER
|
|
2
|
+
// PR merges first (e.g. a stacked base PR must land, or a sibling PR closes an issue this one
|
|
3
|
+
// requires). That is a WAIT, not a human escalation: record the discovered edge(s) in the
|
|
4
|
+
// `pr_dependencies` DAG and park the PR back in `waiting_deps` so the merge poller's dependency
|
|
5
|
+
// pass (`pollMerges` block 1) advances it — publishing `deps-cleared` — once every named PR has
|
|
6
|
+
// merged. The process re-enters its existing `wait-deps` catch, so no human has to babysit an
|
|
7
|
+
// ordering constraint the machinery already knows how to satisfy.
|
|
8
|
+
//
|
|
9
|
+
// `dependsOn` is whatever the agent returned (see prompts/fix-ci.md, prompts/rebase.md): a
|
|
10
|
+
// string of one or more `owner/repo#N` refs (or PR URLs) separated by commas/whitespace/newlines,
|
|
11
|
+
// or an array of such tokens. We parse each robustly (reusing `parsePr`), drop self-references and
|
|
12
|
+
// duplicates, and insert missing edges idempotently — a worker retry never double-inserts, and an
|
|
13
|
+
// already-recorded edge is a no-op.
|
|
14
|
+
import type { AppJobHandler } from "@nanobpm/urban";
|
|
15
|
+
import { ensurePr, parsePr } from "../../app/service.ts";
|
|
16
|
+
|
|
17
|
+
interface In extends Record<string, unknown> {
|
|
18
|
+
prKey: string;
|
|
19
|
+
dependsOn?: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface DependencyRow {
|
|
23
|
+
pr_key: string;
|
|
24
|
+
depends_on_key: string;
|
|
25
|
+
created_at: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Normalize the agent's `dependsOn` into a de-duplicated list of `owner/repo#N` keys.
|
|
29
|
+
* Accepts a string (split on commas/whitespace/newlines) or an array of such tokens; unparseable
|
|
30
|
+
* tokens are ignored, mirroring `parseDependsOn`'s tolerance for the `Depends-on:` PR-body line. */
|
|
31
|
+
function parseDependsOn(raw: unknown): string[] {
|
|
32
|
+
const tokens: string[] = [];
|
|
33
|
+
if (typeof raw === "string") {
|
|
34
|
+
tokens.push(...raw.split(/[,\s]+/));
|
|
35
|
+
} else if (Array.isArray(raw)) {
|
|
36
|
+
for (const item of raw) {
|
|
37
|
+
if (typeof item === "string") tokens.push(...item.split(/[,\s]+/));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const out = new Set<string>();
|
|
41
|
+
for (const tok of tokens) {
|
|
42
|
+
const parsed = parsePr(tok);
|
|
43
|
+
if (parsed) out.add(parsed.prKey);
|
|
44
|
+
}
|
|
45
|
+
return [...out];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const handler: AppJobHandler<In> = async (job, app) => {
|
|
49
|
+
const prKey = job.variables.prKey;
|
|
50
|
+
const depKeys = parseDependsOn(job.variables.dependsOn);
|
|
51
|
+
const ts = new Date().toISOString();
|
|
52
|
+
|
|
53
|
+
const depTable = app.data.table<DependencyRow>("pr_dependencies", "pr_key");
|
|
54
|
+
|
|
55
|
+
// Append the discovered edges to whatever the plan DAG already declared — never wipe the set
|
|
56
|
+
// (a `registerDependencies`-style replace would drop still-relevant sibling ordering). Dedupe
|
|
57
|
+
// against existing rows so this is safe to retry.
|
|
58
|
+
const existing = await depTable.find({ pr_key: prKey });
|
|
59
|
+
const have = new Set(existing.map((d) => d.depends_on_key));
|
|
60
|
+
let recorded = 0;
|
|
61
|
+
for (const depKey of depKeys) {
|
|
62
|
+
if (depKey === prKey || have.has(depKey)) continue; // never wait on self; skip known edges
|
|
63
|
+
await depTable.insert({ pr_key: prKey, depends_on_key: depKey, created_at: ts });
|
|
64
|
+
have.add(depKey);
|
|
65
|
+
recorded += 1;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (depKeys.length === 0) {
|
|
69
|
+
// The agent signalled "waiting-on-pr" but named no parseable PR. Whether this actually strands
|
|
70
|
+
// the PR depends on the existing DAG: with no other edges the poller clears it immediately (an
|
|
71
|
+
// empty dep set trivially "all merged"); if prior edges already exist it will keep waiting on
|
|
72
|
+
// those. Either way the miswiring — a "waiting-on-pr" signal with no parseable ref — is a
|
|
73
|
+
// defect worth logging loudly.
|
|
74
|
+
const clause =
|
|
75
|
+
have.size === 0
|
|
76
|
+
? "it will clear immediately (no other dependencies recorded)"
|
|
77
|
+
: `it still has ${have.size} previously-recorded dependency edge(s) to wait on`;
|
|
78
|
+
app.log(
|
|
79
|
+
"error",
|
|
80
|
+
`record-dependency: ${prKey} reported waiting-on-pr but no parseable dependsOn ref; ` +
|
|
81
|
+
`${clause}. Raw: ${JSON.stringify(job.variables.dependsOn)}`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Heal a missing FK parent (engine/app.db desync) before updating `pull_requests`; otherwise the
|
|
86
|
+
// update silently no-ops and the instance re-enters `wait-deps` with no row for the merge poller
|
|
87
|
+
// to watch — wedging the merge loop. Mirrors persist-round's heal; repo/number are derived from
|
|
88
|
+
// the canonical `owner/repo#N` prKey since the RecordDepIn envelope carries only prKey/dependsOn.
|
|
89
|
+
const parsed = parsePr(prKey);
|
|
90
|
+
if (parsed) {
|
|
91
|
+
await ensurePr(app.data, { prKey, repo: parsed.repo, number: parsed.number, url: parsed.url });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Park the PR back in the merge-stage dependency wait so `pollMerges` block 1 watches it.
|
|
95
|
+
await app.data.table("pull_requests", "pr_key").update(prKey, {
|
|
96
|
+
status: "waiting_deps",
|
|
97
|
+
updated_at: ts,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
app.log("info", `record-dependency: ${prKey} waiting on ${depKeys.length} PR(s)`, {
|
|
101
|
+
prKey,
|
|
102
|
+
dependsOn: depKeys,
|
|
103
|
+
recorded,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return {};
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export default handler;
|
|
@@ -83,7 +83,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
83
83
|
});
|
|
84
84
|
waveOf = new Map();
|
|
85
85
|
for (const t of tasks) waveOf.set(t.id, 0);
|
|
86
|
-
app.log(
|
|
86
|
+
app.log.warn(`record-plan: ${planKey} plan not levelizable, running flat`, {
|
|
87
87
|
err: err.message,
|
|
88
88
|
});
|
|
89
89
|
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { test } from "node:test";
|
|
9
9
|
import { assertEquals, assertRejects } from "#test-assert";
|
|
10
10
|
import { BpmnError } from "@nanobpm/urban";
|
|
11
|
+
import { noopLog } from "../../test/log.ts";
|
|
11
12
|
import handler from "./worker.ts";
|
|
12
13
|
import { MAX_PLAN_REVIEW_ROUNDS, type PlanReview } from "../../app/plan.ts";
|
|
13
14
|
|
|
@@ -28,7 +29,7 @@ function fakeApp(existing: PlanReview[] = []) {
|
|
|
28
29
|
};
|
|
29
30
|
},
|
|
30
31
|
},
|
|
31
|
-
log: ()
|
|
32
|
+
log: noopLog(),
|
|
32
33
|
_rows: rows,
|
|
33
34
|
} as any;
|
|
34
35
|
}
|
|
@@ -92,7 +92,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
92
92
|
// `PLAN_REJECTED`, so the engine parks the instance on an incident rather than dispatching an
|
|
93
93
|
// un-approved plan. The round is 0-based, so `round + 1 >= cap` is the last permitted round.
|
|
94
94
|
if (round + 1 >= MAX_PLAN_REVIEW_ROUNDS) {
|
|
95
|
-
app.log(
|
|
95
|
+
app.log.error(`record-plan-review: ${planKey} not approved after ${MAX_PLAN_REVIEW_ROUNDS} round(s)`, {
|
|
96
96
|
round,
|
|
97
97
|
});
|
|
98
98
|
throw new BpmnError(
|
|
@@ -102,7 +102,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
// Otherwise loop: the planner revises against this round's findings.
|
|
105
|
-
app.log(
|
|
105
|
+
app.log.info(`record-plan-review: ${planKey} round ${round} — revise`, { approved: false });
|
|
106
106
|
return { planApproved: false, planFindings: roundFindings };
|
|
107
107
|
};
|
|
108
108
|
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { test } from "node:test";
|
|
10
10
|
import { assertEquals, assertRejects } from "#test-assert";
|
|
11
11
|
import { BpmnError } from "@nanobpm/urban";
|
|
12
|
+
import { noopLog } from "../../test/log.ts";
|
|
12
13
|
import handler from "./worker.ts";
|
|
13
14
|
import type { PlanTaskStatus } from "../../app/plan.ts";
|
|
14
15
|
|
|
@@ -45,7 +46,7 @@ function fakeApp(rows: Row[]) {
|
|
|
45
46
|
};
|
|
46
47
|
},
|
|
47
48
|
},
|
|
48
|
-
log: ()
|
|
49
|
+
log: noopLog(),
|
|
49
50
|
_plans: plans,
|
|
50
51
|
} as any;
|
|
51
52
|
}
|
|
@@ -43,7 +43,7 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
43
43
|
outcome,
|
|
44
44
|
updated_at: ts,
|
|
45
45
|
});
|
|
46
|
-
app.log(
|
|
46
|
+
app.log.error(`record-results: ${planKey} finalized with 0 opened PRs`, {
|
|
47
47
|
taskCount: rows.length,
|
|
48
48
|
});
|
|
49
49
|
throw new BpmnError("NO_WORK_DISPATCHED", `${planKey}: ${outcome}`);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import { assertEquals, assertStringIncludes } from "#test-assert";
|
|
3
|
+
import { noopLog } from "../../test/log.ts";
|
|
3
4
|
import handler, { parseResult } from "./worker.ts";
|
|
4
5
|
|
|
5
6
|
function fakeApp() {
|
|
@@ -19,7 +20,7 @@ function fakeApp() {
|
|
|
19
20
|
};
|
|
20
21
|
},
|
|
21
22
|
},
|
|
22
|
-
log: ()
|
|
23
|
+
log: noopLog(),
|
|
23
24
|
};
|
|
24
25
|
return { app, inserts };
|
|
25
26
|
}
|
|
@@ -67,7 +67,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
67
67
|
jobKey,
|
|
68
68
|
});
|
|
69
69
|
} catch (err) {
|
|
70
|
-
app.log(
|
|
70
|
+
app.log.error(`record-trial-merge: audit persist failed for ${planKey} wave ${wave}`, { err: String(err) });
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
const trialMergeRed = trialMergeDecision(result) === "escalate";
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// finish the plan with a still-pending task.
|
|
4
4
|
import { test } from "node:test";
|
|
5
5
|
import { assertEquals } from "#test-assert";
|
|
6
|
+
import { noopLog } from "../../test/log.ts";
|
|
6
7
|
import handler from "./worker.ts";
|
|
7
8
|
import type { PlanTaskStatus } from "../../app/plan.ts";
|
|
8
9
|
import { _clearMergeProtocolCache } from "../../app/mergeProtocol.ts";
|
|
@@ -61,7 +62,7 @@ function fakeApp(rows: Row[]) {
|
|
|
61
62
|
};
|
|
62
63
|
},
|
|
63
64
|
},
|
|
64
|
-
log: ()
|
|
65
|
+
log: noopLog(),
|
|
65
66
|
engine: {
|
|
66
67
|
createInstance: () => Promise.resolve({ processInstanceKey: "pi" }),
|
|
67
68
|
},
|
|
@@ -164,7 +164,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
164
164
|
});
|
|
165
165
|
}
|
|
166
166
|
} catch (err) {
|
|
167
|
-
app.log(
|
|
167
|
+
app.log.error(`record-wave: recording delta for ${taskId} failed`, {
|
|
168
168
|
err: String(err),
|
|
169
169
|
});
|
|
170
170
|
}
|
|
@@ -194,7 +194,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
194
194
|
try {
|
|
195
195
|
await submitPr(app.data, app.engine, parsed, depPrKeys);
|
|
196
196
|
} catch (err) {
|
|
197
|
-
app.log(
|
|
197
|
+
app.log.error(`record-wave: handoff failed for ${parsed.prKey}`, {
|
|
198
198
|
err: String(err),
|
|
199
199
|
});
|
|
200
200
|
}
|
|
@@ -220,7 +220,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
220
220
|
if (meta?.headRef) head.headRef = meta.headRef;
|
|
221
221
|
if (meta?.headSha) head.headSha = meta.headSha;
|
|
222
222
|
} catch (err) {
|
|
223
|
-
app.log(
|
|
223
|
+
app.log.error(`record-wave: pr head fetch failed for ${head.repo}#${head.prNumber}`, { err: String(err) });
|
|
224
224
|
}
|
|
225
225
|
return head;
|
|
226
226
|
}));
|
|
@@ -253,7 +253,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
253
253
|
for (const f of files) set.add(f);
|
|
254
254
|
touchesByTask.set(o.taskId, set);
|
|
255
255
|
} catch (err) {
|
|
256
|
-
app.log(
|
|
256
|
+
app.log.error(`record-wave: pr files fetch failed for ${o.repo}#${o.number}`, {
|
|
257
257
|
err: String(err),
|
|
258
258
|
});
|
|
259
259
|
}
|
|
@@ -261,7 +261,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
261
261
|
const edges = deriveExclusions(touchesByTask);
|
|
262
262
|
if (edges.length > 0) {
|
|
263
263
|
const { inserted, updated } = await recordExclusions(app.data, planKey, edges);
|
|
264
|
-
app.log(
|
|
264
|
+
app.log.info(`record-wave: merge-exclusion scan wave ${currentWave}`, {
|
|
265
265
|
planKey,
|
|
266
266
|
edges: edges.length,
|
|
267
267
|
inserted,
|
|
@@ -269,7 +269,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
269
269
|
});
|
|
270
270
|
}
|
|
271
271
|
} catch (err) {
|
|
272
|
-
app.log(
|
|
272
|
+
app.log.error(`record-wave: merge-exclusion scan failed for ${planKey}`, {
|
|
273
273
|
err: String(err),
|
|
274
274
|
});
|
|
275
275
|
}
|
|
@@ -291,7 +291,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
291
291
|
updated_at: ts,
|
|
292
292
|
});
|
|
293
293
|
} catch (err) {
|
|
294
|
-
app.log(
|
|
294
|
+
app.log.error(`record-wave: arming wave gate failed for ${planKey}`, { err: String(err) });
|
|
295
295
|
}
|
|
296
296
|
|
|
297
297
|
return {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import { assert, assertEquals, assertStringIncludes } from "#test-assert";
|
|
3
3
|
import type { DataLayer } from "@nanobpm/urban";
|
|
4
|
+
import { noopLog } from "../../test/log.ts";
|
|
4
5
|
import { appendEntry } from "../../app/blackboard.ts";
|
|
5
6
|
import handler from "./worker.ts";
|
|
6
7
|
|
|
@@ -38,7 +39,7 @@ test("retro-gather: emits a digest brief + learning count for the plan", async (
|
|
|
38
39
|
await appendEntry(data, "o/r#3", { author_task: "t1", kind: "learning", body: "regen before build" });
|
|
39
40
|
await appendEntry(data, "o/r#3", { author_task: "t2", kind: "learning", body: "use nextest" });
|
|
40
41
|
|
|
41
|
-
const app = { data, log: ()
|
|
42
|
+
const app = { data, log: noopLog() };
|
|
42
43
|
const out = await handler(
|
|
43
44
|
{ variables: { planKey: "o/r#3" } } as any,
|
|
44
45
|
app as any,
|
|
@@ -53,7 +54,7 @@ test("retro-gather: emits a digest brief + learning count for the plan", async (
|
|
|
53
54
|
test("retro-gather: an epic with no learnings still renders a valid brief", async () => {
|
|
54
55
|
const { data, stores } = memData();
|
|
55
56
|
stores["plans"] = [{ plan_key: "o/r#4", repo: "o/r", issue_url: "", title: null }];
|
|
56
|
-
const app = { data, log: ()
|
|
57
|
+
const app = { data, log: noopLog() };
|
|
57
58
|
const out = await handler(
|
|
58
59
|
{ variables: { planKey: "o/r#4" } } as any,
|
|
59
60
|
app as any,
|
|
@@ -18,7 +18,7 @@ interface Out extends Record<string, unknown> {
|
|
|
18
18
|
const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
19
19
|
const planKey = job.variables.planKey;
|
|
20
20
|
const digest = await gatherRetro(app.data, planKey);
|
|
21
|
-
app.log(
|
|
21
|
+
app.log.info(`retro-gather: ${planKey} — ${digest.counts.learnings} learnings, ${digest.counts.deltas} deltas`);
|
|
22
22
|
return {
|
|
23
23
|
retroDigest: renderRetroBrief(digest),
|
|
24
24
|
retroLearnings: digest.counts.learnings,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import { assertEquals } from "#test-assert";
|
|
3
|
+
import { noopLog } from "../../test/log.ts";
|
|
3
4
|
import handler from "./worker.ts";
|
|
4
5
|
|
|
5
6
|
function fakeApp() {
|
|
@@ -29,7 +30,7 @@ function fakeApp() {
|
|
|
29
30
|
},
|
|
30
31
|
};
|
|
31
32
|
}
|
|
32
|
-
const app = { data: { table: (n: string, pk?: string) => tbl(n, pk) }, log: ()
|
|
33
|
+
const app = { data: { table: (n: string, pk?: string) => tbl(n, pk) }, log: noopLog() };
|
|
33
34
|
return { app, stores };
|
|
34
35
|
}
|
|
35
36
|
|
|
@@ -53,7 +53,7 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
53
53
|
report,
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
-
app.log(
|
|
56
|
+
app.log.info(`retro-record: ${planKey} — status=${status}${prKey ? ` pr=${prKey}` : ""}`);
|
|
57
57
|
return {};
|
|
58
58
|
};
|
|
59
59
|
|