@nanobpm/nano-workforce 0.56.0 → 0.58.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 (90) hide show
  1. package/.github/workflows/ci.yml +7 -0
  2. package/AGENTS.md +83 -1
  3. package/CHANGELOG.md +14 -0
  4. package/README.md +10 -2
  5. package/SPEC.md +27 -24
  6. package/app/agentCompletion.test.ts +337 -0
  7. package/app/agentCompletion.ts +219 -0
  8. package/app/agentGuide.ts +1 -1
  9. package/app/answer-escalation.test.ts +106 -0
  10. package/app/answerEscalation.test.ts +67 -0
  11. package/app/baseGuard.test.ts +9 -1
  12. package/app/baseGuard.ts +11 -0
  13. package/app/escalationSla.test.ts +39 -0
  14. package/app/escalationSla.ts +28 -0
  15. package/app/escalationTaxonomy.test.ts +115 -0
  16. package/app/escalationTaxonomy.ts +115 -0
  17. package/app/feature.test.ts +161 -0
  18. package/app/feature.ts +173 -0
  19. package/app/github.test.ts +179 -1
  20. package/app/github.ts +132 -0
  21. package/app/mergeProtocol.test.ts +15 -0
  22. package/app/mergeProtocol.ts +10 -0
  23. package/app/persist-escalation.test.ts +34 -36
  24. package/app/plan.test.ts +267 -313
  25. package/app/plan.ts +173 -231
  26. package/app/reviewWait.ts +12 -4
  27. package/app/roundResultDefault.test.ts +111 -2
  28. package/app/roundResultDefault.ts +35 -0
  29. package/app/service.test.ts +6 -7
  30. package/app/service.ts +52 -35
  31. package/db/migrations/026_agent_completion.sql +36 -0
  32. package/db/migrations/027_retire_escalation_subsystem.sql +43 -0
  33. package/db/migrations/028_feature_runs.sql +28 -0
  34. package/e2e/agent-answerable.e2e.ts +185 -0
  35. package/e2e/convergence-escalation.e2e.ts +180 -0
  36. package/e2e/convergence-loop.e2e.ts +1 -1
  37. package/e2e/feature-run.e2e.ts +231 -0
  38. package/e2e/plan-fanout-sla.e2e.ts +238 -0
  39. package/e2e/plan-fanout.e2e.ts +303 -0
  40. package/e2e/retire-escalation-subsystem.e2e.ts +223 -0
  41. package/e2e/support/github-admit.ts +99 -0
  42. package/e2e/user-task-spine.e2e.ts +155 -0
  43. package/nano.app.json +41 -11
  44. package/openapi.yaml +237 -84
  45. package/operations/agentCompleteEscalation.ts +53 -0
  46. package/operations/listActivePrs.test.ts +39 -6
  47. package/operations/postMessage.ts +10 -41
  48. package/operations/revertEscalationCompletion.ts +44 -0
  49. package/operations/startAndMessage.test.ts +62 -60
  50. package/operations/startFeature.ts +127 -0
  51. package/operations/startPlanFanout.admission.integration.test.ts +263 -0
  52. package/operations/startPlanFanout.ts +70 -11
  53. package/package.json +4 -1
  54. package/pages/cockpit.page.json +1 -0
  55. package/pages/epic-detail.page.json +11 -37
  56. package/pages/epic.page.json +5 -2
  57. package/pages/feature.page.json +82 -0
  58. package/pages/home.page.json +6 -18
  59. package/resources/agent-guide.md +90 -26
  60. package/resources/forms/feature-escalation.form +27 -0
  61. package/resources/forms/plan-review-decision.form +27 -0
  62. package/resources/forms/pr-escalation.form +23 -0
  63. package/resources/forms/spine-demo.form +15 -0
  64. package/resources/forms/trial-merge-decision.form +25 -0
  65. package/resources/processes/convergence-loop.bpmn +127 -75
  66. package/resources/processes/feature.bpmn +240 -0
  67. package/resources/processes/plan-fanout.bpmn +322 -222
  68. package/resources/processes/spine-demo.bpmn +72 -0
  69. package/scripts/check-migrations.ts +68 -0
  70. package/workers/answer-escalation/worker.ts +78 -0
  71. package/workers/converge-feature/worker.ts +51 -0
  72. package/workers/ensure-base-branch/head-task.integration.test.ts +126 -0
  73. package/workers/ensure-base-branch/worker.test.ts +104 -0
  74. package/workers/ensure-base-branch/worker.ts +31 -0
  75. package/workers/finalize/worker.ts +0 -2
  76. package/workers/mark-merged/worker.ts +0 -2
  77. package/workers/merge/worker.ts +6 -5
  78. package/workers/persist-escalation/worker.ts +28 -32
  79. package/workers/record-feature/worker.ts +61 -0
  80. package/workers/record-plan-review/worker.test.ts +9 -10
  81. package/workers/record-plan-review/worker.ts +15 -5
  82. package/workers/resolve-trial-attention/worker.test.ts +77 -0
  83. package/workers/resolve-trial-attention/worker.ts +43 -0
  84. package/operations/answerFeatureEscalation.test.ts +0 -112
  85. package/operations/answerFeatureEscalation.ts +0 -58
  86. package/operations/answerPlanEscalation.test.ts +0 -115
  87. package/operations/answerPlanEscalation.ts +0 -41
  88. package/workers/persist-plan-escalation/worker.test.ts +0 -80
  89. package/workers/persist-plan-escalation/worker.ts +0 -73
  90. package/workers/persist-task-escalation/worker.ts +0 -120
package/app/plan.test.ts CHANGED
@@ -121,100 +121,12 @@ test("re-plan of a finished issue clears stale plan_reviews rows", async () => {
121
121
  number: 7,
122
122
  url: "https://github.com/owner/repo/issues/7",
123
123
  planKey: PLAN_KEY,
124
- });
124
+ }, "epic/agent-protocol");
125
125
 
126
126
  assertEquals(stores.plan_reviews.rows.length, 0);
127
127
  assertEquals(stores.plan_tasks.rows.length, 0);
128
128
  });
129
129
 
130
- // Red/green regression for re-plan clearing stale open escalations (issue #25).
131
- //
132
- // `plan_escalations` is written by the implementation-phase escalation loop and denormalised onto
133
- // the plan row (`open_task_*`). When `startPlan` re-plans a finished issue it deletes the prior
134
- // `plan_tasks`, so any still-"open" escalation from that run points at a task that no longer
135
- // exists. If those rows (and the plan's denormalised pointer) survive the re-plan,
136
- // `refreshOpenTaskEscalation` re-surfaces a dead question in the answer form — the same
137
- // stale-row class as `plan_reviews` above. This drives `startPlan` against the in-memory data
138
- // layer and asserts both the escalation rows and the denormalised pointer are cleared.
139
- test("re-plan of a finished issue clears stale open escalations and the denormalised open_task_* pointer", async () => {
140
- const PLAN_KEY = "owner/repo#8";
141
- const stores: Record<string, { rows: unknown[]; key: string }> = {
142
- plans: {
143
- rows: [{
144
- plan_key: PLAN_KEY,
145
- status: "done",
146
- task_count: 1,
147
- open_task_escalation_id: 5,
148
- open_task_question: "stale question from prior run?",
149
- open_task_corr_key: `${PLAN_KEY}:task-1`,
150
- open_task_id: "task-1",
151
- }],
152
- key: "plan_key",
153
- },
154
- plan_tasks: { rows: [{ id: 1, plan_key: PLAN_KEY, task_id: "task-1" }], key: "id" },
155
- plan_reviews: { rows: [], key: "plan_key" },
156
- plan_escalations: {
157
- rows: [{
158
- id: 5,
159
- plan_key: PLAN_KEY,
160
- task_id: "task-1",
161
- corr_key: `${PLAN_KEY}:task-1`,
162
- question: "stale question from prior run?",
163
- status: "open",
164
- }],
165
- key: "id",
166
- },
167
- plan_task_deps: { rows: [], key: "plan_key" },
168
- };
169
- const data = {
170
- table: (name: string, key: string) =>
171
- memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
172
- } as any;
173
- const engine = {
174
- createInstance: () => Promise.resolve({ processInstanceKey: "PI-1" }),
175
- } as any;
176
-
177
- await startPlan(data, engine, {
178
- repo: "owner/repo",
179
- number: 8,
180
- url: "https://github.com/owner/repo/issues/8",
181
- planKey: PLAN_KEY,
182
- });
183
-
184
- // Stale escalation rows from the prior run must not survive a re-plan …
185
- assertEquals(stores.plan_escalations.rows.length, 0);
186
- // … and the plan's denormalised "surfaced escalation" pointer must be reset,
187
- // so `refreshOpenTaskEscalation` can't re-surface a question for a deleted task.
188
- const plan = stores.plans.rows[0] as Record<string, unknown>;
189
- assertEquals(plan.open_task_escalation_id, null);
190
- assertEquals(plan.open_task_question, null);
191
- assertEquals(plan.open_task_corr_key, null);
192
- assertEquals(plan.open_task_id, null);
193
- });
194
-
195
- // Red/green coverage for the implementation-phase escalation lifecycle (issue #25).
196
- //
197
- // `refreshOpenTaskEscalation` and `answerTaskEscalation` (issue #25) drive new stateful
198
- // behaviour — denormalising the plan's "surfaced" escalation, mirroring the answer onto the
199
- // task row, and publishing the correlated resume message — that had no unit coverage. These
200
- // drive both against the in-memory data layer above and assert the oldest-first surfacing,
201
- // the answer mirroring, and the published message.
202
- import {
203
- answerPlanEscalation,
204
- answerTaskEscalation,
205
- currentPlanReviewEpoch,
206
- PLAN_ESCALATION_MESSAGE,
207
- refreshOpenTaskEscalation,
208
- } from "./plan.ts";
209
-
210
- function escalationStores(rows: unknown[]): Record<string, { rows: unknown[]; key: string }> {
211
- return {
212
- plans: { rows: [{ plan_key: "owner/repo#9" }], key: "plan_key" },
213
- plan_escalations: { rows, key: "id" },
214
- plan_tasks: { rows: [], key: "id" },
215
- };
216
- }
217
-
218
130
  function memData(stores: Record<string, { rows: any[]; key: string }>) {
219
131
  return {
220
132
  table: (name: string, key: string) =>
@@ -222,227 +134,22 @@ function memData(stores: Record<string, { rows: any[]; key: string }>) {
222
134
  } as any;
223
135
  }
224
136
 
225
- test("refreshOpenTaskEscalation surfaces the OLDEST open escalation, then clears when none remain", async () => {
226
- const stores = escalationStores([
227
- { id: 2, plan_key: "owner/repo#9", task_id: "b", corr_key: "owner/repo#9:b", question: "Q-b", status: "open" },
228
- { id: 1, plan_key: "owner/repo#9", task_id: "a", corr_key: "owner/repo#9:a", question: "Q-a", status: "open" },
229
- ]);
230
- const data = memData(stores);
231
-
232
- await refreshOpenTaskEscalation(data, "owner/repo#9");
233
- let plan = stores.plans.rows[0] as any;
234
- assertEquals(plan.open_task_escalation_id, 1);
235
- assertEquals(plan.open_task_question, "Q-a");
236
- assertEquals(plan.open_task_corr_key, "owner/repo#9:a");
237
- assertEquals(plan.open_task_id, "a");
238
-
239
- // Once the oldest is answered, the next-oldest is surfaced.
240
- (stores.plan_escalations.rows.find((r: any) => r.id === 1) as any).status = "answered";
241
- await refreshOpenTaskEscalation(data, "owner/repo#9");
242
- plan = stores.plans.rows[0] as any;
243
- assertEquals(plan.open_task_escalation_id, 2);
244
- assertEquals(plan.open_task_id, "b");
245
-
246
- // With nothing open the denormalised fields clear.
247
- (stores.plan_escalations.rows.find((r: any) => r.id === 2) as any).status = "answered";
248
- await refreshOpenTaskEscalation(data, "owner/repo#9");
249
- plan = stores.plans.rows[0] as any;
250
- assertEquals(plan.open_task_escalation_id, null);
251
- assertEquals(plan.open_task_question, null);
252
- assertEquals(plan.open_task_corr_key, null);
253
- assertEquals(plan.open_task_id, null);
254
- });
255
-
256
- test("answerTaskEscalation records the answer, mirrors it onto the task, publishes the resume message, and re-surfaces the next escalation", async () => {
257
- const stores = escalationStores([
258
- { id: 1, plan_key: "owner/repo#9", task_id: "a", corr_key: "owner/repo#9:a", question: "Q-a", status: "open", answer: null },
259
- { id: 2, plan_key: "owner/repo#9", task_id: "b", corr_key: "owner/repo#9:b", question: "Q-b", status: "open", answer: null },
260
- ]);
261
- stores.plan_tasks.rows.push({ id: 10, plan_key: "owner/repo#9", task_id: "a", answer: null });
262
- const data = memData(stores);
263
-
264
- const published: any[] = [];
265
- const engine = {
266
- publishMessage: (m: any) => {
267
- published.push(m);
268
- return Promise.resolve();
269
- },
270
- } as any;
271
-
272
- const r = await answerTaskEscalation(data, engine, "owner/repo#9:a", "do it");
273
- assertEquals(r.ok, true);
274
- assertEquals(r.escalationId, 1);
275
- assertEquals(r.planKey, "owner/repo#9");
276
- assertEquals(r.taskId, "a");
277
-
278
- // Escalation row marked answered with the recorded answer.
279
- const esc = stores.plan_escalations.rows.find((x: any) => x.id === 1) as any;
280
- assertEquals(esc.status, "answered");
281
- assertEquals(esc.answer, "do it");
282
-
283
- // Answer mirrored onto the task row.
284
- assertEquals((stores.plan_tasks.rows[0] as any).answer, "do it");
285
-
286
- // Correlated resume message published on the shared constant channel.
287
- assertEquals(published.length, 1);
288
- assertEquals(published[0].name, "feature-escalation-answered");
289
- assertEquals(published[0].correlationKey, "owner/repo#9:a");
290
- assertEquals(published[0].variables.answer, "do it");
291
-
292
- // Next-oldest open escalation re-surfaced on the plan row.
293
- assertEquals((stores.plans.rows[0] as any).open_task_escalation_id, 2);
294
- });
295
-
296
- test("answerTaskEscalation is a no-op when no open escalation matches the correlation key", async () => {
297
- const stores = escalationStores([]);
298
- const data = memData(stores);
299
- const engine = {
300
- publishMessage: () => Promise.reject(new Error("should not publish")),
301
- } as any;
302
- const r = await answerTaskEscalation(data, engine, "owner/repo#9:missing", "x");
303
- assertEquals(r.ok, false);
304
- });
305
-
306
- // Red/green regression (PR #131 suppressed advisory, app/plan.ts:455).
137
+ // Coverage for the epic base-branch control (issue nano-ide #124 / 019_plan_base_branch.sql; ADR 0003).
307
138
  //
308
- // Clearing a trial-merge wave's "Needs attention" row (`resolveTrialMergeAttention`)
309
- // is a best-effort cosmetic cleanup, but it must be RETRIABLE: if it ran only AFTER
310
- // the escalation was committed as `answered` and the resume message was published,
311
- // a transient DB error there would 500 the whole answer flow while the escalation is
312
- // already answered/resumed a retry then 404s (no open escalation) and the red row
313
- // is pinned forever (the very failure the insert-first ordering elsewhere avoids).
314
- // The fix runs the idempotent resolution BEFORE the commit/publish, so a failure
315
- // leaves the escalation OPEN and nothing is orphaned — the caller can safely retry.
316
- test("answerTaskEscalation stays retriable (escalation open, no orphaned resume) when clearing 'Needs attention' fails", async () => {
317
- const stores = escalationStores([
318
- {
319
- id: 1,
320
- plan_key: "owner/repo#9",
321
- task_id: "trial-merge-wave-0",
322
- corr_key: "owner/repo#9:trial-merge-wave-0",
323
- question: "Q",
324
- status: "open",
325
- answer: null,
326
- },
327
- ]);
328
- stores.plan_trial_merges = {
329
- rows: [{ id: 100, plan_key: "owner/repo#9", wave: 0, resolved: 0 }],
330
- key: "id",
331
- };
332
- const base = memData(stores);
333
- // Inject a transient failure in the trial-merge audit table's `update` only.
334
- const data = {
335
- table: (name: string, key: string) => {
336
- const t = base.table(name, key);
337
- if (name === "plan_trial_merges") {
338
- return { ...t, update: () => Promise.reject(new Error("transient DB error")) };
339
- }
340
- return t;
341
- },
342
- } as any;
343
-
344
- const published: any[] = [];
345
- const engine = {
346
- publishMessage: (m: any) => {
347
- published.push(m);
348
- return Promise.resolve();
349
- },
350
- } as any;
351
-
352
- await assertRejects(() =>
353
- answerTaskEscalation(data, engine, "owner/repo#9:trial-merge-wave-0", "proceed")
354
- );
355
-
356
- // Escalation must remain OPEN so a retry can recover (never committed as answered).
357
- const esc = stores.plan_escalations.rows.find((x: any) => x.id === 1) as any;
358
- assertEquals(esc.status, "open");
359
- assertEquals(esc.answer, null);
360
- // No orphaned resume message was published.
361
- assertEquals(published.length, 0);
362
- });
363
-
364
- test("currentPlanReviewEpoch counts answered plan-review escalations only", async () => {
365
- const stores = {
366
- plan_review_escalations: {
367
- rows: [
368
- { id: 1, plan_key: "owner/repo#10", status: "answered" },
369
- { id: 2, plan_key: "owner/repo#10", status: "open" },
370
- { id: 3, plan_key: "owner/repo#other", status: "answered" },
371
- ],
372
- key: "id",
373
- },
374
- };
375
- assertEquals(await currentPlanReviewEpoch(memData(stores), "owner/repo#10"), 1);
376
- });
377
-
378
- test("answerPlanEscalation records directive, clears the plan pointer, and publishes the resume message", async () => {
379
- const stores = {
380
- plans: {
381
- rows: [{
382
- plan_key: "owner/repo#11",
383
- open_plan_escalation_id: 7,
384
- open_plan_findings: "reviewer findings",
385
- open_plan_round: 2,
386
- }],
387
- key: "plan_key",
388
- },
389
- plan_review_escalations: {
390
- rows: [{
391
- id: 7,
392
- plan_key: "owner/repo#11",
393
- epoch: 0,
394
- round: 2,
395
- findings: "reviewer findings",
396
- status: "open",
397
- directive: null,
398
- note: null,
399
- }],
400
- key: "id",
401
- },
402
- };
403
- const published: any[] = [];
404
- const engine = {
405
- publishMessage: (m: any) => {
406
- published.push(m);
407
- return Promise.resolve();
408
- },
409
- } as any;
410
-
411
- const r = await answerPlanEscalation(memData(stores), engine, "owner/repo#11", "revise", "Use issue-1 as seam.");
412
- assertEquals(r.ok, true);
413
- assertEquals(r.directive, "revise");
414
- const esc = stores.plan_review_escalations.rows[0] as any;
415
- assertEquals(esc.status, "answered");
416
- assertEquals(esc.directive, "revise");
417
- assertEquals(esc.note, "Use issue-1 as seam.");
418
- const plan = stores.plans.rows[0] as any;
419
- assertEquals(plan.open_plan_escalation_id, null);
420
- assertEquals(plan.open_plan_findings, null);
421
- assertEquals(plan.open_plan_round, null);
422
- assertEquals(published[0].name, PLAN_ESCALATION_MESSAGE);
423
- assertEquals(published[0].correlationKey, "owner/repo#11");
424
- assertEquals(published[0].variables.planEscalationDirective, "revise");
425
- assertEquals(
426
- String(published[0].variables.planFindings).includes("Use issue-1 as seam."),
427
- true,
428
- );
429
- });
430
-
431
- // Coverage for the epic base-branch control (issue nano-ide #124 / 019_plan_base_branch.sql).
432
- //
433
- // A plan may pin a base branch so the fleet branches off — and opens every PR against — a long-lived
434
- // integration branch instead of the repo default, keeping an epic off the default branch (and off any
435
- // merge-to-default side effect such as auto-publishing) until the integration branch is deliberately
436
- // merged. `normalizeBaseBranch` decides "unset" (fall back to default), `renderBaseBranchBrief` is the
437
- // authoritative prompt override, and `startPlan` must persist the branch and seed BOTH the `baseBranch`
438
- // variable and the `baseBranchBrief` (which rides `appendPrompt`) — or leave them null when unpinned.
439
- import { InvalidBaseBranchError, normalizeBaseBranch, renderBaseBranchBrief } from "./plan.ts";
440
-
441
- test("normalizeBaseBranch: blank/whitespace/undefined → null; a real branch is trimmed", () => {
442
- assertEquals(normalizeBaseBranch(undefined), null);
443
- assertEquals(normalizeBaseBranch(null), null);
444
- assertEquals(normalizeBaseBranch(""), null);
445
- assertEquals(normalizeBaseBranch(" "), null);
139
+ // Every plan must pin a base branch so the fleet branches off — and opens every PR against — a
140
+ // long-lived integration branch instead of the repo default, keeping an epic off the default branch
141
+ // (and off any merge-to-default side effect such as auto-publishing) until the integration branch is
142
+ // deliberately merged. Since ADR 0003, base is REQUIRED: `normalizeBaseBranch` rejects a blank/absent
143
+ // value (`MissingBaseBranchError`) instead of falling back to the default. `renderBaseBranchBrief` is
144
+ // the authoritative prompt override, and `startPlan` persists the branch and seeds BOTH the
145
+ // `baseBranch` variable and the `baseBranchBrief` (which rides `appendPrompt`) unconditionally.
146
+ import { InvalidBaseBranchError, MissingBaseBranchError, normalizeBaseBranch, renderBaseBranchBrief } from "./plan.ts";
147
+
148
+ test("normalizeBaseBranch: blank/whitespace/undefined MissingBaseBranchError; a real branch is trimmed", () => {
149
+ assertThrows(() => normalizeBaseBranch(undefined), MissingBaseBranchError);
150
+ assertThrows(() => normalizeBaseBranch(null), MissingBaseBranchError);
151
+ assertThrows(() => normalizeBaseBranch(""), MissingBaseBranchError);
152
+ assertThrows(() => normalizeBaseBranch(" "), MissingBaseBranchError);
446
153
  assertEquals(normalizeBaseBranch(" epic/agent-protocol "), "epic/agent-protocol");
447
154
  });
448
155
 
@@ -450,6 +157,8 @@ test("normalizeBaseBranch: accepts conservative git-branch shapes", () => {
450
157
  assertEquals(normalizeBaseBranch("main"), "main");
451
158
  assertEquals(normalizeBaseBranch("release-1.2"), "release-1.2");
452
159
  assertEquals(normalizeBaseBranch("feature/x_y.z"), "feature/x_y.z");
160
+ // A plausible `epic/*` integration branch (the 019 convention) is returned unchanged.
161
+ assertEquals(normalizeBaseBranch("epic/agent-protocol"), "epic/agent-protocol");
453
162
  });
454
163
 
455
164
  test("normalizeBaseBranch: rejects injection-prone / implausible branch names", () => {
@@ -516,7 +225,7 @@ test("startPlan pins the base branch: persisted on the row + seeded as baseBranc
516
225
  assertEquals(seen.baseBranchBrief.includes("gh pr create --base epic/agent-protocol"), true);
517
226
  });
518
227
 
519
- test("startPlan without a base branch keeps default-branch behaviour (null row + null variables)", async () => {
228
+ test("startPlan renders baseBranchBrief unconditionally now that base is required", async () => {
520
229
  const PLAN_KEY = "owner/repo#200";
521
230
  const stores: Record<string, { rows: any[]; key: string }> = {
522
231
  plans: { rows: [], key: "plan_key" },
@@ -539,9 +248,254 @@ test("startPlan without a base branch keeps default-branch behaviour (null row +
539
248
  number: 200,
540
249
  url: "https://github.com/owner/repo/issues/200",
541
250
  planKey: PLAN_KEY,
251
+ }, "epic/gate-branch");
252
+
253
+ assertEquals((stores.plans.rows[0] as any).base_branch, "epic/gate-branch");
254
+ assertEquals(seen.baseBranch, "epic/gate-branch");
255
+ // The brief is always rendered — there is no null fork any more.
256
+ assertEquals(seen.baseBranchBrief.includes("gh pr create --base epic/gate-branch"), true);
257
+ });
258
+
259
+ test("startPlan grandfathers a pre-existing null base_branch row: re-plan reads it without error", async () => {
260
+ // Pre-ADR-0003 / in-flight rows carry base_branch = null (the column stays nullable). Re-planning
261
+ // such a finished issue must read the old null row without error and re-pin it to the new explicit
262
+ // base — the required-ness is enforced at admission of the new launch, not by a DB NOT NULL.
263
+ const PLAN_KEY = "owner/repo#201";
264
+ const stores: Record<string, { rows: any[]; key: string }> = {
265
+ plans: { rows: [{ plan_key: PLAN_KEY, status: "done", task_count: 0, base_branch: null }], key: "plan_key" },
266
+ plan_tasks: { rows: [], key: "id" },
267
+ plan_reviews: { rows: [], key: "plan_key" },
268
+ plan_escalations: { rows: [], key: "id" },
269
+ plan_task_deps: { rows: [], key: "plan_key" },
270
+ };
271
+ const data = memData(stores);
272
+ let seen: any = null;
273
+ const engine = {
274
+ createInstance: (req: any) => {
275
+ seen = req.variables;
276
+ return Promise.resolve({ processInstanceKey: "PI-3" });
277
+ },
278
+ } as any;
279
+
280
+ await startPlan(data, engine, {
281
+ repo: "owner/repo",
282
+ number: 201,
283
+ url: "https://github.com/owner/repo/issues/201",
284
+ planKey: PLAN_KEY,
285
+ }, "epic/gate-branch");
286
+
287
+ // The grandfathered null row is re-pinned to the new explicit base without throwing.
288
+ assertEquals((stores.plans.rows[0] as any).base_branch, "epic/gate-branch");
289
+ assertEquals(seen.baseBranch, "epic/gate-branch");
290
+ });
291
+
292
+ // ── admitPlan decision matrix (ADR 0003 §Decision, rules 1-4) ────────────────
293
+ // The fail-fast admission gate composes four ORDERED rules before any fan-out. These drive it
294
+ // through a faked github transport (token mode + stubbed `globalThis.fetch`) and an in-memory
295
+ // `plans` table, asserting each rule's accept/reject and that the ORDER is load-bearing.
296
+ import { BaseBranchMustExistError } from "./github.ts";
297
+ import { admitPlan, DefaultBaseNotConfirmedError, findActivePlansByBase, SharedBaseError } from "./plan.ts";
298
+
299
+ interface AdmitRepo {
300
+ repo: string;
301
+ defaultBranch: string;
302
+ branches: Set<string>;
303
+ creates: string[]; // refs created via POST
304
+ metaCalls: number; // GETs to /repos/:repo (default-branch resolution)
305
+ }
306
+
307
+ function admitFetch(state: AdmitRepo) {
308
+ return (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
309
+ const u = new URL(String(url));
310
+ const method = (init?.method ?? "GET").toUpperCase();
311
+ const path = u.pathname;
312
+ if (method === "GET" && path === `/repos/${state.repo}`) {
313
+ state.metaCalls += 1;
314
+ return Promise.resolve(
315
+ new Response(JSON.stringify({ default_branch: state.defaultBranch }), {
316
+ status: 200,
317
+ headers: { "content-type": "application/json" },
318
+ }),
319
+ );
320
+ }
321
+ const refPrefix = `/repos/${state.repo}/git/ref/heads/`;
322
+ if (method === "GET" && path.startsWith(refPrefix)) {
323
+ const branch = decodeURIComponent(path.slice(refPrefix.length));
324
+ if (!state.branches.has(branch)) return Promise.resolve(new Response("Not Found", { status: 404 }));
325
+ return Promise.resolve(
326
+ new Response(JSON.stringify({ ref: `refs/heads/${branch}`, object: { sha: `${branch}-sha` } }), {
327
+ status: 200,
328
+ headers: { "content-type": "application/json" },
329
+ }),
330
+ );
331
+ }
332
+ if (method === "POST" && path === `/repos/${state.repo}/git/refs`) {
333
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
334
+ const body = JSON.parse(String(init?.body ?? "{}")) as { ref?: string };
335
+ const ref = String(body.ref ?? "");
336
+ const branch = ref.replace(/^refs\/heads\//, "");
337
+ if (state.branches.has(branch)) {
338
+ return Promise.resolve(new Response(JSON.stringify({ message: "Reference already exists" }), { status: 422 }));
339
+ }
340
+ state.creates.push(ref);
341
+ state.branches.add(branch);
342
+ return Promise.resolve(new Response(JSON.stringify({ ref }), { status: 201 }));
343
+ }
344
+ return Promise.resolve(new Response(`unexpected ${method} ${path}`, { status: 500 }));
345
+ };
346
+ }
347
+
348
+ async function withAdmit<T>(state: AdmitRepo, fn: () => Promise<T>): Promise<T> {
349
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
350
+ const prevFetch = globalThis.fetch;
351
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
352
+ globalThis.fetch = admitFetch(state) as typeof fetch;
353
+ try {
354
+ return await fn();
355
+ } finally {
356
+ globalThis.fetch = prevFetch;
357
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
358
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
359
+ }
360
+ }
361
+
362
+ function admitData(planRows: any[] = []) {
363
+ return memData({ plans: { rows: planRows, key: "plan_key" } });
364
+ }
365
+
366
+ test("admitPlan rule 1: blank/absent base → MissingBaseBranchError (before any github call)", async () => {
367
+ const state: AdmitRepo = { repo: "o/r1", defaultBranch: "main", branches: new Set(["main"]), creates: [], metaCalls: 0 };
368
+ await withAdmit(state, async () => {
369
+ await assertRejects(() => admitPlan(admitData(), state.repo, "", "tok"), MissingBaseBranchError);
370
+ await assertRejects(() => admitPlan(admitData(), state.repo, null, "tok"), MissingBaseBranchError);
371
+ });
372
+ // Rule 1 fires before rule 2/3 — the default-branch endpoint is never hit.
373
+ assertEquals(state.metaCalls, 0);
374
+ assertEquals(state.creates.length, 0);
375
+ });
376
+
377
+ test("admitPlan rule 1: implausible base → InvalidBaseBranchError", async () => {
378
+ const state: AdmitRepo = { repo: "o/r1b", defaultBranch: "main", branches: new Set(["main"]), creates: [], metaCalls: 0 };
379
+ await withAdmit(state, async () => {
380
+ await assertRejects(() => admitPlan(admitData(), state.repo, "bad branch;rm -rf", "tok"), InvalidBaseBranchError);
542
381
  });
382
+ assertEquals(state.metaCalls, 0);
383
+ });
384
+
385
+ test("admitPlan rule 2: missing non-epic/* base → BaseBranchMustExistError (synchronous edge-400 path)", async () => {
386
+ const state: AdmitRepo = { repo: "o/r2", defaultBranch: "main", branches: new Set(["main"]), creates: [], metaCalls: 0 };
387
+ await withAdmit(state, async () => {
388
+ await assertRejects(() => admitPlan(admitData(), state.repo, "release-9", "tok"), BaseBranchMustExistError);
389
+ });
390
+ assertEquals(state.creates.length, 0);
391
+ });
392
+
393
+ test("admitPlan rule 2: missing epic/* base → created off default HEAD, then admitted", async () => {
394
+ const state: AdmitRepo = { repo: "o/r3", defaultBranch: "main", branches: new Set(["main"]), creates: [], metaCalls: 0 };
395
+ const base = await withAdmit(state, () => admitPlan(admitData(), state.repo, "epic/new-thing", "tok"));
396
+ assertEquals(base, "epic/new-thing");
397
+ assertEquals(state.creates, ["refs/heads/epic/new-thing"]);
398
+ });
543
399
 
544
- assertEquals((stores.plans.rows[0] as any).base_branch, null);
545
- assertEquals(seen.baseBranch, null);
546
- assertEquals(seen.baseBranchBrief, null);
400
+ test("admitPlan rule 3: default-branch target WITHOUT confirmDefaultBase → DefaultBaseNotConfirmedError", async () => {
401
+ const state: AdmitRepo = { repo: "o/r4", defaultBranch: "main", branches: new Set(["main"]), creates: [], metaCalls: 0 };
402
+ await withAdmit(state, async () => {
403
+ await assertRejects(() => admitPlan(admitData(), state.repo, "main", "tok"), DefaultBaseNotConfirmedError);
404
+ });
405
+ });
406
+
407
+ test("admitPlan rule 3: default-branch target WITH confirmDefaultBase → admitted", async () => {
408
+ const state: AdmitRepo = { repo: "o/r5", defaultBranch: "main", branches: new Set(["main"]), creates: [], metaCalls: 0 };
409
+ const base = await withAdmit(state, () =>
410
+ admitPlan(admitData(), state.repo, "main", "tok", { confirmDefaultBase: true }),
411
+ );
412
+ assertEquals(base, "main");
413
+ });
414
+
415
+ test("admitPlan rule 4: active shared CUSTOM base WITHOUT allowSharedBase → SharedBaseError", async () => {
416
+ const state: AdmitRepo = { repo: "o/r6", defaultBranch: "main", branches: new Set(["main", "epic/shared"]), creates: [], metaCalls: 0 };
417
+ const planRows = [{ plan_key: "o/r6#1", repo: "o/r6", base_branch: "epic/shared", status: "planning" }];
418
+ await withAdmit(state, async () => {
419
+ await assertRejects(() => admitPlan(admitData(planRows), state.repo, "epic/shared", "tok"), SharedBaseError);
420
+ });
421
+ });
422
+
423
+ test("admitPlan rule 4: same-issue re-submit is admitted — selfPlanKey excludes the launch's OWN active row", async () => {
424
+ // Idempotency regression: startPlan short-circuits an in-flight plan to `alreadyRunning`, but that
425
+ // reachable only if admitPlan does NOT 409 the retry against the plan's own active row. With
426
+ // selfPlanKey set, the shared-base guard excludes that row, so the same-issue re-submit is admitted.
427
+ const state: AdmitRepo = { repo: "o/r6b", defaultBranch: "main", branches: new Set(["main", "epic/shared"]), creates: [], metaCalls: 0 };
428
+ const planRows = [{ plan_key: "o/r6b#1", repo: "o/r6b", base_branch: "epic/shared", status: "planning" }];
429
+ const base = await withAdmit(state, () =>
430
+ admitPlan(admitData(planRows), state.repo, "epic/shared", "tok", { selfPlanKey: "o/r6b#1" }),
431
+ );
432
+ assertEquals(base, "epic/shared");
433
+ });
434
+
435
+ test("admitPlan rule 4: a DIFFERENT active plan on the same base still trips the guard even with selfPlanKey set", async () => {
436
+ // selfPlanKey excludes only the launch's own row — a genuine collision with another epic still 409s.
437
+ const state: AdmitRepo = { repo: "o/r6c", defaultBranch: "main", branches: new Set(["main", "epic/shared"]), creates: [], metaCalls: 0 };
438
+ const planRows = [{ plan_key: "o/r6c#2", repo: "o/r6c", base_branch: "epic/shared", status: "planning" }];
439
+ await withAdmit(state, async () => {
440
+ await assertRejects(
441
+ () => admitPlan(admitData(planRows), state.repo, "epic/shared", "tok", { selfPlanKey: "o/r6c#1" }),
442
+ SharedBaseError,
443
+ );
444
+ });
445
+ });
446
+
447
+ test("admitPlan rule 4: same custom base WITH allowSharedBase → admitted", async () => {
448
+ const state: AdmitRepo = { repo: "o/r7", defaultBranch: "main", branches: new Set(["main", "epic/shared"]), creates: [], metaCalls: 0 };
449
+ const planRows = [{ plan_key: "o/r7#1", repo: "o/r7", base_branch: "epic/shared", status: "planning" }];
450
+ const base = await withAdmit(state, () =>
451
+ admitPlan(admitData(planRows), state.repo, "epic/shared", "tok", { allowSharedBase: true }),
452
+ );
453
+ assertEquals(base, "epic/shared");
454
+ });
455
+
456
+ test("admitPlan rule 4: two plans sharing the DEFAULT branch → always admitted (exempt)", async () => {
457
+ const state: AdmitRepo = { repo: "o/r8", defaultBranch: "main", branches: new Set(["main"]), creates: [], metaCalls: 0 };
458
+ // Another active plan already targets the default branch — the shared-base guard exempts it.
459
+ const planRows = [{ plan_key: "o/r8#1", repo: "o/r8", base_branch: "main", status: "planning" }];
460
+ const base = await withAdmit(state, () =>
461
+ admitPlan(admitData(planRows), state.repo, "main", "tok", { confirmDefaultBase: true }),
462
+ );
463
+ assertEquals(base, "main");
464
+ });
465
+
466
+ test("admitPlan rule 4: a TERMINAL-status plan on the same base does NOT trip the guard", async () => {
467
+ const state: AdmitRepo = { repo: "o/r9", defaultBranch: "main", branches: new Set(["main", "epic/done-base"]), creates: [], metaCalls: 0 };
468
+ const planRows = [{ plan_key: "o/r9#1", repo: "o/r9", base_branch: "epic/done-base", status: "done" }];
469
+ const base = await withAdmit(state, () => admitPlan(admitData(planRows), state.repo, "epic/done-base", "tok"));
470
+ assertEquals(base, "epic/done-base");
471
+ });
472
+
473
+ test("admitPlan ORDER: a blank base is rejected before confirm-default / shared-base run", async () => {
474
+ // Even with an active shared plan present AND the base being the default, rule 1 must fire first.
475
+ const state: AdmitRepo = { repo: "o/r10", defaultBranch: "main", branches: new Set(["main"]), creates: [], metaCalls: 0 };
476
+ const planRows = [{ plan_key: "o/r10#1", repo: "o/r10", base_branch: "main", status: "planning" }];
477
+ await withAdmit(state, async () => {
478
+ await assertRejects(() => admitPlan(admitData(planRows), state.repo, " ", "tok"), MissingBaseBranchError);
479
+ });
480
+ assertEquals(state.metaCalls, 0); // never reached rule 3
481
+ });
482
+
483
+ test("admitPlan ORDER: a typo'd non-epic base is rejected (rule 2) before confirm-default (rule 3)", async () => {
484
+ const state: AdmitRepo = { repo: "o/r11", defaultBranch: "main", branches: new Set(["main"]), creates: [], metaCalls: 0 };
485
+ await withAdmit(state, async () => {
486
+ await assertRejects(() => admitPlan(admitData(), state.repo, "mian", "tok"), BaseBranchMustExistError);
487
+ });
488
+ // ensureBaseBranch (rule 2) throws for the missing non-epic/* branch before fetchDefaultBranch (rule 3).
489
+ assertEquals(state.metaCalls, 0);
490
+ });
491
+
492
+ test("findActivePlansByBase returns only non-terminal plans on the matching repo + base", async () => {
493
+ const rows = [
494
+ { plan_key: "o/x#1", repo: "o/x", base_branch: "epic/b", status: "planning" },
495
+ { plan_key: "o/x#2", repo: "o/x", base_branch: "epic/b", status: "done" },
496
+ { plan_key: "o/x#3", repo: "o/x", base_branch: "epic/other", status: "planning" },
497
+ ];
498
+ const active = await findActivePlansByBase(admitData(rows), "o/x", "epic/b");
499
+ assertEquals(active.length, 1);
500
+ assertEquals(active[0].plan_key, "o/x#1");
547
501
  });