@nanobpm/nano-workforce 0.56.0 → 0.58.0
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/.github/workflows/ci.yml +7 -0
- package/AGENTS.md +83 -1
- package/CHANGELOG.md +14 -0
- package/README.md +10 -2
- package/SPEC.md +27 -24
- package/app/agentCompletion.test.ts +337 -0
- package/app/agentCompletion.ts +219 -0
- package/app/agentGuide.ts +1 -1
- package/app/answer-escalation.test.ts +106 -0
- package/app/answerEscalation.test.ts +67 -0
- package/app/baseGuard.test.ts +9 -1
- package/app/baseGuard.ts +11 -0
- package/app/escalationSla.test.ts +39 -0
- package/app/escalationSla.ts +28 -0
- package/app/escalationTaxonomy.test.ts +115 -0
- package/app/escalationTaxonomy.ts +115 -0
- package/app/feature.test.ts +161 -0
- package/app/feature.ts +173 -0
- package/app/github.test.ts +179 -1
- package/app/github.ts +132 -0
- package/app/mergeProtocol.test.ts +15 -0
- package/app/mergeProtocol.ts +10 -0
- package/app/persist-escalation.test.ts +34 -36
- package/app/plan.test.ts +267 -313
- package/app/plan.ts +173 -231
- package/app/reviewWait.ts +12 -4
- package/app/roundResultDefault.test.ts +111 -2
- package/app/roundResultDefault.ts +35 -0
- package/app/service.test.ts +6 -7
- package/app/service.ts +52 -35
- package/db/migrations/026_agent_completion.sql +36 -0
- package/db/migrations/027_retire_escalation_subsystem.sql +43 -0
- package/db/migrations/028_feature_runs.sql +28 -0
- package/e2e/agent-answerable.e2e.ts +185 -0
- package/e2e/convergence-escalation.e2e.ts +180 -0
- package/e2e/convergence-loop.e2e.ts +1 -1
- package/e2e/feature-run.e2e.ts +231 -0
- package/e2e/plan-fanout-sla.e2e.ts +238 -0
- package/e2e/plan-fanout.e2e.ts +303 -0
- package/e2e/retire-escalation-subsystem.e2e.ts +223 -0
- package/e2e/support/github-admit.ts +99 -0
- package/e2e/user-task-spine.e2e.ts +155 -0
- package/nano.app.json +41 -11
- package/openapi.yaml +237 -84
- package/operations/agentCompleteEscalation.ts +53 -0
- package/operations/listActivePrs.test.ts +39 -6
- package/operations/postMessage.ts +10 -41
- package/operations/revertEscalationCompletion.ts +44 -0
- package/operations/startAndMessage.test.ts +62 -60
- package/operations/startFeature.ts +127 -0
- package/operations/startPlanFanout.admission.integration.test.ts +263 -0
- package/operations/startPlanFanout.ts +70 -11
- package/package.json +4 -1
- package/pages/cockpit.page.json +1 -0
- package/pages/epic-detail.page.json +11 -37
- package/pages/epic.page.json +5 -2
- package/pages/feature.page.json +82 -0
- package/pages/home.page.json +6 -18
- package/resources/agent-guide.md +90 -26
- package/resources/forms/feature-escalation.form +27 -0
- package/resources/forms/plan-review-decision.form +27 -0
- package/resources/forms/pr-escalation.form +23 -0
- package/resources/forms/spine-demo.form +15 -0
- package/resources/forms/trial-merge-decision.form +25 -0
- package/resources/processes/convergence-loop.bpmn +127 -75
- package/resources/processes/feature.bpmn +240 -0
- package/resources/processes/plan-fanout.bpmn +322 -222
- package/resources/processes/spine-demo.bpmn +72 -0
- package/scripts/check-migrations.ts +68 -0
- package/workers/answer-escalation/worker.ts +78 -0
- package/workers/converge-feature/worker.ts +51 -0
- package/workers/ensure-base-branch/head-task.integration.test.ts +126 -0
- package/workers/ensure-base-branch/worker.test.ts +104 -0
- package/workers/ensure-base-branch/worker.ts +31 -0
- package/workers/finalize/worker.ts +0 -2
- package/workers/mark-merged/worker.ts +0 -2
- package/workers/merge/worker.ts +6 -5
- package/workers/persist-escalation/worker.ts +28 -32
- package/workers/record-feature/worker.ts +61 -0
- package/workers/record-plan-review/worker.test.ts +9 -10
- package/workers/record-plan-review/worker.ts +15 -5
- package/workers/resolve-trial-attention/worker.test.ts +77 -0
- package/workers/resolve-trial-attention/worker.ts +43 -0
- package/operations/answerFeatureEscalation.test.ts +0 -112
- package/operations/answerFeatureEscalation.ts +0 -58
- package/operations/answerPlanEscalation.test.ts +0 -115
- package/operations/answerPlanEscalation.ts +0 -41
- package/workers/persist-plan-escalation/worker.test.ts +0 -80
- package/workers/persist-plan-escalation/worker.ts +0 -73
- package/workers/persist-task-escalation/worker.ts +0 -120
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
// repo's lightweight model-guard style (see mergeRebaseArm.test.ts).
|
|
16
16
|
|
|
17
17
|
import { test } from "node:test";
|
|
18
|
-
import { assert, assertStringIncludes } from "#test-assert";
|
|
18
|
+
import { assert, assertEquals, assertStringIncludes } from "#test-assert";
|
|
19
19
|
import { readFileSync } from "node:fs";
|
|
20
|
+
import { routeRoundResult } from "./roundResultDefault.ts";
|
|
20
21
|
|
|
21
22
|
const bpmn = readFileSync("resources/processes/convergence-loop.bpmn", "utf8");
|
|
22
23
|
|
|
@@ -59,12 +60,18 @@ test("gw-status defaults to the addressed arm, not escalation", () => {
|
|
|
59
60
|
);
|
|
60
61
|
});
|
|
61
62
|
|
|
62
|
-
test("escalation is an explicit needs_input/blocked arm", () => {
|
|
63
|
+
test("escalation is an explicit needs_input/blocked arm gated on a non-blank question", () => {
|
|
63
64
|
const esc = flowElement("f_escalate");
|
|
64
65
|
assert(esc, "f_escalate flow missing");
|
|
65
66
|
assertStringIncludes(esc, 'targetRef="persist-escalation"');
|
|
66
67
|
// Escalation now only fires on an explicit human-blocking status.
|
|
67
68
|
assertStringIncludes(esc, 'status = "needs_input" or status = "blocked"');
|
|
69
|
+
// ...AND only when the round carries an answerable question. A blank/absent/whitespace-only
|
|
70
|
+
// question can no longer route to escalation (retires the blank-question fabrication failure
|
|
71
|
+
// mode); it falls through to the addressed default and re-enters the review wait. The guard
|
|
72
|
+
// trims so a whitespace-only question (" ") is treated as blank too.
|
|
73
|
+
assertStringIncludes(esc, 'question != null');
|
|
74
|
+
assertStringIncludes(esc, 'trim(question) != ""');
|
|
68
75
|
});
|
|
69
76
|
|
|
70
77
|
test("the default (addressed) arm carries no condition and re-enters the guard", () => {
|
|
@@ -92,3 +99,105 @@ test("regression: an empty/unknown status no longer routes to persist-escalation
|
|
|
92
99
|
const addressed = flowElement("f_addressed");
|
|
93
100
|
assert(addressed && /targetRef="gw-guard"/.test(addressed), "default arm must re-enter gw-guard");
|
|
94
101
|
});
|
|
102
|
+
|
|
103
|
+
// The canonical router (app/roundResultDefault.ts) mirrors the gw-status routing above, with the
|
|
104
|
+
// escalation decision delegated to the single taxonomy. These unit tests pin its behaviour — the
|
|
105
|
+
// same rules the structural BPMN assertions above enforce on the committed model.
|
|
106
|
+
|
|
107
|
+
test("routeRoundResult: a converged round converges", () => {
|
|
108
|
+
assertEquals(routeRoundResult("converged", null), "converged");
|
|
109
|
+
assertEquals(routeRoundResult("converged", "ignored"), "converged");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("routeRoundResult: a human-blocking status with a question escalates", () => {
|
|
113
|
+
assertEquals(routeRoundResult("needs_input", "please decide"), "escalate");
|
|
114
|
+
assertEquals(routeRoundResult("blocked", "please decide"), "escalate");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("routeRoundResult: a blank-question human-blocking status re-enters the loop (no fabrication)", () => {
|
|
118
|
+
for (const status of ["needs_input", "blocked"]) {
|
|
119
|
+
for (const question of [undefined, null, "", " "]) {
|
|
120
|
+
assertEquals(
|
|
121
|
+
routeRoundResult(status, question),
|
|
122
|
+
"reenter",
|
|
123
|
+
`blank-question ${status} must re-enter the review wait, not escalate`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("routeRoundResult: an addressed/unknown/empty status re-enters the loop", () => {
|
|
130
|
+
for (const status of [undefined, "", "addressed", "waiting", "in_progress"]) {
|
|
131
|
+
assertEquals(routeRoundResult(status, "q"), "reenter", `status ${JSON.stringify(status)} re-enters`);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("routeRoundResult: status is matched exactly, mirroring the untrimmed gw-status conditions", () => {
|
|
136
|
+
// The gw-status gateway conditions do NOT trim `status` (unlike `question`, which trims for
|
|
137
|
+
// blank-detection). `status` is a machine enum, so a whitespace-padded token is NOT the enum
|
|
138
|
+
// value: it must route exactly as the deployed model does — never converge/escalate in code
|
|
139
|
+
// while the model re-enters. This pins the no-drift contract.
|
|
140
|
+
assertEquals(routeRoundResult("converged ", null), "reenter", "'converged ' is not the enum → re-enter");
|
|
141
|
+
assertEquals(routeRoundResult(" converged", null), "reenter", "' converged' is not the enum → re-enter");
|
|
142
|
+
assertEquals(routeRoundResult("needs_input ", "q"), "reenter", "'needs_input ' is not the enum → re-enter");
|
|
143
|
+
assertEquals(routeRoundResult("blocked ", "q"), "reenter", "'blocked ' is not the enum → re-enter");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// --- Liveness guard: a non-escalation early return must not wedge on the durable answer-wait. ---
|
|
147
|
+
// `persist-escalation` may complete WITHOUT opening an escalation (worker returns
|
|
148
|
+
// `escalated:false` for a blank-question / non-decision-required job — defence-in-depth). The
|
|
149
|
+
// model must branch on that output so such a token re-enters the loop instead of flowing into
|
|
150
|
+
// `wait-answer`, which would block forever with no escalation for a human to answer.
|
|
151
|
+
|
|
152
|
+
test("persist-escalation routes through the gw-escalated liveness gateway, not straight to wait-answer", () => {
|
|
153
|
+
// The only flow out of persist-escalation goes to the gateway.
|
|
154
|
+
const escOut = allFlows().filter((f) => /sourceRef="persist-escalation"/.test(f));
|
|
155
|
+
assertEquals(escOut.length, 1, "persist-escalation must have exactly one outgoing flow");
|
|
156
|
+
assertStringIncludes(escOut[0], 'targetRef="gw-escalated"');
|
|
157
|
+
// No flow leaves persist-escalation directly for wait-answer.
|
|
158
|
+
assert(
|
|
159
|
+
!escOut.some((f) => /targetRef="wait-answer"/.test(f)),
|
|
160
|
+
"persist-escalation must not flow directly into wait-answer",
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("gw-escalated waits only when an escalation was opened, else re-enters the guard", () => {
|
|
165
|
+
const gw = flat.match(/<bpmn:exclusiveGateway\b[^>]*\bid="gw-escalated"[^>]*>/);
|
|
166
|
+
assert(gw, "gw-escalated gateway missing");
|
|
167
|
+
// Its default arm must be the re-enter (no-escalation) arm, never the answer-wait.
|
|
168
|
+
assertStringIncludes(gw[0], 'default="f_escReenter"');
|
|
169
|
+
|
|
170
|
+
// The wait arm is guarded on the worker's `escalated` output — a token only reaches the
|
|
171
|
+
// durable answer-wait when an escalation actually exists.
|
|
172
|
+
const wait = flowElement("f_escWait");
|
|
173
|
+
assert(wait, "f_escWait flow missing");
|
|
174
|
+
assertStringIncludes(wait, 'sourceRef="gw-escalated"');
|
|
175
|
+
assertStringIncludes(wait, 'targetRef="wait-answer"');
|
|
176
|
+
assertStringIncludes(wait, "escalated = true");
|
|
177
|
+
|
|
178
|
+
// The default (no-escalation) arm carries no condition and re-enters gw-guard (forward
|
|
179
|
+
// progress), so a non-escalation early return can never wedge on wait-answer.
|
|
180
|
+
const reenter = flowElement("f_escReenter");
|
|
181
|
+
assert(reenter, "f_escReenter flow missing");
|
|
182
|
+
assertStringIncludes(reenter, 'sourceRef="gw-escalated"');
|
|
183
|
+
assertStringIncludes(reenter, 'targetRef="gw-guard"');
|
|
184
|
+
assert(
|
|
185
|
+
!/conditionExpression/.test(reenter),
|
|
186
|
+
"f_escReenter is the default arm and must not carry a conditionExpression",
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("regression: every flow into wait-answer is either escalated-gated or a fixed-question escalation", () => {
|
|
191
|
+
// wait-answer must never be reachable by an unconditional edge from the agent-raised
|
|
192
|
+
// persist-escalation (whose escalation is conditional). The agent path reaches it only via
|
|
193
|
+
// gw-escalated's `escalated = true` arm; the max-rounds / review-stalled paths set a fixed
|
|
194
|
+
// non-blank question and so always escalate.
|
|
195
|
+
const intoWait = allFlows().filter((f) => /targetRef="wait-answer"/.test(f));
|
|
196
|
+
assert(intoWait.length > 0, "no flow targets wait-answer");
|
|
197
|
+
for (const f of intoWait) {
|
|
198
|
+
assert(
|
|
199
|
+
!/sourceRef="persist-escalation"/.test(f),
|
|
200
|
+
"wait-answer must not be entered directly from persist-escalation",
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Review-round "safe default" routing — the canonical, testable mirror of the convergence-loop
|
|
2
|
+
// `gw-status` exclusive gateway (see resources/processes/convergence-loop.bpmn and the structural
|
|
3
|
+
// guard in roundResultDefault.test.ts).
|
|
4
|
+
//
|
|
5
|
+
// A review round that exits without a machine-readable result (empty/unknown status) must NOT
|
|
6
|
+
// escalate: the round-cap gate and the review-wait timeout already provide the human-escalation
|
|
7
|
+
// safety nets, and `persist-round` defaults an absent status to `addressed`. Escalation is an
|
|
8
|
+
// EXPLICIT decision, delegated here to the single canonical taxonomy so this routing can never
|
|
9
|
+
// drift from the tier logic every other raise site uses.
|
|
10
|
+
//
|
|
11
|
+
// Crucially, a human-blocking status (`needs_input` / `blocked`) with a BLANK question is a
|
|
12
|
+
// NON-escalation — it re-enters the durable review wait rather than fabricating an answerable
|
|
13
|
+
// escalation. The gateway enforces the same rule via its `f_escalate` condition.
|
|
14
|
+
|
|
15
|
+
import { classifyEscalation } from "./escalationTaxonomy.ts";
|
|
16
|
+
|
|
17
|
+
/** Where a review-round result routes:
|
|
18
|
+
* • `converged` — the PR is done (success path).
|
|
19
|
+
* • `escalate` — a decision-required escalation (human-blocking status + answerable question).
|
|
20
|
+
* • `reenter` — everything else re-enters the durable review wait / round-cap guard: an
|
|
21
|
+
* addressed/waiting/unknown/empty status, OR a human-blocking status whose
|
|
22
|
+
* question is blank (the retired blank-question fabrication path). */
|
|
23
|
+
export type RoundRouting = "converged" | "escalate" | "reenter";
|
|
24
|
+
|
|
25
|
+
/** Route a review-round result exactly as `gw-status` does, delegating the escalation decision
|
|
26
|
+
* to {@link classifyEscalation}. A round only escalates when the taxonomy classifies it as
|
|
27
|
+
* `decision-required`; otherwise it converges or re-enters the loop. */
|
|
28
|
+
export function routeRoundResult(
|
|
29
|
+
status: string | null | undefined,
|
|
30
|
+
question?: string | null,
|
|
31
|
+
): RoundRouting {
|
|
32
|
+
if ((status ?? "") === "converged") return "converged";
|
|
33
|
+
const disposition = classifyEscalation({ kind: "review-round", status, question });
|
|
34
|
+
return disposition === "decision-required" ? "escalate" : "reenter";
|
|
35
|
+
}
|
package/app/service.test.ts
CHANGED
|
@@ -43,7 +43,7 @@ function withGithubOff(run: () => Promise<void>): Promise<void> {
|
|
|
43
43
|
});
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
test("re-submit of a cancelled PR
|
|
46
|
+
test("re-submit of a cancelled PR marks stale open escalations", async () => {
|
|
47
47
|
await withGithubOff(async () => {
|
|
48
48
|
const PR_KEY = "owner/repo#42";
|
|
49
49
|
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
@@ -56,8 +56,6 @@ test("re-submit of a cancelled PR clears stale open escalations + the denormalis
|
|
|
56
56
|
title: "old title",
|
|
57
57
|
status: "abandoned", // terminal -> re-open path
|
|
58
58
|
current_round: 3,
|
|
59
|
-
open_escalation_id: 5,
|
|
60
|
-
open_escalation_question: "(no question provided)",
|
|
61
59
|
}],
|
|
62
60
|
key: "pr_key",
|
|
63
61
|
},
|
|
@@ -81,15 +79,16 @@ test("re-submit of a cancelled PR clears stale open escalations + the denormalis
|
|
|
81
79
|
prKey: PR_KEY,
|
|
82
80
|
});
|
|
83
81
|
|
|
84
|
-
// The prior run's open escalation is retired (not left "open" to resurface a dead form
|
|
82
|
+
// The prior run's open escalation is retired (not left "open" to resurface a dead form on the
|
|
83
|
+
// re-opened PR). The review-loop escalation is now a native userTask (open state derived from
|
|
84
|
+
// the canonical `escalations` row status), so there is no denormalised PR-row pointer to clear.
|
|
85
85
|
const esc = stores.escalations.rows[0] as Record<string, unknown>;
|
|
86
86
|
assertEquals(esc.status, "stale");
|
|
87
|
-
// … and the PR row is re-opened with the denormalised escalation pointer cleared.
|
|
88
87
|
const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
|
|
89
88
|
assertEquals(pr.status, "converging");
|
|
90
89
|
assertEquals(pr.current_round, 1);
|
|
91
|
-
assertEquals(pr.open_escalation_id,
|
|
92
|
-
assertEquals(pr.open_escalation_question,
|
|
90
|
+
assertEquals(pr.open_escalation_id, undefined);
|
|
91
|
+
assertEquals(pr.open_escalation_question, undefined);
|
|
93
92
|
assertEquals(pr.process_key, "PI-9");
|
|
94
93
|
});
|
|
95
94
|
});
|
package/app/service.ts
CHANGED
|
@@ -115,8 +115,6 @@ interface PullRequest {
|
|
|
115
115
|
updated_at: string;
|
|
116
116
|
converged_at: string | null;
|
|
117
117
|
merged_at: string | null;
|
|
118
|
-
open_escalation_id: number | null;
|
|
119
|
-
open_escalation_question: string | null;
|
|
120
118
|
// Job-activation visibility (005_job_activation.sql), written by the poller's
|
|
121
119
|
// `pollJobActivation` pass. `active_worker` is the leasing worker's name while an
|
|
122
120
|
// agent is actively working the `senior:pr-review` round; NULL means the job is
|
|
@@ -353,10 +351,11 @@ export async function submitPr(
|
|
|
353
351
|
const abandonToken = existing?.abandon_token ?? mintAbandonToken();
|
|
354
352
|
if (existing) {
|
|
355
353
|
// A prior run (cancelled, converged, or otherwise superseded) may have left an OPEN
|
|
356
|
-
// escalation row
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
354
|
+
// escalation row. A fresh convergence run must not inherit that stale answer — the
|
|
355
|
+
// "(no question provided)" bleed-through on resubmit (Magikcraft/nano-bpm #597/#599).
|
|
356
|
+
// Mark any still-open escalations `stale`, mirroring the plan re-plan cleanup (issue #25
|
|
357
|
+
// in plan.ts). The review-loop escalation is now a native userTask; its open state is derived
|
|
358
|
+
// from the canonical `escalations` row status, so there is no denormalised PR-row pointer to clear.
|
|
360
359
|
for (const e of await escs(data).find({ pr_key: parsed.prKey, status: "open" })) {
|
|
361
360
|
await escs(data).update(e.id, { status: "stale" });
|
|
362
361
|
}
|
|
@@ -372,10 +371,6 @@ export async function submitPr(
|
|
|
372
371
|
outcome: null,
|
|
373
372
|
converged_at: null,
|
|
374
373
|
merged_at: null,
|
|
375
|
-
// Drop any denormalised open-escalation pointer from the prior run so the answer form
|
|
376
|
-
// does not resurface a dead/stale question on the re-opened PR.
|
|
377
|
-
open_escalation_id: null,
|
|
378
|
-
open_escalation_question: null,
|
|
379
374
|
abandon_token: abandonToken,
|
|
380
375
|
updated_at: ts,
|
|
381
376
|
});
|
|
@@ -486,22 +481,26 @@ export async function answerEscalation(
|
|
|
486
481
|
prKey: string,
|
|
487
482
|
answer: string,
|
|
488
483
|
) {
|
|
489
|
-
const open = (await escs(data).find({ pr_key: prKey, status: "open" })).sort((a, b) => b.id - a.id)
|
|
490
|
-
if (
|
|
484
|
+
const open = (await escs(data).find({ pr_key: prKey, status: "open" })).sort((a, b) => b.id - a.id);
|
|
485
|
+
if (open.length === 0) return { ok: false, reason: "no open escalation" };
|
|
491
486
|
const ts = now();
|
|
492
|
-
await escs(data).update(open.id, { answer, status: "answered", answered_at: ts });
|
|
487
|
+
await escs(data).update(open[0].id, { answer, status: "answered", answered_at: ts });
|
|
488
|
+
// `pr.persist-escalation` always INSERTs a new open row, so a retry can leave duplicate open rows
|
|
489
|
+
// for this PR. Retire any older ones to `stale` so none is left `open` to phantom-surface on
|
|
490
|
+
// /status (mirrors `submitPr`'s resubmit cleanup and the review loop's `pr.answer-escalation`).
|
|
491
|
+
for (const dup of open.slice(1)) {
|
|
492
|
+
await escs(data).update(dup.id, { status: "stale" });
|
|
493
|
+
}
|
|
493
494
|
await prs(data).update(prKey, {
|
|
494
495
|
status: "converging",
|
|
495
496
|
updated_at: ts,
|
|
496
|
-
open_escalation_id: null,
|
|
497
|
-
open_escalation_question: null,
|
|
498
497
|
});
|
|
499
498
|
await engine.publishMessage({
|
|
500
499
|
name: "escalation-answered",
|
|
501
500
|
correlationKey: prKey,
|
|
502
|
-
variables: { answer, escalationId: open.id },
|
|
501
|
+
variables: { answer, escalationId: open[0].id },
|
|
503
502
|
});
|
|
504
|
-
return { ok: true, escalationId: open.id };
|
|
503
|
+
return { ok: true, escalationId: open[0].id };
|
|
505
504
|
}
|
|
506
505
|
|
|
507
506
|
/** A PR currently in flight, as reported by the status endpoint. */
|
|
@@ -526,27 +525,45 @@ export interface ActivePr {
|
|
|
526
525
|
|
|
527
526
|
/** Every tracked PR not in a terminal state (converged/abandoned), newest-updated first. Backs
|
|
528
527
|
* the GET status endpoint so an operator or an external harness can see what is in flight
|
|
529
|
-
* without reading the datasource directly.
|
|
528
|
+
* without reading the datasource directly. The open-escalation question is derived from the
|
|
529
|
+
* canonical `escalations` audit row — the single source of truth (no denormalised PR-row
|
|
530
|
+
* pointer). A PR reads `status="escalated"` only while a token is parked awaiting a human answer,
|
|
531
|
+
* and the row it raised carries `status="open"` until that answer is recorded — by the review
|
|
532
|
+
* loop's `pr.answer-escalation` step on `wait-answer` completion, or the merge loop's
|
|
533
|
+
* `answerEscalation` message path. Deriving from the row (not a per-loop wait mechanism) surfaces
|
|
534
|
+
* BOTH loops' escalations: the merge loop parks on a message catch with no user task, so a
|
|
535
|
+
* user-task probe would silently hide it. Once answered the row leaves `open`, so `openEscalation`
|
|
536
|
+
* derives back to null. */
|
|
530
537
|
export async function activePrs(data: DataLayer): Promise<ActivePr[]> {
|
|
531
538
|
const all = await prs(data).all();
|
|
532
|
-
|
|
539
|
+
const active = all
|
|
533
540
|
.filter((p) => !TERMINAL_STATUSES.includes(p.status))
|
|
534
|
-
.sort((a, b) => (a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : 0))
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
541
|
+
.sort((a, b) => (a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : 0));
|
|
542
|
+
// Only an `escalated` PR is parked awaiting a human answer (either loop). Surface the question
|
|
543
|
+
// from its latest still-open `escalations` row; a resubmit retires stale rows and finalize/merge
|
|
544
|
+
// move the PR off `escalated`, so an open row on an escalated PR is a genuinely live escalation.
|
|
545
|
+
// Fetch every open row in one query (avoids an N+1 over escalated PRs), then keep the newest per PR.
|
|
546
|
+
const openEscByPr = new Map<string, string>();
|
|
547
|
+
const escalatedPrs = new Set(active.filter((p) => p.status === "escalated").map((p) => p.pr_key));
|
|
548
|
+
for (const e of (await escs(data).find({ status: "open" })).sort((a, b) => b.id - a.id)) {
|
|
549
|
+
if (!escalatedPrs.has(e.pr_key) || openEscByPr.has(e.pr_key)) continue;
|
|
550
|
+
if (e.question) openEscByPr.set(e.pr_key, e.question);
|
|
551
|
+
}
|
|
552
|
+
return active.map((p) => ({
|
|
553
|
+
prKey: p.pr_key,
|
|
554
|
+
repo: p.repo,
|
|
555
|
+
number: p.number,
|
|
556
|
+
url: p.url,
|
|
557
|
+
title: p.title ?? null,
|
|
558
|
+
status: p.status,
|
|
559
|
+
round: p.current_round,
|
|
560
|
+
processKey: p.process_key ?? null,
|
|
561
|
+
waitingSince: p.waiting_since ?? null,
|
|
562
|
+
openEscalation: openEscByPr.get(p.pr_key) ?? null,
|
|
563
|
+
updatedAt: p.updated_at,
|
|
564
|
+
activeWorker: p.active_worker ?? null,
|
|
565
|
+
leaseUntil: p.lease_until ?? null,
|
|
566
|
+
}));
|
|
550
567
|
}
|
|
551
568
|
|
|
552
569
|
/** One review-ready poll pass (SPEC §10): for every PR waiting on a review, fetch its GitHub
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
-- Agent-answerable escalations (epic #156, slice U6; ADR 0046). The escalation user tasks landed by
|
|
2
|
+
-- U0–U3 are completed by whoever holds the assignment — a human via the task inbox, or an AGENT
|
|
3
|
+
-- assignee via the host-side completer. Both drive the SAME `.form` contract and the SAME engine
|
|
4
|
+
-- `completeUserTask` resume path (no parallel lane); this ledger is the attribution + reversibility
|
|
5
|
+
-- surface layered over that one completion.
|
|
6
|
+
--
|
|
7
|
+
-- One row per escalation user-task completion routed through the canonical attributed completer
|
|
8
|
+
-- (app/agentCompletion.ts). It records WHO completed the task (agent vs human, and their id), the
|
|
9
|
+
-- exact typed form variables submitted, and — for an AGENT completion — whether a human has since
|
|
10
|
+
-- reverted/overridden it. A completed user task cannot be un-completed in the engine, so
|
|
11
|
+
-- reversibility is modelled here: an agent answer is never a silent irreversible commit — a human
|
|
12
|
+
-- can mark it reverted (recording who + when), and any host-side consumer reads this ledger to see
|
|
13
|
+
-- whether the latest completion is still authoritative.
|
|
14
|
+
--
|
|
15
|
+
-- Expand-only / additive (AGENTS.md forward-only migrations): a new table, no existing shape
|
|
16
|
+
-- touched. `actor_kind` is agent | human; `reversible` is 1 for agent completions (a human may
|
|
17
|
+
-- override) and 0 for human completions (already the authority).
|
|
18
|
+
CREATE TABLE task_completions (
|
|
19
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
20
|
+
user_task_key TEXT NOT NULL, -- the engine user-task key that was completed
|
|
21
|
+
process_instance_key TEXT, -- owning process instance, when known
|
|
22
|
+
element_id TEXT, -- the escalation task's BPMN elementId
|
|
23
|
+
actor_kind TEXT NOT NULL CHECK (actor_kind IN ('agent', 'human')), -- agent | human
|
|
24
|
+
actor_id TEXT NOT NULL, -- the completing identity (agent id / operator)
|
|
25
|
+
variables_json TEXT NOT NULL, -- the typed form variables submitted (JSON)
|
|
26
|
+
reversible INTEGER NOT NULL DEFAULT 0, -- 1 = a human may override (agent completions)
|
|
27
|
+
reverted INTEGER NOT NULL DEFAULT 0, -- 1 once a human has reverted/overridden it
|
|
28
|
+
reverted_by TEXT, -- the human identity that reverted it
|
|
29
|
+
reverted_note TEXT, -- the human's corrective guidance overriding the agent answer
|
|
30
|
+
reverted_at TEXT,
|
|
31
|
+
created_at TEXT NOT NULL
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
-- The completer and the revert path look a completion up by its user-task key (newest first);
|
|
35
|
+
-- index it to avoid a scan.
|
|
36
|
+
CREATE INDEX idx_task_completions_key ON task_completions(user_task_key);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
-- Retire the bespoke escalation subsystem — the destructive CONTRACT phase of the
|
|
2
|
+
-- expand-and-contract migration to native BPMN `userTask` + `.form` (ADR 0002, epic
|
|
3
|
+
-- #156). The escalation-conversion slices already stopped WRITING/reading these
|
|
4
|
+
-- surfaces (U2 vacated the plan/task/plan-review mirrors; U3 vacated the PR
|
|
5
|
+
-- review-loop `open_escalation_*` mirror), deriving open-escalation state from parked
|
|
6
|
+
-- user tasks (`searchUserTasks`) instead. Nothing reads these tables/columns any more,
|
|
7
|
+
-- so drop them.
|
|
8
|
+
--
|
|
9
|
+
-- Forward-only and numbered after the current highest prefix (026); migrations apply
|
|
10
|
+
-- in order and are auto-applied on boot. The migration runner wraps each file in its
|
|
11
|
+
-- own transaction — this file must NOT contain BEGIN/COMMIT.
|
|
12
|
+
--
|
|
13
|
+
-- NB: the merge-loop escalation (out of scope for #156) still uses the shared
|
|
14
|
+
-- `escalations` audit table + the `escalation-answered` message path — those are
|
|
15
|
+
-- deliberately KEPT here.
|
|
16
|
+
|
|
17
|
+
-- 1. The per-task implementation-phase escalation audit table (006_task_escalation.sql).
|
|
18
|
+
-- Superseded by the `feature-escalation` user task; its answer now resumes the
|
|
19
|
+
-- process directly. DROP TABLE also drops its indexes.
|
|
20
|
+
DROP TABLE IF EXISTS plan_escalations;
|
|
21
|
+
|
|
22
|
+
-- 2. The plan-review cap escalation audit table (020_plan_review_escalation.sql).
|
|
23
|
+
-- Superseded by the `plan-review-decision` user task; the review epoch is now the
|
|
24
|
+
-- durable process variable `planReviewEpoch`, not the count of answered rows.
|
|
25
|
+
DROP TABLE IF EXISTS plan_review_escalations;
|
|
26
|
+
|
|
27
|
+
-- 3. The denormalised "currently-open escalation" pointers on `pull_requests`
|
|
28
|
+
-- (003_open_escalation.sql). The open-escalation question is derived from the
|
|
29
|
+
-- canonical `escalations` audit row + parked user tasks — no denormalised pointer.
|
|
30
|
+
ALTER TABLE pull_requests DROP COLUMN open_escalation_id;
|
|
31
|
+
ALTER TABLE pull_requests DROP COLUMN open_escalation_question;
|
|
32
|
+
|
|
33
|
+
-- 4. The denormalised per-plan "oldest open task escalation" pointer
|
|
34
|
+
-- (006_task_escalation.sql) and the "open plan-review escalation" pointer
|
|
35
|
+
-- (020_plan_review_escalation.sql) on `plans`. Both are surfaced from the task
|
|
36
|
+
-- inbox (`searchUserTasks`) now, with no denormalised mirror on the plan row.
|
|
37
|
+
ALTER TABLE plans DROP COLUMN open_task_escalation_id;
|
|
38
|
+
ALTER TABLE plans DROP COLUMN open_task_question;
|
|
39
|
+
ALTER TABLE plans DROP COLUMN open_task_corr_key;
|
|
40
|
+
ALTER TABLE plans DROP COLUMN open_task_id;
|
|
41
|
+
ALTER TABLE plans DROP COLUMN open_plan_escalation_id;
|
|
42
|
+
ALTER TABLE plans DROP COLUMN open_plan_findings;
|
|
43
|
+
ALTER TABLE plans DROP COLUMN open_plan_round;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
-- Single-issue feature run (issue #172): the "missing middle" between Epics
|
|
2
|
+
-- (plan-fanout: one issue → many PRs) and PR convergence (an already-open PR →
|
|
3
|
+
-- review → merge). One row per issue handed to a single implementation agent
|
|
4
|
+
-- that raises exactly ONE PR, then OPTIONALLY hands that PR to the convergence
|
|
5
|
+
-- loop (and, with auto-merge, the merge-loop).
|
|
6
|
+
--
|
|
7
|
+
-- The downstream PR lifecycle (review rounds, escalations, merge) is NOT
|
|
8
|
+
-- duplicated here: once `converge` hands the opened PR to `submitPr`, its live
|
|
9
|
+
-- state lives on the `pull_requests` row keyed by `pr_key`. `feature_runs` only
|
|
10
|
+
-- tracks the feature run's own lifecycle up to the hand-off.
|
|
11
|
+
|
|
12
|
+
CREATE TABLE feature_runs (
|
|
13
|
+
feature_key TEXT PRIMARY KEY, -- "<owner>/<repo>#<issue-number>"
|
|
14
|
+
repo TEXT NOT NULL, -- "<owner>/<repo>"
|
|
15
|
+
issue_number INTEGER NOT NULL,
|
|
16
|
+
issue_url TEXT NOT NULL,
|
|
17
|
+
base_branch TEXT NOT NULL, -- the branch the agent branches off / opens its PR against
|
|
18
|
+
status TEXT NOT NULL, -- running | opened | converging | blocked | skipped | failed | abandoned
|
|
19
|
+
process_key TEXT, -- engine process-instance key (feature.bpmn)
|
|
20
|
+
pr_key TEXT, -- the PR this run produced ("<owner>/<repo>#<n>") → pull_requests
|
|
21
|
+
converge INTEGER NOT NULL DEFAULT 0, -- 0|1 — hand the opened PR to the convergence loop
|
|
22
|
+
auto_merge INTEGER NOT NULL DEFAULT 0, -- 0|1 — drive the merge-loop after convergence (converge=0 ⇒ moot)
|
|
23
|
+
outcome TEXT, -- final summary / note from the agent
|
|
24
|
+
created_at TEXT NOT NULL,
|
|
25
|
+
updated_at TEXT NOT NULL
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
CREATE INDEX idx_feature_runs_status ON feature_runs(status);
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// End-to-end proof for agent-answerable escalations (epic #156, slice U6; ADR 0046). Boots the whole
|
|
2
|
+
// app against the WASM engine and drives the REAL plan-fanout.bpmn to the implementation-phase task
|
|
3
|
+
// escalation — the native `feature-escalation` userTask + form the human path (U2) completes — then
|
|
4
|
+
// completes it through the HOST-SIDE AGENT COMPLETER (`completeEscalationAsAgent`) instead of a raw
|
|
5
|
+
// human completion. It proves the three things U6 promises:
|
|
6
|
+
//
|
|
7
|
+
// 1. an AGENT assignee completing the SAME `.form` resumes the process with typed vars IDENTICAL to
|
|
8
|
+
// a human completion — asserted on the cumulative taken sequence flows: `{resolution:"answer"}`
|
|
9
|
+
// routes `w_gw_answer -> implement-task`, exactly as the U2 human test asserts (an empty/wrong
|
|
10
|
+
// completion would take the abandon default);
|
|
11
|
+
// 2. attribution is recorded — the `task_completions` ledger row is actor_kind=agent + the agent id
|
|
12
|
+
// + the submitted variables;
|
|
13
|
+
// 3. the completion is reversible — a human reverts it, and the ledger records who + when.
|
|
14
|
+
//
|
|
15
|
+
// No `.bpmn` is touched by this slice: the agent uses the same process + form the human does, only
|
|
16
|
+
// the completion CALLER differs. Network isolation mirrors the sibling e2e suites (github forced to
|
|
17
|
+
// offline `token` mode). Run with `npm run e2e`.
|
|
18
|
+
|
|
19
|
+
import assert from "node:assert/strict";
|
|
20
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { dirname, join, resolve } from "node:path";
|
|
23
|
+
import { after, before, describe, test } from "node:test";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
import type { EngineJob } from "@nanobpm/urban/runtime";
|
|
26
|
+
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
27
|
+
import { admitGithubState, installAdmitGithub } from "./support/github-admit.ts";
|
|
28
|
+
import {
|
|
29
|
+
completeEscalationAsAgent,
|
|
30
|
+
latestCompletion,
|
|
31
|
+
revertAgentCompletion,
|
|
32
|
+
type TaskCompletion,
|
|
33
|
+
} from "../app/agentCompletion.ts";
|
|
34
|
+
|
|
35
|
+
const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
36
|
+
|
|
37
|
+
const GITHUB_ENV_OVERRIDES: Record<string, string> = {
|
|
38
|
+
NANO_PR_GITHUB_TRANSPORT: "token",
|
|
39
|
+
GITHUB_TOKEN: "",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
interface InboxTask {
|
|
43
|
+
userTaskKey: string;
|
|
44
|
+
elementId?: string;
|
|
45
|
+
variables?: Record<string, unknown>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface TakenFlow {
|
|
49
|
+
from: string;
|
|
50
|
+
to: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function takenFlows(app: TestApp): string[] {
|
|
54
|
+
const snapshot = app.snapshot();
|
|
55
|
+
const flows = Array.isArray(snapshot.takenSequenceFlows) ? snapshot.takenSequenceFlows : [];
|
|
56
|
+
return flows
|
|
57
|
+
.filter((f): f is TakenFlow => typeof f === "object" && f !== null && "from" in f && "to" in f)
|
|
58
|
+
.map((f) => `${f.from}->${f.to}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type Stub = (job: EngineJob) => Record<string, unknown> | void;
|
|
62
|
+
|
|
63
|
+
describe("agent-answerable escalations (U6 — same form, agent completer, attribution + reversibility)", () => {
|
|
64
|
+
const savedEnv = new Map<string, string | undefined>();
|
|
65
|
+
let restoreGithub: (() => void) | undefined;
|
|
66
|
+
|
|
67
|
+
before(() => {
|
|
68
|
+
for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
|
|
69
|
+
savedEnv.set(k, process.env[k]);
|
|
70
|
+
process.env[k] = v;
|
|
71
|
+
}
|
|
72
|
+
// ADR 0003: `startPlanFanout` + the `pr.ensure-base-branch` head task now pass through base
|
|
73
|
+
// admission, which reads/creates the base ref. Pin the hermetic `token` transport + fetch stub.
|
|
74
|
+
restoreGithub = installAdmitGithub(admitGithubState("owner/repo", "main"));
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
after(() => {
|
|
78
|
+
restoreGithub?.();
|
|
79
|
+
for (const [k, v] of savedEnv) {
|
|
80
|
+
if (v === undefined) delete process.env[k];
|
|
81
|
+
else process.env[k] = v;
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
async function withApp(
|
|
86
|
+
stubs: Record<string, Stub>,
|
|
87
|
+
body: (ctx: { app: TestApp; planKey: string; processKey: string }) => Promise<void>,
|
|
88
|
+
): Promise<void> {
|
|
89
|
+
const dbDir = mkdtempSync(join(tmpdir(), "nwf-u6-"));
|
|
90
|
+
const app = await bootTestApp(APP_ROOT, {
|
|
91
|
+
env: { NANO_APP_DB_URL: `file:${join(dbDir, "app.db")}` },
|
|
92
|
+
});
|
|
93
|
+
try {
|
|
94
|
+
for (const [jobType, stub] of Object.entries(stubs)) {
|
|
95
|
+
await app.engine.registerWorker(jobType, async (job) => stub(job) ?? undefined);
|
|
96
|
+
}
|
|
97
|
+
const planKey = "owner/repo#1";
|
|
98
|
+
const started = await app.api?.call("startPlanFanout", { body: { issue: planKey, baseBranch: "epic/e2e" } });
|
|
99
|
+
assert.equal(started?.status, 202, "startPlanFanout accepted the issue");
|
|
100
|
+
await app.settle();
|
|
101
|
+
const plan = await app.db
|
|
102
|
+
.table<{ plan_key: string; process_key: string | null }>("plans", "plan_key")
|
|
103
|
+
.findOne({ plan_key: planKey });
|
|
104
|
+
assert.ok(plan?.process_key, "the plan row carries the engine process-instance key");
|
|
105
|
+
await body({ app, planKey, processKey: plan!.process_key! });
|
|
106
|
+
} finally {
|
|
107
|
+
await app.stop();
|
|
108
|
+
rmSync(dbDir, { recursive: true, force: true });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function openTask(app: TestApp, processKey: string, elementId: string): Promise<InboxTask> {
|
|
113
|
+
const tasks = await app.engine.searchUserTasks({ processInstanceKey: processKey });
|
|
114
|
+
const match = tasks.find((t) => t.elementId === elementId);
|
|
115
|
+
assert.ok(match, `expected an open ${elementId} user task (open: ${tasks.map((t) => t.elementId).join(", ")})`);
|
|
116
|
+
return match!;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const singleTaskPlan: Stub = () => ({ tasks: [{ id: "t1", title: "T1", prompt: "do t1" }] });
|
|
120
|
+
const approveReview: Stub = () => ({ approved: true, findings: "" });
|
|
121
|
+
|
|
122
|
+
test("an agent completes the SAME feature-escalation form a human would → process resumes with the identical typed vars, attributed, and reversible", async () => {
|
|
123
|
+
let featureCalls = 0;
|
|
124
|
+
await withApp(
|
|
125
|
+
{
|
|
126
|
+
"senior:plan": singleTaskPlan,
|
|
127
|
+
"senior:plan-review": approveReview,
|
|
128
|
+
"senior:feature": () => {
|
|
129
|
+
featureCalls += 1;
|
|
130
|
+
return featureCalls === 1
|
|
131
|
+
? { status: "escalated", question: "Which API should I use?", summary: "parked for a human" }
|
|
132
|
+
: { status: "blocked", summary: "resumed after answer" };
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
async ({ app, processKey }) => {
|
|
136
|
+
const task = await openTask(app, processKey, "feature-escalation");
|
|
137
|
+
assert.ok(task.userTaskKey, "the feature escalation carries a completable userTaskKey");
|
|
138
|
+
|
|
139
|
+
// Complete AS AN AGENT through the host-side completer — the same typed `{resolution, answer}`
|
|
140
|
+
// a human submits through the inbox, only the caller differs.
|
|
141
|
+
const r = await completeEscalationAsAgent(app.db, app.engine, {
|
|
142
|
+
userTaskKey: task.userTaskKey,
|
|
143
|
+
agentId: "senior:answer-bot",
|
|
144
|
+
variables: { resolution: "answer", answer: "use v2" },
|
|
145
|
+
});
|
|
146
|
+
assert.equal(r.ok, true, "the agent completer accepted the escalation completion");
|
|
147
|
+
assert.equal(r.elementId, "feature-escalation");
|
|
148
|
+
await app.settle();
|
|
149
|
+
|
|
150
|
+
// IDENTICAL resume to the human path (mirrors U2's human test): the typed resolution loops
|
|
151
|
+
// the child back to re-dispatch the SAME task — NOT the abandon default.
|
|
152
|
+
const flows = takenFlows(app);
|
|
153
|
+
assert.ok(
|
|
154
|
+
flows.includes("w_gw_answer->implement-task"),
|
|
155
|
+
`agent answer routed back to implement-task (flows: ${flows.join(", ")})`,
|
|
156
|
+
);
|
|
157
|
+
assert.ok(
|
|
158
|
+
!flows.includes("w_gw_answer->w_end"),
|
|
159
|
+
"the abandon (default) flow was NOT taken",
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
// Attribution: the ledger records an AGENT completion with the agent id + the exact vars.
|
|
163
|
+
const completions = await app.db
|
|
164
|
+
.table<TaskCompletion>("task_completions", "id")
|
|
165
|
+
.find({ user_task_key: task.userTaskKey });
|
|
166
|
+
assert.equal(completions.length, 1, "exactly one attribution row for the completion");
|
|
167
|
+
const row = completions[0];
|
|
168
|
+
assert.equal(row.actor_kind, "agent");
|
|
169
|
+
assert.equal(row.actor_id, "senior:answer-bot");
|
|
170
|
+
assert.equal(row.element_id, "feature-escalation");
|
|
171
|
+
assert.deepEqual(JSON.parse(row.variables_json), { resolution: "answer", answer: "use v2" });
|
|
172
|
+
assert.equal(row.reversible, 1, "an agent completion is reversible");
|
|
173
|
+
assert.equal(row.reverted, 0);
|
|
174
|
+
|
|
175
|
+
// Reversibility: a human overrides the agent's answer; the ledger records who + when.
|
|
176
|
+
const rev = await revertAgentCompletion(app.db, row.id, { kind: "human", id: "alice" });
|
|
177
|
+
assert.equal(rev.ok, true, "a human can revert the agent completion");
|
|
178
|
+
const after = (await latestCompletion(app.db, task.userTaskKey))!;
|
|
179
|
+
assert.equal(after.reverted, 1);
|
|
180
|
+
assert.equal(after.reverted_by, "alice");
|
|
181
|
+
assert.ok(after.reverted_at, "reverted_at is stamped");
|
|
182
|
+
},
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
});
|