@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.
Files changed (115) hide show
  1. package/.github/workflows/ci.yml +60 -0
  2. package/.github/workflows/release.yml +58 -0
  3. package/.releaserc.json +17 -0
  4. package/AGENTS.md +168 -0
  5. package/CHANGELOG.md +231 -0
  6. package/LICENSE +202 -0
  7. package/README.md +303 -0
  8. package/SPEC.md +492 -0
  9. package/actions/abandon.test.ts +93 -0
  10. package/actions/abandon.ts +23 -0
  11. package/actions/blackboard.test.ts +195 -0
  12. package/actions/blackboard.ts +76 -0
  13. package/actions/cancel.ts +29 -0
  14. package/actions/feature-answer-hook.ts +44 -0
  15. package/actions/message.ts +49 -0
  16. package/actions/plan-hook.ts +19 -0
  17. package/actions/plan-start.ts +17 -0
  18. package/actions/start.ts +19 -0
  19. package/actions/status.ts +22 -0
  20. package/actions/webhook-submit.ts +21 -0
  21. package/app/abandon.test.ts +97 -0
  22. package/app/abandon.ts +105 -0
  23. package/app/baseGuard.test.ts +35 -0
  24. package/app/baseGuard.ts +62 -0
  25. package/app/blackboard.test.ts +295 -0
  26. package/app/blackboard.ts +301 -0
  27. package/app/github.test.ts +59 -0
  28. package/app/github.ts +647 -0
  29. package/app/mergeExclusion.test.ts +168 -0
  30. package/app/mergeExclusion.ts +211 -0
  31. package/app/mergeProtocol.test.ts +124 -0
  32. package/app/mergeProtocol.ts +193 -0
  33. package/app/mergeRebaseArm.test.ts +72 -0
  34. package/app/mergeTrain.test.ts +91 -0
  35. package/app/mergeTrain.ts +117 -0
  36. package/app/persist-escalation.test.ts +119 -0
  37. package/app/persist-round.test.ts +65 -0
  38. package/app/plan.test.ts +317 -0
  39. package/app/plan.ts +321 -0
  40. package/app/record-plan-review.test.ts +38 -0
  41. package/app/reviewWait.test.ts +70 -0
  42. package/app/reviewWait.ts +59 -0
  43. package/app/rounds.test.ts +74 -0
  44. package/app/rounds.ts +48 -0
  45. package/app/service.test.ts +101 -0
  46. package/app/service.ts +895 -0
  47. package/app/taskDelta.test.ts +144 -0
  48. package/app/taskDelta.ts +175 -0
  49. package/app/trialMerge.test.ts +15 -0
  50. package/app/trialMerge.ts +102 -0
  51. package/app/waves.test.ts +128 -0
  52. package/app/waves.ts +116 -0
  53. package/assets/icon.svg +13 -0
  54. package/components/review-round.json +69 -0
  55. package/db/migrations/001_init.sql +46 -0
  56. package/db/migrations/002_transcript.sql +7 -0
  57. package/db/migrations/003_open_escalation.sql +8 -0
  58. package/db/migrations/004_merge.sql +36 -0
  59. package/db/migrations/004_planning.sql +37 -0
  60. package/db/migrations/005_job_activation.sql +15 -0
  61. package/db/migrations/005_plan_deps.sql +20 -0
  62. package/db/migrations/006_plan_review.sql +22 -0
  63. package/db/migrations/006_task_escalation.sql +52 -0
  64. package/db/migrations/007_plan_review_job_key.sql +14 -0
  65. package/db/migrations/007_wave_gate.sql +16 -0
  66. package/db/migrations/008_review_nudge.sql +9 -0
  67. package/db/migrations/009_plan_blackboard.sql +46 -0
  68. package/db/migrations/010_plan_task_deltas.sql +27 -0
  69. package/db/migrations/011_plan_merge_exclusions.sql +26 -0
  70. package/db/migrations/012_merge_protocol_attempt.sql +4 -0
  71. package/db/migrations/013_merge_train_waiting_lane.sql +6 -0
  72. package/db/migrations/014_plan_trial_merges.sql +21 -0
  73. package/db/migrations/015_pr_abandon_token.sql +9 -0
  74. package/deno.json +24 -0
  75. package/deno.lock +1776 -0
  76. package/main.ts +71 -0
  77. package/nano-ide.ext.json +7 -0
  78. package/nano.app.json +138 -0
  79. package/nanobpm.project.json +20 -0
  80. package/package.json +56 -0
  81. package/pages/epic.page.json +195 -0
  82. package/pages/home.page.json +296 -0
  83. package/prompts/feature.md +132 -0
  84. package/prompts/fix-ci.md +65 -0
  85. package/prompts/plan-review.md +69 -0
  86. package/prompts/plan.md +183 -0
  87. package/prompts/rebase.md +82 -0
  88. package/prompts/review-round.md +171 -0
  89. package/prompts/trial-merge.md +43 -0
  90. package/renovate.json +21 -0
  91. package/resources/processes/convergence-loop.bpmn +399 -0
  92. package/resources/processes/merge-loop.bpmn +585 -0
  93. package/resources/processes/plan-fanout.bpmn +546 -0
  94. package/scripts/check-agent-prompts.test.ts +84 -0
  95. package/scripts/check-agent-prompts.ts +143 -0
  96. package/scripts/layout-bpmn.ts +99 -0
  97. package/scripts/purge-db.ts +57 -0
  98. package/scripts/upgrade-from-pack.ts +334 -0
  99. package/tsconfig.json +51 -0
  100. package/workers/arm-merge/worker.ts +18 -0
  101. package/workers/finalize/worker.ts +89 -0
  102. package/workers/mark-merged/worker.ts +21 -0
  103. package/workers/merge/worker.ts +119 -0
  104. package/workers/persist-escalation/worker.ts +107 -0
  105. package/workers/persist-round/worker.ts +52 -0
  106. package/workers/persist-task-escalation/worker.ts +112 -0
  107. package/workers/record-plan/worker.ts +135 -0
  108. package/workers/record-plan-review/worker.ts +92 -0
  109. package/workers/record-results/worker.ts +30 -0
  110. package/workers/record-trial-merge/worker.test.ts +104 -0
  111. package/workers/record-trial-merge/worker.ts +88 -0
  112. package/workers/record-wave/worker.test.ts +221 -0
  113. package/workers/record-wave/worker.ts +308 -0
  114. package/workers/select-wave/worker.test.ts +130 -0
  115. package/workers/select-wave/worker.ts +84 -0
@@ -0,0 +1,18 @@
1
+ // pr.arm-merge — arm the merge poller: park the PR in `waiting_merge` so the next poll pass
2
+ // evaluates its mergeability and correlates `merge-ready`. Reached both on entry to the merge
3
+ // stage (after dependencies clear) and after a human answers a merge escalation (re-check).
4
+ import type { AppJobHandler } from "@nanobpm/urban";
5
+
6
+ interface In extends Record<string, unknown> {
7
+ prKey: string;
8
+ }
9
+
10
+ const handler: AppJobHandler<In> = async (job, app) => {
11
+ await app.data.table("pull_requests", "pr_key").update(job.variables.prKey, {
12
+ status: "waiting_merge",
13
+ updated_at: new Date().toISOString(),
14
+ });
15
+ return {};
16
+ };
17
+
18
+ export default handler;
@@ -0,0 +1,89 @@
1
+ // pr.finalize — the PR has converged. Record the final round and either (a) hand off to the
2
+ // merge stage (start the `merge-loop` process and park the PR in `waiting_deps`) when auto-merge
3
+ // is on, or (b) close the PR out as `converged` (review-only mode).
4
+ import type { AppJobHandler } from "@nanobpm/urban";
5
+ import { AUTO_MERGE, startMerge } from "../../app/service.ts";
6
+
7
+ // Extends Record so the declared fields are typed while the job may still carry
8
+ // other process variables (e.g. io.nanobpm.agentResult, read by transcriptOf).
9
+ interface In extends Record<string, unknown> {
10
+ prKey: string;
11
+ repo: string;
12
+ prNumber: number;
13
+ prUrl: string;
14
+ round: number;
15
+ summary?: string;
16
+ }
17
+
18
+ const AGENT_RESULT_KEY = "io.nanobpm.agentResult";
19
+ function transcriptOf(vars: Record<string, unknown>): string | null {
20
+ const env = vars[AGENT_RESULT_KEY] as { output?: unknown } | undefined;
21
+ return typeof env?.output === "string" ? env.output : null;
22
+ }
23
+
24
+ const handler: AppJobHandler<In> = async (job, app) => {
25
+ // `summary` is left undefined when absent so the write boundary omits it: the
26
+ // nullable `rounds.summary` stays NULL and `pull_requests.outcome` is untouched
27
+ // rather than being coerced to "".
28
+ const { prKey, repo, prNumber, prUrl, round, summary } = job.variables;
29
+ const now = new Date().toISOString();
30
+
31
+ await app.data.table("rounds", "id").insert({
32
+ pr_key: prKey,
33
+ round_no: round,
34
+ status: "converged",
35
+ summary,
36
+ transcript: transcriptOf(job.variables),
37
+ started_at: now,
38
+ ended_at: now,
39
+ });
40
+
41
+ // In auto-merge mode, start the separate merge-loop instance (keyed on prKey) that lands the
42
+ // PR *before* advancing the row into the merge stage. Best-effort: a failure here must not fail
43
+ // the convergence finalize. But we only flip the PR into the non-terminal `waiting_deps` status
44
+ // once merge-loop is actually running — otherwise the PR would be parked in a merge-stage status
45
+ // with no process behind it, and `submitPr` refuses to restart it (only `cancel` recovers). On
46
+ // failure we leave the PR terminal as `converged` so a human/operator can (re)start merge.
47
+ let status = "converged";
48
+ if (AUTO_MERGE) {
49
+ try {
50
+ const { mergeProcessKey } = await startMerge(app.data, app.engine, {
51
+ repo,
52
+ number: prNumber,
53
+ url: prUrl,
54
+ prKey,
55
+ round,
56
+ });
57
+ // `startMerge` can resolve without throwing yet with a null key (mirroring the engine's
58
+ // nullable `processInstanceKey`). Only park the PR in the merge-stage `waiting_deps` status
59
+ // when merge-loop is actually running — otherwise leave it terminal as `converged` so a
60
+ // human/operator can (re)start merge rather than stranding it with no process behind it.
61
+ if (mergeProcessKey != null) {
62
+ status = "waiting_deps";
63
+ } else {
64
+ app.log("error", `finalize: merge-loop start returned no process key for ${prKey}; leaving PR converged`);
65
+ }
66
+ } catch (err) {
67
+ app.log("error", `finalize: could not start merge-loop for ${prKey}; leaving PR converged`, {
68
+ err: String(err),
69
+ });
70
+ }
71
+ }
72
+
73
+ // Converged bookkeeping is recorded in both modes; `outcome`/`converged_at` capture the review
74
+ // result. In auto-merge mode (when merge-loop started) the *status* moves into the merge stage
75
+ // rather than resting at `converged`, so the merge poller starts watching immediately.
76
+ await app.data.table("pull_requests", "pr_key").update(prKey, {
77
+ status,
78
+ current_round: round,
79
+ outcome: summary,
80
+ converged_at: now,
81
+ updated_at: now,
82
+ open_escalation_id: null,
83
+ open_escalation_question: null,
84
+ });
85
+
86
+ return {};
87
+ };
88
+
89
+ export default handler;
@@ -0,0 +1,21 @@
1
+ // pr.mark-merged — the PR has landed (directly or via the merge queue). Record the terminal
2
+ // `merged` state; the merge audit trail is written by pr.merge, so this only closes the row out.
3
+ import type { AppJobHandler } from "@nanobpm/urban";
4
+
5
+ interface In extends Record<string, unknown> {
6
+ prKey: string;
7
+ }
8
+
9
+ const handler: AppJobHandler<In> = async (job, app) => {
10
+ const now = new Date().toISOString();
11
+ await app.data.table("pull_requests", "pr_key").update(job.variables.prKey, {
12
+ status: "merged",
13
+ merged_at: now,
14
+ updated_at: now,
15
+ open_escalation_id: null,
16
+ open_escalation_question: null,
17
+ });
18
+ return {};
19
+ };
20
+
21
+ export default handler;
@@ -0,0 +1,119 @@
1
+ // pr.merge — attempt to land the PR (SPEC §11). Returns `mergeStatus`:
2
+ // • merged — landed now (direct merge) → process marks it merged
3
+ // • queued — added to the repo's merge queue → process waits for `merge-landed`
4
+ // • blocked — GitHub refused (conflict / failing gate / perms) → escalate to a human, who
5
+ // resolves it and replies to retry (the process re-arms and re-polls).
6
+ // HOW it lands is governed by the target repo's published merge protocol (#43): a `mergify-queue`
7
+ // repo (e.g. Magikcraft/nano-bpm, auto-merge OFF) is landed by posting `@mergifyio queue` and
8
+ // waiting for the queue, NOT a direct `gh pr merge` — which that repo refuses. The actual gh/API
9
+ // calls live in app/github.ts; this worker records the attempt in the `merges` audit table and
10
+ // shapes the escalation payload on a block.
11
+ import type { AppJobHandler } from "@nanobpm/urban";
12
+ import { enqueueViaComment, mergePr } from "../../app/github.ts";
13
+ import { MERGE_ADMIN, MERGE_METHOD } from "../../app/service.ts";
14
+ import { loadMergeProtocol } from "../../app/mergeProtocol.ts";
15
+ import { checkBaseTarget } from "../../app/baseGuard.ts";
16
+
17
+ interface In extends Record<string, unknown> {
18
+ prKey: string;
19
+ repo: string;
20
+ prNumber: number;
21
+ }
22
+
23
+ interface Out extends Record<string, unknown> {
24
+ mergeStatus: "merged" | "queued" | "blocked";
25
+ status?: string;
26
+ question?: string;
27
+ }
28
+
29
+ const handler: AppJobHandler<In, Out> = async (job, app) => {
30
+ const { prKey, repo, prNumber } = job.variables;
31
+ const token = process.env.GITHUB_TOKEN ?? "";
32
+ const now = new Date().toISOString();
33
+
34
+ // Dead-end-base guard (#60): never land a PR into a base branch that has itself already merged
35
+ // to the default branch — the merge would land into a dead branch and never reach `main`.
36
+ // GitHub only auto-retargets a PR when its base is *deleted* on merge; a merged-but-undeleted
37
+ // base (typical in a stacked epic) stays the target and reads CLEAN, so nothing else catches it.
38
+ // Best-effort: a transport hiccup leaves `deadEnd:false`, so this never blocks a valid merge.
39
+ const guard = await checkBaseTarget(repo, prNumber, token).catch(() => null);
40
+ if (guard?.deadEnd) {
41
+ await app.data.table("merges", "id").insert({
42
+ pr_key: prKey,
43
+ outcome: "blocked",
44
+ method: "base-guard",
45
+ detail: `base '${guard.base}' has already merged into '${guard.defaultBranch}' (dead-end target)`,
46
+ at: now,
47
+ });
48
+ return {
49
+ mergeStatus: "blocked",
50
+ status: "blocked",
51
+ question:
52
+ `This PR targets '${guard.base}', which has already merged into '${guard.defaultBranch}'. ` +
53
+ `Merging now would land into a dead-end branch and never reach '${guard.defaultBranch}'. ` +
54
+ `Retarget it (gh pr edit ${prNumber} --repo ${repo} --base ${guard.defaultBranch}), then reply to retry.`,
55
+ };
56
+ }
57
+
58
+ // Load the repo protocol for every worker invocation. A retry after fix-ci/rebase is a fresh
59
+ // landing attempt, so land-method decisions must not be latched across earlier heads.
60
+ const protocol = await loadMergeProtocol(repo, token).catch(() => null);
61
+ const method = protocol?.land.method ?? "gh-merge";
62
+
63
+ let outcome: "merged" | "queued" | "blocked";
64
+ let detail: string;
65
+ let auditMethod: string;
66
+
67
+ if (method === "mergify-queue") {
68
+ // Land via the repo's on-demand queue: post the enqueue comment; the poller's queued→landed
69
+ // watch (service.ts block 3) then advances the process when the queue merges it.
70
+ const comment = protocol?.land.comment ?? "@mergifyio queue";
71
+ const ok = await enqueueViaComment(repo, prNumber, token, comment);
72
+ outcome = ok ? "queued" : "blocked";
73
+ detail = ok ? `enqueued via "${comment}"` : `failed to post enqueue comment "${comment}"`;
74
+ auditMethod = "queue-comment";
75
+ } else if (method === "ui") {
76
+ // The repo requires a human to click Merge; Merlin can't. Escalate rather than pretend.
77
+ outcome = "blocked";
78
+ detail = "repo merge protocol requires a manual UI merge (land.method=ui)";
79
+ auditMethod = "ui";
80
+ } else {
81
+ const admin = method === "admin" || MERGE_ADMIN;
82
+ const res = await mergePr(repo, prNumber, token, { method: MERGE_METHOD, admin });
83
+ // No usable transport → treat as a block so a human is asked to configure/merge, rather than
84
+ // silently completing the process without landing the PR.
85
+ outcome = res?.outcome ?? "blocked";
86
+ detail = res?.detail ?? "no GitHub transport available (configure gh or GITHUB_TOKEN)";
87
+ auditMethod = outcome === "queued" ? "queue" : MERGE_METHOD;
88
+ }
89
+
90
+ await app.data.table("merges", "id").insert({
91
+ pr_key: prKey,
92
+ outcome,
93
+ method: auditMethod,
94
+ detail,
95
+ at: now,
96
+ });
97
+
98
+ if (outcome === "queued") {
99
+ await app.data.table("pull_requests", "pr_key").update(prKey, {
100
+ status: "queued",
101
+ updated_at: now,
102
+ });
103
+ return { mergeStatus: "queued" };
104
+ }
105
+ if (outcome === "merged") {
106
+ return { mergeStatus: "merged" };
107
+ }
108
+ // blocked → hand the escalation machinery a concrete question.
109
+ const docHint = protocol?.doc ? ` See the repo's merge protocol (${protocol.doc}).` : "";
110
+ return {
111
+ mergeStatus: "blocked",
112
+ status: "blocked",
113
+ question:
114
+ `Automated merge was blocked: ${detail}. ` +
115
+ `Resolve it on GitHub (rebase / fix a required check / grant merge rights), then reply to retry.${docHint}`,
116
+ };
117
+ };
118
+
119
+ export default handler;
@@ -0,0 +1,107 @@
1
+ // pr.persist-escalation — records the round that raised an escalation and opens an escalation
2
+ // row for a human to answer. Handles both the agent-raised path (status = needs_input | blocked)
3
+ // and the MAX_ROUNDS guard (status = blocked, question set by the process). Returns
4
+ // `escalationId` for the UI.
5
+ import type { AppJobHandler } from "@nanobpm/urban";
6
+
7
+ // Extends Record so the declared fields are typed while the job may still carry
8
+ // other process variables (e.g. io.nanobpm.agentResult, read by transcriptOf).
9
+ interface In extends Record<string, unknown> {
10
+ prKey: string;
11
+ round: number;
12
+ status?: string;
13
+ summary?: string;
14
+ question?: string;
15
+ // False on the "review stalled" arm: `persist-round` already recorded this `round` as
16
+ // `addressed`, so this escalation must not insert a second `rounds` row for the same
17
+ // `pr_key`/`round_no` (which would record one round as both addressed and blocked). Absent
18
+ // on the agent-raised / max-rounds arms, where no prior round row exists — so it defaults on.
19
+ recordRound?: boolean;
20
+ }
21
+
22
+ // A string variable, or undefined when it is absent, empty, or whitespace-only.
23
+ // The write boundary owns *type* defaults (undefined -> column DEFAULT/NULL); this
24
+ // owns a *domain* rule: a blank prompt or status counts as "missing" so it can't
25
+ // reach the escalation control flow or the UI answer form.
26
+ function nonBlank(v: unknown): string | undefined {
27
+ return typeof v === "string" && v.trim() !== "" ? v.trim() : undefined;
28
+ }
29
+
30
+ const AGENT_RESULT_KEY = "io.nanobpm.agentResult";
31
+ function transcriptOf(vars: Record<string, unknown>): string | null {
32
+ const env = vars[AGENT_RESULT_KEY] as { output?: unknown } | undefined;
33
+ return typeof env?.output === "string" ? env.output : null;
34
+ }
35
+
36
+ // Synthesize a concrete, answerable question when the agent left one blank. A blank question is
37
+ // almost always a *no-result* round: a prompt-less agent that never wrote its result file, so
38
+ // `status` is empty and `gw-status` falls through its default `f_escalate` arm (the empty
39
+ // "(no question provided)" escalations on Magikcraft/nano-bpm #597/#599). Throwing here parked a
40
+ // `JobNoRetries` incident that could NOT be diagnosed or remediated from the UI. Instead we open
41
+ // an escalation a human can actually answer, with the agent's transcript attached below it.
42
+ function fabricateQuestion(rawStatus: string | undefined, hasTranscript: boolean): string {
43
+ const tail = hasTranscript
44
+ ? " Review the agent's response shown below, then reply with how it should proceed — or cancel and resubmit."
45
+ : " No agent response was captured. Reply with how it should proceed, or cancel and resubmit.";
46
+ if (!rawStatus) {
47
+ return "The review agent finished without a machine-readable result (no status was reported), " +
48
+ "so this round could not be classified as converged, addressed, or a specific request." + tail;
49
+ }
50
+ return `The review agent reported status "${rawStatus}" without a question, so this round ` +
51
+ "could not be resolved automatically." + tail;
52
+ }
53
+
54
+ const handler: AppJobHandler<In> = async (job, app) => {
55
+ const { prKey, round, summary } = job.variables;
56
+ // `status` drives the escalation kind (control flow); a blank/absent status is an
57
+ // unclassified escalation -> a question needing input. `question` is denormalised
58
+ // onto pull_requests below and bound by the UI answer form, so it must be a
59
+ // concrete, non-blank value. `summary` is left undefined so the write boundary
60
+ // omits it and the nullable column stays NULL.
61
+ const rawStatus = nonBlank(job.variables.status);
62
+ const status = rawStatus ?? "needs_input";
63
+ const transcript = transcriptOf(job.variables);
64
+ // A blank question must never open an unanswerable escalation. Every legitimate arm sets a
65
+ // concrete question — the agent contract requires one for needs_input/blocked, and the
66
+ // max-rounds + review-timeout arms set a literal via the model. When one is still missing
67
+ // (a no-result round through the `gw-status` default), fabricate an actionable question that
68
+ // references the attached transcript rather than throwing (which parked an un-remediable
69
+ // incident). This keeps the loop recoverable entirely from the UI.
70
+ const question = nonBlank(job.variables.question) ?? fabricateQuestion(rawStatus, transcript != null);
71
+ const kind = status === "needs_input" ? "question" : "blocker";
72
+ const now = new Date().toISOString();
73
+
74
+ // Skip the round insert when the caller already recorded this round (the "review stalled"
75
+ // arm runs after `persist-round`): re-inserting would duplicate the `pr_key`/`round_no` row.
76
+ if (job.variables.recordRound !== false) {
77
+ await app.data.table("rounds", "id").insert({
78
+ pr_key: prKey,
79
+ round_no: round,
80
+ status,
81
+ summary,
82
+ transcript,
83
+ started_at: now,
84
+ ended_at: now,
85
+ });
86
+ }
87
+ const escalationId = await app.data.table("escalations", "id").insert({
88
+ pr_key: prKey,
89
+ round_no: round,
90
+ kind,
91
+ question,
92
+ transcript,
93
+ status: "open",
94
+ asked_at: now,
95
+ });
96
+ await app.data.table("pull_requests", "pr_key").update(prKey, {
97
+ status: "escalated",
98
+ current_round: round,
99
+ updated_at: now,
100
+ open_escalation_id: Number(escalationId),
101
+ open_escalation_question: question,
102
+ });
103
+
104
+ return { escalationId: Number(escalationId) };
105
+ };
106
+
107
+ export default handler;
@@ -0,0 +1,52 @@
1
+ // pr.persist-round — records a completed round (an `addressed` round where the agent pushed
2
+ // changes, or a `waiting` round where there was nothing to triage yet) and parks the PR in
3
+ // `waiting_review` so the poller starts watching for / soliciting the next review.
4
+ //
5
+ // Data access goes through the injected app datasource gateway (`app.data.table<T>`), the RAD
6
+ // `Table<T>` surface — `rounds.insert(...)` / `pull_requests.update(...)`, not hand-written SQL.
7
+ import type { AppJobHandler } from "@nanobpm/urban";
8
+
9
+ // Extends Record so the declared fields are typed while the job may still carry
10
+ // other process variables (e.g. io.nanobpm.agentResult, read by transcriptOf).
11
+ interface In extends Record<string, unknown> {
12
+ prKey: string;
13
+ round: number;
14
+ status?: string;
15
+ summary?: string;
16
+ }
17
+
18
+ // The harness records the agent's full (byte-capped) stdout on the result envelope; keep it
19
+ // for audit so a human can see what the agent did this round.
20
+ const AGENT_RESULT_KEY = "io.nanobpm.agentResult";
21
+ function transcriptOf(vars: Record<string, unknown>): string | null {
22
+ const env = vars[AGENT_RESULT_KEY] as { output?: unknown } | undefined;
23
+ return typeof env?.output === "string" ? env.output : null;
24
+ }
25
+
26
+ const handler: AppJobHandler<In> = async (job, app) => {
27
+ // This worker is the "addressed"/"waiting" path, so `status` resolves to one of those
28
+ // domain values. `summary` is left undefined when absent: the write boundary omits it so the
29
+ // nullable `rounds.summary` column stays NULL rather than being coerced to "".
30
+ const { prKey, round, status = "addressed", summary } = job.variables;
31
+ const now = new Date().toISOString();
32
+
33
+ await app.data.table("rounds", "id").insert({
34
+ pr_key: prKey,
35
+ round_no: round,
36
+ status,
37
+ summary,
38
+ transcript: transcriptOf(job.variables),
39
+ started_at: now,
40
+ ended_at: now,
41
+ });
42
+ await app.data.table("pull_requests", "pr_key").update(prKey, {
43
+ status: "waiting_review",
44
+ current_round: round,
45
+ waiting_since: now,
46
+ updated_at: now,
47
+ });
48
+
49
+ return {};
50
+ };
51
+
52
+ export default handler;
@@ -0,0 +1,112 @@
1
+ // pr.persist-task-escalation — an implementation agent escalated a task during the
2
+ // fan-out (issue #25). It reported `status = "escalated"` with a `question` and,
3
+ // ideally, a work-preserving DRAFT PR, then completed its job. This worker records
4
+ // that escalation and parks the plan on a per-task human answer:
5
+ // • opens (or refreshes) a `plan_escalations` row (status = open),
6
+ // • marks the `plan_tasks` row `escalated` with the question / draft PR / corr key,
7
+ // • re-points the plan's denormalised "open task escalation" fields at the
8
+ // oldest still-open escalation, so the page's single answer form surfaces it.
9
+ //
10
+ // The process then parks the child at the `feature-escalation-answered` message
11
+ // catch (correlationKey `<plan_key>:<task_id>`). Answering it (page form or
12
+ // `/hooks/feature-answer`) resumes the child, which re-dispatches the SAME task.
13
+ //
14
+ // Retry-safe: if an open escalation already exists for this corr key (a worker
15
+ // re-activation before the wait subscription opened), it is UPDATED, not
16
+ // duplicated — a fresh escalation row is only created after the previous one was
17
+ // answered.
18
+ import type { AppJobHandler } from "@nanobpm/urban";
19
+ import {
20
+ featureCorrKey,
21
+ planEscalations,
22
+ planTasks,
23
+ refreshOpenTaskEscalation,
24
+ } from "../../app/plan.ts";
25
+
26
+ interface TaskIn {
27
+ id?: unknown;
28
+ title?: unknown;
29
+ }
30
+ interface In extends Record<string, unknown> {
31
+ planKey: string;
32
+ task?: TaskIn;
33
+ question?: unknown;
34
+ pr?: unknown;
35
+ summary?: unknown;
36
+ }
37
+ interface Out extends Record<string, unknown> {
38
+ escalationId: number;
39
+ }
40
+
41
+ // A non-blank trimmed string, else undefined. A blank question/PR must not reach
42
+ // the UI answer form or masquerade as preserved work.
43
+ const str = (v: unknown): string | undefined =>
44
+ typeof v === "string" && v.trim() !== "" ? v.trim() : undefined;
45
+
46
+ const handler: AppJobHandler<In, Out> = async (job, app) => {
47
+ const planKey = job.variables.planKey;
48
+ const taskId = str(job.variables.task?.id);
49
+ if (!taskId) {
50
+ // No task binding means we cannot correlate a resume — fail loudly rather
51
+ // than silently parking a token that can never be answered.
52
+ throw new Error("persist-task-escalation: missing task.id in child scope");
53
+ }
54
+ const corrKey = featureCorrKey(planKey, taskId);
55
+ const question = str(job.variables.question);
56
+ if (!question) {
57
+ // A blank question would surface a non-actionable placeholder in the answer
58
+ // form and, on a retry, overwrite a previously recorded question. The output
59
+ // contract requires `question` for an escalation — fail loudly, like the
60
+ // missing-task.id guard above, rather than park an unanswerable escalation.
61
+ throw new Error("persist-task-escalation: missing question for escalated task");
62
+ }
63
+ const draftPr = str(job.variables.pr) ?? null;
64
+ // Prior-attempt context the escalating agent reported. Persist it so the UI
65
+ // and any later resume/debugging keep the task's summary instead of NULL.
66
+ const summary = str(job.variables.summary) ?? null;
67
+ const ts = new Date().toISOString();
68
+
69
+ const escTable = planEscalations(app.data);
70
+ const existing = (await escTable.find({ corr_key: corrKey, status: "open" }))
71
+ .sort((a, b) => b.id - a.id)[0];
72
+ let escalationId: number;
73
+ if (existing) {
74
+ await escTable.update(existing.id, {
75
+ question,
76
+ draft_pr_key: draftPr ?? existing.draft_pr_key,
77
+ });
78
+ escalationId = existing.id;
79
+ } else {
80
+ escalationId = Number(
81
+ await escTable.insert({
82
+ plan_key: planKey,
83
+ task_id: taskId,
84
+ corr_key: corrKey,
85
+ question,
86
+ draft_pr_key: draftPr,
87
+ status: "open",
88
+ asked_at: ts,
89
+ }),
90
+ );
91
+ }
92
+
93
+ for (const t of await planTasks(app.data).find({ plan_key: planKey, task_id: taskId })) {
94
+ await planTasks(app.data).update(t.id, {
95
+ status: "escalated",
96
+ open_question: question,
97
+ // Clear any answer from a prior (already-answered) escalation so the task
98
+ // row stays aligned with the currently-open question — otherwise a
99
+ // re-escalated task would show a stale answer next to the new question.
100
+ answer: null,
101
+ draft_pr_key: draftPr ?? t.draft_pr_key,
102
+ summary: summary ?? t.summary,
103
+ corr_key: corrKey,
104
+ updated_at: ts,
105
+ });
106
+ }
107
+
108
+ await refreshOpenTaskEscalation(app.data, planKey);
109
+ return { escalationId };
110
+ };
111
+
112
+ export default handler;
@@ -0,0 +1,135 @@
1
+ // pr.record-plan — persists the plan the `senior:plan` agent emitted, LEVELIZES the task
2
+ // DAG into ordered waves (issue #20), and hands the process the first wave to run.
3
+ //
4
+ // The planner emits `{ tasks: [{ id?, title?, prompt, dependsOn? }] }`. This worker:
5
+ // • assigns each task a stable `id` (planner slug, else `t<index>`) and an index,
6
+ // • computes each task's `wave` from its `dependsOn` DAG (app/waves.ts): independent
7
+ // tasks share a wave, a dependent task lands 1 + max(dep wave),
8
+ // • writes one `plan_tasks` row per task (status `pending`, with its `wave`) and one
9
+ // `plan_task_deps` row per dependency edge (idempotent: cleared + rewritten per plan),
10
+ // • records the task count, moves the plan to `dispatched`, and emits `currentWave = 0`
11
+ // plus `waveCount` so the wave loop (`select-wave → implement → record-wave`) can run.
12
+ //
13
+ // If the planner emits a malformed DAG (cycle / unknown or self dependency / duplicate id),
14
+ // levelization can't order the tasks. Rather than dead-lock the plan we DEGRADE to the old
15
+ // flat behaviour — a single wave (wave 0) of all tasks, run fully in parallel — and log a
16
+ // warning; the ordering is lost but every task still runs. No `plan_task_deps` are recorded
17
+ // in that case (the edges were invalid).
18
+ import type { AppJobHandler } from "@nanobpm/urban";
19
+ import { planTaskDeps, planTasks } from "../../app/plan.ts";
20
+ import { computeWaves, WaveError, type WaveTask } from "../../app/waves.ts";
21
+
22
+ interface RawTask {
23
+ id?: unknown;
24
+ title?: unknown;
25
+ prompt?: unknown;
26
+ dependsOn?: unknown;
27
+ }
28
+ interface In extends Record<string, unknown> {
29
+ planKey: string;
30
+ tasks?: RawTask[];
31
+ note?: string;
32
+ }
33
+ interface NormalTask {
34
+ id: string;
35
+ title: string;
36
+ prompt: string;
37
+ dependsOn: string[];
38
+ }
39
+ interface Out extends Record<string, unknown> {
40
+ currentWave: number;
41
+ waveCount: number;
42
+ }
43
+
44
+ const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? "" : String(v));
45
+ const strList = (v: unknown): string[] =>
46
+ Array.isArray(v) ? v.map((x) => str(x).trim()).filter((s) => s !== "") : [];
47
+
48
+ const handler: AppJobHandler<In, Out> = async (job, app) => {
49
+ const { planKey, note } = job.variables;
50
+ const raw = Array.isArray(job.variables.tasks) ? job.variables.tasks : [];
51
+ const ts = new Date().toISOString();
52
+
53
+ const tasks: NormalTask[] = raw.map((t, i) => {
54
+ const id = str(t?.id).trim() || `t${i + 1}`;
55
+ return {
56
+ id,
57
+ title: str(t?.title).trim() || id,
58
+ prompt: str(t?.prompt),
59
+ // Dedupe: a planner-emitted `["a","a"]` would otherwise violate the
60
+ // `plan_task_deps` PK on the second edge insert and fail the job.
61
+ dependsOn: [...new Set(strList(t?.dependsOn))],
62
+ };
63
+ });
64
+
65
+ // Levelize the DAG. A malformed graph degrades to a single all-parallel wave (see header).
66
+ const forLevel: WaveTask[] = tasks.map((t) => ({ id: t.id, dependsOn: t.dependsOn }));
67
+ let waveOf = new Map<string, number>();
68
+ let waveCount = tasks.length > 0 ? 1 : 0;
69
+ let depsValid = true;
70
+ try {
71
+ const levelled = computeWaves(forLevel);
72
+ waveOf = levelled.waveOf;
73
+ waveCount = levelled.waveCount;
74
+ } catch (err) {
75
+ if (!(err instanceof WaveError)) throw err;
76
+ depsValid = false;
77
+ // The DAG is unusable (cycle / self / unknown dep, or a DUPLICATE task id). Rewrite every
78
+ // task to a guaranteed-unique positional id and drop deps: the wave loop's task_id-keyed
79
+ // maps (select-wave / record-wave) would otherwise collide on duplicate ids and silently
80
+ // lose updates, leaving some rows stuck `pending`. Ordering is lost; all tasks run flat.
81
+ tasks.forEach((t, i) => {
82
+ t.id = `t${i + 1}`;
83
+ });
84
+ waveOf = new Map();
85
+ for (const t of tasks) waveOf.set(t.id, 0);
86
+ app.log("warn", `record-plan: ${planKey} plan not levelizable, running flat`, {
87
+ err: err.message,
88
+ });
89
+ }
90
+
91
+ // Idempotency: a retry (or re-run) of this job must not duplicate rows for the same plan.
92
+ const taskTable = planTasks(app.data);
93
+ const existing = await taskTable.find({ plan_key: planKey });
94
+ for (const row of existing) await taskTable.delete(row.id);
95
+ // `plan_task_deps` is keyed on `plan_key`, so one delete clears the plan's whole edge set.
96
+ const depTable = planTaskDeps(app.data);
97
+ await depTable.delete(planKey);
98
+
99
+ for (let i = 0; i < tasks.length; i++) {
100
+ const t = tasks[i];
101
+ await taskTable.insert({
102
+ plan_key: planKey,
103
+ task_index: i,
104
+ task_id: t.id,
105
+ title: t.title,
106
+ prompt: t.prompt,
107
+ status: "pending",
108
+ wave: waveOf.get(t.id) ?? 0,
109
+ created_at: ts,
110
+ updated_at: ts,
111
+ });
112
+ if (depsValid) {
113
+ for (const dep of t.dependsOn) {
114
+ await depTable.insert({
115
+ plan_key: planKey,
116
+ task_id: t.id,
117
+ depends_on_task_id: dep,
118
+ });
119
+ }
120
+ }
121
+ }
122
+
123
+ const patch: Record<string, unknown> = {
124
+ status: tasks.length > 0 ? "dispatched" : "done",
125
+ task_count: tasks.length,
126
+ updated_at: ts,
127
+ };
128
+ if (tasks.length === 0) patch.outcome = note ? str(note) : "planner emitted no tasks";
129
+ await app.data.table("plans", "plan_key").update(planKey, patch);
130
+
131
+ // Kick off the wave loop at wave 0.
132
+ return { currentWave: 0, waveCount };
133
+ };
134
+
135
+ export default handler;