@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.
@@ -55,7 +55,9 @@ function memData(stores: Stores) {
55
55
 
56
56
  /** A fake engine whose `searchVariables` serves the given per-instance variables. A name-less,
57
57
  * key-less query (`{}` — the batched /status read, issue #936) returns EVERY variable across all
58
- * instances, each row carrying its owning `processInstanceKey`, exactly as the real engine does. */
58
+ * instances, each row carrying its owning `processInstanceKey`, exactly as the real engine does.
59
+ * These tests exercise the LEGACY fallback path (issue #947): the row carries no
60
+ * `merge_requested` projection, so the policy is read back off the engine. */
59
61
  function fakeEngine(varsByInstance: Record<string, Record<string, unknown>>) {
60
62
  return {
61
63
  searchVariables: (q: { processInstanceKey?: string; scopeKey?: string; name?: string }) => {
@@ -112,20 +114,20 @@ async function mergeOfWith(
112
114
 
113
115
  test("merge policy: a loop seeded autoMerge=true reads requested+effective (the #916 phantom)", async () => {
114
116
  const merge = await mergeOf({ autoMerge: true, convergeOnly: false, humanApproval: false });
115
- assertEquals(merge, { requested: true, enabled: true, effective: true, blockedBy: null });
117
+ assertEquals(merge, { requested: true, enabled: true, effective: true, blockedBy: null, stale: false });
116
118
  });
117
119
 
118
120
  test("merge policy: a legacy in-flight loop (convergeOnly only) derives requested from its inverse", async () => {
119
121
  // Seeded BEFORE `autoMerge` existed: only the legacy inverted variable is present.
120
122
  const review = await mergeOf({ convergeOnly: true });
121
- assertEquals(review, { requested: false, enabled: true, effective: false, blockedBy: null });
123
+ assertEquals(review, { requested: false, enabled: true, effective: false, blockedBy: null, stale: false });
122
124
  const merging = await mergeOf({ convergeOnly: false });
123
- assertEquals(merging, { requested: true, enabled: true, effective: true, blockedBy: null });
125
+ assertEquals(merging, { requested: true, enabled: true, effective: true, blockedBy: null, stale: false });
124
126
  });
125
127
 
126
128
  test("merge policy: humanApproval gates the effective merge and is named as the blocker", async () => {
127
129
  const merge = await mergeOf({ autoMerge: true, humanApproval: true });
128
- assertEquals(merge, { requested: true, enabled: true, effective: false, blockedBy: "human-approval" });
130
+ assertEquals(merge, { requested: true, enabled: true, effective: false, blockedBy: "human-approval", stale: false });
129
131
  });
130
132
 
131
133
  test("merge policy: an approved PR with finalization still pending reports no blocker (PR #917 review)", async () => {
@@ -135,12 +137,12 @@ test("merge policy: an approved PR with finalization still pending reports no bl
135
137
  // an approval that is already complete. (`status` stays non-terminal: `converged_at` is only
136
138
  // stamped once `pr.finalize` actually runs.)
137
139
  const merge = await mergeOf({ autoMerge: true, humanApproval: false }, { status: "converging" });
138
- assertEquals(merge, { requested: true, enabled: true, effective: true, blockedBy: null });
140
+ assertEquals(merge, { requested: true, enabled: true, effective: true, blockedBy: null, stale: false });
139
141
  });
140
142
 
141
143
  test("merge policy: a review-only request reads requested=false (distinct from env/human blocking)", async () => {
142
144
  const merge = await mergeOf({ autoMerge: false, convergeOnly: true });
143
- assertEquals(merge, { requested: false, enabled: true, effective: false, blockedBy: null });
145
+ assertEquals(merge, { requested: false, enabled: true, effective: false, blockedBy: null, stale: false });
144
146
  });
145
147
 
146
148
  test("merge policy: no readable instance key (or neither variable) defaults to the historical merge default", async () => {
@@ -151,9 +153,9 @@ test("merge policy: no readable instance key (or neither variable) defaults to t
151
153
  user_tasks: { rows: [], key: "id" },
152
154
  };
153
155
  const [noKey] = await activePrs(memData(stores), fakeEngine({}));
154
- assertEquals(noKey.merge, { requested: true, enabled: true, effective: true, blockedBy: null });
156
+ assertEquals(noKey.merge, { requested: true, enabled: true, effective: true, blockedBy: null, stale: true });
155
157
  const bare = await mergeOf({});
156
- assertEquals(bare, { requested: true, enabled: true, effective: true, blockedBy: null });
158
+ assertEquals(bare, { requested: true, enabled: true, effective: true, blockedBy: null, stale: true });
157
159
  });
158
160
 
159
161
  test("merge policy: NANO_PR_AUTO_MERGE=0 blocks a requested merge as env, never a review-only one", async () => {
@@ -165,9 +167,9 @@ test("merge policy: NANO_PR_AUTO_MERGE=0 blocks a requested merge as env, never
165
167
  try {
166
168
  const mod: typeof import("./service.ts") = await import(`./service.ts?mergeStatusAutoMergeOff=${Date.now()}`);
167
169
  const requested = await mergeOfWith(mod, { autoMerge: true, humanApproval: true });
168
- assertEquals(requested, { requested: true, enabled: false, effective: false, blockedBy: "env" });
170
+ assertEquals(requested, { requested: true, enabled: false, effective: false, blockedBy: "env", stale: false });
169
171
  const reviewOnly = await mergeOfWith(mod, { autoMerge: false });
170
- assertEquals(reviewOnly, { requested: false, enabled: false, effective: false, blockedBy: null });
172
+ assertEquals(reviewOnly, { requested: false, enabled: false, effective: false, blockedBy: null, stale: false });
171
173
  } finally {
172
174
  if (prev === undefined) delete process.env["NANO_PR_AUTO_MERGE"];
173
175
  else process.env["NANO_PR_AUTO_MERGE"] = prev;
@@ -220,5 +222,5 @@ test("merge policy: a delayed successful probe cannot resurrect a policy after a
220
222
  } as any;
221
223
  const prs = await activePrs(memData(stores), engine);
222
224
  const pr = prs.find((p: { prKey?: string; pr_key?: string; number?: number }) => p.number === 1);
223
- assertEquals(pr?.merge, { requested: true, enabled: true, effective: true, blockedBy: null }, "failed probe → default, not a half-read policy");
225
+ assertEquals(pr?.merge, { requested: true, enabled: true, effective: true, blockedBy: null, stale: true }, "failed probe → default, not a half-read policy");
224
226
  });
@@ -0,0 +1,72 @@
1
+ // Migration guard for 132_pull_requests_merge_policy.sql (issue #947): the durable merge-policy
2
+ // projection `/status` serves. Applies the migration to a real in-memory SQLite DB over a
3
+ // pre-existing `pull_requests` table and asserts the expand-and-contract contract:
4
+ // * both columns are added NULLABLE with no default — a pre-existing row reads
5
+ // `merge_requested IS NULL` (the legacy marker `activePrs` falls back to the #937 batched
6
+ // engine read for), never a fabricated 0/1;
7
+ // * the writers' stamped values round-trip (0/1 for `merge_requested`, NULL/0/1 for
8
+ // `merge_human_approval`).
9
+ import { readFileSync } from "node:fs";
10
+ import { DatabaseSync } from "node:sqlite";
11
+ import { test } from "node:test";
12
+ import { fileURLToPath } from "node:url";
13
+ import { assertEquals } from "#test-assert";
14
+
15
+ const MIGRATION_132 = fileURLToPath(
16
+ new URL("../db/migrations/132_pull_requests_merge_policy.sql", import.meta.url),
17
+ );
18
+
19
+ function migratedDb(): DatabaseSync {
20
+ const db = new DatabaseSync(":memory:");
21
+ db.exec(
22
+ `CREATE TABLE pull_requests (
23
+ pr_key TEXT PRIMARY KEY,
24
+ repo TEXT NOT NULL,
25
+ number INTEGER NOT NULL,
26
+ status TEXT NOT NULL,
27
+ process_key TEXT
28
+ );
29
+ INSERT INTO pull_requests (pr_key, repo, number, status, process_key)
30
+ VALUES ('owner/repo#1', 'owner/repo', 1, 'converging', 'PI-1');`,
31
+ );
32
+ db.exec(readFileSync(MIGRATION_132, "utf8"));
33
+ return db;
34
+ }
35
+
36
+ test("migration 132 adds both merge-policy columns NULLABLE — a pre-existing row reads legacy (NULL)", () => {
37
+ const db = migratedDb();
38
+ // node:sqlite rows are null-prototype objects; spread to a plain object for the deep-equal.
39
+ const row = {
40
+ ...(db
41
+ .prepare(`SELECT merge_requested, merge_human_approval FROM pull_requests WHERE pr_key = 'owner/repo#1'`)
42
+ .get() as { merge_requested: number | null; merge_human_approval: number | null }),
43
+ };
44
+ assertEquals(
45
+ row,
46
+ { merge_requested: null, merge_human_approval: null },
47
+ "a row written before the projection existed must NOT be back-filled — NULL is the legacy marker the /status fallback keys on",
48
+ );
49
+ });
50
+
51
+ test("migration 132 columns round-trip the writers' stamped values", () => {
52
+ const db = migratedDb();
53
+ db.prepare(
54
+ `UPDATE pull_requests SET merge_requested = 1, merge_human_approval = 0 WHERE pr_key = 'owner/repo#1'`,
55
+ ).run();
56
+ const row = {
57
+ ...(db
58
+ .prepare(`SELECT merge_requested, merge_human_approval FROM pull_requests WHERE pr_key = 'owner/repo#1'`)
59
+ .get() as { merge_requested: number | null; merge_human_approval: number | null }),
60
+ };
61
+ assertEquals(row, { merge_requested: 1, merge_human_approval: 0 });
62
+ // A review-only run stamps 0; the approval gate stamps 1 while outstanding.
63
+ db.prepare(
64
+ `UPDATE pull_requests SET merge_requested = 0, merge_human_approval = 1 WHERE pr_key = 'owner/repo#1'`,
65
+ ).run();
66
+ const gated = {
67
+ ...(db
68
+ .prepare(`SELECT merge_requested, merge_human_approval FROM pull_requests WHERE pr_key = 'owner/repo#1'`)
69
+ .get() as { merge_requested: number | null; merge_human_approval: number | null }),
70
+ };
71
+ assertEquals(gated, { merge_requested: 0, merge_human_approval: 1 });
72
+ });
@@ -33,7 +33,7 @@ import {
33
33
  const MIG = (name: string) => readFileSync(fileURLToPath(new URL(`../db/migrations/${name}`, import.meta.url)), "utf8");
34
34
  const PAGE = (name: string) => JSON.parse(readFileSync(fileURLToPath(new URL(`../pages/${name}`, import.meta.url)), "utf8"));
35
35
 
36
- const READ_MODEL_MIGRATION = "131_pull_requests_read_model_termination_reason.sql";
36
+ const READ_MODEL_MIGRATION = "133_pull_requests_read_model_merge_policy.sql";
37
37
 
38
38
  // The real base `pull_requests` columns, in schema order — DERIVED from the migration chain (not a
39
39
  // hand-kept list that could silently omit one), used by both the drift guard and the e2e stand-in.
@@ -12,82 +12,18 @@
12
12
  // consumer together so they cannot drift again.
13
13
  import { test } from "node:test";
14
14
  import { assert, assertEquals } from "#test-assert";
15
- import { agentNodeRepoEnvelope, flattenAgentTaskEnvelope, repoEnvelopeVars } from "./repoEnvelope.ts";
15
+ import { agentNodeRepoEnvelope, flattenAgentTaskEnvelope, recoverBranchCreate, repoEnvelopeVars } from "./repoEnvelope.ts";
16
16
 
17
- // ─── A faithful mirror of the harness's consumer shape ───────────────────────────────
18
- // These mirror the EXACT semantics of c8ctl-plugin-nano `normalizeTaskEnvelope`
19
- // (c8ctl-plugin.js): `collectEnvelopeFrom` reassembles the reserved
20
- // `io.nanobpm.agentTask` namespace out of BOTH a whole-object/JSON value AND flattened
21
- // dotpath keys, `deepMerge`s the two sources, and `normalizeTaskEnvelope` then reads
22
- // `branch` from the TOP LEVEL of the merged envelope (`isPlainObject(raw.branch)`),
23
- // NOT from `raw.repository.branch`. Keeping this mirror in lock-step with the harness
24
- // is the point of the test: if the producer emits a shape the consumer doesn't read,
25
- // the round-trip below drops `branch.create` and fails.
26
- const AGENT_TASK_NS = "io.nanobpm.agentTask";
27
-
28
- function isPlainObject(v: unknown): v is Record<string, unknown> {
29
- return v != null && typeof v === "object" && !Array.isArray(v);
30
- }
31
-
32
- function deepMerge(base: unknown, over: unknown): unknown {
33
- if (!isPlainObject(base)) return isPlainObject(over) ? deepMerge({}, over) : over;
34
- const out: Record<string, unknown> = { ...base };
35
- if (!isPlainObject(over)) return out;
36
- for (const [k, v] of Object.entries(over)) {
37
- if (v === undefined) continue;
38
- out[k] = isPlainObject(v) && isPlainObject(out[k]) ? deepMerge(out[k], v) : v;
39
- }
40
- return out;
41
- }
42
-
43
- function setPath(obj: Record<string, unknown>, path: string[], value: unknown): void {
44
- let cur = obj;
45
- for (let i = 0; i < path.length - 1; i++) {
46
- const k = path[i];
47
- if (!isPlainObject(cur[k])) cur[k] = {};
48
- cur = cur[k] as Record<string, unknown>;
49
- }
50
- cur[path[path.length - 1]] = value;
51
- }
52
-
53
- /** Mirror of the harness's `collectEnvelopeFrom`: reassemble the reserved namespace from a
54
- * whole-object/JSON value AND flattened dotpath keys (element templates / task headers emit the
55
- * latter). */
56
- function collectEnvelopeFrom(source: Record<string, unknown>): Record<string, unknown> {
57
- const out: Record<string, unknown> = {};
58
- if (!isPlainObject(source)) return out;
59
- const whole = source[AGENT_TASK_NS];
60
- if (whole != null) {
61
- let val: unknown = whole;
62
- if (typeof whole === "string") {
63
- try {
64
- val = JSON.parse(whole);
65
- } catch {
66
- val = undefined;
67
- }
68
- }
69
- if (isPlainObject(val)) Object.assign(out, deepMerge(out, val));
70
- }
71
- const prefix = `${AGENT_TASK_NS}.`;
72
- for (const [key, value] of Object.entries(source)) {
73
- if (!key.startsWith(prefix)) continue;
74
- const rest = key.slice(prefix.length);
75
- if (!rest) continue;
76
- setPath(out, rest.split("."), value);
77
- }
78
- return out;
79
- }
80
-
81
- /** Mirror of the harness's `normalizeTaskEnvelope` branch slice: it reads `branch` from the TOP
82
- * LEVEL of the merged envelope — `isPlainObject(raw.branch) ? raw.branch : {}` — never from
83
- * `raw.repository.branch`. Returns the consumer's recovered `branch.create` (undefined when the
84
- * producer put it somewhere the consumer doesn't look). */
85
- function harnessReadsBranchCreate(customHeaders: Record<string, unknown>, variables: Record<string, unknown>): string | undefined {
86
- const raw = deepMerge(collectEnvelopeFrom(customHeaders), collectEnvelopeFrom(variables)) as Record<string, unknown>;
87
- const branch = isPlainObject(raw.branch) ? raw.branch : {};
88
- const create = branch.create;
89
- return create == null ? undefined : String(create);
90
- }
17
+ // ─── The consumer mirror ─────────────────────────────────────────────────────────────
18
+ // `recoverBranchCreate` (app/repoEnvelope.ts) is the ONE canonical mirror of the c8ctl harness's
19
+ // consumer shape (`collectEnvelopeFrom` + `normalizeTaskEnvelope`): it reassembles the reserved
20
+ // `io.nanobpm.agentTask` namespace out of BOTH a whole-object/JSON value AND flattened dotpath keys,
21
+ // `deepMerge`s the two sources (variables over headers), and reads `branch` from the TOP LEVEL of the
22
+ // merged envelope (`isPlainObject(raw.branch)`), NOT from `raw.repository.branch`. Keeping that mirror
23
+ // in lock-step with the harness is the point of this test: if the producer emits a shape the consumer
24
+ // doesn't read, the round-trip below drops `branch.create` and fails. Aliased here under the
25
+ // harness-centric name the assertions read naturally with.
26
+ const harnessReadsBranchCreate = recoverBranchCreate;
91
27
 
92
28
  // ─── The round-trip contract ──────────────────────────────────────────────────────────
93
29
 
@@ -8,6 +8,7 @@
8
8
  // rebase) and the PRE-PR implementation dispatch (`feature.ts`: `startFeature`; `plan.ts`:
9
9
  // `startPlan` → the epic's `implement-cell`) derive from this single implementation without a
10
10
  // `plan.ts ↔ service.ts` import cycle (AGENTS.md "derivation over duplication — no drift surfaces").
11
+ import { isPlausibleBranchName } from "./baseBranch.ts";
11
12
  import { readEnvOr } from "./contracts.ts";
12
13
  import { isCommitSha } from "./world/index.ts";
13
14
 
@@ -34,6 +35,43 @@ export function isResolvableRepo(repo: unknown): repo is string {
34
35
  return typeof repo === "string" && isPlainOwnerRepo(repo.trim());
35
36
  }
36
37
 
38
+ /** The FLAT, dotted process-variable NAME a per-slice worker emits to attach the deterministic
39
+ * `branch.create` to the implement-cell's agent-job envelope (issue #945). The c8ctl harness's
40
+ * `collectEnvelopeFrom` reassembles the reserved namespace from BOTH the whole-object
41
+ * `io.nanobpm.agentTask` variable (the whole-epic seed, which carries `repository` + `task` but no
42
+ * per-slice `branch`) AND any dotted `io.nanobpm.agentTask.*` variables in scope, deep-merging them —
43
+ * so a dotted-name variable set in the MI-child scope lands on the SAME envelope the seed started,
44
+ * and `normalizeTaskEnvelope` then reads `branch.create` from the merged top level. A
45
+ * `<zeebe:ioMapping>` target with dots canNOT produce this: it builds a NESTED
46
+ * `{io:{nanobpm:{agentTask:…}}}` object the flat-key harness never reads (see {@link
47
+ * flattenAgentTaskEnvelope}). Only a worker result key literally NAMED with this dotted string (the
48
+ * engine propagates worker result variables verbatim, mapped or not) materialises the dotted
49
+ * variable. */
50
+ export const AGENT_TASK_BRANCH_CREATE_VAR = `${AGENT_TASK_NS}.branch.create`;
51
+
52
+ /** The ONE canonical derivation of an implementation slice's deterministic pre-PR branch —
53
+ * `feat/<taskId>` (the agent-guide's `feat/*` convention, resources/prompts/feature.md). This is the
54
+ * single source of truth for BOTH the whole-run feature seed (`feature.ts` `startFeature`, where the
55
+ * branch is known at seed time and rides the envelope's `branch.create` directly) AND the epic
56
+ * plan-fanout's per-slice emission (`workers/slice-branch`, run inside each implement-cell instance, which stamps it onto the implement-cell
57
+ * envelope via {@link AGENT_TASK_BRANCH_CREATE_VAR} because each slice's `task.id` differs per child
58
+ * and can't be named at the whole-epic seed). Issue #945: the plan-fanout implement-cell previously
59
+ * carried NO `branch.create`, so every c8ctl retry cut a random `nano/agent-work/*` fallback branch
60
+ * and the durable WIP checkpoint (keyed on `feat/<task.id>`) was never restored — each retry discarded
61
+ * the prior session's work. Deriving it here once keeps the envelope and the prompt's branch
62
+ * instruction (which tells the agent to use `feat/<task.id>`) from drifting into two sources.
63
+ *
64
+ * Returns `null` for a `taskId` whose derived branch is not a plausible git ref (mirrors the
65
+ * delivery-runner's `feat/<node.id>` guard, #776): an ill-formed `branch.create` would turn the
66
+ * isolation guarantee into a launch/checkout failure, so a blank/implausible id degrades to omission
67
+ * (the pre-#945 agent-cuts-its-own-branch behaviour) rather than emit an unusable value. */
68
+ export function sliceBranchCreate(taskId: string | null | undefined): string | null {
69
+ const id = typeof taskId === "string" ? taskId.trim() : "";
70
+ if (id === "") return null;
71
+ const branch = `feat/${id}`;
72
+ return isPlausibleBranchName(branch) ? branch : null;
73
+ }
74
+
37
75
  /** Flatten a repository-isolation envelope object (as built by {@link repoEnvelopeVars} —
38
76
  * `{ "io.nanobpm.agentTask": { repository: { … } } }`) into the FLAT, dotted `io.nanobpm.agentTask.*`
39
77
  * key → string-value pairs the c8ctl worker harness reads from a service task's `<zeebe:taskHeaders>`
@@ -66,6 +104,90 @@ export function flattenAgentTaskEnvelope(envelope: Record<string, unknown>): Rec
66
104
  return out;
67
105
  }
68
106
 
107
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
108
+ return v != null && typeof v === "object" && !Array.isArray(v);
109
+ }
110
+
111
+ function deepMerge(base: unknown, over: unknown): unknown {
112
+ if (!isPlainObject(base)) return isPlainObject(over) ? deepMerge({}, over) : over;
113
+ const out: Record<string, unknown> = { ...base };
114
+ if (!isPlainObject(over)) return out;
115
+ for (const [k, v] of Object.entries(over)) {
116
+ if (v === undefined) continue;
117
+ out[k] = isPlainObject(v) && isPlainObject(out[k]) ? deepMerge(out[k], v) : v;
118
+ }
119
+ return out;
120
+ }
121
+
122
+ function setPath(obj: Record<string, unknown>, path: string[], value: unknown): void {
123
+ let cur = obj;
124
+ for (let i = 0; i < path.length - 1; i++) {
125
+ const k = path[i];
126
+ const next = cur[k];
127
+ // After the ensure-object step the slot is always a plain object; narrow via the type guard
128
+ // (no `as` — the ban-`as` gate) so the descent stays type-safe.
129
+ if (!isPlainObject(next)) {
130
+ const fresh: Record<string, unknown> = {};
131
+ cur[k] = fresh;
132
+ cur = fresh;
133
+ } else {
134
+ cur = next;
135
+ }
136
+ }
137
+ cur[path[path.length - 1]] = value;
138
+ }
139
+
140
+ /** Mirror of the c8ctl harness's `collectEnvelopeFrom`: reassemble the reserved namespace out of BOTH
141
+ * a whole-object/JSON `io.nanobpm.agentTask` value AND flattened dotted `io.nanobpm.agentTask.*` keys
142
+ * (the channel a per-slice worker's dotted-name result variable uses, issue #945), deep-merging the
143
+ * two. This is the ONE consumer-side mirror shared by the round-trip contract tests
144
+ * (`app/repoEnvelope.test.ts`) and the e2e fan-out proof (`e2e/plan-fanout.e2e.ts`) so producer and
145
+ * consumer can never drift into two readings of where `branch.create` lives. */
146
+ function collectEnvelopeFrom(source: Record<string, unknown>): Record<string, unknown> {
147
+ const out: Record<string, unknown> = {};
148
+ if (!isPlainObject(source)) return out;
149
+ const whole = source[AGENT_TASK_NS];
150
+ if (whole != null) {
151
+ let val: unknown = whole;
152
+ if (typeof whole === "string") {
153
+ try {
154
+ val = JSON.parse(whole);
155
+ } catch {
156
+ val = undefined;
157
+ }
158
+ }
159
+ if (isPlainObject(val)) Object.assign(out, deepMerge(out, val));
160
+ }
161
+ const prefix = `${AGENT_TASK_NS}.`;
162
+ for (const [key, value] of Object.entries(source)) {
163
+ if (!key.startsWith(prefix)) continue;
164
+ const rest = key.slice(prefix.length);
165
+ if (!rest) continue;
166
+ setPath(out, rest.split("."), value);
167
+ }
168
+ return out;
169
+ }
170
+
171
+ /** Mirror of the c8ctl harness's `normalizeTaskEnvelope` branch slice: deep-merge the envelope
172
+ * recovered from `customHeaders` under that recovered from `variables` (variables win), then read
173
+ * `branch` from the TOP LEVEL of the merged envelope (`isPlainObject(raw.branch)`), NEVER from
174
+ * `raw.repository.branch`. Returns the consumer's recovered `branch.create` (`undefined` when the
175
+ * producer put it somewhere the consumer doesn't look, or omitted it). This is the ONE place tests
176
+ * assert "the harness sees this branch" — keeping producer (`repoEnvelopeVars` / the per-slice dotted
177
+ * variable) and consumer pinned together (issues #882/#945). */
178
+ export function recoverBranchCreate(
179
+ customHeaders: Record<string, unknown>,
180
+ variables: Record<string, unknown>,
181
+ ): string | undefined {
182
+ const merged = deepMerge(collectEnvelopeFrom(customHeaders), collectEnvelopeFrom(variables));
183
+ // deepMerge of two plain-object envelope slices is always a plain object; narrow via the type guard
184
+ // (no `as` — the ban-`as` gate). A non-object merge (impossible here) reads as "no branch".
185
+ const raw = isPlainObject(merged) ? merged : {};
186
+ const branch = isPlainObject(raw.branch) ? raw.branch : {};
187
+ const create = branch.create;
188
+ return create == null ? undefined : String(create);
189
+ }
190
+
69
191
  /** Raised by `requireRepoEnvelopeVars` when the repository-isolation envelope is REQUIRED on a fan-out
70
192
  * path but its inputs are unresolved (a blank base/head `ref`, or a `repo` that is not a plain
71
193
  * `owner/repo`). Issue #729: the fan-out dispatch paths must fail loudly here rather than let
@@ -62,6 +62,16 @@ function memOpen(stores: Record<string, { rows: any[]; key: string }>, ops?: str
62
62
  }
63
63
  return { changed };
64
64
  }
65
+ if (/^UPDATE "pull_requests" SET "merge_human_approval" = 1 WHERE "pr_key" = \? AND "process_key" = \? AND "converged_at" IS NULL$/.test(sql)) {
66
+ let changed = 0;
67
+ for (const r of stores.pull_requests?.rows ?? []) {
68
+ if (r.pr_key === params[0] && r.process_key === params[1] && r.converged_at == null) {
69
+ r.merge_human_approval = 1;
70
+ changed++;
71
+ }
72
+ }
73
+ return { changed };
74
+ }
65
75
  throw new Error(`unexpected exec sql: ${sql}`);
66
76
  },
67
77
  };
@@ -1259,6 +1269,25 @@ function adoptLoop(status: string, loopState: string, convergedAt: string | null
1259
1269
  return { data, engine, narrowed };
1260
1270
  }
1261
1271
 
1272
+ test("a gated submitPr stamps an OUTSTANDING approval matching the engine seed (PR #950 review)", async () => {
1273
+ await withGithubOff(async () => {
1274
+ const { data, engine, stores, vars } = captureRoot();
1275
+ await submitPr(
1276
+ data,
1277
+ engine,
1278
+ { repo: "owner/repo", number: 25, url: "https://github.com/owner/repo/pull/25", prKey: "owner/repo#25" },
1279
+ [],
1280
+ 20,
1281
+ false,
1282
+ null,
1283
+ true, // humanApproval
1284
+ );
1285
+ const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
1286
+ assertEquals(pr.merge_human_approval, vars()?.humanApproval === true ? 1 : 0, "projection agrees with the engine seed");
1287
+ assertEquals(pr.merge_human_approval, 1, "a gated run projects the outstanding gate");
1288
+ });
1289
+ });
1290
+
1262
1291
  test("a gated submitPr adopting a live loop narrows it to humanApproval (issue #826)", async () => {
1263
1292
  await withGithubOff(async () => {
1264
1293
  const pr = { repo: "owner/repo", number: 14, url: "https://github.com/owner/repo/pull/14", prKey: "owner/repo#14" };
@@ -1464,6 +1493,140 @@ test("startMerge seeds the merge-policy triple so /status needs no per-instance
1464
1493
  });
1465
1494
  });
1466
1495
 
1496
+ // ── Issue #947: the durable merge-policy projection the writers stamp ────────────────────────────
1497
+ // `/status` serves the merge policy off the `pull_requests` row (zero engine reads) — these pin the
1498
+ // writer side of that contract: every path that KNOWS the policy must stamp it.
1499
+
1500
+ test("submitPr stamps the merge-policy projection on the row (insert path)", async () => {
1501
+ await withGithubOff(async () => {
1502
+ const { data, engine, stores } = captureRoot();
1503
+ await submitPr(data, engine, {
1504
+ repo: "owner/repo",
1505
+ number: 21,
1506
+ url: "https://github.com/owner/repo/pull/21",
1507
+ prKey: "owner/repo#21",
1508
+ });
1509
+ const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
1510
+ assertEquals(pr.merge_requested, 1, "the default submit requests the merge");
1511
+ assertEquals(pr.merge_human_approval, 0, "an ungated run mirrors the engine seed humanApproval:false (PR #950 review)");
1512
+ });
1513
+ });
1514
+
1515
+ test("submitPr stamps a review-only intent as merge_requested 0 (convergeOnly)", async () => {
1516
+ await withGithubOff(async () => {
1517
+ const { data, engine, stores } = captureRoot();
1518
+ await submitPr(
1519
+ data,
1520
+ engine,
1521
+ { repo: "owner/repo", number: 22, url: "https://github.com/owner/repo/pull/22", prKey: "owner/repo#22" },
1522
+ [],
1523
+ 20,
1524
+ true, // convergeOnly
1525
+ );
1526
+ const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
1527
+ assertEquals(pr.merge_requested, 0, "a review-only run projects requested=false with no engine read");
1528
+ assertEquals(pr.merge_human_approval, 0, "no gate on a review-only run");
1529
+ });
1530
+ });
1531
+
1532
+ test("submitPr reopen restamps the projection to the FRESH run's intent (run-scoped reset)", async () => {
1533
+ await withGithubOff(async () => {
1534
+ const { data, engine, stores } = captureRoot();
1535
+ (stores.pull_requests.rows as unknown[]).push({
1536
+ pr_key: "owner/repo#23",
1537
+ repo: "owner/repo",
1538
+ number: 23,
1539
+ url: "https://github.com/owner/repo/pull/23",
1540
+ status: "converged", // terminal -> reopen path
1541
+ current_round: 2,
1542
+ process_key: "PI-OLD",
1543
+ // The PRIOR run's projection: review-only, approval decided. A fresh default submit must
1544
+ // overwrite both — a carried-over stamp would misreport the new run's policy.
1545
+ merge_requested: 0,
1546
+ merge_human_approval: 0,
1547
+ });
1548
+ await submitPr(data, engine, {
1549
+ repo: "owner/repo",
1550
+ number: 23,
1551
+ url: "https://github.com/owner/repo/pull/23",
1552
+ prKey: "owner/repo#23",
1553
+ });
1554
+ const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
1555
+ assertEquals(pr.merge_requested, 1, "the fresh run's intent wins");
1556
+ assertEquals(pr.merge_human_approval, 0, "the approval state mirrors the fresh ungated seed");
1557
+ });
1558
+ });
1559
+
1560
+ test("a gated submitPr adopting a live loop stamps merge_human_approval on the row (issue #947)", async () => {
1561
+ await withGithubOff(async () => {
1562
+ const pr = { repo: "owner/repo", number: 14, url: "https://github.com/owner/repo/pull/14", prKey: "owner/repo#14" };
1563
+ const live = adoptLoop("converging", "ACTIVE");
1564
+ await submitPr(live.data, live.engine, pr, [], 20, false, null, true);
1565
+ const row = (await live.data.table("pull_requests", "pr_key").get(pr.prKey)) as {
1566
+ merge_human_approval?: number | null;
1567
+ } | null;
1568
+ assertEquals(row?.merge_human_approval, 1, "the gate is mirrored onto the /status projection");
1569
+
1570
+ const ungated = adoptLoop("converging", "ACTIVE");
1571
+ await submitPr(ungated.data, ungated.engine, pr);
1572
+ const ungatedRow = (await ungated.data.table("pull_requests", "pr_key").get(pr.prKey)) as {
1573
+ merge_human_approval?: number | null;
1574
+ } | null;
1575
+ assertEquals(ungatedRow?.merge_human_approval ?? null, null, "an ungated adopter leaves the projection undecided");
1576
+ });
1577
+ });
1578
+
1579
+ test("a gated adopter never gates a merge-loop or a row re-pointed mid-handoff (PR #950 review)", async () => {
1580
+ await withGithubOff(async () => {
1581
+ const pr = { repo: "owner/repo", number: 14, url: "https://github.com/owner/repo/pull/14", prKey: "owner/repo#14" };
1582
+ // Variant 1: the key already resolves to the merge-loop (startMerge re-pointed it, converged_at unset).
1583
+ const merge = adoptLoop("converging", "ACTIVE");
1584
+ merge.engine.searchProcessInstances = () =>
1585
+ Promise.resolve([{ processInstanceKey: "PI-LOOP", state: "ACTIVE", processDefinitionId: "merge-loop" }]);
1586
+ await submitPr(merge.data, merge.engine, pr, [], 20, false, null, true);
1587
+ const mergeRow = (await merge.data.table("pull_requests", "pr_key").get(pr.prKey)) as any;
1588
+ assertEquals(mergeRow?.merge_human_approval ?? null, null, "merge-loop policy untouched");
1589
+ assertEquals(merge.narrowed.length, 0, "no humanApproval set on the merge-loop");
1590
+ // Variant 2: the handoff re-points the row between the engine read and the stamp.
1591
+ const race = adoptLoop("converging", "ACTIVE");
1592
+ race.engine.setVariables = async () => {
1593
+ const row = (await race.data.table("pull_requests", "pr_key").get(pr.prKey)) as any;
1594
+ row.process_key = "PI-MERGE";
1595
+ row.merge_human_approval = 0;
1596
+ };
1597
+ await submitPr(race.data, race.engine, pr, [], 20, false, null, true);
1598
+ const raceRow = (await race.data.table("pull_requests", "pr_key").get(pr.prKey)) as any;
1599
+ assertEquals(raceRow?.merge_human_approval, 0, "the handoff's merge-stage policy survives");
1600
+ });
1601
+ });
1602
+
1603
+ test("startMerge stamps the merge-stage policy on the row (issue #947)", async () => {
1604
+ await withGithubOff(async () => {
1605
+ const { data, engine, stores } = captureRoot();
1606
+ (stores.pull_requests.rows as unknown[]).push({
1607
+ pr_key: "owner/repo#24",
1608
+ repo: "owner/repo",
1609
+ number: 24,
1610
+ url: "https://github.com/owner/repo/pull/24",
1611
+ status: "converged",
1612
+ current_round: 1,
1613
+ process_key: "PI-CONV",
1614
+ merge_requested: 1,
1615
+ merge_human_approval: 1, // the convergence-stage gate — decided by the merge hand-off
1616
+ });
1617
+ await startMerge(data, engine, {
1618
+ repo: "owner/repo",
1619
+ number: 24,
1620
+ url: "https://github.com/owner/repo/pull/24",
1621
+ prKey: "owner/repo#24",
1622
+ round: 1,
1623
+ });
1624
+ const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
1625
+ assertEquals(pr.merge_requested, 1, "a merge loop only starts for a requested merge");
1626
+ assertEquals(pr.merge_human_approval, 0, "…and an approved one — the gate is behind it");
1627
+ });
1628
+ });
1629
+
1467
1630
  // The repository envelope drives the c8ctl harness's isolated workspace provisioning: it is
1468
1631
  // emitted under the reserved `io.nanobpm.agentTask` namespace with the PR head branch as the
1469
1632
  // checkout ref, and omitted entirely when the head branch couldn't be resolved (so the harness
@@ -2681,7 +2844,7 @@ test("submitPr handoffFrom: a failed reset restores the prior owner so the hand-
2681
2844
  const PR_KEY = "owner/repo#79";
2682
2845
  const stores: Record<string, { rows: any[]; key: string }> = {
2683
2846
  pull_requests: {
2684
- rows: [{ pr_key: PR_KEY, repo: "owner/repo", number: 79, url: "https://github.com/owner/repo/pull/79", title: "t", status: "waiting_merge", current_round: 4, process_key: "PI-MERGE" }],
2847
+ rows: [{ pr_key: PR_KEY, repo: "owner/repo", number: 79, url: "https://github.com/owner/repo/pull/79", title: "t", status: "waiting_merge", current_round: 4, process_key: "PI-MERGE", merge_requested: 0, merge_human_approval: 1 }],
2685
2848
  key: "pr_key",
2686
2849
  },
2687
2850
  escalations: { rows: [], key: "id" },
@@ -2722,6 +2885,10 @@ test("submitPr handoffFrom: a failed reset restores the prior owner so the hand-
2722
2885
  assertEquals(row.process_key, "PI-MERGE", "rollback restores the prior owner, never leaves the cancelled key");
2723
2886
  assertEquals(row.status, "waiting_merge", "rollback restores the prior status");
2724
2887
  assertEquals(row.current_round, 4);
2888
+ // PR #950 review: the merge-policy projection is restored WITH ownership, never left as the
2889
+ // failed run's policy under the old owner (which /status would serve as authoritative).
2890
+ assertEquals(row.merge_requested, 0, "rollback restores the prior merge intent");
2891
+ assertEquals(row.merge_human_approval, 1, "rollback restores the prior approval state");
2725
2892
 
2726
2893
  failReset = false;
2727
2894
  const retry = await submitPr(data, engine, parsed, [], undefined, false, null, false, false, true, "PI-MERGE");