@nanobpm/nano-workforce 0.51.0 → 0.53.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/app/agentic/families/presence.family.test.ts +341 -0
- package/app/agentic/families/presence.family.ts +279 -0
- package/app/agentic/families/relay.family.test.ts +402 -0
- package/app/agentic/families/relay.family.ts +331 -0
- package/db/migrations/023_agentic_presence.sql +30 -0
- package/db/migrations/024_agentic_transcript.sql +43 -0
- package/docs/adr/0002-escalations-are-user-tasks-and-forms.md +167 -0
- package/docs/adr/0003-epic-base-branch-admission.md +123 -0
- package/package.json +1 -1
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# ADR 0003 — Epic base-branch admission: explicit, auto-created, and guarded
|
|
2
|
+
|
|
3
|
+
Status: **Proposed.**
|
|
4
|
+
Date: 2026-08-13.
|
|
5
|
+
|
|
6
|
+
> **Scope note.** A **nano-workforce-local** ADR — it governs how *this app* admits an epic for
|
|
7
|
+
> execution. Platform-wide ADRs live in `Magikcraft/nano-bpm/docs/adr` (referenced by number + repo).
|
|
8
|
+
> Continues nano-workforce's series after ADR 0001 (ADR 0002 is planned but not yet written; see below).
|
|
9
|
+
|
|
10
|
+
Relates to:
|
|
11
|
+
nano-workforce **ADR 0001** (cross-repo epics + integration branches — this ADR hardens *how* an epic's
|
|
12
|
+
integration branch is chosen, created, and protected),
|
|
13
|
+
nano-workforce **ADR 0002** *(planned — not yet written in this repo; escalations as user tasks)*: the
|
|
14
|
+
confirm / shared-base gates are human decisions that *could* later surface as user tasks; see Open questions,
|
|
15
|
+
nano-bpm **ADR 0058** (the OpenAPI endpoint surface — `startPlanFanout` is a spec operation whose request
|
|
16
|
+
schema this ADR changes),
|
|
17
|
+
migration `019_plan_base_branch.sql` (the `plans.base_branch` column, whose example is `epic/agent-protocol`),
|
|
18
|
+
and in this repo: `operations/startPlanFanout.ts` (the launch operation), `app/plan.ts`
|
|
19
|
+
(`startPlan`, `normalizeBaseBranch`, `renderBaseBranchBrief`, `PLAN_TERMINAL_STATUSES`), `app/baseGuard.ts`
|
|
20
|
+
+ `app/github.ts` (`baseBranchLanded`, `fetchDefaultBranch`), and `resources/processes/plan-fanout.bpmn`.
|
|
21
|
+
|
|
22
|
+
## Context
|
|
23
|
+
|
|
24
|
+
An epic is launched through the `startPlanFanout` operation, which calls `startPlan(data, engine, parsed,
|
|
25
|
+
baseBranch)`. The `base_branch` (migration 019) tells every fanned-out task agent to branch off it and open
|
|
26
|
+
its PR against it (`renderBaseBranchBrief`), landing the whole epic on a long-lived integration branch that
|
|
27
|
+
reaches the default branch — and any merge-to-default side effect such as auto-publishing — only when the
|
|
28
|
+
integration branch is deliberately merged.
|
|
29
|
+
|
|
30
|
+
Four gaps make this a footgun surface:
|
|
31
|
+
|
|
32
|
+
1. **Implicit default.** `normalizeBaseBranch` maps a blank/absent value to `null`, silently meaning "target
|
|
33
|
+
the repository default branch." An operator who *meant* to name an integration branch but omitted it
|
|
34
|
+
lands every task straight onto the default branch (e.g. `main`) with **no integration buffer** — and
|
|
35
|
+
any merge-to-default side effect fires per task.
|
|
36
|
+
2. **No branch creation.** Nothing creates the integration branch. `baseGuard`/`baseBranchLanded` only
|
|
37
|
+
*read* (`gh pr list`, `fetchPrBase`, `fetchDefaultBranch`). So if the named branch doesn't exist, the
|
|
38
|
+
**first task's** `git fetch origin <branch>` / `gh pr create --base <branch>` fails — a late, per-task
|
|
39
|
+
failure instead of a clean admission error.
|
|
40
|
+
3. **No typo guard.** A mistyped branch name is indistinguishable from an intended new one; without
|
|
41
|
+
creation it fails late, and *with* naive creation it would silently spawn a wrong-rooted branch.
|
|
42
|
+
4. **No collision guard.** Two in-flight epics can target the **same** integration branch, interleaving
|
|
43
|
+
commits and poisoning each other's base — with no warning.
|
|
44
|
+
|
|
45
|
+
The pieces to fix this already exist: `fetchDefaultBranch` (default-branch identity), `PLAN_TERMINAL_STATUSES`
|
|
46
|
+
(`done|failed|abandoned` → the complement is "active"), a strict `isPlausibleBranchName` allowlist, and the
|
|
47
|
+
`019` example convention `epic/*`.
|
|
48
|
+
|
|
49
|
+
## Decision
|
|
50
|
+
|
|
51
|
+
**Every epic launch must state its base branch explicitly, and `startPlanFanout` admits it through one
|
|
52
|
+
fail-fast gate** — `admitPlan(...)` — run before any task fans out, backed by a durable `ensure-base-branch`
|
|
53
|
+
head step in `plan-fanout.bpmn`. The gate has four ordered rules:
|
|
54
|
+
|
|
55
|
+
### 1. Required + explicit — no implicit default
|
|
56
|
+
|
|
57
|
+
`baseBranch` becomes a **required** field of `StartPlanFanoutRequest`. `normalizeBaseBranch` **rejects** a
|
|
58
|
+
blank/absent value (`MissingBaseBranchError` → HTTP 400) instead of returning `null`. "Land on the default
|
|
59
|
+
branch" is now a **conscious, named, confirmed** choice (rule 3), never a silent fallback. The
|
|
60
|
+
`base_branch == null ? default : brief` fork in the launch/prompt path is removed; `renderBaseBranchBrief`
|
|
61
|
+
is always rendered. (`plans.base_branch` stays nullable in the DB **only** to grandfather pre-migration
|
|
62
|
+
rows; new launches always set it.)
|
|
63
|
+
|
|
64
|
+
### 2. Create-if-missing, idempotently — with an `epic/*` guard
|
|
65
|
+
|
|
66
|
+
`ensureBaseBranch(repo, branch, token)`:
|
|
67
|
+
- **Exists** → no-op (never reset — a reset would nuke in-flight task PRs stacked on it; a stacked epic's
|
|
68
|
+
base already exists and is left alone).
|
|
69
|
+
- **Missing and matches `epic/*`** → create `refs/heads/<branch>` off the **default branch HEAD**.
|
|
70
|
+
- **Missing and *not* `epic/*`** → **reject** (`BaseBranchMustExistError`): a non-`epic/*` branch must
|
|
71
|
+
already exist, so a typo can't silently spawn a wrong-rooted branch. (`epic/*` is the `019` convention.)
|
|
72
|
+
|
|
73
|
+
Runs at admission (fail fast) **and** as a head `ensure-base-branch` service task in `plan-fanout.bpmn` so
|
|
74
|
+
it is durable + retriable even on a re-plan.
|
|
75
|
+
|
|
76
|
+
### 3. Confirm-default — naming the default branch is deliberate
|
|
77
|
+
|
|
78
|
+
If the explicit target **equals the repository default** (via `fetchDefaultBranch`), admission requires an
|
|
79
|
+
explicit `confirmDefaultBase: true`, else **reject** (`DefaultBaseNotConfirmedError` → 400) with a message
|
|
80
|
+
spelling out the consequence ("every task lands directly on `<default>` with no integration branch; any
|
|
81
|
+
merge-to-default side effect fires per task"). This is the single guardrail on the one dangerous explicit
|
|
82
|
+
value.
|
|
83
|
+
|
|
84
|
+
### 4. Shared-base guard — one integration branch, one epic
|
|
85
|
+
|
|
86
|
+
If another plan whose `status ∉ PLAN_TERMINAL_STATUSES` (i.e. **active**) targets the **same repo + same
|
|
87
|
+
base branch**, admission **rejects** (`SharedBaseError` → 409) unless `allowSharedBase: true`. **Exempt: the
|
|
88
|
+
default branch** — many epics target the default concurrently and don't collide (each task PR is
|
|
89
|
+
independent). The guard fires only for a **shared custom integration branch**, the genuinely dangerous case.
|
|
90
|
+
|
|
91
|
+
### Recommended defaults (confirm before build)
|
|
92
|
+
|
|
93
|
+
- **Hard-require flags**, not soft warnings, for rules 3 and 4: a warning in a headless submit flow is
|
|
94
|
+
ignorable; a required `confirmDefaultBase` / `allowSharedBase` is a "warn you can't skip."
|
|
95
|
+
- **`epic/*` auto-create guard** (rule 2): auto-create only `epic/*`; any other non-existent name is a
|
|
96
|
+
400. Matches the `019` convention.
|
|
97
|
+
|
|
98
|
+
## Consequences
|
|
99
|
+
|
|
100
|
+
- **A footgun class disappears:** no silent land-on-main, no first-task-fails-on-missing-branch, no typo'd
|
|
101
|
+
wrong-rooted branch, no two-epics-one-branch interleave — all become clean admission errors.
|
|
102
|
+
- **Breaking API + launch-path change.** `StartPlanFanoutRequest.baseBranch` is now required; any
|
|
103
|
+
fire-and-forget caller/CLI/epic template that omitted it will (intentionally) 400. The OpenAPI spec +
|
|
104
|
+
generated types (`nano-generated/api-io.d.ts`, controller) regenerate; the submit surface + docs update.
|
|
105
|
+
- **Back-compat:** pre-migration / in-flight `base_branch = null` plans are **grandfathered** (the column
|
|
106
|
+
stays nullable; the required-ness is enforced at *admission* of new launches, not by a DB `NOT NULL`).
|
|
107
|
+
- **New write permission exercised:** ref creation (already held by the token that pushes task branches).
|
|
108
|
+
- **Ties into ADR 0001/0002:** this is the admission half of the integration-branch story (0001), and its
|
|
109
|
+
human gates could later be modeled as user tasks (planned ADR 0002) rather than flags — see Open questions.
|
|
110
|
+
|
|
111
|
+
## Open questions
|
|
112
|
+
|
|
113
|
+
- **Gates as user tasks (planned ADR 0002)?** Should `confirmDefaultBase` / shared-base become an inbox **user
|
|
114
|
+
task** ("Epic X wants to target `main` / share `epic/y` — approve?") instead of a submit-time flag? For
|
|
115
|
+
*pre-fan-out* admission a synchronous flag is simpler and fail-fast; a user task fits only if we want a
|
|
116
|
+
human in the launch loop. Deferred.
|
|
117
|
+
- **Auto-create root.** Always off default HEAD — correct for a fresh integration branch. Should a stacked
|
|
118
|
+
epic be able to declare `stackOn: <branch>` so its base is auto-created off *another* epic's branch
|
|
119
|
+
rather than default? (Today: that base must pre-exist.)
|
|
120
|
+
- **Convention scope.** Is `epic/*` the only auto-createable prefix, or should the app config own the
|
|
121
|
+
allowlist?
|
|
122
|
+
- **Grandfathered nulls.** Leave historical `null` rows as-is, or backfill them to the (then-current)
|
|
123
|
+
default branch for a uniform read model?
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.53.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|