@nanobpm/nano-workforce 0.171.1 → 0.171.3

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.
@@ -1,14 +1,15 @@
1
- // Unit coverage for pr.record-feature-escalation — a feature run parked on the native
2
- // `feature-escalation` user task (issue #210). Runs on the `escalated` arm, before the user task
3
- // exists, and must flip the row to the non-terminal `escalated` status and append the agent's
4
- // `question` to the canonical `feature_escalations` audit log so the nwf UI can surface it (the poller
5
- // can't read task-local vars, so this is the question's source of truth). Issue #332 dropped the
6
- // denormalised `feature_runs.escalation_question` / `escalation_user_task_key` columns, so this worker
7
- // now only flips the status and appends the audit row.
1
+ // Unit coverage for pr.record-feature-escalation — the shared `implement-cell`'s escalation recorder
2
+ // (ADR 0006 S4). It runs on the cell's `escalated` arm for BOTH callers of the cell: a standalone
3
+ // `feature` run (`subjectKey` = its `feature_key`, which HAS a `feature_runs` row to flip to
4
+ // `escalated`) and a plan-fanout wave slice (`subjectKey` = the epic's `plan_key`, which has NO
5
+ // `feature_runs` row the flip must be a guarded no-op there). In both cases it appends the resolved
6
+ // `question` to the canonical `feature_escalations` audit log (the poller can't read task-local vars,
7
+ // so this is the question's source of truth) and re-emits it, synthesising an answerable one via the
8
+ // #360 no-result net when the agent left none.
8
9
  import { test } from "node:test";
9
- import { assertEquals } from "#test-assert";
10
+ import { assertEquals, assertRejects } from "#test-assert";
10
11
  import { noopLog } from "../../test/log.ts";
11
- import handler from "./worker.ts";
12
+ import handler, { NO_RESULT_QUESTION } from "./worker.ts";
12
13
 
13
14
  // biome-ignore lint/suspicious/noExplicitAny: tiny in-memory app double, mirrors record-blocked-ack.worker.test
14
15
  function fakeApp(rows: Record<string, unknown>[]): any {
@@ -44,14 +45,17 @@ function fakeApp(rows: Record<string, unknown>[]): any {
44
45
  };
45
46
  }
46
47
 
47
- test("record-feature-escalation: flips the run to escalated and appends the question to the audit log", async () => {
48
+ test("feature subject: flips the run to escalated, appends the agent's question, and re-emits it", async () => {
48
49
  const rows = [{ feature_key: "owner/repo#7", status: "running" }];
49
50
  const app = fakeApp(rows);
50
51
  const out = await handler(
51
- { jobKey: "job-1", variables: { featureKey: "owner/repo#7", question: "Which API should I use?" } } as never,
52
+ {
53
+ jobKey: "job-1",
54
+ variables: { subjectKey: "owner/repo#7", status: "escalated", question: "Which API should I use?" },
55
+ } as never,
52
56
  app,
53
57
  );
54
- assertEquals(out, {});
58
+ assertEquals(out, { question: "Which API should I use?" });
55
59
  assertEquals(rows[0].status, "escalated");
56
60
  // Issue #305/#332: the question is the sole responsibility of the canonical `feature_escalations`
57
61
  // audit log so `pollUserTasks` can source it from a surviving table (the denormalised column is gone).
@@ -61,25 +65,70 @@ test("record-feature-escalation: flips the run to escalated and appends the ques
61
65
  assertEquals(app.stores.feature_escalations[0].job_key, "job-1");
62
66
  });
63
67
 
64
- test("record-feature-escalation: a retried job (same jobKey) reuses its audit row, never duplicating", async () => {
68
+ test("wave subject (no feature_runs row): the status flip is a guarded no-op, the audit row is still keyed by planKey", async () => {
69
+ // A plan-embedded wave slice's `subjectKey` is the epic's `plan_key`; there is NO standalone
70
+ // `feature_runs` row, so the escalated-status flip must be a no-op (never fabricating a bogus row),
71
+ // while the question is still appended keyed by the plan for the poller to surface.
72
+ const app = fakeApp([]);
73
+ const out = await handler(
74
+ {
75
+ jobKey: "job-w",
76
+ variables: { subjectKey: "owner/repo#1", status: "escalated", question: "Rebase or rework?" },
77
+ } as never,
78
+ app,
79
+ );
80
+ assertEquals(out, { question: "Rebase or rework?" });
81
+ assertEquals(app.stores.feature_runs.length, 0, "no feature_runs row is fabricated for a wave subject");
82
+ assertEquals(app.stores.feature_escalations.length, 1);
83
+ assertEquals(app.stores.feature_escalations[0].feature_key, "owner/repo#1");
84
+ assertEquals(app.stores.feature_escalations[0].question, "Rebase or rework?");
85
+ });
86
+
87
+ test("no-result: a missing status/question synthesises the #360 answerable question and re-emits it", async () => {
88
+ // The implement stage was the only agent stage with no net for "I couldn't read the agent's result".
89
+ // A non-clean-terminal slice with no machine-readable status routes onto the SAME escalation task, so
90
+ // a human can enrol the PR or abandon the slice instead of the epic dying with a blank reason.
91
+ const rows = [{ feature_key: "owner/repo#8", status: "running" }];
92
+ const app = fakeApp(rows);
93
+ const out = await handler({ jobKey: "job-8", variables: { subjectKey: "owner/repo#8" } } as never, app);
94
+ assertEquals(out, { question: NO_RESULT_QUESTION });
95
+ assertEquals(rows[0].status, "escalated");
96
+ assertEquals(app.stores.feature_escalations.length, 1);
97
+ assertEquals(app.stores.feature_escalations[0].question, NO_RESULT_QUESTION);
98
+ });
99
+
100
+ test("blank-question escalated: still synthesises the #360 question (never a dead-end task)", async () => {
101
+ const app = fakeApp([]);
102
+ const out = await handler(
103
+ { jobKey: "job-b", variables: { subjectKey: "owner/repo#2", status: "escalated", question: " " } } as never,
104
+ app,
105
+ );
106
+ assertEquals(out, { question: NO_RESULT_QUESTION });
107
+ assertEquals(app.stores.feature_escalations[0].question, NO_RESULT_QUESTION);
108
+ });
109
+
110
+ test("fails fast (incident) when subjectKey is absent — never appends a corrupt undefined-keyed audit row", async () => {
111
+ // The cell's escalation arm always supplies `subjectKey` (the callActivity input), so a missing key
112
+ // can only mean the `implement-cell` ioMapping/dataEnvelope regressed. The worker must raise an
113
+ // incident (throw) rather than keying `feature_escalations`/`feature_runs` with `undefined` and
114
+ // masking the regression — symmetric with `record-feature-implementing`'s fail-fast guard (#642).
115
+ const app = fakeApp([]);
116
+ await assertRejects(() => handler({ jobKey: "job-x", variables: {} } as never, app));
117
+ assertEquals(app.stores.feature_escalations, undefined, "no audit row is appended when subjectKey is absent");
118
+ });
119
+
120
+ test("a retried job (same jobKey) reuses its audit row, never duplicating", async () => {
65
121
  // `record-feature-escalation` is at-least-once: a job that crashed/timed out AFTER the insert but
66
122
  // before job completion re-runs with the SAME jobKey. The `job_key` idempotency guard must reuse the
67
- // existing `feature_escalations` row rather than append a duplicate (which would bloat the append-only
68
- // log and could skew "latest question" selection). Mirrors `record-plan-review`'s `plan_reviews` guard.
123
+ // existing `feature_escalations` row rather than append a duplicate (mirrors `record-plan-review`).
69
124
  const rows = [{ feature_key: "owner/repo#7", status: "running" }];
70
125
  const app = fakeApp(rows);
71
- const job = { jobKey: "job-retry", variables: { featureKey: "owner/repo#7", question: "Which API?" } } as never;
126
+ const job = {
127
+ jobKey: "job-retry",
128
+ variables: { subjectKey: "owner/repo#7", status: "escalated", question: "Which API?" },
129
+ } as never;
72
130
  await handler(job, app);
73
131
  await handler(job, app); // retry with the same jobKey
74
132
  assertEquals(app.stores.feature_escalations.length, 1, "the retry reuses the row, no duplicate append");
75
133
  assertEquals(app.stores.feature_escalations[0].job_key, "job-retry");
76
134
  });
77
-
78
- test("record-feature-escalation: a blank/absent question is appended as NULL (badge/affordance stay off)", async () => {
79
- const rows = [{ feature_key: "owner/repo#8", status: "running" }];
80
- const app = fakeApp(rows);
81
- await handler({ jobKey: "job-8", variables: { featureKey: "owner/repo#8", question: " " } } as never, app);
82
- assertEquals(rows[0].status, "escalated");
83
- assertEquals(app.stores.feature_escalations.length, 1);
84
- assertEquals(app.stores.feature_escalations[0].question, null);
85
- });
@@ -1,42 +1,89 @@
1
- // pr.record-feature-escalation — a feature run has parked on the native `feature-escalation`
2
- // user task (the agent reported `status:"escalated"` with a non-blank `question`). This service
3
- // task runs on the `escalated` arm, immediately BEFORE the user task is created, and:
4
- // • flips `feature_runs.status` to the non-terminal `escalated` so status-based views and counts
5
- // flag it (and a re-dispatch of the same issue short-circuits while it is parked), and
6
- // • appends the agent's `question` to the append-only `feature_escalations` audit log (issue #305),
7
- // the canonical, poller-readable source for a parked run's question.
1
+ // pr.record-feature-escalation — the shared `implement-cell`'s escalation recorder (ADR 0006 S4).
8
2
  //
9
- // The completable `userTaskKey` is NOT recorded here the task does not exist yet at this point.
10
- // `pollUserTasks` (app/service.ts) reads the engine's open `feature-escalation` task directly once it
11
- // is observable and projects it onto the `user_tasks` Tasks inbox, pairing it with the audit log's
12
- // question. Capturing the question HERE (not in the poller) is required because the WASM testkit
13
- // engine does not surface a user task's ioMapping-mapped local variables through the user-task query,
14
- // so the process variable must be persisted while it is still in scope on the job.
3
+ // The atomic `implement-cell` owns its `record-escalation` `human-escalation` loop, so this one
4
+ // service task runs on the cell's `escalated` arm (`ic_gw` "clean terminal?" `record-escalation`),
5
+ // immediately BEFORE the shared `human-escalation` cell parks on its user task, for EVERY caller that
6
+ // composes the cell: a standalone `feature` run (`subjectKey` = its `feature_key`) and a plan-fanout
7
+ // wave slice (`subjectKey` = the epic's `plan_key`, which has no standalone `feature_runs` row). It:
8
+ //
9
+ // • synthesises an answerable `question` when the agent left none (a no-machine-readable result, or a
10
+ // blank-question `escalated`) via the #360 no-result net, so the parked task is never a dead end,
11
+ // and re-emits it as the `question` variable so the `feature-escalation` form (and the answer loop)
12
+ // see it (mirrors record-trial-merge),
13
+ // • appends that question to the append-only `feature_escalations` audit log keyed by `subjectKey` —
14
+ // the canonical, poller-readable source `pollUserTasks` reads to enrich the parked task's question
15
+ // on the Tasks inbox (issue #358), and
16
+ // • flips the run to the non-terminal `escalated` status so status-based views/counts flag it — but
17
+ // ONLY when a `feature_runs` row for `subjectKey` exists. A plan-embedded wave has NO such row (the
18
+ // plan/epic IS the subject), so the flip is a guarded no-op there (it never fabricates a row).
19
+ //
20
+ // The completable `userTaskKey` is NOT recorded here — the task does not exist yet (it lives on the
21
+ // `human-escalation` grandchild instance the cell spawns next). Capturing the question HERE (not in the
22
+ // poller) is required because the WASM engine does not surface a user task's ioMapping-mapped local
23
+ // variables through the user-task query, so the process variable must be persisted while it is still in
24
+ // scope on this job.
15
25
  import type { AppJobHandler } from "@nanobpm/urban";
26
+ import { classifyEscalation } from "../../app/escalationTaxonomy.ts";
16
27
  import { featureRuns, recordFeatureEscalation } from "../../app/feature.ts";
17
28
  import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
18
29
 
19
- // Input typed off the model data envelope (`RecordFeatureEscalationIn` in feature.bpmn) — ADR 0040.
30
+ // Input typed off the model data envelope (`RecordEscalationIn` in implement-cell.bpmn) — ADR 0040.
20
31
  type In = WorkerInputs["pr.record-feature-escalation"];
32
+ interface Out extends Record<string, unknown> {
33
+ question: string;
34
+ }
35
+
36
+ const str = (v: unknown): string | undefined =>
37
+ typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
38
+
39
+ // The answerable prompt synthesised when the agent left no usable question — the implement-stage
40
+ // analogue of record-trial-merge's synthesised trial-merge question. It names the recoverable work (a
41
+ // PR may exist on the slice's branch) and the two answers the cell's `ic_gw_answer` gateway routes on.
42
+ const NO_RESULT_QUESTION =
43
+ 'The implementation agent finished without a machine-readable result (no status was reported), so we cannot tell whether the slice succeeded. It may still have opened a PR (check for a branch targeting the epic base). Choose "Answer" and give guidance to re-run the slice — or choose "Abandon" to skip it and continue.';
44
+
45
+ const handler: AppJobHandler<In, Out> = async (job, app) => {
46
+ const subjectKey = job.variables.subjectKey;
47
+ // Fail fast rather than keying the `feature_escalations` audit log (and the guarded `feature_runs`
48
+ // flip) with `undefined`. The cell's `escalated` arm always supplies `subjectKey` (the callActivity
49
+ // input — a feature run's `feature_key` or a wave slice's `plan_key`), so a missing key can only mean
50
+ // the `implement-cell` ioMapping/dataEnvelope regressed. Raising an incident surfaces that regression
51
+ // with a clear trail instead of appending a corrupt `undefined`-keyed audit row and masking it —
52
+ // symmetric with `record-feature-implementing`'s fail-fast guard (#642).
53
+ if (!subjectKey) {
54
+ throw new Error(
55
+ "record-feature-escalation: subjectKey absent — the implement-cell's escalation-arm ioMapping/dataEnvelope has regressed (would key feature_escalations/feature_runs with undefined)",
56
+ );
57
+ }
58
+ const rawQuestion = str(job.variables.question);
59
+ // The agent's own question is authoritative when it declared a real escalation with one; otherwise
60
+ // (a no-machine-readable result, or an escalation with a blank question) synthesise an answerable one
61
+ // so the parked task is never a dead end. Route through the single canonical taxonomy so this net can
62
+ // never drift from the tier logic every other raise site uses. A "task"-kind escalation is
63
+ // `decision-required` only when the agent left an answerable question, so this already covers the
64
+ // blank-question case; the extra `&& rawQuestion` is the type narrowing that lets us hand the string
65
+ // through without an assertion.
66
+ const agentEscalated = classifyEscalation({ kind: "task", status: job.variables.status, question: rawQuestion }) ===
67
+ "decision-required";
68
+ const question = agentEscalated && rawQuestion ? rawQuestion : NO_RESULT_QUESTION;
69
+
70
+ // Append to the canonical `feature_escalations` audit log (the surviving table `pollUserTasks` reads),
71
+ // keyed by `subjectKey` — the feature run's `feature_key`, or the epic's `plan_key` for a wave slice.
72
+ await recordFeatureEscalation(app.data, { featureKey: subjectKey, question, jobKey: job.jobKey });
73
+
74
+ // Guarded status flip: a standalone `feature` run has a `feature_runs` row to flip to the non-terminal
75
+ // `escalated`; a plan-embedded wave (`subjectKey` = `plan_key`) has none, so the flip is a no-op there
76
+ // rather than fabricating a bogus row (the plan/epic is the subject, tracked in `plans`).
77
+ const runs = featureRuns(app.data);
78
+ if (await runs.get(subjectKey)) {
79
+ await runs.update(subjectKey, { status: "escalated", updated_at: new Date().toISOString() });
80
+ }
81
+
82
+ app.log.info("record-escalation", { subjectKey, synthesised: question === NO_RESULT_QUESTION });
21
83
 
22
- const nonBlank = (v: unknown): string | null =>
23
- typeof v === "string" && v.trim().length > 0 ? v.trim() : null;
24
-
25
- const handler: AppJobHandler<In> = async (job, app) => {
26
- const featureKey = job.variables.featureKey;
27
- const question = nonBlank(job.variables.question);
28
- await featureRuns(app.data).update(featureKey, {
29
- status: "escalated",
30
- updated_at: new Date().toISOString(),
31
- });
32
- // Append the question to the canonical `feature_escalations` audit log (issue #305) — the SURVIVING
33
- // table `pollUserTasks` reads to enrich the parked `feature-escalation` task's question on the Tasks
34
- // inbox (the feature analogue of `record-plan-review` writing `plan_reviews`). The denormalised
35
- // `feature_runs.escalation_question` column it used to dual-write was dropped in the contract phase
36
- // (issue #332), so this log is now the sole source of the question text.
37
- await recordFeatureEscalation(app.data, { featureKey, question, jobKey: job.jobKey });
38
- app.log.info("record-feature-escalation", { featureKey, hasQuestion: question !== null });
39
- return {};
84
+ // Re-emit the resolved question so the `feature-escalation` form (and the answer loop) see it.
85
+ return { question };
40
86
  };
41
87
 
42
88
  export default handler;
89
+ export { NO_RESULT_QUESTION };
@@ -4,7 +4,7 @@
4
4
  // while parked on the native `feature-escalation` user task. Without it, the answer loop-back left
5
5
  // `feature_runs.status` a stale `escalated` through the whole re-implementation (the #632 tear).
6
6
  import { test } from "node:test";
7
- import { assertEquals } from "#test-assert";
7
+ import { assertEquals, assertRejects } from "#test-assert";
8
8
  import { noopLog } from "../../test/log.ts";
9
9
  import handler from "./worker.ts";
10
10
 
@@ -55,3 +55,33 @@ test("record-feature-implementing: a confirming write on the first entry (alread
55
55
  assertEquals(rows[0].status, "running");
56
56
  assertEquals(rows[0].updated_at !== "2025-01-01T00:00:00.000Z", true, "updated_at was refreshed on the confirming write");
57
57
  });
58
+
59
+ test("record-feature-implementing: keyed by `subjectKey` when composed inside the implement-cell answer loop", async () => {
60
+ // ADR 0006 S4 — inside the shared `implement-cell` the reset runs on `ic_answerLoop` keyed by
61
+ // `subjectKey` (the callActivity input), NOT `featureKey`. A standalone feature run's `subjectKey`
62
+ // IS its `feature_key`, so the escalated row resets to `running` before re-implementation.
63
+ const rows = [{ feature_key: "owner/repo#7", status: "escalated", updated_at: "2025-01-01T00:00:00.000Z" }];
64
+ const app = fakeApp(rows);
65
+ await handler({ jobKey: "job-s", variables: { subjectKey: "owner/repo#7" } } as never, app);
66
+ assertEquals(rows[0].status, "running");
67
+ });
68
+
69
+ test("record-feature-implementing: a wave subject (no feature_runs row) is a guarded no-op — never fabricates a row", async () => {
70
+ // A plan-embedded wave slice composes the same cell with `subjectKey` = the epic's `plan_key`, which
71
+ // has NO standalone `feature_runs` row. The reset must be a guarded no-op there (symmetric with
72
+ // `record-feature-escalation`'s guarded flip) rather than creating a bogus row.
73
+ const rows: Record<string, unknown>[] = [];
74
+ const app = fakeApp(rows);
75
+ const out = await handler({ jobKey: "job-w", variables: { subjectKey: "owner/repo#epic-42" } } as never, app);
76
+ assertEquals(out, {});
77
+ assertEquals(rows.length, 0, "no feature_runs row was fabricated for a wave subject");
78
+ });
79
+
80
+ test("record-feature-implementing: fails fast (incident) when neither subjectKey nor featureKey is present", async () => {
81
+ // Both edges into `implement-task` always supply exactly one key, so a missing key can only mean the
82
+ // BPMN ioMapping/dataEnvelope regressed — the misconfiguration that reintroduces the stale
83
+ // `status="escalated"` class (#642). The worker must raise an incident (throw) rather than silently
84
+ // skipping the reset and masking the regression.
85
+ const app = fakeApp([]);
86
+ await assertRejects(() => handler({ jobKey: "job-x", variables: {} } as never, app));
87
+ });
@@ -1,11 +1,18 @@
1
1
  // pr.record-feature-implementing — the twin of `record-feature-escalation` (issue #642). This
2
2
  // service task sits on BOTH edges into `implement-task`: the first entry (`f_toImplement`, off
3
- // `ensure-base-branch`) AND the answer re-entry (`w_answerLoop`, off the `w_gw_answer` gateway).
4
- // It stamps `feature_runs.status="running"` so the run is `escalated` ONLY while a token is parked
5
- // on the native `feature-escalation` user task honouring the invariant `record-feature-escalation`
6
- // (the sole `escalated` writer) would otherwise violate on the answer loop-back: it had no symmetric
7
- // reset, so `status` stayed a stale `escalated` through the ENTIRE post-answer re-implementation
8
- // (the #632 tear). Parity with the PR `status="escalated"` contract, which holds only while parked.
3
+ // `ensure-base-branch` in `feature.bpmn`) AND the answer re-entry which, after the ADR 0006 S4
4
+ // composition, lives on the shared `implement-cell`'s answer loop (`ic_answerLoop` `record-implementing`
5
+ // `implement-task`), keyed by `subjectKey`. It stamps `feature_runs.status="running"` so the run is
6
+ // `escalated` ONLY while a token is parked on the native escalation user task honouring the invariant
7
+ // `record-feature-escalation` (the sole `escalated` writer) would otherwise violate on the answer
8
+ // loop-back: it had no symmetric reset, so `status` stayed a stale `escalated` through the ENTIRE
9
+ // post-answer re-implementation (the #632 tear). Parity with the PR `status="escalated"` contract,
10
+ // which holds only while parked.
11
+ //
12
+ // Keyed by `subjectKey` (the composed cell) or `featureKey` (feature.bpmn's first entry). A
13
+ // plan-embedded wave slice (`subjectKey` = the epic's `plan_key`) has no `feature_runs` row, so the
14
+ // reset is a guarded no-op there — symmetric with `record-feature-escalation`'s guarded flip, never
15
+ // fabricating a bogus row.
9
16
  //
10
17
  // Idempotent-safe: re-stamping `running` is a no-op FOR THE STATUS, so the at-least-once job can retry
11
18
  // freely; it does still refresh `updated_at` on every invocation (a confirming timestamp write), and
@@ -19,12 +26,29 @@ import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
19
26
  type In = WorkerInputs["pr.record-feature-implementing"];
20
27
 
21
28
  const handler: AppJobHandler<In> = async (job, app) => {
22
- const featureKey = job.variables.featureKey;
23
- await featureRuns(app.data).update(featureKey, {
24
- status: "running",
25
- updated_at: new Date().toISOString(),
26
- });
27
- app.log.info("record-feature-implementing", { featureKey });
29
+ // Keyed by `subjectKey` when composed inside the shared `implement-cell` answer loop (ADR 0006 S4),
30
+ // or by `featureKey` on `feature.bpmn`'s first-entry edge. A plan-embedded wave slice
31
+ // (`subjectKey` = the epic's `plan_key`) has no `feature_runs` row, so the reset is a guarded no-op
32
+ // there — symmetric with `record-feature-escalation`'s guarded flip — never fabricating a bogus row.
33
+ const subjectKey = job.variables.subjectKey ?? job.variables.featureKey;
34
+ // Fail fast rather than silently skipping the status reset. Both edges into `implement-task` always
35
+ // supply exactly one key (`feature.bpmn`'s first entry → `featureKey`; the `implement-cell` answer
36
+ // loop → `subjectKey`), so a missing key can only mean the BPMN ioMapping/dataEnvelope regressed —
37
+ // exactly the misconfiguration that reintroduces the stale `status="escalated"` class (#642). Raising
38
+ // an incident surfaces that regression with a clear trail instead of masking it behind a silent no-op.
39
+ if (!subjectKey) {
40
+ throw new Error(
41
+ "record-feature-implementing: neither subjectKey nor featureKey present — the implement edge's ioMapping/dataEnvelope has regressed (would silently skip the escalated→running reset, #642)",
42
+ );
43
+ }
44
+ const runs = featureRuns(app.data);
45
+ if (await runs.get(subjectKey)) {
46
+ await runs.update(subjectKey, {
47
+ status: "running",
48
+ updated_at: new Date().toISOString(),
49
+ });
50
+ }
51
+ app.log.info("record-feature-implementing", { subjectKey });
28
52
  return {};
29
53
  };
30
54
 
@@ -1,95 +0,0 @@
1
- // Unit coverage for pr.record-wave-escalation — the plan-fanout implement-stage escalation net (issue
2
- // #360). It runs on the `w_gw` "clean terminal?" gateway's `not clean` arm, BEFORE the shared
3
- // `feature-escalation` user task, and must:
4
- // • pass through the agent's own answerable question when it declared a genuine escalation, but
5
- // • SYNTHESISE an answerable question when the agent left none (a no-machine-readable result) so the
6
- // parked task is never a dead end — the fix for the silent epic-death this issue reports, and
7
- // • append the resolved question to the canonical `feature_escalations` audit log keyed by `planKey`
8
- // (the plan-root IS the subject of an embedded slice), the source `pollUserTasks` reads (issue #358),
9
- // • re-emit the resolved `question` so the `feature-escalation` form and the answer loop see it.
10
- import { test } from "node:test";
11
- import { assertEquals } from "#test-assert";
12
- import { noopLog } from "../../test/log.ts";
13
- import handler, { NO_RESULT_QUESTION } from "./worker.ts";
14
-
15
- // biome-ignore lint/suspicious/noExplicitAny: tiny in-memory app double, mirrors record-feature-escalation.worker.test
16
- function fakeApp(): any {
17
- const stores: Record<string, Record<string, unknown>[]> = {};
18
- return {
19
- stores,
20
- data: {
21
- table(name: string, key: string) {
22
- const store = (stores[name] ??= []);
23
- return {
24
- // biome-ignore lint/suspicious/noExplicitAny: test double
25
- get: (k: any) => Promise.resolve(store.find((r) => r[key] === k)),
26
- // biome-ignore lint/suspicious/noExplicitAny: test double
27
- find: (q: any) => Promise.resolve(store.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
28
- // biome-ignore lint/suspicious/noExplicitAny: test double
29
- findOne: (q: any) =>
30
- Promise.resolve(store.find((r) => Object.entries(q).every(([f, v]) => r[f] === v)) ?? null),
31
- // biome-ignore lint/suspicious/noExplicitAny: test double
32
- insert: (row: any) => {
33
- store.push(row);
34
- return Promise.resolve(store.length);
35
- },
36
- // biome-ignore lint/suspicious/noExplicitAny: test double
37
- update: (k: any, patch: any) => {
38
- const row = store.find((r) => r[key] === k);
39
- if (row) Object.assign(row, patch);
40
- return Promise.resolve(row);
41
- },
42
- };
43
- },
44
- },
45
- log: noopLog(),
46
- };
47
- }
48
-
49
- test("record-wave-escalation: a no-machine-readable result synthesises an answerable question and audits it (issue #360)", async () => {
50
- const app = fakeApp();
51
- // The agent finished with no status and no question — the exact failure that used to fall straight to
52
- // terminal `blocked` and silently kill the epic. It must now become an answerable escalation.
53
- const out = await handler(
54
- { jobKey: "job-1", variables: { planKey: "owner/repo#64", status: undefined, question: undefined } } as never,
55
- app,
56
- );
57
-
58
- assertEquals(out, { question: NO_RESULT_QUESTION });
59
- assertEquals(app.stores.feature_escalations.length, 1);
60
- assertEquals(app.stores.feature_escalations[0].feature_key, "owner/repo#64");
61
- assertEquals(app.stores.feature_escalations[0].question, NO_RESULT_QUESTION);
62
- assertEquals(app.stores.feature_escalations[0].job_key, "job-1");
63
- });
64
-
65
- test("record-wave-escalation: a genuine agent escalation passes its own question through unchanged (issue #360)", async () => {
66
- const app = fakeApp();
67
- const out = await handler(
68
- { jobKey: "job-2", variables: { planKey: "owner/repo#64", status: "escalated", question: "Which auth library should the scaffold use?" } } as never,
69
- app,
70
- );
71
-
72
- assertEquals(out, { question: "Which auth library should the scaffold use?" });
73
- assertEquals(app.stores.feature_escalations[0].question, "Which auth library should the scaffold use?");
74
- });
75
-
76
- test("record-wave-escalation: an escalated status with a blank question is still given an answerable one (issue #360)", async () => {
77
- // `escalated` with no usable question is as much a dead end as a no-result — the taxonomy classes it a
78
- // NON-escalation, so without synthesis the parked task would show nothing to decide. Synthesise instead.
79
- const app = fakeApp();
80
- const out = await handler(
81
- { jobKey: "job-3", variables: { planKey: "owner/repo#64", status: "escalated", question: " " } } as never,
82
- app,
83
- );
84
-
85
- assertEquals(out, { question: NO_RESULT_QUESTION });
86
- assertEquals(app.stores.feature_escalations[0].question, NO_RESULT_QUESTION);
87
- });
88
-
89
- test("record-wave-escalation: a retried job (same jobKey) reuses its audit row, never duplicating (issue #360)", async () => {
90
- const app = fakeApp();
91
- const job = { jobKey: "job-retry", variables: { planKey: "owner/repo#64", status: undefined, question: undefined } } as never;
92
- await handler(job, app);
93
- await handler(job, app);
94
- assertEquals(app.stores.feature_escalations.length, 1, "the retry reuses the row, no duplicate append");
95
- });
@@ -1,69 +0,0 @@
1
- // pr.record-wave-escalation — a plan-fanout wave slice did NOT return a clean terminal result, so its
2
- // `implement` subprocess routes here (the `w_gw` "clean terminal?" gateway's `not clean` arm) BEFORE
3
- // the `feature-escalation` user task is created. It is the plan-fanout analogue of feature.bpmn's
4
- // `record-feature-escalation`, extended with the no-result net of issue #360.
5
- //
6
- // The implement stage was the ONLY agent stage with no net for "I couldn't read the agent's result":
7
- // • review rounds re-enter the durable review wait (app/roundResultDefault.ts),
8
- // • trial merge raises an answerable human escalation (workers/record-trial-merge/worker.ts),
9
- // • implement/wave coerced a missing status straight to terminal `blocked` — silently failing the
10
- // epic and orphaning any PR the agent opened (issue #360).
11
- // This worker closes that gap by routing every non-clean-terminal slice onto the SAME
12
- // `feature-escalation` user task a genuine `status:"escalated"` already uses, so a human can enrol the
13
- // PR or abandon the slice instead of the epic dying with a blank reason.
14
- //
15
- // It does two things while the process variables are still in scope on the job:
16
- // • synthesises an answerable `question` when the agent didn't provide one (a no-machine-readable
17
- // result carries no question), mirroring record-trial-merge, and re-emits it as the `question`
18
- // variable so the `feature-escalation` form and the poller both see it, and
19
- // • appends that question to the append-only `feature_escalations` audit log keyed by `planKey` — the
20
- // canonical, poller-readable source `pollUserTasks` reads to enrich the parked task's question on
21
- // the Tasks inbox (issue #358). The plan-root embeds the slice as a multi-instance subprocess, so
22
- // there is no standalone `feature_runs` row; the epic (plan) IS the subject, hence the `planKey`
23
- // key. Capturing it HERE (not in the poller) is required because the WASM engine does not surface a
24
- // user task's ioMapping-mapped local variables through the user-task query.
25
- import type { AppJobHandler } from "@nanobpm/urban";
26
- import { classifyEscalation } from "../../app/escalationTaxonomy.ts";
27
- import { recordFeatureEscalation } from "../../app/feature.ts";
28
- import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
29
-
30
- // Input typed off the model data envelope (`RecordWaveEscalationIn` in plan-fanout.bpmn) — ADR 0040.
31
- type In = WorkerInputs["pr.record-wave-escalation"];
32
- interface Out extends Record<string, unknown> {
33
- question: string;
34
- }
35
-
36
- const str = (v: unknown): string | undefined =>
37
- typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
38
-
39
- // The answerable prompt synthesised when the agent left no usable question — the implement-stage
40
- // analogue of record-trial-merge's synthesised trial-merge question. It names the recoverable work (a
41
- // PR may exist on the slice's branch) and the two answers the `w_gw_answer` gateway routes on.
42
- const NO_RESULT_QUESTION =
43
- 'The implementation agent finished without a machine-readable result (no status was reported), so we cannot tell whether the slice succeeded. It may still have opened a PR (check for a branch targeting the epic base). Choose "Answer" and give guidance to re-run the slice — or choose "Abandon" to skip it and continue the epic.';
44
-
45
- const handler: AppJobHandler<In, Out> = async (job, app) => {
46
- const planKey = job.variables.planKey;
47
- const rawQuestion = str(job.variables.question);
48
- // The agent's own question is authoritative when it declared a real escalation with one; otherwise
49
- // (a no-machine-readable result, or an escalation with a blank question) synthesise an answerable
50
- // one so the parked task is never a dead end. Route through the single canonical taxonomy so this
51
- // net can never drift from the tier logic every other raise site uses.
52
- // A "task"-kind escalation is `decision-required` only when the agent left an answerable question, so
53
- // this already covers the blank-question case; the extra `&& rawQuestion` is the type narrowing that lets
54
- // us hand the string through without an assertion.
55
- const agentEscalated = classifyEscalation({ kind: "task", status: job.variables.status, question: rawQuestion }) ===
56
- "decision-required";
57
- const question = agentEscalated && rawQuestion ? rawQuestion : NO_RESULT_QUESTION;
58
-
59
- // Append to the canonical `feature_escalations` audit log (the surviving table `pollUserTasks` reads),
60
- // keyed by `planKey` because the plan-root instance IS the subject of the embedded slice's escalation.
61
- await recordFeatureEscalation(app.data, { featureKey: planKey, question, jobKey: job.jobKey });
62
- app.log.info("record-wave-escalation", { planKey, synthesised: question === NO_RESULT_QUESTION });
63
-
64
- // Re-emit the resolved question so the `feature-escalation` form (and the answer loop) see it.
65
- return { question };
66
- };
67
-
68
- export default handler;
69
- export { NO_RESULT_QUESTION };