@nanobpm/nano-workforce 0.105.0 → 0.106.1

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.
@@ -13,11 +13,23 @@
13
13
  // The rule: no two migration files may share a numeric prefix. The pre-existing historical
14
14
  // duplicates are forward-only and already applied, so they cannot be renamed — they are
15
15
  // grandfathered in GRANDFATHERED_DUPES. Any NEW duplicate prefix fails the build.
16
+ //
17
+ // A second, independent invariant lives here too (issue #357): once a migration has merged to
18
+ // `main` it is IMMUTABLE — never renamed, deleted, or edited. The runtime keys the migration ledger
19
+ // by FILENAME, so renaming a merged migration makes the runner re-apply it against an already-
20
+ // migrated DB and abort boot ("duplicate column"); editing one silently no-ops on every existing
21
+ // install (the ledger already has that name) while diverging fresh installs. `checkImmutability`
22
+ // diffs `db/migrations/` against the merge-base with `origin/main` and fails on any rename, delete,
23
+ // or content change to a file that existed there. It compares against the merge-base (the branch's
24
+ // fork point), NOT `origin/main`'s tip, so a branch that is merely behind main isn't wrongly flagged
25
+ // for migrations added to main after it forked.
26
+ import { execFileSync } from "node:child_process";
16
27
  import { readdirSync } from "node:fs";
17
28
  import { dirname, join } from "node:path";
18
29
  import { fileURLToPath } from "node:url";
19
30
 
20
- const MIGRATIONS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "db", "migrations");
31
+ const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
32
+ const MIGRATIONS_DIR = join(REPO_ROOT, "db", "migrations");
21
33
 
22
34
  // Historical collisions that predate this gate. Forward-only + already applied ⇒ cannot be
23
35
  // renumbered. New duplicates are NOT allowed here — fix them before merge.
@@ -32,10 +44,112 @@ const MIGRATIONS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "db",
32
44
  // cannot be renumbered (a rename re-runs `CREATE TABLE`/`ALTER TABLE DROP COLUMN` on migrated DBs and
33
45
  // fails). They create three disjoint schema objects, so their relative apply order is irrelevant.
34
46
  // Grandfather 049; any NEW duplicate prefix still fails the build.
35
- const GRANDFATHERED_DUPES: ReadonlySet<string> = new Set(["004", "005", "006", "007", "049"]);
47
+ //
48
+ // 052 is the same story across two PRs: #351 landed `052_worker_durable_resume` and #355 landed
49
+ // `052_plan_conformance`, each the branch-local "next" prefix, colliding silently only once both were
50
+ // on main. Both are already applied forward-only, and — reinforced now by the immutability check
51
+ // below — renumbering a merged migration is itself forbidden (the rename would re-run it and abort
52
+ // boot, issue #357). The two create disjoint tables (`worker_durable_resume`, `plan_conformance`), so
53
+ // apply order is irrelevant. Grandfather 052; any NEW duplicate prefix still fails the build.
54
+ const GRANDFATHERED_DUPES: ReadonlySet<string> = new Set(["004", "005", "006", "007", "049", "052"]);
36
55
 
37
56
  const PREFIX = /^(\d{3})_[^/]*\.sql$/;
38
57
 
58
+ function git(args: string[]): string {
59
+ return execFileSync("git", args, { cwd: REPO_ROOT, encoding: "utf8" });
60
+ }
61
+
62
+ /** The commit to treat as "already merged" — the branch's fork point from `origin/main` (fall back to
63
+ * local `main`). `null` when no baseline is resolvable (e.g. a shallow clone with no `main`), in
64
+ * which case the immutability check is skipped with a warning; CI checks out full history. Override
65
+ * with `MIGRATION_BASELINE_REF` (used by the gate's own tests). */
66
+ function resolveBaseline(): string | null {
67
+ const override = process.env.MIGRATION_BASELINE_REF;
68
+ const upstreams = override ? [override] : ["origin/main", "main"];
69
+ for (const ref of upstreams) {
70
+ try {
71
+ // An explicit override is used as-is; a branch name is resolved to its merge-base with HEAD so a
72
+ // branch that is merely behind main isn't blamed for migrations main gained after it forked.
73
+ if (override) {
74
+ git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
75
+ return ref;
76
+ }
77
+ return git(["merge-base", ref, "HEAD"]).trim();
78
+ } catch {
79
+ // try the next candidate
80
+ }
81
+ }
82
+ return null;
83
+ }
84
+
85
+ /** Classify a `git diff --find-renames --name-status <baseline> -- db/migrations` listing into
86
+ * immutability violations. Renames (`R`), deletes (`D`), and content edits (`M`) of a merged
87
+ * migration are violations; additions (`A`) are allowed. Exported for unit coverage. */
88
+ export function immutabilityErrorsFromDiff(statusOutput: string): string[] {
89
+ const errors: string[] = [];
90
+ const name = (p: string): string => p.slice(p.lastIndexOf("/") + 1);
91
+ for (const line of statusOutput.split("\n")) {
92
+ if (line.trim() === "") continue;
93
+ const [status, ...paths] = line.split("\t");
94
+ if (status.startsWith("R")) {
95
+ errors.push(
96
+ ` ${name(paths[0])} -> ${name(paths[1])}: a merged migration was RENAMED. The ledger keys ` +
97
+ `by filename, so the renamed file re-runs on every existing install and aborts boot. Keep ` +
98
+ `the original filename; add a NEW migration for further change.`,
99
+ );
100
+ } else if (status.startsWith("D")) {
101
+ errors.push(
102
+ ` ${name(paths[0])}: a merged migration was DELETED. Forward-only migrations are immutable — ` +
103
+ `removing one desyncs the ledger from the schema. Leave it in place.`,
104
+ );
105
+ } else if (status.startsWith("M")) {
106
+ errors.push(
107
+ ` ${name(paths[0])}: a merged migration was EDITED. The ledger already records it, so the ` +
108
+ `edit silently no-ops on every migrated install while diverging fresh ones. Add a NEW ` +
109
+ `migration instead of changing a merged one.`,
110
+ );
111
+ }
112
+ }
113
+ return errors;
114
+ }
115
+
116
+ /** Fail on any rename, delete, or content change to a migration already present at the baseline — a
117
+ * merged migration is immutable (issue #357). Additions are fine. Returns whether the immutability
118
+ * gate actually ran (false when it was skipped because no baseline/diff was available), so the
119
+ * caller's success message doesn't claim a guarantee the gate never checked. */
120
+ function checkImmutability(errors: string[]): boolean {
121
+ const baseline = resolveBaseline();
122
+ if (baseline === null) {
123
+ console.warn(
124
+ "check-migrations: WARN — no origin/main baseline resolvable; skipping the immutability check " +
125
+ "(CI runs it with full history via fetch-depth: 0).",
126
+ );
127
+ return false;
128
+ }
129
+
130
+ let statusOutput: string;
131
+ try {
132
+ // Diff the baseline tree against the WORKING TREE (staged + unstaged), detecting renames, scoped
133
+ // to db/migrations. `--find-renames` surfaces a rename as one `R` row instead of a delete+add.
134
+ statusOutput = git([
135
+ "diff",
136
+ "--find-renames",
137
+ "--name-status",
138
+ baseline,
139
+ "--",
140
+ "db/migrations",
141
+ ]);
142
+ } catch {
143
+ console.warn(
144
+ `check-migrations: WARN — could not diff migrations against ${baseline}; skipping the immutability check.`,
145
+ );
146
+ return false;
147
+ }
148
+
149
+ errors.push(...immutabilityErrorsFromDiff(statusOutput));
150
+ return true;
151
+ }
152
+
39
153
  function main(): void {
40
154
  const files = readdirSync(MIGRATIONS_DIR)
41
155
  .filter((f) => f.endsWith(".sql"))
@@ -68,12 +182,20 @@ function main(): void {
68
182
  }
69
183
  }
70
184
 
185
+ const immutabilityChecked = checkImmutability(errors);
186
+
71
187
  if (errors.length > 0) {
72
- console.error(`check-migrations: db/migrations has colliding prefixes:\n${errors.join("\n")}`);
188
+ console.error(`check-migrations: db/migrations failed its merge-safety checks:\n${errors.join("\n")}`);
73
189
  process.exit(1);
74
190
  }
75
191
 
76
- console.log(`check-migrations: OK (${files.length} migrations, no colliding prefixes).`);
192
+ const immutabilityClause = immutabilityChecked
193
+ ? "none renamed/deleted/edited"
194
+ : "immutability check skipped (no baseline)";
195
+ console.log(
196
+ `check-migrations: OK (${files.length} migrations, no colliding prefixes, ${immutabilityClause}).`,
197
+ );
77
198
  }
78
199
 
79
- main();
200
+ if (import.meta.main) main();
201
+
@@ -0,0 +1,49 @@
1
+ // heal-migration-ledger — one-shot recovery for an install wedged by issue #357 (a migration that
2
+ // was renamed after it merged, so the runtime re-runs it and boot aborts on "duplicate column").
3
+ //
4
+ // Usage:
5
+ // node --experimental-strip-types scripts/heal-migration-ledger.ts <path-to-sqlite.db>
6
+ //
7
+ // It reconciles the `_urban_migrations` ledger for every known historical rename
8
+ // (`RENAMED_MIGRATIONS`): where the OLD filename is recorded as applied but the NEW one is not, it
9
+ // records the new filename as applied. This is a pure ledger-alias — it makes NO schema change — and
10
+ // is safe to run repeatedly and on a healthy DB (it only acts on the broken state). After running it,
11
+ // restart the node; the renamed migration is skipped and boot completes.
12
+ import { existsSync } from "node:fs";
13
+ import { DatabaseSync } from "node:sqlite";
14
+ import { healMigrationLedger } from "../app/migrationHeal.ts";
15
+
16
+ function main(): void {
17
+ const dbPath = process.argv[2];
18
+ if (!dbPath) {
19
+ console.error(
20
+ "usage: node --experimental-strip-types scripts/heal-migration-ledger.ts <path-to-sqlite.db>",
21
+ );
22
+ process.exit(2);
23
+ }
24
+
25
+ // `DatabaseSync` silently CREATES an empty DB when the path doesn't exist, so a typo'd path would
26
+ // "heal" a brand-new empty file and misleadingly report "nothing to heal". Fail loudly instead.
27
+ if (!existsSync(dbPath)) {
28
+ console.error(
29
+ `heal-migration-ledger: ${dbPath} — no such file. Pass the path to the existing install's SQLite DB.`,
30
+ );
31
+ process.exit(2);
32
+ }
33
+
34
+ const db = new DatabaseSync(dbPath);
35
+ try {
36
+ const healed = healMigrationLedger(db);
37
+ if (healed.length === 0) {
38
+ console.log(`heal-migration-ledger: ${dbPath} — nothing to heal (ledger already consistent).`);
39
+ } else {
40
+ console.log(
41
+ `heal-migration-ledger: ${dbPath} — aliased ${healed.length} migration(s) as applied: ${healed.join(", ")}. Restart the node to complete boot.`,
42
+ );
43
+ }
44
+ } finally {
45
+ db.close();
46
+ }
47
+ }
48
+
49
+ if (import.meta.main) main();
@@ -0,0 +1,121 @@
1
+ // Test helpers for exercising the forward-only SQLite migration set the way the runtime does.
2
+ //
3
+ // `applyMigrationSet` mirrors `@nanobpm/urban`'s `applyMigrations` (ledger keyed by FILENAME, each
4
+ // migration wrapped in its own transaction, skip-applied / apply-new) against a `node:sqlite`
5
+ // `DatabaseSync`, so upgrade tests reproduce the exact boot-time behaviour — including the
6
+ // "duplicate column" abort a renamed migration causes — without booting the whole app.
7
+ import { execFileSync } from "node:child_process";
8
+ import { readdirSync, readFileSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import type { DatabaseSync } from "node:sqlite";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
14
+ const MIGRATIONS_DIR = join(REPO_ROOT, "db", "migrations");
15
+ const MIGRATIONS_TABLE = "_urban_migrations";
16
+
17
+ export interface MigrationFile {
18
+ name: string;
19
+ sql: string;
20
+ }
21
+
22
+ function byName(files: MigrationFile[]): MigrationFile[] {
23
+ // Match the runtime's plain lexical `.sort()` on filename (apply order).
24
+ return [...files].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
25
+ }
26
+
27
+ /**
28
+ * Apply `files` to `db` exactly as the runtime does: create the ledger, skip any filename already
29
+ * recorded, and for each remaining file run its SQL + record the filename atomically in one
30
+ * transaction. Throws the same rolled-back error the runtime raises when a migration's SQL fails.
31
+ * @returns the filenames newly applied.
32
+ */
33
+ export function applyMigrationSet(
34
+ db: DatabaseSync,
35
+ files: MigrationFile[],
36
+ now: () => Date = () => new Date(),
37
+ ): string[] {
38
+ db.exec(
39
+ `CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)`,
40
+ );
41
+ const applied = new Set(
42
+ (db.prepare(`SELECT name FROM ${MIGRATIONS_TABLE}`).all() as { name: string }[]).map(
43
+ (r) => r.name,
44
+ ),
45
+ );
46
+ const newlyApplied: string[] = [];
47
+ for (const file of byName(files)) {
48
+ if (applied.has(file.name)) continue;
49
+ db.exec("BEGIN");
50
+ try {
51
+ db.exec(file.sql);
52
+ db.prepare(`INSERT INTO ${MIGRATIONS_TABLE} (name, applied_at) VALUES (?, ?)`).run(
53
+ file.name,
54
+ now().toISOString(),
55
+ );
56
+ db.exec("COMMIT");
57
+ } catch (err) {
58
+ db.exec("ROLLBACK");
59
+ throw new Error(
60
+ `migration "${file.name}" failed and was rolled back (no partial schema change, not recorded as applied): ${err instanceof Error ? err.message : String(err)}`,
61
+ { cause: err },
62
+ );
63
+ }
64
+ newlyApplied.push(file.name);
65
+ }
66
+ return newlyApplied;
67
+ }
68
+
69
+ /** The current worktree's migration set, read from `db/migrations/`. */
70
+ export function readMigrationSetFromDisk(): MigrationFile[] {
71
+ return readdirSync(MIGRATIONS_DIR)
72
+ .filter((f) => f.endsWith(".sql"))
73
+ .map((name) => ({ name, sql: readFileSync(join(MIGRATIONS_DIR, name), "utf8") }));
74
+ }
75
+
76
+ function git(args: string[]): string {
77
+ return execFileSync("git", args, { cwd: REPO_ROOT, encoding: "utf8" });
78
+ }
79
+
80
+ /**
81
+ * The migration set as it existed at a git ref (tag/branch/sha), read straight from the object
82
+ * store so it works from any worktree. Returns `null` if the ref (or git) is unavailable — e.g. a
83
+ * shallow clone without tags — so callers can skip rather than fail spuriously (CI fetches full
84
+ * history + tags, so it exercises the real path).
85
+ */
86
+ export function readMigrationSetFromGit(ref: string): MigrationFile[] | null {
87
+ let listing: string;
88
+ try {
89
+ listing = git(["ls-tree", "-r", "--name-only", ref, "--", "db/migrations"]);
90
+ } catch {
91
+ return null;
92
+ }
93
+ const paths = listing
94
+ .split("\n")
95
+ .map((l) => l.trim())
96
+ .filter((l) => l.endsWith(".sql"));
97
+ if (paths.length === 0) return null;
98
+ const files: MigrationFile[] = [];
99
+ for (const path of paths) {
100
+ const name = path.slice(path.lastIndexOf("/") + 1);
101
+ files.push({ name, sql: git(["show", `${ref}:${path}`]) });
102
+ }
103
+ return files;
104
+ }
105
+
106
+ /**
107
+ * The most recent release tag (`vN.N.N`) reachable from HEAD's parent — i.e. the release we would be
108
+ * upgrading a live install FROM. Falls back to the tag on HEAD itself, then `null` if none is
109
+ * reachable (shallow clone without tags).
110
+ */
111
+ export function previousReleaseTag(): string | null {
112
+ for (const from of ["HEAD^", "HEAD"]) {
113
+ try {
114
+ const ref = git(["describe", "--tags", "--abbrev=0", "--match", "v*", from]).trim();
115
+ if (ref) return ref;
116
+ } catch {
117
+ // try the next base
118
+ }
119
+ }
120
+ return null;
121
+ }
@@ -0,0 +1,50 @@
1
+ // Unit coverage for pr.conformance-ack — the operator acknowledged a conformance-review escalation
2
+ // (issue #216). It must settle the parked `plan_conformance` row at `review_status = 'reviewed'` so
3
+ // the inbox scan drops it (the retro instance COMPLETES normally, so `instanceTracking.onTerminated`
4
+ // never fires) and fold the operator's disposition note into the audit `summary`.
5
+ import { test } from "node:test";
6
+ import { assertEquals } from "#test-assert";
7
+ import { noopLog } from "../../test/log.ts";
8
+ import handler from "./worker.ts";
9
+
10
+ function fakeApp(rows: Record<string, unknown>[]) {
11
+ const stores: Record<string, Record<string, unknown>[]> = { plan_conformance: rows };
12
+ return {
13
+ data: {
14
+ table(name: string, key: string) {
15
+ const store = (stores[name] ??= []);
16
+ return {
17
+ get: (k: any) => Promise.resolve(store.find((r) => r[key] === k)),
18
+ find: (q: any) => Promise.resolve(store.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
19
+ insert: (row: any) => {
20
+ store.push(row);
21
+ return Promise.resolve(store.length);
22
+ },
23
+ update: (k: any, patch: any) => {
24
+ const row = store.find((r) => r[key] === k);
25
+ if (row) Object.assign(row, patch);
26
+ return Promise.resolve(row);
27
+ },
28
+ };
29
+ },
30
+ },
31
+ log: noopLog(),
32
+ } as any;
33
+ }
34
+
35
+ test("conformance-ack: settles the review at reviewed and appends the operator note to the summary", async () => {
36
+ const rows = [{ plan_key: "owner/repo#7", review_status: "reviewing", summary: "slice 2 reduced" }];
37
+ const app = fakeApp(rows);
38
+ const out = await handler({ variables: { planKey: "owner/repo#7", note: "filed follow-up #9" } } as any, app);
39
+ assertEquals(out, {});
40
+ assertEquals(rows[0].review_status, "reviewed");
41
+ assertEquals(rows[0].summary, "slice 2 reduced\n\nOperator ack: filed follow-up #9");
42
+ });
43
+
44
+ test("conformance-ack: a blank note settles the review without touching the summary", async () => {
45
+ const rows = [{ plan_key: "owner/repo#8", review_status: "reviewing", summary: "auth cache unverified" }];
46
+ const app = fakeApp(rows);
47
+ await handler({ variables: { planKey: "owner/repo#8", note: " " } } as any, app);
48
+ assertEquals(rows[0].review_status, "reviewed");
49
+ assertEquals(rows[0].summary, "auth cache unverified");
50
+ });
@@ -0,0 +1,31 @@
1
+ // pr.conformance-ack — an operator acknowledged a conformance-review escalation (issue #216).
2
+ //
3
+ // When the spec-conformance audit finds the epic did NOT cleanly meet its spec, the `retro` process
4
+ // routes to the `conformance-escalation` operator user task (retro.bpmn), which parks the instance
5
+ // on the operators' inbox with `plan_conformance.review_status = 'reviewing'`. This worker fires once
6
+ // the operator completes that ack task: it settles the row at `reviewed` (so the inbox scan drops it)
7
+ // and folds the operator's optional disposition note into the audit `summary`. The instance then
8
+ // continues to the lessons (retro) synthesis, so the ack is NON-blocking — delivery already landed.
9
+ // Persistence goes through the record gateway (`app.data`), never hand-written SQL.
10
+ import type { AppJobHandler } from "@nanobpm/urban";
11
+ import { acknowledgeConformance } from "../../app/conformance.ts";
12
+
13
+ interface In extends Record<string, unknown> {
14
+ planKey: string;
15
+ note?: unknown;
16
+ }
17
+
18
+ const str = (v: unknown): string | undefined =>
19
+ typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
20
+
21
+ const handler: AppJobHandler<In, Record<string, never>> = async (job, app) => {
22
+ const planKey = job.variables.planKey;
23
+ const note = str(job.variables.note);
24
+
25
+ await acknowledgeConformance(app.data, planKey, note);
26
+ app.log.info("conformance-ack", { planKey, note: note ?? null });
27
+
28
+ return {};
29
+ };
30
+
31
+ export default handler;
@@ -1,5 +1,5 @@
1
1
  import { test } from "node:test";
2
- import { assertEquals } from "#test-assert";
2
+ import { assertEquals, assertRejects } from "#test-assert";
3
3
  import { noopLog } from "../../test/log.ts";
4
4
  import handler from "./worker.ts";
5
5
 
@@ -36,8 +36,9 @@ function fakeApp() {
36
36
 
37
37
  test("conformance-record: persists a filed conformance from hoisted result vars", async () => {
38
38
  const { app, stores } = fakeApp();
39
- await handler(
39
+ const out = await handler(
40
40
  {
41
+ processInstanceKey: "retro-inst-5",
41
42
  variables: {
42
43
  planKey: "o/r#5",
43
44
  status: "filed",
@@ -66,12 +67,17 @@ test("conformance-record: persists a filed conformance from hoisted result vars"
66
67
  assertEquals(row.deviations_unraised, 1);
67
68
  assertEquals(row.has_deviations, 1);
68
69
  assertEquals(row.report, "the full conformance report");
70
+ // Tracks the retro instance and enters the inbox scan (issue #216) — a deviation escalates.
71
+ assertEquals(row.process_key, "retro-inst-5");
72
+ assertEquals(row.review_status, "reviewing");
73
+ // The gateway routes off the returned ground-truth flag, not the agent's hoisted var.
74
+ assertEquals(out, { hasDeviations: true });
69
75
  });
70
76
 
71
77
  test("conformance-record: derives has_deviations from ground truth even when the agent flag is absent", async () => {
72
78
  const { app, stores } = fakeApp();
73
79
  await handler(
74
- { variables: { planKey: "o/r#6", status: "filed", commentUrl: "https://x/6#c", slicesNotVerified: 1 } } as any,
80
+ { processInstanceKey: "retro-inst-6", variables: { planKey: "o/r#6", status: "filed", commentUrl: "https://x/6#c", slicesNotVerified: 1 } } as any,
75
81
  app as any,
76
82
  );
77
83
  // The agent didn't set hasDeviations, but a not-verified item means the epic didn't cleanly meet spec.
@@ -80,12 +86,15 @@ test("conformance-record: derives has_deviations from ground truth even when the
80
86
 
81
87
  test("conformance-record: a clean epic records has_deviations = 0", async () => {
82
88
  const { app, stores } = fakeApp();
83
- await handler(
84
- { variables: { planKey: "o/r#7", status: "filed", commentUrl: "https://x/7#c", slicesMet: 3, hasDeviations: false } } as any,
89
+ const out = await handler(
90
+ { processInstanceKey: "retro-inst-7", variables: { planKey: "o/r#7", status: "filed", commentUrl: "https://x/7#c", slicesMet: 3, hasDeviations: false } } as any,
85
91
  app as any,
86
92
  );
87
93
  assertEquals(stores.plan_conformance[0].has_deviations, 0);
88
94
  assertEquals(stores.plan_conformance[0].slices_met, 3);
95
+ // No deviation → settles straight to `reviewed`, never entering the inbox scan.
96
+ assertEquals(stores.plan_conformance[0].review_status, "reviewed");
97
+ assertEquals(out, { hasDeviations: false });
89
98
  });
90
99
 
91
100
  test("conformance-record: coerces filed without a comment URL to skipped", async () => {
@@ -139,6 +148,7 @@ test("conformance-record: coerces string-encoded numeric counts hoisted by the a
139
148
  // These must be parsed, not silently coerced to 0 (which would wrongly clear the verdict).
140
149
  await handler(
141
150
  {
151
+ processInstanceKey: "retro-inst-12",
142
152
  variables: {
143
153
  planKey: "o/r#12",
144
154
  status: "filed",
@@ -167,6 +177,7 @@ test("conformance-record: honours a string-encoded hasDeviations flag", async ()
167
177
  // string "true" must still record a deviation — a stringified boolean can't silently be dropped.
168
178
  await handler(
169
179
  {
180
+ processInstanceKey: "retro-inst-13",
170
181
  variables: {
171
182
  planKey: "o/r#13",
172
183
  status: "filed",
@@ -197,3 +208,34 @@ test("conformance-record: defaults to skipped when the agent reported nothing",
197
208
  );
198
209
  assertEquals(stores.plan_conformance[0].status, "skipped");
199
210
  });
211
+
212
+ test("conformance-record: coerces a numeric processInstanceKey to a string (TEXT process_key never drifts)", async () => {
213
+ const { app, stores } = fakeApp();
214
+ await handler(
215
+ { processInstanceKey: 220592130 as any, variables: { planKey: "o/r#11", status: "filed", commentUrl: "https://x/11#c", slicesNotVerified: 1 } } as any,
216
+ app as any,
217
+ );
218
+ const row = stores.plan_conformance[0];
219
+ assertEquals(row.process_key, "220592130");
220
+ assertEquals(typeof row.process_key, "string");
221
+ assertEquals(row.review_status, "reviewing");
222
+ });
223
+
224
+ test("conformance-record: fails (not a silent, untrackable escalation) when there is no processKey but there are deviations", async () => {
225
+ const { app, stores } = fakeApp();
226
+ // No `processInstanceKey` + deviations: the handler would otherwise return `hasDeviations:true`
227
+ // (routing retro to `conformance-escalation`) while the row is `reviewed`/`process_key=null`, an
228
+ // ack `pollUserTasks` can never surface nor `onTerminated` clear — an invisible, wedged escalation.
229
+ // Fail loudly instead so the run retries/alerts rather than encoding that silent state.
230
+ await assertRejects(
231
+ () =>
232
+ handler(
233
+ { variables: { planKey: "o/r#12", status: "filed", commentUrl: "https://x/12#c", slicesNotVerified: 1, hasDeviations: true } } as any,
234
+ app as any,
235
+ ),
236
+ Error,
237
+ "no processInstanceKey",
238
+ );
239
+ // Nothing was persisted: the throw precedes the write, so no untrackable row is left behind.
240
+ assertEquals(stores.plan_conformance.length, 0);
241
+ });
@@ -73,6 +73,28 @@ const handler: AppJobHandler<In> = async (job, app) => {
73
73
  (asBool(job.variables.hasDeviations) ||
74
74
  slicesReduced > 0 || slicesNotVerified > 0 || deviationsUnraised > 0);
75
75
 
76
+ // Track this retro instance on the conformance row so `pollUserTasks` can find the escalation ack
77
+ // task, but only mark it `reviewing` when there IS something to escalate — a clean run settles
78
+ // straight to `reviewed` and never enters the inbox scan (migration 054). Coerce the instance key
79
+ // to a string (the engine can hand back a numeric key) so `plan_conformance.process_key` (TEXT)
80
+ // never drifts to a number and break the string-filter reads in `pollUserTasks`/`openUserTasks` —
81
+ // the same `String(...)` coercion app/service.ts applies when it stamps `process_key`.
82
+ const processKey = job.processInstanceKey != null ? String(job.processInstanceKey) : null;
83
+
84
+ // Invariant: an escalation must be trackable. If we found deviations to escalate but have no
85
+ // process key to key the `reviewing` row off, the `hasDeviations` return below would still route
86
+ // retro to the `conformance-escalation` user task — yet `pollUserTasks` can never surface that ack
87
+ // (it skips rows without `process_key`) nor can the `onTerminated` binding ever clear it, so the
88
+ // escalation wedges forever, invisible to any human. Rather than record that silent, unreachable
89
+ // state, fail loudly so the run retries/alerts. `job.processInstanceKey` is always present for an
90
+ // activated job, so this only fires on a genuine engine-contract violation.
91
+ if (hasDeviations && processKey == null) {
92
+ throw new Error(
93
+ `conformance-record: ${planKey} has deviations to escalate but no processInstanceKey to track ` +
94
+ "the escalation — refusing to route to an untrackable conformance-escalation ack task",
95
+ );
96
+ }
97
+
76
98
  await recordConformance(app.data, planKey, {
77
99
  status,
78
100
  commentUrl: filed ? commentUrl : null,
@@ -84,12 +106,22 @@ const handler: AppJobHandler<In> = async (job, app) => {
84
106
  hasDeviations,
85
107
  summary,
86
108
  report,
109
+ processKey,
110
+ // Only enter the `reviewing` inbox scan when we actually have a `processKey` to key off — a
111
+ // null key can never be found by `pollUserTasks` (it skips rows without `process_key`) nor
112
+ // cleared by the `instanceTracking` `onTerminated` binding, so a `reviewing` row with no key
113
+ // would wedge forever. The invariant guard above already rejected `hasDeviations` with a null
114
+ // key, so `reviewing` here always carries a non-null `processKey`.
115
+ reviewStatus: hasDeviations ? "reviewing" : "reviewed",
87
116
  });
88
117
 
89
118
  app.log.info(
90
119
  `conformance-record: ${planKey} — status=${status} deviations=${hasDeviations ? "yes" : "no"}`,
91
120
  );
92
- return {};
121
+ // Return the ground-truth `hasDeviations` as a process variable so the `gw-deviations` gateway
122
+ // routes to the human ack task (retro.bpmn) — overriding the agent's hoisted flag with the value
123
+ // reconciled against the recorded counts above.
124
+ return { hasDeviations };
93
125
  };
94
126
 
95
127
  export default handler;