@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/SPEC.md +14 -4
  3. package/app/retro.ts +6 -6
  4. package/nano.app.json +4 -0
  5. package/openapi.yaml +85 -14
  6. package/operations/answerFeatureEscalation.test.ts +12 -0
  7. package/operations/answerFeatureEscalation.ts +25 -7
  8. package/operations/appendBlackboard.ts +10 -1
  9. package/operations/blackboard.test.ts +2 -1
  10. package/operations/checkAbandon.test.ts +2 -1
  11. package/operations/checkAbandon.ts +4 -1
  12. package/operations/getAgentInstructions.test.ts +2 -1
  13. package/operations/getAgentInstructions.ts +2 -1
  14. package/operations/getVersion.test.ts +2 -1
  15. package/operations/getVersion.ts +2 -1
  16. package/operations/listActivePrs.test.ts +2 -1
  17. package/operations/listActivePrs.ts +1 -0
  18. package/operations/postMessage.ts +9 -1
  19. package/operations/readBlackboard.ts +4 -1
  20. package/operations/startAndMessage.test.ts +39 -7
  21. package/operations/startConvergenceLoop.ts +25 -13
  22. package/operations/startPlanFanout.ts +17 -4
  23. package/package.json +1 -1
  24. package/prompts/fix-ci.md +14 -3
  25. package/prompts/rebase.md +10 -0
  26. package/resources/processes/merge-loop.bpmn +73 -20
  27. package/test/log.ts +12 -0
  28. package/workers/finalize/worker.test.ts +2 -1
  29. package/workers/finalize/worker.ts +2 -2
  30. package/workers/merge/worker.test.ts +2 -1
  31. package/workers/record-dependency/worker.test.ts +116 -0
  32. package/workers/record-dependency/worker.ts +109 -0
  33. package/workers/record-plan/worker.ts +1 -1
  34. package/workers/record-plan-review/worker.test.ts +2 -1
  35. package/workers/record-plan-review/worker.ts +2 -2
  36. package/workers/record-results/worker.test.ts +2 -1
  37. package/workers/record-results/worker.ts +1 -1
  38. package/workers/record-trial-merge/worker.test.ts +2 -1
  39. package/workers/record-trial-merge/worker.ts +1 -1
  40. package/workers/record-wave/worker.test.ts +2 -1
  41. package/workers/record-wave/worker.ts +7 -7
  42. package/workers/retro-gather/worker.test.ts +3 -2
  43. package/workers/retro-gather/worker.ts +1 -1
  44. package/workers/retro-record/worker.test.ts +2 -1
  45. 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("warn", `record-plan: ${planKey} plan not levelizable, running flat`, {
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("error", `record-plan-review: ${planKey} not approved after ${MAX_PLAN_REVIEW_ROUNDS} round(s)`, {
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("info", `record-plan-review: ${planKey} round ${round} — revise`, { approved: false });
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("error", `record-results: ${planKey} finalized with 0 opened PRs`, {
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: () => undefined,
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("error", `record-trial-merge: audit persist failed for ${planKey} wave ${wave}`, { err: String(err) });
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: () => undefined,
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("error", `record-wave: recording delta for ${taskId} failed`, {
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("error", `record-wave: handoff failed for ${parsed.prKey}`, {
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("error", `record-wave: pr head fetch failed for ${head.repo}#${head.prNumber}`, { err: String(err) });
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("error", `record-wave: pr files fetch failed for ${o.repo}#${o.number}`, {
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("info", `record-wave: merge-exclusion scan wave ${currentWave}`, {
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("error", `record-wave: merge-exclusion scan failed for ${planKey}`, {
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("error", `record-wave: arming wave gate failed for ${planKey}`, { err: String(err) });
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: () => undefined };
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: () => undefined };
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("info", `retro-gather: ${planKey} — ${digest.counts.learnings} learnings, ${digest.counts.deltas} deltas`);
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: () => undefined };
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("info", `retro-record: ${planKey} — status=${status}${prKey ? ` pr=${prKey}` : ""}`);
56
+ app.log.info(`retro-record: ${planKey} — status=${status}${prKey ? ` pr=${prKey}` : ""}`);
57
57
  return {};
58
58
  };
59
59