@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.
- package/CHANGELOG.md +12 -0
- package/app/agentCompletion.test.ts +32 -0
- package/app/agentCompletion.ts +15 -1
- package/app/deliveryDispatch.test.ts +6 -2
- package/app/escalationTaxonomy.ts +5 -4
- package/app/fineGrainedCells.test.ts +93 -0
- package/app/planFanoutCleanTerminal.test.ts +29 -26
- package/app/planFanoutPreflight.test.ts +11 -4
- package/app/pollUserTasks.test.ts +63 -0
- package/app/service.ts +62 -27
- package/app/userTaskInboxDriftGuard.test.ts +102 -0
- package/app/userTasks.ts +35 -2
- package/e2e/agent-answerable.e2e.ts +15 -12
- package/e2e/feature-preflight.e2e.ts +6 -4
- package/e2e/feature-run.e2e.ts +38 -26
- package/e2e/plan-fanout-sla.e2e.ts +14 -10
- package/e2e/plan-fanout.e2e.ts +26 -21
- package/nano.app.json +0 -4
- package/package.json +3 -3
- package/resources/processes/feature.bpmn +55 -175
- package/resources/processes/implement-cell.bpmn +78 -22
- package/resources/processes/plan-fanout.bpmn +245 -296
- package/workers/record-feature-escalation/worker.test.ts +74 -25
- package/workers/record-feature-escalation/worker.ts +79 -32
- package/workers/record-feature-implementing/worker.test.ts +31 -1
- package/workers/record-feature-implementing/worker.ts +36 -12
- package/workers/record-wave-escalation/worker.test.ts +0 -95
- package/workers/record-wave-escalation/worker.ts +0 -69
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
// Unit coverage for pr.record-feature-escalation —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// `
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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("
|
|
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
|
-
{
|
|
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("
|
|
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 (
|
|
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 = {
|
|
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 —
|
|
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
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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 (`
|
|
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
|
-
|
|
23
|
-
|
|
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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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 };
|