@nanobpm/nano-workforce 0.208.3 → 0.208.5

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/AGENTS.md CHANGED
@@ -253,6 +253,23 @@ Two different `nano:dataEnvelope` uses have different rules — don't conflate t
253
253
  it as a process variable: besides being unnecessary, seeding a var an
254
254
  in-subprocess gateway reads re-introduces the multi-instance `=null`-shadow
255
255
  gotcha above.
256
+ - **A per-MI-child value reaches an agent job's reserved `io.nanobpm.agentTask`
257
+ envelope via a worker result key literally NAMED with the dotted path — never a
258
+ dotted `ioMapping` target.** The whole-run seed can only set the
259
+ `io.nanobpm.agentTask` object ONCE at the root (uniform for every cell), so a
260
+ per-slice field (e.g. the implement-cell's deterministic `branch.create =
261
+ feat/<task.id>`, issue #945) can't ride it. A `<zeebe:output
262
+ target="io.nanobpm.agentTask.branch.create">` does NOT work — a dotted ioMapping
263
+ target builds a NESTED `{io:{nanobpm:{agentTask:…}}}` object the flat-key harness
264
+ never reads. The working channel: a worker running in a per-slice process instance (e.g.
265
+ `pr.slice-branch`, the first step of each implement-cell instance — NOT a task in the shared MI body, whose undeclared result keys land on the shared root where a sibling overwrites them) returns a result key literally named
266
+ `"io.nanobpm.agentTask.branch.create"`; the engine propagates worker result
267
+ variables verbatim (mapped or not), materialising a FLAT dotted process variable
268
+ that the harness's `collectEnvelopeFrom` deep-merges with the whole-object seed.
269
+ Derive the value from the ONE canonical `sliceBranchCreate` (app/repoEnvelope.ts)
270
+ so the envelope, the prompt's branch instruction, and the reconcile lookup share a
271
+ single source. `recoverBranchCreate(headers, variables)` (same module) is the ONE
272
+ consumer-side mirror tests use to assert "the harness sees this branch".
256
273
 
257
274
  ## Urban page runtime: rendering primitives are not JS-truthy
258
275
 
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.208.5](https://github.com/nanobpm/nano-workforce/compare/v0.208.4...v0.208.5) (2026-11-11)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **status:** serve the /status merge policy from a durable pull_requests projection ([#950](https://github.com/nanobpm/nano-workforce/issues/950)) ([c3f19d5](https://github.com/nanobpm/nano-workforce/commit/c3f19d50ef6cf4f731f50ab778b5cd7ebdb3454e)), closes [#937](https://github.com/nanobpm/nano-workforce/issues/937) [#937](https://github.com/nanobpm/nano-workforce/issues/937) [#947](https://github.com/nanobpm/nano-workforce/issues/947)
6
+
7
+ ## [0.208.4](https://github.com/nanobpm/nano-workforce/compare/v0.208.3...v0.208.4) (2026-11-11)
8
+
9
+ ### Bug Fixes
10
+
11
+ * **plan-fanout:** emit deterministic branch.create on the implement-cell envelope ([#945](https://github.com/nanobpm/nano-workforce/issues/945)) ([#946](https://github.com/nanobpm/nano-workforce/issues/946)) ([0977243](https://github.com/nanobpm/nano-workforce/commit/097724344a2f3942af5b1796e2e0a7e8363b44f9))
12
+
1
13
  ## [0.208.3](https://github.com/nanobpm/nano-workforce/compare/v0.208.2...v0.208.3) (2026-11-11)
2
14
 
3
15
  ### Bug Fixes
@@ -85,6 +85,21 @@ function memData(
85
85
  }
86
86
  return { changed: 0 };
87
87
  }
88
+ // The guarded merge-approval projection stamp (issue #947, PR #950 review): ownership is checked
89
+ // IN the UPDATE predicate, so only a row still owned by the approving instance is stamped.
90
+ const mp = /^UPDATE "pull_requests" SET "merge_human_approval" = 0 WHERE "process_key" = \?$/.exec(sql);
91
+ if (mp) {
92
+ const store = stores.pull_requests;
93
+ if (!store) throw new Error("no such table: pull_requests");
94
+ let changed = 0;
95
+ for (const r of store.rows) {
96
+ if (r.process_key === params[0]) {
97
+ r.merge_human_approval = 0;
98
+ changed++;
99
+ }
100
+ }
101
+ return { changed };
102
+ }
88
103
  // The FIRST-HAND agent-revert tombstone keyed on `source_completion_id` (Copilot review of #806) —
89
104
  // `invalidateAdjudicationByCompletion`. Tombstones EVERY live row this completion produced (a
90
105
  // completion settles one question, so at most one) so reverting a first-hand agent answer (which has
@@ -991,3 +1006,143 @@ test("snapshotUserTaskFormOptions: an unresolvable form_key never throws (safe r
991
1006
  "an unresolvable form_key falls back to the element's static form",
992
1007
  );
993
1008
  });
1009
+
1010
+ // ── Issue #947: a merge-approval APPROVE mirrors onto the durable /status merge-policy projection ──
1011
+ // `submitPr` stamps `merge_human_approval = 1` when it gates a loop; the human's approve at the
1012
+ // `merge-approval` task clears the loop's `humanApproval` variable via the task's output mapping.
1013
+ // The canonical completer must stamp the SAME decision onto the row column `/status` reads, or the
1014
+ // projection reports `blockedBy: "human-approval"` long after approval landed.
1015
+
1016
+ test("a merge-approval APPROVE stamps merge_human_approval=0 on the owning PR row (issue #947)", async () => {
1017
+ const stores = {
1018
+ task_completions: { rows: [] as any[], key: "id" },
1019
+ pull_requests: {
1020
+ rows: [
1021
+ { pr_key: "owner/repo#7", process_key: "PI-7", merge_requested: 1, merge_human_approval: 1 },
1022
+ ],
1023
+ key: "pr_key",
1024
+ },
1025
+ };
1026
+ const data = memData(stores);
1027
+ const { engine } = fakeEngine([{ userTaskKey: "ut-ma", elementId: "merge-approval", processInstanceKey: "PI-7" }]);
1028
+
1029
+ const r = await completeEscalationAsHuman(data, engine, {
1030
+ userTaskKey: "ut-ma",
1031
+ operatorId: "alice",
1032
+ variables: { mergeDecision: "approve" },
1033
+ });
1034
+
1035
+ assertEquals(r.ok, true);
1036
+ const row = stores.pull_requests.rows[0];
1037
+ assertEquals(row.merge_human_approval, 0, "the approval is mirrored onto the /status projection");
1038
+ assertEquals(row.merge_requested, 1, "the merge intent is untouched");
1039
+ });
1040
+
1041
+ test("a merge-approval REVISE leaves the projection gated (issue #947)", async () => {
1042
+ const stores = {
1043
+ task_completions: { rows: [] as any[], key: "id" },
1044
+ pull_requests: {
1045
+ rows: [{ pr_key: "owner/repo#8", process_key: "PI-8", merge_requested: 1, merge_human_approval: 1 }],
1046
+ key: "pr_key",
1047
+ },
1048
+ };
1049
+ const data = memData(stores);
1050
+ const { engine } = fakeEngine([{ userTaskKey: "ut-mr", elementId: "merge-approval", processInstanceKey: "PI-8" }]);
1051
+
1052
+ const r = await completeEscalationAsHuman(data, engine, {
1053
+ userTaskKey: "ut-mr",
1054
+ operatorId: "alice",
1055
+ variables: { mergeDecision: "revise", answer: "split the migration" },
1056
+ });
1057
+
1058
+ assertEquals(r.ok, true);
1059
+ assertEquals(stores.pull_requests.rows[0].merge_human_approval, 1, "a revise keeps the gate outstanding");
1060
+ });
1061
+
1062
+ test("a merge-approval approve against a SUPERSEDED instance never clears the fresh run's gate (issue #947)", async () => {
1063
+ // The stamp is ownership-guarded on the instance the task parked on: after a reopen re-pointed
1064
+ // `process_key` at the fresh run, a straggler completion resolved against the OLD instance must
1065
+ // not clear the new run's projection.
1066
+ const stores = {
1067
+ task_completions: { rows: [] as any[], key: "id" },
1068
+ pull_requests: {
1069
+ rows: [{ pr_key: "owner/repo#9", process_key: "PI-NEW", merge_requested: 1, merge_human_approval: 1 }],
1070
+ key: "pr_key",
1071
+ },
1072
+ };
1073
+ const data = memData(stores);
1074
+ const { engine } = fakeEngine([{ userTaskKey: "ut-stale", elementId: "merge-approval", processInstanceKey: "PI-OLD" }]);
1075
+
1076
+ const r = await completeEscalationAsHuman(data, engine, {
1077
+ userTaskKey: "ut-stale",
1078
+ operatorId: "alice",
1079
+ variables: { mergeDecision: "approve" },
1080
+ });
1081
+
1082
+ assertEquals(r.ok, true, "the engine completion itself still lands");
1083
+ assertEquals(stores.pull_requests.rows[0].merge_human_approval, 1, "the fresh run's gate is not cleared by a stale instance");
1084
+ });
1085
+
1086
+ test("a reopen racing the approval stamp can never let the PREVIOUS run clear the successor's gate (PR #950 review)", async () => {
1087
+ // A reopen re-points `process_key` the moment the stamp looks the row up (simulated in the
1088
+ // table's `findOne`). A read-then-write stamp would then clear the FRESH run's gate; the guarded
1089
+ // single-statement UPDATE checks ownership atomically, so the successor's gate survives.
1090
+ const stores = {
1091
+ task_completions: { rows: [] as any[], key: "id" },
1092
+ pull_requests: {
1093
+ rows: [{ pr_key: "owner/repo#10", process_key: "PI-OLD", merge_requested: 1, merge_human_approval: 1 }],
1094
+ key: "pr_key",
1095
+ },
1096
+ };
1097
+ const base = memData(stores);
1098
+ const reopen = () => {
1099
+ const live = stores.pull_requests.rows[0];
1100
+ live.process_key = "PI-NEW";
1101
+ live.merge_human_approval = 1; // the fresh gated run restarts outstanding
1102
+ };
1103
+ const data: any = Object.create(base);
1104
+ data.table = (name: string, key: string) => {
1105
+ const t: any = base.table(name, key);
1106
+ if (name !== "pull_requests") return t;
1107
+ return {
1108
+ ...t,
1109
+ findOne: async (q: any) => {
1110
+ const row = await t.findOne(q);
1111
+ reopen(); // the reopen lands between a lookup and its write
1112
+ return row;
1113
+ },
1114
+ };
1115
+ };
1116
+ // An atomic statement leaves no window: the reopen can only land after it (and resets the gate).
1117
+ data.open = () => {
1118
+ const db: any = base.open();
1119
+ return { ...db, exec: async (sql: string, params?: unknown[]) => {
1120
+ const res = await db.exec(sql, params);
1121
+ reopen();
1122
+ return res;
1123
+ } };
1124
+ };
1125
+ const { engine } = fakeEngine([{ userTaskKey: "ut-race", elementId: "merge-approval", processInstanceKey: "PI-OLD" }]);
1126
+ const r = await completeEscalationAsHuman(data, engine, {
1127
+ userTaskKey: "ut-race",
1128
+ operatorId: "alice",
1129
+ variables: { mergeDecision: "approve" },
1130
+ });
1131
+ assertEquals(r.ok, true);
1132
+ assertEquals(stores.pull_requests.rows[0].merge_human_approval, 1, "the successor's gate stays outstanding");
1133
+ });
1134
+
1135
+ test("a merge-approval projection-stamp failure never masks the engine completion (issue #947)", async () => {
1136
+ const stores = { task_completions: { rows: [] as any[], key: "id" } };
1137
+ const data = memData(stores); // no pull_requests store — the stamp's table read throws
1138
+ const { engine, completed } = fakeEngine([{ userTaskKey: "ut-mx", elementId: "merge-approval", processInstanceKey: "PI-X" }]);
1139
+
1140
+ const r = await completeEscalationAsHuman(data, engine, {
1141
+ userTaskKey: "ut-mx",
1142
+ operatorId: "alice",
1143
+ variables: { mergeDecision: "approve" },
1144
+ });
1145
+
1146
+ assertEquals(r.ok, true, "best-effort: the projection miss is swallowed");
1147
+ assertEquals(completed.length, 1, "the engine resume already happened");
1148
+ });
@@ -25,6 +25,7 @@ import type { DataLayer, EngineClient, GatewayDataSource } from "@nanobpm/urban"
25
25
  import { invalidateAdjudication, invalidateAdjudicationByCompletion } from "./adjudications.ts";
26
26
  import { CONFORMANCE_ESCALATION_ELEMENT } from "./conformance.ts";
27
27
  import { DELIVERY_HUMAN_ELEMENT, ESCALATION_FORM, GENERIC_HUMAN_FORM, isDeliveryEscalationTwin, isDeliveryHumanElement, isDeliveryWaitEscalationTwin } from "./deliveryHuman.ts";
28
+ import { withMergePolicyLock } from "./mergePolicyLock.ts";
28
29
  import { ACP_PERMISSION_ELEMENT, EMPTY_PLAN_ELEMENT, PR_MERGE_APPROVAL_ELEMENT, READINESS_ESCALATION_ELEMENT, READINESS_ESCALATION_PF_ELEMENT } from "./userTasks.ts";
29
30
 
30
31
  const now = () => new Date().toISOString();
@@ -432,6 +433,11 @@ export async function completeUserTaskAttributed(
432
433
  created_at: now(),
433
434
  });
434
435
  const completionId = Number(id);
436
+ // Merge-approval approve: serialize the engine completion + projection stamp against gated adoption
437
+ // on the same loop (PR #950 review) so a concurrent adopter's stale `1` can never land after our `0`.
438
+ const approvingMerge = target.elementId === PR_MERGE_APPROVAL_ELEMENT && target.variables.mergeDecision === "approve";
439
+ const lockKey = approvingMerge ? target.processInstanceKey?.trim() : undefined;
440
+ const resume = async (): Promise<void> => {
435
441
  try {
436
442
  // Carry the completed user-task's identity forward on the resumed token (Copilot review of #806).
437
443
  // `pr.answer-escalation` (record-answer) reconciles the durable adjudication AFTER this completion
@@ -461,9 +467,42 @@ export async function completeUserTaskAttributed(
461
467
  }
462
468
  throw err;
463
469
  }
470
+ // Merge-policy projection (issue #947): a human APPROVE at the convergence loop's `merge-approval`
471
+ // task clears the loop's `humanApproval` variable via the task's output mapping — mirror that
472
+ // decision onto the durable row column `/status` reads, so the projection lifts the
473
+ // `human-approval` blocker the moment approval lands instead of reporting a stale block until
474
+ // `startMerge` re-stamps. Best-effort (the engine resume above already succeeded — a projection
475
+ // miss only leaves the row reading blocked until the merge hand-off, never blocks the merge) and
476
+ // ownership-guarded on the instance the task parked on, so a completion replayed against a stale
477
+ // instance can never clear a SUCCESSOR run's gate after a reopen re-pointed `process_key`.
478
+ if (approvingMerge) {
479
+ await stampMergeApprovalProjection(data, target.processInstanceKey);
480
+ }
481
+ };
482
+ if (lockKey) await withMergePolicyLock(lockKey, resume);
483
+ else await resume();
464
484
  return { completionId };
465
485
  }
466
486
 
487
+ /** Mirror a `merge-approval` approve onto the durable `/status` merge-policy projection
488
+ * (`merge_human_approval = 0` — decided-and-approved; 132_pull_requests_merge_policy.sql). The
489
+ * stamp is keyed by the OWNING instance: the row is only updated while its `process_key` still
490
+ * names the instance the completed task parked on, so a late/stray completion against an
491
+ * already-replaced run is a no-op. Best-effort and never throws — the engine-side completion is
492
+ * the authority; this column only feeds the read projection. */
493
+ async function stampMergeApprovalProjection(data: DataLayer, processInstanceKey: string | null | undefined): Promise<void> {
494
+ const key = processInstanceKey?.trim();
495
+ if (!key) return;
496
+ try {
497
+ // ONE guarded UPDATE (PR #950 review): the ownership check and the stamp are a single atomic
498
+ // statement, so a reopen that re-points `process_key` between a lookup and a write can never let
499
+ // the PREVIOUS run's approval clear its successor's gate (no read-then-write window).
500
+ await data.open().exec(`UPDATE "pull_requests" SET "merge_human_approval" = 0 WHERE "process_key" = ?`, [key]);
501
+ } catch {
502
+ // swallow — a projection miss never masks a completion the engine has already resumed
503
+ }
504
+ }
505
+
467
506
  /** The newest recorded completion for a user-task key, or undefined. */
468
507
  export async function latestCompletion(
469
508
  data: DataLayer,
package/app/contracts.ts CHANGED
@@ -479,7 +479,7 @@ export const WIRE_CONTRACTS = {
479
479
  name: "io.nanobpm.agentTask.repository",
480
480
  owner: "app/repoEnvelope.ts",
481
481
  semantics:
482
- "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 delivery-graph runner's agent cells, issue #686) — the BASE branch, off which the harness cuts a new feature branch named by the optional `branch.create`. On the PRE-PR path `branch.create` is the deterministic `feat/<task.id>` (emitted for a single-task feature run AND — per issue #776 — the deterministic `feat/<node.id>` for each single-instance delivery-graph agent cell, injected per-cell by `agentNodeRepoEnvelope`/`app/deliveryRunner.ts` so a forgetful agent can never be left committing on the base branch and stranding its run on a non-ff push; the epic plan-fanout seed still omits it because its MI children each cut a per-child `feat/<task.id>` the app can't name at compile time, so those agents branch themselves). On the PR-based paths `branch.create` is set to the PR HEAD branch itself (equal to `ref`, jwulf/c8ctl-plugin-nano#231): without it the harness's base-branch guard treats the checkout as base-like and cuts a throwaway `nano/agent-work/<base>-<run>` fallback, stranding the round's commits OFF the PR; with `ref == create == head` the harness does a no-op `checkout -B <head>` and commits/pushes to the PR head branch. So `branch.create` is present on both the pre-PR and PR-based paths (only a blank/omitted value drops the key). 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) and a `cloneTimeoutMs` (from `NANO_PR_CLONE_TIMEOUT_MS`, default 600000 = 10 min) that raises the harness's 120s default so a large monorepo's blobless single-branch clone provisions instead of dying at 120s (issue #694). World-restore (issue #324, ADR 0062 Slice 4/5): an optional `sha` — 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. The field is named `sha` because that is the field the c8ctl harness's `provisionRepo` reads to drive the checkout — an earlier `commitSha` key was a silent no-op (issue #695). Alongside the `repository` slice the envelope carries a sibling `task.allowPr: true` (issue #770): c8ctl-plugin-nano (≥1.60.2) only resolves the git credential (GITHUB_TOKEN, or the `gh` default) for repo provisioning behind that flag, so every repo-backed envelope sets it or the clone dies with `unable to get password from user`; the repoless path emits no envelope and so no `task`. Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91, branch-cut guard jwulf/c8ctl-plugin-nano#231). PLACEMENT (issue #882): `branch` is emitted as a TOP-LEVEL sibling of `repository` (and of `task`) under the namespace — the harness `normalizeTaskEnvelope` reads `branch` from the envelope ROOT (`isPlainObject(raw.branch)`), never from `repository.branch`, so the producer must not nest it there (nesting was the #227 two-sides-of-one-contract drift that left `branch.create` silently unread — nanobpm/nano-bpm#1344). The round-trip contract test `app/repoEnvelope.test.ts` pins producer and consumer together.",
482
+ "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 delivery-graph runner's agent cells, issue #686) — the BASE branch, off which the harness cuts a new feature branch named by the optional `branch.create`. On the PRE-PR path `branch.create` is the deterministic `feat/<task.id>` (emitted for a single-task feature run AND — per issue #776 — the deterministic `feat/<node.id>` for each single-instance delivery-graph agent cell, injected per-cell by `agentNodeRepoEnvelope`/`app/deliveryRunner.ts` so a forgetful agent can never be left committing on the base branch and stranding its run on a non-ff push; the epic plan-fanout's per-slice `feat/<task.id>` differs per MI child and so can't ride the whole-epic seed — instead the `pr.slice-branch` worker — the first step of EACH implement-cell process instance, so the value is instance-local and sibling slices cannot overwrite it — stamps it as a FLAT dotted `io.nanobpm.agentTask.branch.create` process variable (issue #945, derived via the ONE canonical `sliceBranchCreate`), which the harness's `collectEnvelopeFrom` deep-merges with the whole-object seed so a retry re-derives the SAME branch and the durable WIP checkpoint restore matches). On the PR-based paths `branch.create` is set to the PR HEAD branch itself (equal to `ref`, jwulf/c8ctl-plugin-nano#231): without it the harness's base-branch guard treats the checkout as base-like and cuts a throwaway `nano/agent-work/<base>-<run>` fallback, stranding the round's commits OFF the PR; with `ref == create == head` the harness does a no-op `checkout -B <head>` and commits/pushes to the PR head branch. So `branch.create` is present on both the pre-PR and PR-based paths (only a blank/omitted value drops the key). 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) and a `cloneTimeoutMs` (from `NANO_PR_CLONE_TIMEOUT_MS`, default 600000 = 10 min) that raises the harness's 120s default so a large monorepo's blobless single-branch clone provisions instead of dying at 120s (issue #694). World-restore (issue #324, ADR 0062 Slice 4/5): an optional `sha` — 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. The field is named `sha` because that is the field the c8ctl harness's `provisionRepo` reads to drive the checkout — an earlier `commitSha` key was a silent no-op (issue #695). Alongside the `repository` slice the envelope carries a sibling `task.allowPr: true` (issue #770): c8ctl-plugin-nano (≥1.60.2) only resolves the git credential (GITHUB_TOKEN, or the `gh` default) for repo provisioning behind that flag, so every repo-backed envelope sets it or the clone dies with `unable to get password from user`; the repoless path emits no envelope and so no `task`. Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91, branch-cut guard jwulf/c8ctl-plugin-nano#231). PLACEMENT (issue #882): `branch` is emitted as a TOP-LEVEL sibling of `repository` (and of `task`) under the namespace — the harness `normalizeTaskEnvelope` reads `branch` from the envelope ROOT (`isPlainObject(raw.branch)`), never from `repository.branch`, so the producer must not nest it there (nesting was the #227 two-sides-of-one-contract drift that left `branch.create` silently unread — nanobpm/nano-bpm#1344). The round-trip contract test `app/repoEnvelope.test.ts` pins producer and consumer together.",
483
483
  shape:
484
484
  'io.nanobpm.agentTask: { repository: { provider: "github", url: string, ref?: string, singleBranch: true, filter: "blob:none", cloneTimeoutMs: number, baseRef?: string, sha?: string }, branch?: { create: string }, task: { allowPr: true } } — `branch` is a TOP-LEVEL sibling of `repository` (issue #882): the harness `normalizeTaskEnvelope` reads it from the envelope root (`isPlainObject(raw.branch)`), NOT from `repository.branch`, so the flattened header is `io.nanobpm.agentTask.branch.create` (never `…repository.branch.create`).',
485
485
  },
package/app/feature.ts CHANGED
@@ -21,7 +21,7 @@ import { coalesceTitle, fetchIssueTitle } from "./github.ts";
21
21
  import { derivedTrackingTable } from "./instanceTracking.ts";
22
22
  import { ESCALATION_SLA_TIMEOUT, normalizeBaseBranch, type ParsedIssue, renderBaseBranchBrief } from "./plan.ts";
23
23
  import type { ReadinessProbe } from "./readiness.ts";
24
- import { requireRepoEnvelopeVars } from "./repoEnvelope.ts";
24
+ import { requireRepoEnvelopeVars, sliceBranchCreate } from "./repoEnvelope.ts";
25
25
 
26
26
  /** Optional intake-time readiness gate for a feature run (issue #295): the `capability`/`command`/…
27
27
  * probes the run must ALL satisfy before its implementation agent is dispatched (parked, durably, at
@@ -503,7 +503,10 @@ export async function startFeature(
503
503
  const taskId = featureTaskId(parsed.number);
504
504
  // Pre-PR feature branch (issue #684): the deterministic `feat/<task.id>` the harness creates off the
505
505
  // base for the implementation agent (the agent-guide's `feat/*` convention), matching `task.id`.
506
- const prePrBranch = `feat/${taskId}`;
506
+ // Derived via the ONE canonical `sliceBranchCreate` (issue #945) so the whole-run feature seed and the
507
+ // epic plan-fanout's per-slice emission can never drift into two branch-naming sources. A single-issue
508
+ // task id is always `issue-<number>`, so the derivation is never null here.
509
+ const prePrBranch = sliceBranchCreate(taskId) ?? `feat/${taskId}`;
507
510
  const { processInstanceKey } = await engine.createInstance({
508
511
  processDefinitionId: FEATURE_PROCESS_ID,
509
512
  variables: {
@@ -0,0 +1,168 @@
1
+ // DEFECT-CLASS GUARD (issue #945): every process path that activates an agent job backed by a
2
+ // repository envelope MUST name a deterministic `branch.create`, and that branch MUST be the one the
3
+ // agent's prompt tells it to use — `feat/<task.id>` (resources/prompts/feature.md). Before #945 the
4
+ // plan-fanout implement-cell carried NO `branch.create`: the deterministic branch existed only as
5
+ // prose in the prompt, so every c8ctl retry cut a random `nano/agent-work/*` fallback branch and the
6
+ // durable WIP checkpoint (keyed on `feat/<task.id>`) was never restored — each retry discarded the
7
+ // prior session's work. The root cause was a DRIFT SURFACE: two sources of truth (the prompt text and
8
+ // the envelope) for one fact.
9
+ //
10
+ // This guard pins the single source of truth (`sliceBranchCreate`, app/repoEnvelope.ts) and asserts
11
+ // every consumer derives the SAME branch from it, so a new agent-job-with-repo path — or an edit that
12
+ // re-introduces a hand-written branch — fails CI:
13
+ //
14
+ // 1. The ONE derivation: `sliceBranchCreate(taskId)` is `feat/<taskId>`, validated as a plausible git
15
+ // ref (an ill-formed id degrades to omission, never an unusable branch.create).
16
+ // 2. Every consumer delegates to it: the whole-run feature seed (`feature.ts`), the implement-cell
17
+ // reconcile lookup (`implementReconcile.ts` `implementCellBranch`), and the per-slice envelope
18
+ // emission (`workers/caps-prepare`).
19
+ // 3. The prompt's branch instruction is the SAME `feat/<task.id>` the envelope cuts — the two read
20
+ // off one value, not two.
21
+ // 4. Every execution path into the `implement-task` agent job passes through the `pr.slice-branch`
22
+ // emitter, which runs INSIDE each implement-cell process instance (so siblings can't clobber it) —
23
+ // checked on the sourceRef/targetRef graph, not on XML declaration order.
24
+ import { readFileSync } from "node:fs";
25
+ import { test } from "node:test";
26
+ import { dirname, join } from "node:path";
27
+ import { fileURLToPath } from "node:url";
28
+ import { assert, assertEquals } from "#test-assert";
29
+ import { implementCellBranch } from "./implementReconcile.ts";
30
+ import { AGENT_TASK_BRANCH_CREATE_VAR, sliceBranchCreate } from "./repoEnvelope.ts";
31
+
32
+ const PROCESSES_DIR = join(dirname(fileURLToPath(import.meta.url)), "../resources/processes");
33
+ const PROMPTS_DIR = join(dirname(fileURLToPath(import.meta.url)), "../resources/prompts");
34
+
35
+ function flat(rel: string): string {
36
+ return readFileSync(join(PROCESSES_DIR, rel), "utf8").replace(/\s+/g, " ");
37
+ }
38
+
39
+ test("contract: sliceBranchCreate is the ONE canonical feat/<task.id> derivation (#945)", () => {
40
+ // The deterministic branch for a real task id.
41
+ assertEquals(sliceBranchCreate("issue-7"), "feat/issue-7");
42
+ assertEquals(sliceBranchCreate("tool-metadata-engine"), "feat/tool-metadata-engine");
43
+ assertEquals(sliceBranchCreate("t1"), "feat/t1");
44
+ // A blank / missing id yields NO branch (the caller degrades to omission, never a bare `feat/`).
45
+ assertEquals(sliceBranchCreate(""), null);
46
+ assertEquals(sliceBranchCreate(" "), null);
47
+ assertEquals(sliceBranchCreate(null), null);
48
+ assertEquals(sliceBranchCreate(undefined), null);
49
+ // An id whose derived branch is not a plausible git ref degrades to null (no unusable branch.create).
50
+ assertEquals(sliceBranchCreate("bad..id"), null);
51
+ assertEquals(sliceBranchCreate("has space"), null);
52
+ });
53
+
54
+ test("contract: every implement-cell branch consumer derives from the ONE source (#945)", () => {
55
+ // The reconcile lookup (`implementCellBranch`) — which finds the adoptable PR on the slice's branch —
56
+ // MUST name the same branch the harness cuts (the envelope) and the prompt instructs. It delegates to
57
+ // `sliceBranchCreate`, so the two can never drift.
58
+ for (const taskId of ["issue-7", "tool-metadata-engine", "t1"]) {
59
+ assertEquals(
60
+ implementCellBranch(taskId),
61
+ sliceBranchCreate(taskId),
62
+ `implementCellBranch(${taskId}) must equal the canonical sliceBranchCreate (one source)`,
63
+ );
64
+ }
65
+ });
66
+
67
+ test("contract: the agent prompt's branch instruction is the SAME feat/<task.id> the envelope cuts (#945)", () => {
68
+ // The implement-cell's base prompt (feature.md) tells the agent to branch `feat/<task.id>`. That
69
+ // prose is the SECOND half of the contract — it must name the very branch `sliceBranchCreate`
70
+ // derives for the envelope, so the agent's self-cut branch (pre-#945 fallback) and the harness-cut
71
+ // branch (post-#945) are identical. Pin the literal placeholder so a prompt edit that renames the
72
+ // branch convention fails here.
73
+ const prompt = readFileSync(join(PROMPTS_DIR, "feature.md"), "utf8");
74
+ assert(
75
+ prompt.includes("feat/<task.id>"),
76
+ "feature.md must instruct the agent to branch `feat/<task.id>` — the same branch sliceBranchCreate derives",
77
+ );
78
+ // And the derivation of a concrete id must slot into that placeholder exactly.
79
+ assertEquals(`feat/<task.id>`.replace("<task.id>", "t1"), sliceBranchCreate("t1"));
80
+ });
81
+
82
+ /** Parse the `<bpmn:sequenceFlow>` graph (sourceRef → targetRef) and the boundary-event attachments
83
+ * (host → boundary, an implicit edge) of one process file. */
84
+ function flowGraph(xml: string): Map<string, string[]> {
85
+ const g = new Map<string, string[]>();
86
+ const add = (from: string, to: string): void => {
87
+ g.set(from, [...(g.get(from) ?? []), to]);
88
+ };
89
+ for (const m of xml.matchAll(/<bpmn:sequenceFlow\b[^>]*>/g)) {
90
+ const src = /sourceRef="([^"]+)"/.exec(m[0])?.[1];
91
+ const tgt = /targetRef="([^"]+)"/.exec(m[0])?.[1];
92
+ if (src && tgt) add(src, tgt);
93
+ }
94
+ for (const m of xml.matchAll(/<bpmn:boundaryEvent\b[^>]*>/g)) {
95
+ const id = /\bid="([^"]+)"/.exec(m[0])?.[1];
96
+ const host = /attachedToRef="([^"]+)"/.exec(m[0])?.[1];
97
+ if (id && host) add(host, id);
98
+ }
99
+ return g;
100
+ }
101
+
102
+ /** Can `to` be reached from any of `from` without visiting `avoid`? */
103
+ function reachableAvoiding(g: Map<string, string[]>, from: string[], to: string, avoid: string): boolean {
104
+ const seen = new Set<string>();
105
+ const stack = from.filter((f) => f !== avoid);
106
+ while (stack.length > 0) {
107
+ const n = stack.pop();
108
+ if (n === undefined || seen.has(n)) continue;
109
+ if (n === to) return true;
110
+ seen.add(n);
111
+ for (const next of g.get(n) ?? []) if (next !== avoid) stack.push(next);
112
+ }
113
+ return false;
114
+ }
115
+
116
+ function startEvents(xml: string): string[] {
117
+ return [...xml.matchAll(/<bpmn:startEvent\b[^>]*\bid="([^"]+)"/g)].map((m) => m[1] ?? "");
118
+ }
119
+
120
+ test("contract: every path into the implement-task agent job passes through the slice-branch emitter (#945)", () => {
121
+ const xml = flat("implement-cell.bpmn");
122
+ assert(
123
+ /<bpmn:serviceTask id="slice-branch"[^>]*> <bpmn:extensionElements> <zeebe:taskDefinition type="pr\.slice-branch"/.test(xml),
124
+ "implement-cell must declare the slice-branch task backed by the pr.slice-branch worker",
125
+ );
126
+ const g = flowGraph(xml);
127
+ const starts = startEvents(xml);
128
+ assert(starts.length > 0, "implement-cell must have a start event");
129
+ assert(reachableAvoiding(g, starts, "implement-task", "__none__"), "implement-task must be reachable at all");
130
+ assert(
131
+ !reachableAvoiding(g, starts, "implement-task", "slice-branch"),
132
+ "no execution path may reach implement-task without first passing the slice-branch emitter",
133
+ );
134
+ });
135
+
136
+ test("contract: the path checker detects a bypass of the emitter (guards the guard) (#945)", () => {
137
+ const bypass = flat("implement-cell.bpmn").replace(
138
+ /<bpmn:sequenceFlow id="ic_toSliceBranch" sourceRef="Start" targetRef="slice-branch" \/>/,
139
+ '<bpmn:sequenceFlow id="ic_toSliceBranch" sourceRef="Start" targetRef="implement-task" />',
140
+ );
141
+ assert(bypass.includes('sourceRef="Start" targetRef="implement-task"'), "fixture rewrite must apply");
142
+ assert(reachableAvoiding(flowGraph(bypass), startEvents(bypass), "implement-task", "slice-branch"));
143
+ });
144
+
145
+ test("contract: plan-fanout composes the implement step as a callActivity to implement-cell (#945)", () => {
146
+ // The emitter must run in a SEPARATE process instance per slice (its result keys land on that
147
+ // instance's root, never the shared epic root), so the MI body must call implement-cell rather than
148
+ // inline the agent task.
149
+ const xml = flat("plan-fanout.bpmn");
150
+ assert(
151
+ /<bpmn:callActivity[^>]*id="implement-cell-call"/.test(xml) &&
152
+ /<zeebe:calledElement processId="implement-cell"/.test(xml),
153
+ "plan-fanout must compose the per-wave implement step as a callActivity to implement-cell",
154
+ );
155
+ assert(!xml.includes('type="senior:feature"'), "plan-fanout must not inline the implement agent task");
156
+ });
157
+
158
+ test("contract: the per-slice branch.create rides the FLAT dotted variable the harness reads (#945)", () => {
159
+ // The emission channel is a process variable literally NAMED `io.nanobpm.agentTask.branch.create` —
160
+ // the dotted key the harness's `collectEnvelopeFrom` reassembles and deep-merges with the whole-object
161
+ // seed envelope. Pin the constant to that exact dotted name so a refactor can't silently move it to a
162
+ // nested object (a `<zeebe:ioMapping>` dotted target) the harness never reads.
163
+ assertEquals(AGENT_TASK_BRANCH_CREATE_VAR, "io.nanobpm.agentTask.branch.create");
164
+ assert(
165
+ AGENT_TASK_BRANCH_CREATE_VAR.startsWith("io.nanobpm.agentTask."),
166
+ "the per-slice branch.create variable must live under the reserved io.nanobpm.agentTask namespace",
167
+ );
168
+ });
@@ -22,6 +22,7 @@ import { hasAnswerableQuestion } from "./escalationTaxonomy.ts";
22
22
  import type { HeadPr } from "./github.ts";
23
23
  import { isClaimedCompletion } from "./implementEscalationReason.ts";
24
24
  import { parsePr } from "./prParse.ts";
25
+ import { sliceBranchCreate } from "./repoEnvelope.ts";
25
26
 
26
27
  /** The escalate-arm inputs the reconcile step reads from the implement-cell scope. `subjectKey` is the
27
28
  * cell's `owner/repo#N` subject (a feature run's `feature_key`, or a wave slice's epic `plan_key`) —
@@ -99,9 +100,14 @@ export function retryNudgeFor(status: string): string {
99
100
  return `\n\n---\n\n**Automatic retry — your previous attempt claimed completion but no adoptable pull request was found.** You reported status "${status}", yet a GitHub lookup confirmed there is no adoptable pull request for this slice on its base branch. Your work may NOT be lost: if you already pushed the \`feat/<task.id>\` branch, recover it — check the remote with \`git ls-remote --heads origin feat/<task.id>\`, and if a pull request exists but targets the WRONG base, retarget it onto this run's base branch with \`gh pr edit --base <base>\`. Otherwise you likely worked OUTSIDE your run workspace (e.g. \`cd /tmp\`), and those edits were discarded on teardown. This is your FINAL automatic retry; the next failure escalates to a human. Work ONLY inside your run workspace, then \`git commit -s\`, push \`feat/<task.id>\`, and open a PR on the correct base with \`gh pr create\`. Report \`status: "opened"\` with the \`pr\` key — not "${status}".`;
100
101
  }
101
102
 
102
- /** The cell's deterministic implement branch — `feat/<task.id>` (resources/prompts/feature.md). */
103
+ /** The cell's deterministic implement branch — `feat/<task.id>` (resources/prompts/feature.md).
104
+ * Delegates to the ONE canonical `sliceBranchCreate` (issue #945) so the reconcile lookup, the
105
+ * repository-envelope `branch.create` the harness cuts, and the prompt's branch instruction all share
106
+ * a single derivation — they can never drift into two branch-naming sources. A task id reaching the
107
+ * reconcile step is always a real id, so the derivation is never null here; the `?? `` fallback is
108
+ * defensive only (an implausible id would simply find no adoptable PR, the pre-existing behaviour). */
103
109
  export function implementCellBranch(taskId: string): string {
104
- return `feat/${taskId}`;
110
+ return sliceBranchCreate(taskId) ?? `feat/${taskId}`;
105
111
  }
106
112
 
107
113
  /** The adoptable PR from a head-branch listing: the first OPEN one (a merged/closed PR on the branch is
@@ -0,0 +1,44 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { withMergePolicyLock } from "./mergePolicyLock.ts";
4
+
5
+ // PR #950 review: gated adoption (engine humanApproval=true, stamp 1) and merge approval (engine
6
+ // complete, stamp 0) must never interleave on one loop, or the projection ends at a stale 1.
7
+ test("engine-write/projection-stamp pairs on the same loop never interleave", async () => {
8
+ const log: string[] = [];
9
+ let projection: number | null = null;
10
+ let releaseAdopt!: () => void;
11
+ const adoptEngineWritten = new Promise<void>((r) => {
12
+ releaseAdopt = r;
13
+ });
14
+ const adopt = withMergePolicyLock("loop-1", async () => {
15
+ log.push("adopt:engine");
16
+ await adoptEngineWritten; // the approval tries to run while adoption is mid-pair
17
+ projection = 1;
18
+ log.push("adopt:stamp");
19
+ });
20
+ const approve = withMergePolicyLock("loop-1", async () => {
21
+ log.push("approve:engine");
22
+ projection = 0;
23
+ log.push("approve:stamp");
24
+ });
25
+ await Promise.resolve();
26
+ releaseAdopt();
27
+ await Promise.all([adopt, approve]);
28
+ assert.deepEqual(log, ["adopt:engine", "adopt:stamp", "approve:engine", "approve:stamp"]);
29
+ assert.equal(projection, 0);
30
+ });
31
+
32
+ test("a failing holder releases the lock; other keys run independently", async () => {
33
+ await assert.rejects(
34
+ withMergePolicyLock("loop-2", async () => {
35
+ throw new Error("boom");
36
+ }),
37
+ );
38
+ assert.equal(await withMergePolicyLock("loop-2", async () => "next"), "next");
39
+ let release!: () => void;
40
+ const held = withMergePolicyLock("loop-3", () => new Promise<void>((r) => (release = r)));
41
+ assert.equal(await withMergePolicyLock("loop-4", async () => "free"), "free");
42
+ release();
43
+ await held;
44
+ });
@@ -0,0 +1,25 @@
1
+ /** In-process keyed serializer for the merge-policy projection (issue #947, PR #950 review).
2
+ *
3
+ * Two writers touch one convergence loop's `humanApproval` policy as an engine-write +
4
+ * projection-stamp PAIR: gated adoption (`submitPr`: `setVariables(humanApproval: true)` then stamp
5
+ * `merge_human_approval = 1`) and a `merge-approval` approve (`completeUserTask` then stamp `0`).
6
+ * Interleaved, the adopter's stale `1` can land after the approval's `0`, so `/status` reports a
7
+ * phantom `human-approval` block. Every such pair runs under this lock keyed by the loop's process
8
+ * instance key, so the pairs are applied in a single total order and the projection always matches
9
+ * the last engine write. The app is a single node process, so an in-memory promise chain suffices. */
10
+ const tails = new Map<string, Promise<unknown>>();
11
+
12
+ export async function withMergePolicyLock<T>(processKey: string, fn: () => Promise<T>): Promise<T> {
13
+ const prev = tails.get(processKey) ?? Promise.resolve();
14
+ const run = prev.then(fn, fn);
15
+ const tail = run.then(
16
+ () => undefined,
17
+ () => undefined,
18
+ );
19
+ tails.set(processKey, tail);
20
+ try {
21
+ return await run;
22
+ } finally {
23
+ if (tails.get(processKey) === tail) tails.delete(processKey);
24
+ }
25
+ }