@nanobpm/nano-workforce 0.171.4 → 0.171.6
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 +12 -0
- package/app/contracts.ts +3 -3
- package/app/feature.test.ts +30 -0
- package/app/feature.ts +15 -1
- package/app/plan.test.ts +9 -0
- package/app/plan.ts +11 -0
- package/app/repoEnvelope.ts +102 -0
- package/app/service.test.ts +28 -0
- package/app/service.ts +11 -72
- package/docs/agent-guide.md +24 -0
- package/e2e/feature-run.e2e.ts +16 -1
- package/e2e/plan-fanout.e2e.ts +28 -0
- package/package.json +1 -1
- package/resources/prompts/feature.md +22 -0
- package/resources/prompts/fix-ci.md +8 -0
- package/resources/prompts/retro.md +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.171.6](https://github.com/nanobpm/nano-workforce/compare/v0.171.5...v0.171.6) (2026-09-01)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **agentic:** emit the repository envelope on implementation jobs so they run in an isolated clone ([#685](https://github.com/nanobpm/nano-workforce/issues/685)) ([be98081](https://github.com/nanobpm/nano-workforce/commit/be980818321f18e101d0fbef85b30b75d6a6fd15)), closes [#684](https://github.com/nanobpm/nano-workforce/issues/684)
|
|
6
|
+
|
|
7
|
+
## [0.171.5](https://github.com/nanobpm/nano-workforce/compare/v0.171.4...v0.171.5) (2026-09-01)
|
|
8
|
+
|
|
9
|
+
### Performance Improvements
|
|
10
|
+
|
|
11
|
+
* **agents:** warm the build cache once, then build module-scoped — never a cold `-am` reactor build ([#682](https://github.com/nanobpm/nano-workforce/issues/682)) ([338057b](https://github.com/nanobpm/nano-workforce/commit/338057b7f65cbf4e61cf4d74efaa596b3422532b)), closes [#681](https://github.com/nanobpm/nano-workforce/issues/681)
|
|
12
|
+
|
|
1
13
|
## [0.171.4](https://github.com/nanobpm/nano-workforce/compare/v0.171.3...v0.171.4) (2026-09-01)
|
|
2
14
|
|
|
3
15
|
### Bug Fixes
|
package/app/contracts.ts
CHANGED
|
@@ -382,11 +382,11 @@ export const WIRE_CONTRACTS = {
|
|
|
382
382
|
"io.nanobpm.agentTask.repository": {
|
|
383
383
|
category: "wire",
|
|
384
384
|
name: "io.nanobpm.agentTask.repository",
|
|
385
|
-
owner: "app/
|
|
385
|
+
owner: "app/repoEnvelope.ts",
|
|
386
386
|
semantics:
|
|
387
|
-
"Repo-provisioning envelope the app emits as a `createInstance` process variable (`repoEnvelopeVars
|
|
387
|
+
"Repo-provisioning envelope the app emits as a `createInstance` process variable (`repoEnvelopeVars`, app/repoEnvelope.ts) and the c8ctl worker harness consumes to provision an isolated clone — instead of the agent inheriting the worker's launch dir (issue #684). `ref` is the branch checked out: the PR HEAD branch on the PR-based paths (review-round / fix-ci / rebase), or — on the PRE-PR implementation path (feature.bpmn / plan-fanout's `implement-cell`, issue #684) — the BASE branch, off which the harness cuts a new feature branch named by the optional `branch.create` (the deterministic `feat/<task.id>`, emitted only for a single-task feature run; the epic seed omits it so each slice's agent branches per MI child). Beyond `{provider,url,ref}`, it carries clone-shaping fields for large monorepos (issue #287): `singleBranch:true` + `filter:\"blob:none\"` (a branch-scoped, blobless partial clone — trees fetched up-front, blobs lazily, no `--depth 1` so the merge-base/3-dot diff stays valid) and an optional `baseRef` (the PR base branch, emitted only when resolvable, so the harness fetches its tip and keeps `origin/<base>` reachable). World-restore (issue #324, ADR 0062 Slice 4/5): an optional `commitSha` — the last durable push-checkpoint — is emitted so a REPLACEMENT activation on a fresh worktree reconstructs the tree to the EXACT pushed SHA (inverting the round's `git push` into `git fetch && git checkout <sha>`), omitted when the PR has no checkpoint yet. Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91).",
|
|
388
388
|
shape:
|
|
389
|
-
'{ provider: "github", url: string, ref: string, singleBranch: true, filter: "blob:none", baseRef?: string, commitSha?: string }',
|
|
389
|
+
'{ provider: "github", url: string, ref: string, singleBranch: true, filter: "blob:none", baseRef?: string, commitSha?: string, branch?: { create: string } }',
|
|
390
390
|
},
|
|
391
391
|
"epicSet.submit": {
|
|
392
392
|
category: "wire",
|
package/app/feature.test.ts
CHANGED
|
@@ -131,6 +131,36 @@ test("startFeature: seeds the single task slice + base-branch brief onto the ins
|
|
|
131
131
|
assertEquals(v.status, null);
|
|
132
132
|
});
|
|
133
133
|
|
|
134
|
+
test("startFeature: seeds the pre-PR repository envelope so the harness provisions an isolated clone (#684)", async () => {
|
|
135
|
+
let captured: any = null;
|
|
136
|
+
const engine = {
|
|
137
|
+
createInstance: (req: any) => {
|
|
138
|
+
captured = req;
|
|
139
|
+
return Promise.resolve({ processInstanceKey: "PI-684" });
|
|
140
|
+
},
|
|
141
|
+
} as any;
|
|
142
|
+
await startFeature(
|
|
143
|
+
memData({ feature_runs: { rows: [], key: "feature_key" } }),
|
|
144
|
+
engine,
|
|
145
|
+
PARSED,
|
|
146
|
+
"epic/x",
|
|
147
|
+
true,
|
|
148
|
+
false,
|
|
149
|
+
);
|
|
150
|
+
// Without the envelope the c8ctl harness leaves cwd undefined and the agent mutates the worker's
|
|
151
|
+
// shared launch dir; with it, the harness clones a throwaway workspace. The implementation path is
|
|
152
|
+
// PRE-PR, so it checks out the BASE branch (`ref`) and the harness cuts the deterministic
|
|
153
|
+
// `feat/<task.id>` feature branch off it (`branch.create`).
|
|
154
|
+
const repo = (captured.variables as Record<string, any>)["io.nanobpm.agentTask"].repository;
|
|
155
|
+
assertEquals(repo.url, "https://github.com/owner/repo.git");
|
|
156
|
+
assertEquals(repo.ref, "epic/x");
|
|
157
|
+
assertEquals(repo.branch.create, "feat/issue-42");
|
|
158
|
+
assertEquals(repo.branch.create, `feat/${featureTaskId(PARSED.number)}`);
|
|
159
|
+
// The blobless/single-branch monorepo shaping rides along, exactly like the PR-based envelope.
|
|
160
|
+
assertEquals(repo.singleBranch, true);
|
|
161
|
+
assertEquals(repo.filter, "blob:none");
|
|
162
|
+
});
|
|
163
|
+
|
|
134
164
|
test("startFeature: custom instructions ride the instance as a variable (trimmed)", async () => {
|
|
135
165
|
let captured: any = null;
|
|
136
166
|
const engine = {
|
package/app/feature.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { TRANSCRIPT_URL_BASE_VAR, transcriptUrlBaseFor } from "./agentic/transcr
|
|
|
19
19
|
import { coalesceTitle, fetchIssueTitle } from "./github.ts";
|
|
20
20
|
import { ESCALATION_SLA_TIMEOUT, normalizeBaseBranch, type ParsedIssue, renderBaseBranchBrief } from "./plan.ts";
|
|
21
21
|
import type { ReadinessProbe } from "./readiness.ts";
|
|
22
|
+
import { repoEnvelopeVars } from "./repoEnvelope.ts";
|
|
22
23
|
|
|
23
24
|
/** Optional intake-time readiness gate for a feature run (issue #295): the `capability`/`command`/…
|
|
24
25
|
* probes the run must ALL satisfy before its implementation agent is dispatched (parked, durably, at
|
|
@@ -336,6 +337,10 @@ export async function startFeature(
|
|
|
336
337
|
updated_at: ts,
|
|
337
338
|
});
|
|
338
339
|
}
|
|
340
|
+
const taskId = featureTaskId(parsed.number);
|
|
341
|
+
// Pre-PR feature branch (issue #684): the deterministic `feat/<task.id>` the harness creates off the
|
|
342
|
+
// base for the implementation agent (the agent-guide's `feat/*` convention), matching `task.id`.
|
|
343
|
+
const prePrBranch = `feat/${taskId}`;
|
|
339
344
|
const { processInstanceKey } = await engine.createInstance({
|
|
340
345
|
processDefinitionId: FEATURE_PROCESS_ID,
|
|
341
346
|
variables: {
|
|
@@ -349,7 +354,7 @@ export async function startFeature(
|
|
|
349
354
|
// (resources/prompts/feature.md); `task.id` fixes its deterministic branch `feat/<task.id>` across a
|
|
350
355
|
// resume. Unlike an epic, there is no planner — the whole issue IS the slice.
|
|
351
356
|
task: {
|
|
352
|
-
id:
|
|
357
|
+
id: taskId,
|
|
353
358
|
title: parsed.planKey,
|
|
354
359
|
prompt:
|
|
355
360
|
`Implement the GitHub issue ${parsed.planKey} end to end. Read it in full first ` +
|
|
@@ -406,6 +411,15 @@ export async function startFeature(
|
|
|
406
411
|
// links this feature run to its agent transcript in Nano Explorer (feature.bpmn `implement-task`
|
|
407
412
|
// ioMapping). Read down into the job via `=transcriptUrlBase`.
|
|
408
413
|
[TRANSCRIPT_URL_BASE_VAR]: transcriptUrlBaseFor(),
|
|
414
|
+
// Host-git provisioning (c8ctl, issue #684): deliver the repository envelope so the
|
|
415
|
+
// `senior:feature` implementation agent gets an ISOLATED throwaway clone instead of inheriting
|
|
416
|
+
// the worker's launch dir (which, with several copilot workers on one host, means concurrent
|
|
417
|
+
// implementation jobs share — and clobber — one checkout, violating the durable-resume design).
|
|
418
|
+
// A feature run is PRE-PR: there is no head branch yet, so the harness checks out the BASE
|
|
419
|
+
// branch (`ref = base`) and creates the deterministic `feat/<task.id>` feature branch itself
|
|
420
|
+
// (`branchCreate`), matching the agent-guide's `feat/*` convention. Spread last so an unresolved
|
|
421
|
+
// repo (`{}`) leaves the other vars untouched.
|
|
422
|
+
...repoEnvelopeVars(parsed.repo, base, null, null, prePrBranch),
|
|
409
423
|
},
|
|
410
424
|
});
|
|
411
425
|
const processKey = processInstanceKey == null ? null : String(processInstanceKey);
|
package/app/plan.test.ts
CHANGED
|
@@ -241,6 +241,15 @@ test("startPlan pins the base branch: persisted on the row + seeded as baseBranc
|
|
|
241
241
|
// Process variables the implement-task consumes.
|
|
242
242
|
assertEquals(seen.baseBranch, "epic/agent-protocol");
|
|
243
243
|
assertEquals(seen.baseBranchBrief.includes("gh pr create --base epic/agent-protocol"), true);
|
|
244
|
+
// Host-git provisioning (#684): the whole-epic repository envelope so each slice's implementation
|
|
245
|
+
// agent gets an isolated clone. The epic seed checks out the BASE branch (`ref`) — each slice's
|
|
246
|
+
// `feat/<task.id>` branch differs per MI child, so NO `branch.create` here (the agent branches).
|
|
247
|
+
const repo = seen["io.nanobpm.agentTask"].repository;
|
|
248
|
+
assertEquals(repo.url, "https://github.com/owner/repo.git");
|
|
249
|
+
assertEquals(repo.ref, "epic/agent-protocol");
|
|
250
|
+
assertEquals("branch" in repo, false);
|
|
251
|
+
assertEquals(repo.singleBranch, true);
|
|
252
|
+
assertEquals(repo.filter, "blob:none");
|
|
244
253
|
});
|
|
245
254
|
|
|
246
255
|
test("startPlan renders baseBranchBrief unconditionally now that base is required", async () => {
|
package/app/plan.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
import { derivedTrackingTable } from "./instanceTracking.ts";
|
|
25
25
|
import { clearExclusions } from "./mergeExclusion.ts";
|
|
26
26
|
import type { ReadinessProbe } from "./readiness.ts";
|
|
27
|
+
import { repoEnvelopeVars } from "./repoEnvelope.ts";
|
|
27
28
|
import { clearTaskDeltas } from "./taskDelta.ts";
|
|
28
29
|
|
|
29
30
|
/** The BPMN process this module drives (resources/processes/plan-fanout.bpmn). */
|
|
@@ -1074,6 +1075,16 @@ export async function startPlan(
|
|
|
1074
1075
|
// instance. A ROOT never runs the preflight, so its `gateKey` stays `null`, unused.
|
|
1075
1076
|
gateKey: probes ? `preflight:${parsed.planKey}` : null,
|
|
1076
1077
|
resolvedArtifacts: null,
|
|
1078
|
+
// Host-git provisioning (c8ctl, issue #684): deliver the repository envelope so each epic slice's
|
|
1079
|
+
// `senior:feature` implementation agent (plan-fanout's per-wave `implement-cell`) gets an
|
|
1080
|
+
// ISOLATED throwaway clone instead of inheriting the worker's launch dir — otherwise several
|
|
1081
|
+
// copilot workers on one host share (and clobber) a single checkout, violating the durable-resume
|
|
1082
|
+
// design. This is the whole-epic seed, so it carries `ref = base` (the epic integration branch)
|
|
1083
|
+
// but NO `branchCreate`: each slice's deterministic `feat/<task.id>` branch differs per MI child,
|
|
1084
|
+
// so the agent cuts its own branch inside the isolated clone (per resources/prompts/feature.md).
|
|
1085
|
+
// The process-level variable propagates through the wave subprocess + `implement-cell` callActivity
|
|
1086
|
+
// into each agent job. Spread last so an unresolved repo (`{}`) leaves the other vars untouched.
|
|
1087
|
+
...repoEnvelopeVars(parsed.repo, base),
|
|
1077
1088
|
},
|
|
1078
1089
|
});
|
|
1079
1090
|
const processKey = processInstanceKey == null ? null : String(processInstanceKey);
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// nano-workforce — the repository-provisioning envelope (`io.nanobpm.agentTask.repository`).
|
|
2
|
+
//
|
|
3
|
+
// The ONE canonical builder of the agent-task repository envelope the c8ctl nano worker harness
|
|
4
|
+
// consumes to provision an ISOLATED clone for an agent job — instead of the agent inheriting
|
|
5
|
+
// whatever directory the worker was launched from (which, with several copilot workers on one host,
|
|
6
|
+
// means concurrent jobs share — and clobber — one checkout; see issue #684). It lives in its own
|
|
7
|
+
// module (not service.ts) so BOTH the PR-based dispatch (`service.ts`: review-round / fix-ci /
|
|
8
|
+
// rebase) and the PRE-PR implementation dispatch (`feature.ts`: `startFeature`; `plan.ts`:
|
|
9
|
+
// `startPlan` → the epic's `implement-cell`) derive from this single implementation without a
|
|
10
|
+
// `plan.ts ↔ service.ts` import cycle (AGENTS.md "derivation over duplication — no drift surfaces").
|
|
11
|
+
import { isCommitSha } from "./world/index.ts";
|
|
12
|
+
|
|
13
|
+
/** The reserved namespace key the c8ctl nano worker harness reads the agent-task envelope from
|
|
14
|
+
* (headers ∪ variables, deep-merged). See c8ctl `normalizeTaskEnvelope`. */
|
|
15
|
+
const AGENT_TASK_NS = "io.nanobpm.agentTask";
|
|
16
|
+
|
|
17
|
+
/** Build the repository slice of the agent-task envelope for an agent job. Delivered as a *process
|
|
18
|
+
* variable* under the reserved `io.nanobpm.agentTask` key so the harness provisions an isolated
|
|
19
|
+
* clone — instead of the agent inheriting whatever directory the worker was launched from (which
|
|
20
|
+
* only happened to be a usable checkout for repos already present locally). `ref` is the branch the
|
|
21
|
+
* harness checks out: the PR HEAD branch on the PR-based paths (review-round / fix-ci / rebase), or —
|
|
22
|
+
* on the PRE-PR implementation path (`branchCreate` set) — the BASE branch, off which the harness
|
|
23
|
+
* cuts the new feature branch. When `ref` is unresolved we emit nothing (no `repository.url`) so the
|
|
24
|
+
* harness falls back to the legacy launch-dir behavior rather than silently cloning the repo's
|
|
25
|
+
* default branch. The static `task.prompt` header on the service task deep-merges with this over the
|
|
26
|
+
* same namespace.
|
|
27
|
+
*
|
|
28
|
+
* The clone is requested **branch-scoped and blobless** (`singleBranch: true` + `filter:
|
|
29
|
+
* "blob:none"`) so large monorepos (e.g. `camunda/camunda`, ~1.16 GB) provision within the c8ctl
|
|
30
|
+
* clone timeout instead of full-cloning the whole history (issue #287). `blob:none` is a *blobless*
|
|
31
|
+
* partial clone (trees are still fetched up-front — a *treeless* clone would be `--filter=tree:0`); it
|
|
32
|
+
* keeps the full *commit graph* (so `git merge-base` / the review 3-dot diff stays correct) while
|
|
33
|
+
* fetching file blobs lazily — small upfront, correct diffs. `--depth 1` is deliberately NOT used:
|
|
34
|
+
* it would drop the merge-base and break `git diff origin/<base>...HEAD`. When the PR base branch
|
|
35
|
+
* is known we also emit `baseRef` so the harness fetches the base tip alongside the head, keeping
|
|
36
|
+
* that base reachable for the diff.
|
|
37
|
+
*
|
|
38
|
+
* World-restore (issue #324, ADR 0062 Slice 4/5): when a PR already has a durable push-checkpoint,
|
|
39
|
+
* `commitSha` is emitted so a REPLACEMENT activation (a fresh worktree after a lease loss)
|
|
40
|
+
* reconstructs the working tree to the EXACT pushed SHA — the inversion of the round's outbound
|
|
41
|
+
* `git push` into an inbound `git fetch && git checkout <sha>` — rather than to a branch tip that may
|
|
42
|
+
* have moved. Omitted (no key) when the PR has no checkpoint yet, so a first activation clones the
|
|
43
|
+
* head branch normally.
|
|
44
|
+
*
|
|
45
|
+
* Pre-PR provisioning (issue #684): the implementation path (feature.bpmn / plan-fanout's
|
|
46
|
+
* `implement-cell`) dispatches its agent BEFORE any PR exists, so it passes `ref = base` and a
|
|
47
|
+
* `branchCreate` naming the deterministic `feat/<task.id>` feature branch the harness cuts off that
|
|
48
|
+
* base itself (the agent-guide's `feat/*` convention) — instead of the agent branching by hand — so
|
|
49
|
+
* the isolated clone lands on the right branch deterministically across a resume. `branchCreate` is
|
|
50
|
+
* omitted on the PR-based paths, which check out an existing head. */
|
|
51
|
+
export function repoEnvelopeVars(
|
|
52
|
+
repo: string,
|
|
53
|
+
ref: string | null,
|
|
54
|
+
baseRef: string | null = null,
|
|
55
|
+
commitSha: string | null = null,
|
|
56
|
+
branchCreate: string | null = null,
|
|
57
|
+
): Record<string, unknown> {
|
|
58
|
+
if (!ref) return {};
|
|
59
|
+
// Defence in depth: every current caller derives `repo` from parsePr/parseIssue (regex-bounded to
|
|
60
|
+
// `owner/repo`), but this is an exported helper the fan-out epic gives many new callers. A repo
|
|
61
|
+
// that is not exactly `owner/repo` would build a bogus clone URL, so emit nothing (the harness
|
|
62
|
+
// then falls back to the launch-dir behaviour) rather than handing the harness a malformed URL.
|
|
63
|
+
// The owner is a GitHub login (alphanumeric + hyphen); the repo-name segment additionally allows
|
|
64
|
+
// `.` and `_`. A trailing `.git` is rejected outright so we never emit a double-suffixed
|
|
65
|
+
// `…/owner/repo.git.git`, and the anchored allowlist bars query/fragment/host-injection chars.
|
|
66
|
+
if (!/^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/.test(repo) || /\.git$/i.test(repo)) return {};
|
|
67
|
+
return {
|
|
68
|
+
[AGENT_TASK_NS]: {
|
|
69
|
+
repository: {
|
|
70
|
+
provider: "github",
|
|
71
|
+
url: `https://github.com/${repo}.git`,
|
|
72
|
+
ref,
|
|
73
|
+
// Branch-scoped, blobless partial clone (issue #287): fetch only the head branch with lazy
|
|
74
|
+
// blobs so large monorepos provision within the clone timeout. Single-branch + blob:none
|
|
75
|
+
// (not --depth 1) preserves the commit graph so the review's `git diff origin/<base>...HEAD`
|
|
76
|
+
// has a valid merge-base. Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91).
|
|
77
|
+
singleBranch: true,
|
|
78
|
+
filter: "blob:none",
|
|
79
|
+
// The base branch this PR targets — emitted so the harness fetches its tip alongside the
|
|
80
|
+
// single-branch head, keeping `origin/<base>` reachable for the diff. Omitted when unknown.
|
|
81
|
+
...(baseRef ? { baseRef } : {}),
|
|
82
|
+
// World-restore (issue #324): the last pushed SHA a replacement activation reconstructs the
|
|
83
|
+
// working tree to (inverting the round's push into a fetch+checkout). Only emitted when it is
|
|
84
|
+
// a well-formed 40-hex commit SHA: `commitSha` is forwarded to the harness as an EXACT
|
|
85
|
+
// checkout target, so a non-SHA ref or a whitespace-tainted value could reconstruct to an
|
|
86
|
+
// unintended ref (a moved branch tip) or fail provisioning. A malformed value degrades to
|
|
87
|
+
// omission — the harness then clones the head branch tip, the pre-#324 behaviour. Omitted too
|
|
88
|
+
// when the PR has no durable push-checkpoint yet.
|
|
89
|
+
...(isCommitSha(commitSha) ? { commitSha } : {}),
|
|
90
|
+
// Pre-PR provisioning (issue #684): the implementation path (feature.bpmn / plan-fanout's
|
|
91
|
+
// implement-cell) dispatches its agent BEFORE any PR exists, so `ref` is the BASE branch, not a
|
|
92
|
+
// head. `branchCreate` asks the harness to cut the deterministic `feat/<task.id>` feature branch
|
|
93
|
+
// off that base itself (the agent-guide's `feat/*` convention) instead of the agent branching by
|
|
94
|
+
// hand — making the isolated clone land on the right branch deterministically across a resume.
|
|
95
|
+
// Omitted (no key) on the PR-based paths (review/fix-ci/rebase), which check out an existing head.
|
|
96
|
+
...(typeof branchCreate === "string" && branchCreate.trim() !== ""
|
|
97
|
+
? { branch: { create: branchCreate.trim() } }
|
|
98
|
+
: {}),
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
package/app/service.test.ts
CHANGED
|
@@ -550,6 +550,34 @@ test("repoEnvelopeVars emits commitSha only for a well-formed 40-hex SHA (world-
|
|
|
550
550
|
assertEquals("commitSha" in none, false);
|
|
551
551
|
});
|
|
552
552
|
|
|
553
|
+
// Pre-PR provisioning (issue #684): the implementation path has no head branch yet, so it passes
|
|
554
|
+
// `ref = base` + a `branchCreate` so the harness clones the base and cuts the deterministic
|
|
555
|
+
// `feat/<task.id>` feature branch off it. `branch.create` is emitted only for a non-blank branch and
|
|
556
|
+
// is absent on the PR-based paths (which check out an existing head).
|
|
557
|
+
test("repoEnvelopeVars emits branch.create only for a non-blank pre-PR branch (#684)", () => {
|
|
558
|
+
const repo = (repoEnvelopeVars("owner/repo", "main", null, null, "feat/issue-7") as any)["io.nanobpm.agentTask"]
|
|
559
|
+
.repository;
|
|
560
|
+
assertEquals(repo.ref, "main", "the pre-PR envelope checks out the BASE branch as its ref");
|
|
561
|
+
assertEquals(repo.branch.create, "feat/issue-7", "the harness cuts the deterministic feature branch off the base");
|
|
562
|
+
// Still branch-scoped and blobless like the PR-based envelope.
|
|
563
|
+
assertEquals(repo.singleBranch, true);
|
|
564
|
+
assertEquals(repo.filter, "blob:none");
|
|
565
|
+
// A whitespace-tainted branch is trimmed; a blank/absent one omits the `branch` key entirely so the
|
|
566
|
+
// PR-based paths (and any caller that doesn't pre-create a branch) are unaffected.
|
|
567
|
+
assertEquals(
|
|
568
|
+
(repoEnvelopeVars("owner/repo", "main", null, null, " feat/issue-9 ") as any)["io.nanobpm.agentTask"].repository
|
|
569
|
+
.branch.create,
|
|
570
|
+
"feat/issue-9",
|
|
571
|
+
);
|
|
572
|
+
for (const blank of [null, undefined, "", " "]) {
|
|
573
|
+
const r = (repoEnvelopeVars("owner/repo", "main", null, null, blank as any) as any)["io.nanobpm.agentTask"]
|
|
574
|
+
.repository;
|
|
575
|
+
assertEquals("branch" in r, false, `expected no branch key for ${JSON.stringify(blank)}`);
|
|
576
|
+
}
|
|
577
|
+
// The default (4-arg) PR-based call never emits a branch.create.
|
|
578
|
+
assertEquals("branch" in (repoEnvelopeVars("owner/repo", "feat/x", "main") as any)["io.nanobpm.agentTask"].repository, false);
|
|
579
|
+
});
|
|
580
|
+
|
|
553
581
|
// Durable-resume enrolment gate (issue #325, ADR 0062 Slice 5/5): `worldRestoreSha` — the seam
|
|
554
582
|
// `submitPr`/`startMerge` thread into `repoEnvelopeVars` — hands the harness the last push-checkpoint
|
|
555
583
|
// ONLY when the enrolled fleet advertises `durable-resume`. With no participant it degrades to null,
|
package/app/service.ts
CHANGED
|
@@ -85,6 +85,12 @@ import {
|
|
|
85
85
|
readinessPollEvery,
|
|
86
86
|
readinessTimeout,
|
|
87
87
|
} from "./readiness.ts";
|
|
88
|
+
// The repository-provisioning envelope builder lives in its own module (app/repoEnvelope.ts) so the
|
|
89
|
+
// PRE-PR implementation dispatch (`feature.ts`/`plan.ts`) can reuse the ONE canonical implementation
|
|
90
|
+
// without a `plan.ts ↔ service.ts` import cycle (issue #684). Imported for the PR-based callers
|
|
91
|
+
// (submitPr/startMerge) and re-exported below so the long-standing `import { repoEnvelopeVars } from
|
|
92
|
+
// "./service.ts"` call sites (and its tests) keep resolving.
|
|
93
|
+
import { repoEnvelopeVars } from "./repoEnvelope.ts";
|
|
88
94
|
import { clampNudgeMinutes, reviewWaitTimeout } from "./reviewWait.ts";
|
|
89
95
|
import { trialMergeAudits } from "./trialMerge.ts";
|
|
90
96
|
import {
|
|
@@ -112,7 +118,7 @@ import {
|
|
|
112
118
|
} from "./userTasks.ts";
|
|
113
119
|
import { deriveWaitGate } from "./waitGate.ts";
|
|
114
120
|
import { waveMergeTargets } from "./waves.ts";
|
|
115
|
-
import {
|
|
121
|
+
import { WorldStore } from "./world/index.ts";
|
|
116
122
|
|
|
117
123
|
/** The BPMN process that drives review convergence (`resources/processes/convergence-loop.bpmn`). */
|
|
118
124
|
export const PROCESS_ID = "convergence-loop";
|
|
@@ -413,77 +419,10 @@ async function registerDependencies(data: DataLayer, prKey: string, depKeys: str
|
|
|
413
419
|
}
|
|
414
420
|
}
|
|
415
421
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
/** Build the repository slice of the agent-task envelope for a PR-based agent job (review-round,
|
|
421
|
-
* fix-ci, rebase). Delivered as a *process variable* under the reserved `io.nanobpm.agentTask`
|
|
422
|
-
* key so the harness provisions an isolated clone checked out on the PR's head branch — instead of
|
|
423
|
-
* the agent inheriting whatever directory the worker was launched from (which only happened to be
|
|
424
|
-
* a usable checkout for repos already present locally). `ref` MUST be the PR head branch; when it
|
|
425
|
-
* is unresolved we emit nothing (no `repository.url`) so the harness falls back to the legacy
|
|
426
|
-
* launch-dir behavior rather than silently cloning the repo's default branch. The static
|
|
427
|
-
* `task.prompt` header on the service task deep-merges with this over the same namespace.
|
|
428
|
-
*
|
|
429
|
-
* The clone is requested **branch-scoped and blobless** (`singleBranch: true` + `filter:
|
|
430
|
-
* "blob:none"`) so large monorepos (e.g. `camunda/camunda`, ~1.16 GB) provision within the c8ctl
|
|
431
|
-
* clone timeout instead of full-cloning the whole history (issue #287). `blob:none` is a *blobless*
|
|
432
|
-
* partial clone (trees are still fetched up-front — a *treeless* clone would be `--filter=tree:0`); it
|
|
433
|
-
* keeps the full *commit graph* (so `git merge-base` / the review 3-dot diff stays correct) while
|
|
434
|
-
* fetching file blobs lazily — small upfront, correct diffs. `--depth 1` is deliberately NOT used:
|
|
435
|
-
* it would drop the merge-base and break `git diff origin/<base>...HEAD`. When the PR base branch
|
|
436
|
-
* is known we also emit `baseRef` so the harness fetches the base tip alongside the head, keeping
|
|
437
|
-
* that base reachable for the diff.
|
|
438
|
-
*
|
|
439
|
-
* World-restore (issue #324, ADR 0062 Slice 4/5): when a PR already has a durable push-checkpoint,
|
|
440
|
-
* `commitSha` is emitted so a REPLACEMENT activation (a fresh worktree after a lease loss)
|
|
441
|
-
* reconstructs the working tree to the EXACT pushed SHA — the inversion of the round's outbound
|
|
442
|
-
* `git push` into an inbound `git fetch && git checkout <sha>` — rather than to a branch tip that may
|
|
443
|
-
* have moved. Omitted (no key) when the PR has no checkpoint yet, so a first activation clones the
|
|
444
|
-
* head branch normally. */
|
|
445
|
-
export function repoEnvelopeVars(
|
|
446
|
-
repo: string,
|
|
447
|
-
ref: string | null,
|
|
448
|
-
baseRef: string | null = null,
|
|
449
|
-
commitSha: string | null = null,
|
|
450
|
-
): Record<string, unknown> {
|
|
451
|
-
if (!ref) return {};
|
|
452
|
-
// Defence in depth: every current caller derives `repo` from parsePr/parseIssue (regex-bounded to
|
|
453
|
-
// `owner/repo`), but this is an exported helper the fan-out epic gives many new callers. A repo
|
|
454
|
-
// that is not exactly `owner/repo` would build a bogus clone URL, so emit nothing (the harness
|
|
455
|
-
// then falls back to the launch-dir behaviour) rather than handing the harness a malformed URL.
|
|
456
|
-
// The owner is a GitHub login (alphanumeric + hyphen); the repo-name segment additionally allows
|
|
457
|
-
// `.` and `_`. A trailing `.git` is rejected outright so we never emit a double-suffixed
|
|
458
|
-
// `…/owner/repo.git.git`, and the anchored allowlist bars query/fragment/host-injection chars.
|
|
459
|
-
if (!/^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/.test(repo) || /\.git$/i.test(repo)) return {};
|
|
460
|
-
return {
|
|
461
|
-
[AGENT_TASK_NS]: {
|
|
462
|
-
repository: {
|
|
463
|
-
provider: "github",
|
|
464
|
-
url: `https://github.com/${repo}.git`,
|
|
465
|
-
ref,
|
|
466
|
-
// Branch-scoped, blobless partial clone (issue #287): fetch only the head branch with lazy
|
|
467
|
-
// blobs so large monorepos provision within the clone timeout. Single-branch + blob:none
|
|
468
|
-
// (not --depth 1) preserves the commit graph so the review's `git diff origin/<base>...HEAD`
|
|
469
|
-
// has a valid merge-base. Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91).
|
|
470
|
-
singleBranch: true,
|
|
471
|
-
filter: "blob:none",
|
|
472
|
-
// The base branch this PR targets — emitted so the harness fetches its tip alongside the
|
|
473
|
-
// single-branch head, keeping `origin/<base>` reachable for the diff. Omitted when unknown.
|
|
474
|
-
...(baseRef ? { baseRef } : {}),
|
|
475
|
-
// World-restore (issue #324): the last pushed SHA a replacement activation reconstructs the
|
|
476
|
-
// working tree to (inverting the round's push into a fetch+checkout). Only emitted when it is
|
|
477
|
-
// a well-formed 40-hex commit SHA: `commitSha` is forwarded to the harness as an EXACT
|
|
478
|
-
// checkout target, so a non-SHA ref or a whitespace-tainted value could reconstruct to an
|
|
479
|
-
// unintended ref (a moved branch tip) or fail provisioning. A malformed value degrades to
|
|
480
|
-
// omission — the harness then clones the head branch tip, the pre-#324 behaviour. Omitted too
|
|
481
|
-
// when the PR has no durable push-checkpoint yet.
|
|
482
|
-
...(isCommitSha(commitSha) ? { commitSha } : {}),
|
|
483
|
-
},
|
|
484
|
-
},
|
|
485
|
-
};
|
|
486
|
-
}
|
|
422
|
+
// The repository-provisioning envelope builder now lives in its own module (app/repoEnvelope.ts);
|
|
423
|
+
// re-export it here so the long-standing `import { repoEnvelopeVars } from "./service.ts"` call
|
|
424
|
+
// sites (and its tests) keep resolving (issue #684).
|
|
425
|
+
export { repoEnvelopeVars };
|
|
487
426
|
|
|
488
427
|
/** The last durable push-checkpoint SHA for a PR (issue #324, ADR 0062 Slice 4/5), or `null` when it
|
|
489
428
|
* has none yet. Threaded into `repoEnvelopeVars` so a replacement activation reconstructs the exact
|
package/docs/agent-guide.md
CHANGED
|
@@ -154,6 +154,30 @@ Track a plan the same way you track PRs — its `process_key` is an engine insta
|
|
|
154
154
|
can inspect in §5, and the PRs it opens show up in `/status` as ordinary convergence
|
|
155
155
|
loops.
|
|
156
156
|
|
|
157
|
+
### Build hygiene — implementation agents warm up once, then build module-scoped
|
|
158
|
+
|
|
159
|
+
Implementation and CI-fix agents run on **stateless / ephemeral workers**: the
|
|
160
|
+
workspace, including its build outputs, is thrown away between runs, so each worker pays any
|
|
161
|
+
cold whole-reactor build tax independently. To keep iterations fast, the implementation
|
|
162
|
+
prompts (`resources/prompts/feature.md`, `resources/prompts/fix-ci.md`) instruct every
|
|
163
|
+
agent to, **before iterating on a build in the target repo**:
|
|
164
|
+
|
|
165
|
+
1. **Read the target repo's own `AGENTS.md` / `CONTRIBUTING.md` build section** — the
|
|
166
|
+
recipe is authored there, never duplicated into nano-workforce (a per-repo copy here
|
|
167
|
+
would rot as the target's build changes).
|
|
168
|
+
2. **Run the prescribed dependency warm-up once**, right after checkout. For a Maven
|
|
169
|
+
monorepo that ships the fast path (e.g. camunda), that is
|
|
170
|
+
`./mvnw install -Dquickly -T1C` — it installs every reactor SNAPSHOT into `~/.m2`.
|
|
171
|
+
3. **Then build only the changed module, offline, without `-am`** —
|
|
172
|
+
`./mvnw -Dquickly -o -pl <module> <goals>`. Never run a cold `-am` reactor build per
|
|
173
|
+
iteration.
|
|
174
|
+
|
|
175
|
+
The repo-agnostic infra levers behind this — a host-persisted `~/.m2` and a shared
|
|
176
|
+
remote Maven build cache for JVM-capable worker hosts — live in worker-host config, not
|
|
177
|
+
in per-repo files here. The retro loop (`resources/prompts/retro.md`) promotes any
|
|
178
|
+
durable "warm the cache, then build module-scoped" lesson into the *target repo's*
|
|
179
|
+
`AGENTS.md`, keeping each target's recipe sharp without nano-workforce owning it.
|
|
180
|
+
|
|
157
181
|
---
|
|
158
182
|
|
|
159
183
|
## 3. Answer escalations (unblock a human-in-the-loop wait)
|
package/e2e/feature-run.e2e.ts
CHANGED
|
@@ -123,8 +123,18 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
test("raise-only: an opened PR ends the run at `opened`, taking the raise-only branch", async () => {
|
|
126
|
+
// Host-git provisioning (#684): capture the repository envelope the `senior:feature` agent job
|
|
127
|
+
// carries — proof the implement-cell agent gets an ISOLATED clone (base branch + a harness-cut
|
|
128
|
+
// `feat/<task.id>`) instead of inheriting the worker's launch dir. It rides the process variable
|
|
129
|
+
// all the way down through feature.bpmn's `implement` callActivity into the cell's agent job.
|
|
130
|
+
let agentRepo: any = null;
|
|
126
131
|
await withApp(
|
|
127
|
-
{
|
|
132
|
+
{
|
|
133
|
+
"senior:feature": (job) => {
|
|
134
|
+
agentRepo = (job.variables as Record<string, any>)["io.nanobpm.agentTask"]?.repository ?? null;
|
|
135
|
+
return { status: "opened", pr: "owner/repo#101", summary: "built it" };
|
|
136
|
+
},
|
|
137
|
+
},
|
|
128
138
|
{ baseBranch: "epic/e2e" },
|
|
129
139
|
async ({ app, featureKey }) => {
|
|
130
140
|
const flows = takenFlows(app);
|
|
@@ -134,6 +144,11 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
134
144
|
);
|
|
135
145
|
assert.ok(!flows.includes("gw-converge->converge"), "the converge hand-off branch was NOT taken");
|
|
136
146
|
|
|
147
|
+
assert.ok(agentRepo, "the senior:feature job carried the io.nanobpm.agentTask repository envelope");
|
|
148
|
+
assert.equal(agentRepo.url, "https://github.com/owner/repo.git", "the harness clones the target repo");
|
|
149
|
+
assert.equal(agentRepo.ref, "epic/e2e", "a PRE-PR job checks out the BASE branch, not a head");
|
|
150
|
+
assert.equal(agentRepo.branch.create, "feat/issue-7", "the harness cuts the deterministic feature branch");
|
|
151
|
+
|
|
137
152
|
const run = await featureRow(app, featureKey);
|
|
138
153
|
assert.equal(run.status, "opened", "the run settled at opened");
|
|
139
154
|
assert.equal(run.pr_key, "owner/repo#101", "the raised PR key was recorded");
|
package/e2e/plan-fanout.e2e.ts
CHANGED
|
@@ -121,6 +121,34 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
|
|
|
121
121
|
const singleTaskPlan: Stub = () => ({ tasks: [{ id: "t1", title: "T1", prompt: "do t1" }] });
|
|
122
122
|
const approveReview: Stub = () => ({ approved: true, findings: "" });
|
|
123
123
|
|
|
124
|
+
test("host-git provisioning: each slice's implement-cell agent job carries the repository envelope (#684)", async () => {
|
|
125
|
+
// The whole-epic seed emits `io.nanobpm.agentTask.repository` so every slice's `senior:feature`
|
|
126
|
+
// agent gets an ISOLATED clone instead of clobbering the worker's launch dir. The process variable
|
|
127
|
+
// rides down through the wave subprocess + `implement-cell-call` callActivity into each agent job.
|
|
128
|
+
// The epic seed carries the BASE branch as `ref` but NO `branch.create` — each slice's
|
|
129
|
+
// `feat/<task.id>` differs per MI child, so the agent cuts its own branch inside the clone.
|
|
130
|
+
let agentRepo: any = null;
|
|
131
|
+
await withApp(
|
|
132
|
+
{
|
|
133
|
+
"senior:plan": singleTaskPlan,
|
|
134
|
+
"senior:plan-review": approveReview,
|
|
135
|
+
"senior:feature": (job) => {
|
|
136
|
+
agentRepo = (job.variables as Record<string, any>)["io.nanobpm.agentTask"]?.repository ?? null;
|
|
137
|
+
return { status: "blocked", summary: "n/a" };
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
async ({ app }) => {
|
|
141
|
+
await app.settle();
|
|
142
|
+
assert.ok(agentRepo, "the epic slice's senior:feature job carried the io.nanobpm.agentTask envelope");
|
|
143
|
+
assert.equal(agentRepo.url, "https://github.com/owner/repo.git", "the harness clones the target repo");
|
|
144
|
+
assert.equal(agentRepo.ref, "epic/e2e", "the epic seed checks out the BASE (integration) branch");
|
|
145
|
+
assert.equal("branch" in agentRepo, false, "the epic seed omits branch.create — the agent branches per slice");
|
|
146
|
+
assert.equal(agentRepo.singleBranch, true, "the blobless/single-branch monorepo shaping rides along");
|
|
147
|
+
assert.equal(agentRepo.filter, "blob:none");
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
124
152
|
test("task escalation: a native userTask parks the child; answering routes back to implement-task", async () => {
|
|
125
153
|
let featureCalls = 0;
|
|
126
154
|
await withApp(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.171.
|
|
3
|
+
"version": "0.171.6",
|
|
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",
|
|
@@ -91,6 +91,28 @@ against the wrong base will not be merged into the epic.
|
|
|
91
91
|
pointing at an issue).
|
|
92
92
|
5. Clean up any scratch clone/worktree you created outside the commit.
|
|
93
93
|
|
|
94
|
+
## Build hygiene — warm up once, then build module-scoped; never a cold `-am` reactor build
|
|
95
|
+
|
|
96
|
+
Before you iterate on a build in the target repo,
|
|
97
|
+
**read its `AGENTS.md` / `CONTRIBUTING.md` build section** and run the prescribed
|
|
98
|
+
**dependency warm-up exactly once** right after checkout, then scope every
|
|
99
|
+
subsequent build to the module you changed. Do **not** run a cold whole-reactor /
|
|
100
|
+
all-modules build on each iteration — a stateless worker that skips the warm-up
|
|
101
|
+
pays the upstream reactor-compile tax inline on every run, which can block for
|
|
102
|
+
many minutes.
|
|
103
|
+
|
|
104
|
+
- **Warm up once (blocking, right after branching).** For a Maven monorepo that
|
|
105
|
+
ships this fast path (e.g. camunda), that is
|
|
106
|
+
`./mvnw install -Dquickly -T1C` — it installs every reactor SNAPSHOT into
|
|
107
|
+
`~/.m2` so later module builds resolve them instead of recompiling them.
|
|
108
|
+
- **Then build only the changed module, offline, without `-am`.** e.g.
|
|
109
|
+
`./mvnw -Dquickly -o -pl <module> test-compile`. Dropping `-am` after the
|
|
110
|
+
warm-up is what keeps each iteration to seconds; a per-iteration
|
|
111
|
+
`-am <goals>` (e.g. `-am clean test-compile`) re-compiles the upstream modules from source.
|
|
112
|
+
- **The recipe lives in the target repo, not here.** Always defer to that repo's
|
|
113
|
+
own `AGENTS.md` / `CONTRIBUTING.md` build section for the exact commands — the
|
|
114
|
+
Maven lines above are the common case, not a substitute for reading it.
|
|
115
|
+
|
|
94
116
|
## Closing keywords vs. scope splits — don't close a broader-scoped parent
|
|
95
117
|
|
|
96
118
|
The convergence loop runs a **scope-integrity classifier** on your PR before it
|
|
@@ -50,6 +50,14 @@ the PR's checks yourself (`gh pr checks`, `gh run view`).
|
|
|
50
50
|
3. Apply the **minimal, correct** fix. Keep it scoped to what the failing checks
|
|
51
51
|
demand — do not refactor unrelated code.
|
|
52
52
|
4. Run the relevant check locally to confirm it now passes.
|
|
53
|
+
**Build module-scoped, not cold.** Before iterating, read the target repo's
|
|
54
|
+
`AGENTS.md` / `CONTRIBUTING.md` build section and run its prescribed
|
|
55
|
+
**dependency warm-up once** (for a Maven monorepo that ships it, e.g. camunda,
|
|
56
|
+
`./mvnw install -Dquickly -T1C`), then scope the build to the changed module
|
|
57
|
+
and drop `-am` (`./mvnw -Dquickly -o -pl <module> <goals>`). Never run a cold
|
|
58
|
+
whole-reactor `-am` build per iteration — a stateless worker that skips the
|
|
59
|
+
warm-up pays the upstream reactor-compile tax inline and can block for many
|
|
60
|
+
minutes.
|
|
53
61
|
5. Commit (sign off with `-s` if the repo enforces DCO) and push to the branch.
|
|
54
62
|
6. **Make CI re-validate your fix.** Some repos deliberately run CI only when a
|
|
55
63
|
PR is *opened* (to keep review cheap), so a follow-up push does **not**
|
|
@@ -44,7 +44,11 @@ You have `gh` / git authenticated for the target repository.
|
|
|
44
44
|
4. **Choose the right home for each promoted lesson** — the whole point is to make the knowledge
|
|
45
45
|
*load-bearing*, not just written down:
|
|
46
46
|
- **`AGENTS.md`** (or `CONTRIBUTING.md`) — a convention, a "before you build, run X", a
|
|
47
|
-
non-obvious constraint. The default home.
|
|
47
|
+
non-obvious constraint. The default home. A **build recipe** is the textbook case:
|
|
48
|
+
a durable "warm the dependency cache once, then build module-scoped — never a cold
|
|
49
|
+
whole-reactor build" lesson (e.g. Maven's `./mvnw install -Dquickly -T1C` warm-up,
|
|
50
|
+
then `-pl <module>` without `-am`) belongs in the *target repo's* `AGENTS.md`
|
|
51
|
+
build section, never duplicated into nano-workforce.
|
|
48
52
|
- **A script** — if the lesson is "always run these steps in this order", encode it as a
|
|
49
53
|
script (or a `make`/`npm`/`deno task` target) so it can't be forgotten.
|
|
50
54
|
- **A CI step** — if the lesson is "this class of mistake should never merge", add a guard/gate
|