@nanobpm/nano-workforce 0.208.2 → 0.208.4
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/AGENTS.md +17 -0
- package/CHANGELOG.md +12 -0
- package/app/contracts.ts +1 -1
- package/app/feature.ts +5 -2
- package/app/implementBranchContract.test.ts +168 -0
- package/app/implementReconcile.ts +8 -2
- package/app/loopTermination.test.ts +412 -0
- package/app/mcpExclusions.test.ts +1 -1
- package/app/mergeBlockedThreads.test.ts +62 -2
- package/app/pullRequestReadModel.test.ts +1 -1
- package/app/repoEnvelope.test.ts +11 -75
- package/app/repoEnvelope.ts +122 -0
- package/app/service.ts +300 -30
- package/app/terminalReaderBehaviour.test.ts +15 -4
- package/build-info.json +4 -4
- package/db/migrations/130_pull_requests_termination_reason.sql +17 -0
- package/db/migrations/131_pull_requests_read_model_termination_reason.sql +48 -0
- package/e2e/feature-preflight.e2e.ts +2 -2
- package/e2e/plan-fanout.e2e.ts +105 -9
- package/nano.app.json +4 -0
- package/openapi.yaml +53 -0
- package/operations/cancelInstance.test.ts +9 -0
- package/operations/cancelInstance.ts +28 -2
- package/operations/cancelPrRun.ts +16 -0
- package/package.json +1 -1
- package/pages/home.page.json +1 -1
- package/resources/processes/implement-cell.bpmn +80 -65
- package/workers/caps-prepare/worker.test.ts +11 -0
- package/workers/slice-branch/worker.test.ts +31 -0
- package/workers/slice-branch/worker.ts +39 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
-- Re-create the `pull_requests_read_model` VIEW so it re-exports the new base column (the VIEW must pass
|
|
2
|
+
-- through EVERY base `pull_requests` column — app/pullRequestReadModel.test.ts DRIFT GUARD). A mechanical
|
|
3
|
+
-- re-emission of 128 with `termination_reason` added; the derived columns are emitted VERBATIM from
|
|
4
|
+
-- app/pullRequestReadModel.ts and are unchanged. No BEGIN/COMMIT (the runner wraps each file). Numbered after 130.
|
|
5
|
+
|
|
6
|
+
DROP VIEW IF EXISTS pull_requests_read_model;
|
|
7
|
+
|
|
8
|
+
CREATE VIEW pull_requests_read_model AS
|
|
9
|
+
SELECT
|
|
10
|
+
pr.pr_key AS pr_key,
|
|
11
|
+
pr.repo AS repo,
|
|
12
|
+
pr.number AS number,
|
|
13
|
+
pr.url AS url,
|
|
14
|
+
pr.title AS title,
|
|
15
|
+
COALESCE(pr.derived_status, pr.status) AS status,
|
|
16
|
+
pr.current_round AS current_round,
|
|
17
|
+
pr.process_key AS process_key,
|
|
18
|
+
pr.waiting_since AS waiting_since,
|
|
19
|
+
pr.last_review_id AS last_review_id,
|
|
20
|
+
pr.outcome AS outcome,
|
|
21
|
+
pr.created_at AS created_at,
|
|
22
|
+
pr.updated_at AS updated_at,
|
|
23
|
+
pr.converged_at AS converged_at,
|
|
24
|
+
pr.merged_at AS merged_at,
|
|
25
|
+
pr.active_worker AS active_worker,
|
|
26
|
+
pr.lease_until AS lease_until,
|
|
27
|
+
pr.last_nudge_at AS last_nudge_at,
|
|
28
|
+
pr.fresh_head_run_head AS fresh_head_run_head,
|
|
29
|
+
pr.abandon_token AS abandon_token,
|
|
30
|
+
pr.incident_key AS incident_key,
|
|
31
|
+
pr.incident_type AS incident_type,
|
|
32
|
+
pr.incident_since AS incident_since,
|
|
33
|
+
pr.incident_message AS incident_message,
|
|
34
|
+
pr.last_round_head AS last_round_head,
|
|
35
|
+
pr.last_progress_job_key AS last_progress_job_key,
|
|
36
|
+
pr.last_progress_result AS last_progress_result,
|
|
37
|
+
pr.last_progress_agent_watermark AS last_progress_agent_watermark,
|
|
38
|
+
pr.churn_escalated_round AS churn_escalated_round,
|
|
39
|
+
pr.root_request_key AS root_request_key,
|
|
40
|
+
pr.epic_phase_label AS epic_phase_label,
|
|
41
|
+
pr.acknowledged_at AS acknowledged_at,
|
|
42
|
+
pr.auto_resolve_escalations AS auto_resolve_escalations,
|
|
43
|
+
pr.termination_reason AS termination_reason,
|
|
44
|
+
pr.termination_process_key AS termination_process_key,
|
|
45
|
+
pr.retire_pending_key AS retire_pending_key,
|
|
46
|
+
CASE WHEN COALESCE((COALESCE((COALESCE(("pr"."derived_status" = 'merged'), 0) OR COALESCE(("pr"."derived_status" = 'converged'), 0) OR COALESCE(("pr"."derived_status" = 'abandoned'), 0) OR COALESCE(("pr"."derived_status" = 'closed'), 0) OR COALESCE(("pr"."derived_status" = 'failed'), 0)), 0) AND COALESCE(("pr"."acknowledged_at" = "pr"."acknowledged_at"), 0)), 0) THEN 'history' ELSE 'active' END AS list_bucket,
|
|
47
|
+
CASE WHEN COALESCE((COALESCE((COALESCE(("pr"."derived_status" = 'merged'), 0) OR COALESCE(("pr"."derived_status" = 'converged'), 0) OR COALESCE(("pr"."derived_status" = 'abandoned'), 0) OR COALESCE(("pr"."derived_status" = 'closed'), 0) OR COALESCE(("pr"."derived_status" = 'failed'), 0)), 0) AND (NOT COALESCE(COALESCE(("pr"."acknowledged_at" = "pr"."acknowledged_at"), 0), 0))), 0) THEN 1 ELSE 0 END AS ack_open
|
|
48
|
+
FROM pull_requests__tracking pr;
|
|
@@ -159,7 +159,7 @@ describe("single-issue feature intake readiness gate (feature.bpmn, issue #295)"
|
|
|
159
159
|
assert.ok(
|
|
160
160
|
flows.includes("ensure-base-branch->record-feature-implementing") &&
|
|
161
161
|
flows.includes("record-feature-implementing->implement") &&
|
|
162
|
-
flows.includes("
|
|
162
|
+
flows.includes("slice-branch->implement-task"),
|
|
163
163
|
`the run reaches the implement agent (via the implement cell) only after the gate (flows: ${flows.join(", ")})`,
|
|
164
164
|
);
|
|
165
165
|
// A green probe never escalates.
|
|
@@ -192,7 +192,7 @@ describe("single-issue feature intake readiness gate (feature.bpmn, issue #295)"
|
|
|
192
192
|
assert.ok(!flows.includes("gw-readiness->readiness-preflight"), "an ungated feature never enters the preflight");
|
|
193
193
|
assert.ok(
|
|
194
194
|
flows.includes("record-feature-implementing->implement") &&
|
|
195
|
-
flows.includes("
|
|
195
|
+
flows.includes("slice-branch->implement-task"),
|
|
196
196
|
"an ungated feature reaches the implement agent (via the implement cell)",
|
|
197
197
|
);
|
|
198
198
|
} finally {
|
package/e2e/plan-fanout.e2e.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { after, before, describe, test } from "node:test";
|
|
|
21
21
|
import { fileURLToPath } from "node:url";
|
|
22
22
|
import type { EngineJob } from "@nanobpm/urban/runtime";
|
|
23
23
|
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
24
|
+
import { recoverBranchCreate } from "../app/repoEnvelope.ts";
|
|
24
25
|
import { admitGithubState, installAdmitGithub } from "./support/github-admit.ts";
|
|
25
26
|
import { advancePastTimer, settleFully } from "./support/time.ts";
|
|
26
27
|
|
|
@@ -127,32 +128,127 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
|
|
|
127
128
|
const singleTaskPlan: Stub = () => ({ tasks: [{ id: "t1", title: "T1", prompt: "do t1" }] });
|
|
128
129
|
const approveReview: Stub = () => ({ approved: true, findings: "" });
|
|
129
130
|
|
|
130
|
-
test("host-git provisioning: each slice's implement-cell agent job carries the repository envelope (#684)", async () => {
|
|
131
|
+
test("host-git provisioning: each slice's implement-cell agent job carries the repository envelope AND the deterministic branch.create (#684/#945)", async () => {
|
|
131
132
|
// The whole-epic seed emits `io.nanobpm.agentTask.repository` so every slice's `senior:feature`
|
|
132
133
|
// agent gets an ISOLATED clone instead of clobbering the worker's launch dir. The process variable
|
|
133
134
|
// rides down through the wave subprocess + `implement-cell-call` callActivity into each agent job.
|
|
134
135
|
// The epic seed carries the BASE branch as `ref` but NO `branch.create` — each slice's
|
|
135
|
-
// `feat/<task.id>` differs per MI child, so
|
|
136
|
-
|
|
136
|
+
// `feat/<task.id>` differs per MI child, so it can't be named at seed time. Issue #945: the
|
|
137
|
+
// per-instance `slice-branch` worker (first step of each implement-cell) now stamps the deterministic `branch.create` (`feat/<task.id>`)
|
|
138
|
+
// onto the envelope as a FLAT dotted process variable (`io.nanobpm.agentTask.branch.create`), which
|
|
139
|
+
// the c8ctl harness deep-merges with the whole-object seed — so a retry re-derives the SAME branch
|
|
140
|
+
// and the durable WIP checkpoint restore matches (previously every retry cut a random
|
|
141
|
+
// `nano/agent-work/*` fallback and discarded the prior session's work).
|
|
142
|
+
let agentVars: Record<string, unknown> = {};
|
|
137
143
|
await withApp(
|
|
138
144
|
{
|
|
139
145
|
"senior:plan": singleTaskPlan,
|
|
140
146
|
"senior:plan-review": approveReview,
|
|
141
147
|
"senior:feature": (job) => {
|
|
142
|
-
|
|
148
|
+
agentVars = job.variables as Record<string, unknown>;
|
|
143
149
|
return { status: "blocked", summary: "n/a" };
|
|
144
150
|
},
|
|
145
151
|
},
|
|
146
152
|
async ({ app }) => {
|
|
147
153
|
await app.settle();
|
|
154
|
+
const agentTask = agentVars["io.nanobpm.agentTask"] as Record<string, any> | undefined;
|
|
148
155
|
assert.ok(agentTask, "the epic slice's senior:feature job carried the io.nanobpm.agentTask envelope");
|
|
149
156
|
const agentRepo = agentTask.repository;
|
|
150
157
|
assert.equal(agentRepo.url, "https://github.com/owner/repo.git", "the harness clones the target repo");
|
|
151
158
|
assert.equal(agentRepo.ref, "epic/e2e", "the epic seed checks out the BASE (integration) branch");
|
|
152
|
-
// `branch` is a top-level sibling of `repository` (#882); the epic seed omits it entirely.
|
|
153
|
-
assert.equal("branch" in agentTask, false, "the epic seed omits branch.create — the agent branches per slice");
|
|
154
159
|
assert.equal(agentRepo.singleBranch, true, "the blobless/single-branch monorepo shaping rides along");
|
|
155
160
|
assert.equal(agentRepo.filter, "blob:none");
|
|
161
|
+
// The whole-object seed still omits `branch` (it can't name a per-child branch)…
|
|
162
|
+
assert.equal("branch" in agentTask, false, "the whole-object seed carries no per-slice branch");
|
|
163
|
+
// …but the per-instance slice-branch worker stamps it as a FLAT dotted variable, which the harness
|
|
164
|
+
// merges onto the SAME envelope. Assert the dotted variable is present and names the
|
|
165
|
+
// deterministic `feat/<task.id>` branch (the single task here is `t1`).
|
|
166
|
+
assert.equal(
|
|
167
|
+
agentVars["io.nanobpm.agentTask.branch.create"],
|
|
168
|
+
"feat/t1",
|
|
169
|
+
"slice-branch stamps the deterministic per-slice branch.create as a flat dotted variable (#945)",
|
|
170
|
+
);
|
|
171
|
+
// Round-trip through the harness normalizer shape: the merged envelope must recover
|
|
172
|
+
// `branch.create` from the top level (proving the dotted variable and the whole-object seed
|
|
173
|
+
// merge into one envelope the harness reads).
|
|
174
|
+
assert.equal(
|
|
175
|
+
recoverBranchCreate({}, agentVars),
|
|
176
|
+
"feat/t1",
|
|
177
|
+
"the harness merges the dotted branch.create with the whole-object seed envelope (#945)",
|
|
178
|
+
);
|
|
179
|
+
},
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("sibling isolation: two parallel slices each see their OWN branch.create, never a sibling's (#945)", async () => {
|
|
184
|
+
// A per-slice branch emitted into the SHARED plan-fanout MI body lands on the epic root, so a
|
|
185
|
+
// sibling's emission overwrites it and a slice can inherit `feat/<sibling>` — sending its work and
|
|
186
|
+
// checkpoint to the wrong branch. The emitter runs inside each implement-cell instance instead, so
|
|
187
|
+
// every activation's recovered branch must be `feat/<its own task.id>`.
|
|
188
|
+
const seen: Array<{ id: unknown; branch: string | undefined }> = [];
|
|
189
|
+
await withApp(
|
|
190
|
+
{
|
|
191
|
+
"senior:plan": () => ({
|
|
192
|
+
tasks: [
|
|
193
|
+
{ id: "t1", title: "T1", prompt: "do t1" },
|
|
194
|
+
{ id: "t2", title: "T2", prompt: "do t2" },
|
|
195
|
+
],
|
|
196
|
+
}),
|
|
197
|
+
"senior:plan-review": approveReview,
|
|
198
|
+
"senior:feature": (job) => {
|
|
199
|
+
const vars = job.variables as Record<string, any>;
|
|
200
|
+
seen.push({ id: vars.task?.id, branch: recoverBranchCreate({}, vars) });
|
|
201
|
+
return { status: "blocked", summary: "n/a" };
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
async ({ app }) => {
|
|
205
|
+
await app.settle();
|
|
206
|
+
assert.deepEqual(
|
|
207
|
+
seen.map((s) => s.id).sort(),
|
|
208
|
+
["t1", "t2"],
|
|
209
|
+
"both sibling slices activated their implement-task",
|
|
210
|
+
);
|
|
211
|
+
for (const s of seen) {
|
|
212
|
+
assert.equal(s.branch, `feat/${String(s.id)}`, `slice ${String(s.id)} must see its OWN branch.create`);
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("retry determinism: a re-dispatched implement-task names the SAME branch.create, so the WIP checkpoint restore matches (#945)", async () => {
|
|
219
|
+
// Issue #945's field failure: every c8ctl retry of an implement-cell slice cut a NEW random
|
|
220
|
+
// `nano/agent-work/*` fallback branch, so the durable WIP checkpoint (keyed on the deterministic
|
|
221
|
+
// `feat/<task.id>`) was NEVER restored — each retry started from the epic base and discarded the
|
|
222
|
+
// prior session's work. The fix makes `branch.create` a pure function of `task.id`, so a retry
|
|
223
|
+
// re-derives the SAME branch and the checkpoint restore matches. Drive the implement-task through
|
|
224
|
+
// TWO activations of the SAME slice (an escalation parks it; answering re-dispatches it — the same
|
|
225
|
+
// re-activation an idle-kill retry produces) and assert both carry the identical recovered
|
|
226
|
+
// `branch.create`.
|
|
227
|
+
const seenBranches: Array<string | undefined> = [];
|
|
228
|
+
await withApp(
|
|
229
|
+
{
|
|
230
|
+
"senior:plan": singleTaskPlan,
|
|
231
|
+
"senior:plan-review": approveReview,
|
|
232
|
+
"senior:feature": (job) => {
|
|
233
|
+
seenBranches.push(recoverBranchCreate({}, job.variables as Record<string, unknown>));
|
|
234
|
+
// First activation escalates (parks for a human); the resume (second activation) blocks.
|
|
235
|
+
return seenBranches.length === 1
|
|
236
|
+
? { status: "escalated", question: "Which API should I use?", summary: "parked for a human" }
|
|
237
|
+
: { status: "blocked", summary: "resumed after answer" };
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
async ({ app, processKey }) => {
|
|
241
|
+
// First activation parked on the human-escalation task; answer it to re-dispatch the SAME slice.
|
|
242
|
+
const task = await openTask(app, processKey, "escalation");
|
|
243
|
+
await app.engine.completeUserTask(task.userTaskKey, { resolution: "answer", answer: "use v2" });
|
|
244
|
+
await app.settle();
|
|
245
|
+
assert.equal(seenBranches.length, 2, "the implement-task was activated twice (initial + retry)");
|
|
246
|
+
assert.equal(seenBranches[0], "feat/t1", "the FIRST activation carries the deterministic branch.create");
|
|
247
|
+
assert.equal(
|
|
248
|
+
seenBranches[1],
|
|
249
|
+
seenBranches[0],
|
|
250
|
+
"the RETRY activation re-derives the SAME branch.create — the WIP checkpoint restore matches (#945)",
|
|
251
|
+
);
|
|
156
252
|
},
|
|
157
253
|
);
|
|
158
254
|
});
|
|
@@ -532,7 +628,7 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
|
|
|
532
628
|
|
|
533
629
|
const flows = takenFlows(app);
|
|
534
630
|
assert.ok(
|
|
535
|
-
flows.includes("wait-caps-resolved->implement-cell-call") && flows.includes("
|
|
631
|
+
flows.includes("wait-caps-resolved->implement-cell-call") && flows.includes("slice-branch->implement-task"),
|
|
536
632
|
`caps-resolved released the barrier into the implement cell (flows: ${flows.join(", ")})`,
|
|
537
633
|
);
|
|
538
634
|
assert.equal(
|
|
@@ -614,7 +710,7 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
|
|
|
614
710
|
assert.ok(
|
|
615
711
|
answered.includes("w_gw_answer->record-task-implementing") &&
|
|
616
712
|
answered.includes("record-task-implementing->implement-cell-call") &&
|
|
617
|
-
answered.includes("
|
|
713
|
+
answered.includes("slice-branch->implement-task"),
|
|
618
714
|
`answering the caps escalation reset the stale question and released the barrier into the implement cell (flows: ${answered.join(", ")})`,
|
|
619
715
|
);
|
|
620
716
|
assert.equal(featureRan, true, "the agent was dispatched once the operator answered the caps escalation");
|
|
@@ -668,7 +764,7 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
|
|
|
668
764
|
assert.ok(
|
|
669
765
|
entered.includes("w_gw_answer->record-task-implementing") &&
|
|
670
766
|
entered.includes("record-task-implementing->implement-cell-call") &&
|
|
671
|
-
entered.includes("
|
|
767
|
+
entered.includes("slice-branch->implement-task"),
|
|
672
768
|
`answering the caps escalation reset the stale question and released the barrier into the implement cell (flows: ${entered.join(", ")})`,
|
|
673
769
|
);
|
|
674
770
|
|
package/nano.app.json
CHANGED
|
@@ -180,6 +180,10 @@
|
|
|
180
180
|
"taskType": "pr.select-wave",
|
|
181
181
|
"handler": "workers/select-wave/worker.ts"
|
|
182
182
|
},
|
|
183
|
+
{
|
|
184
|
+
"taskType": "pr.slice-branch",
|
|
185
|
+
"handler": "workers/slice-branch/worker.ts"
|
|
186
|
+
},
|
|
183
187
|
{
|
|
184
188
|
"taskType": "pr.caps-prepare",
|
|
185
189
|
"handler": "workers/caps-prepare/worker.ts"
|
package/openapi.yaml
CHANGED
|
@@ -94,7 +94,15 @@ components:
|
|
|
94
94
|
- autoResolveEscalations
|
|
95
95
|
- merge
|
|
96
96
|
- incident
|
|
97
|
+
- terminationReason
|
|
97
98
|
properties:
|
|
99
|
+
terminationReason:
|
|
100
|
+
type: string
|
|
101
|
+
nullable: true
|
|
102
|
+
description: >-
|
|
103
|
+
Why the app (or an operator) last cancelled this PR's current loop instance (issue #943),
|
|
104
|
+
naming the successor/outcome; null when never. An `orphaned` status means the loop ended
|
|
105
|
+
with no recorded reason, successor, or escalation.
|
|
98
106
|
prKey:
|
|
99
107
|
type: string
|
|
100
108
|
repo:
|
|
@@ -4357,6 +4365,51 @@ paths:
|
|
|
4357
4365
|
application/json:
|
|
4358
4366
|
schema:
|
|
4359
4367
|
$ref: "#/components/schemas/ErrorBody"
|
|
4368
|
+
/actions/cancel-pr:
|
|
4369
|
+
post:
|
|
4370
|
+
operationId: cancelPrRun
|
|
4371
|
+
# x-mcp exclusion: the page-only twin of `cancelInstance` (the per-row Cancel button). Agents use
|
|
4372
|
+
# the guarded `/actions/cancel` door.
|
|
4373
|
+
x-mcp:
|
|
4374
|
+
exclude: true
|
|
4375
|
+
summary: "Page-facing record-consistent cancel (issue #943): identical to `cancelInstance` (shared implementation, records termination provenance) but unguarded, because the declarative page action cannot attach x-hook-secret (see operations/cancelPrRun.ts)."
|
|
4376
|
+
requestBody:
|
|
4377
|
+
required: true
|
|
4378
|
+
content:
|
|
4379
|
+
application/json:
|
|
4380
|
+
schema:
|
|
4381
|
+
$ref: "#/components/schemas/CancelInstanceIntent"
|
|
4382
|
+
responses:
|
|
4383
|
+
"200":
|
|
4384
|
+
description: The instance was terminated; its record derives/flips to `abandoned` and drops out of `listActivePrs`.
|
|
4385
|
+
content:
|
|
4386
|
+
application/json:
|
|
4387
|
+
schema:
|
|
4388
|
+
$ref: "#/components/schemas/CancelInstanceResult"
|
|
4389
|
+
"400":
|
|
4390
|
+
description: Missing/invalid `processInstanceKey`.
|
|
4391
|
+
content:
|
|
4392
|
+
application/json:
|
|
4393
|
+
schema:
|
|
4394
|
+
$ref: "#/components/schemas/ErrorBody"
|
|
4395
|
+
"404":
|
|
4396
|
+
description: "No tracked record owns the key (a clean no-op — the engine is never touched). Body is a CancelInstanceResult with `ok:false`, `state:\"gone\"`, `reconciled:0`."
|
|
4397
|
+
content:
|
|
4398
|
+
application/json:
|
|
4399
|
+
schema:
|
|
4400
|
+
$ref: "#/components/schemas/CancelInstanceResult"
|
|
4401
|
+
"502":
|
|
4402
|
+
description: "The cancel did not fully take: EITHER the engine did NOT stop the instance (the cancel was not committed; the run may still be live), OR the engine stopped it but the tracked record did not settle to a terminal edge (`ok:false`, `reconciled:0`) — a wedged record the caller must not read as done."
|
|
4403
|
+
content:
|
|
4404
|
+
application/json:
|
|
4405
|
+
schema:
|
|
4406
|
+
$ref: "#/components/schemas/CancelInstanceResult"
|
|
4407
|
+
"503":
|
|
4408
|
+
description: The app has no data source configured, so the cancel cannot be reconciled into the record.
|
|
4409
|
+
content:
|
|
4410
|
+
application/json:
|
|
4411
|
+
schema:
|
|
4412
|
+
$ref: "#/components/schemas/ErrorBody"
|
|
4360
4413
|
/actions/incident/retry:
|
|
4361
4414
|
post:
|
|
4362
4415
|
operationId: retryIncident
|
|
@@ -293,3 +293,12 @@ test("record routing also covers PR/plan aggregates (a resolved pull_requests ke
|
|
|
293
293
|
assertEquals(res.body.ok, true);
|
|
294
294
|
assertEquals(res.body.reconciled, 1);
|
|
295
295
|
});
|
|
296
|
+
|
|
297
|
+
test("PR#944: operator-cancel provenance is recorded even when the record fails to reconcile (502)", async () => {
|
|
298
|
+
const rows: any[] = [{ pr_key: "o/r#4", process_key: "400", status: "converging" }];
|
|
299
|
+
const { app } = makeRecordApp({ pull_requests: { rows, key: "pr_key" } }, { readBackState: "TERMINATED" });
|
|
300
|
+
const res = await call(app, { processInstanceKey: "400" });
|
|
301
|
+
assertEquals(res.status, 502, "the unreconciled record still surfaces as 502");
|
|
302
|
+
assertEquals(rows[0].termination_process_key, "400", "provenance is stamped once the engine confirms termination");
|
|
303
|
+
assert(typeof rows[0].termination_reason === "string" && rows[0].termination_reason.length > 0);
|
|
304
|
+
});
|
|
@@ -37,9 +37,12 @@
|
|
|
37
37
|
// door, when NANO_PR_WEBHOOK_SECRET is set callers must present it via the x-hook-secret header —
|
|
38
38
|
// mirroring `reconcileEngineState`/`agentCompleteEscalation`.
|
|
39
39
|
|
|
40
|
+
import type { AppApi } from "@nanobpm/urban";
|
|
40
41
|
import { cancelInstanceReconciling } from "@nanobpm/urban";
|
|
41
42
|
import { engineBackedBindings, resolveTrackedInstance } from "../app/instanceTracking.ts";
|
|
43
|
+
import { recordOperatorCancelProvenance } from "../app/service.ts";
|
|
42
44
|
import { envVar } from "../app/version.ts";
|
|
45
|
+
import type { CancelInstanceResponse } from "../nano-generated/api-io.d.ts";
|
|
43
46
|
import { defineOperation } from "../nano-generated/operations.ts";
|
|
44
47
|
|
|
45
48
|
const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
|
|
@@ -49,6 +52,17 @@ export default defineOperation("cancelInstance", async ({ req, body }, app) => {
|
|
|
49
52
|
app.log.warn("cancelInstance rejected: missing/invalid shared secret");
|
|
50
53
|
return { status: 401, body: { error: "unauthorized" } };
|
|
51
54
|
}
|
|
55
|
+
return cancelTrackedRun(app, body, "/actions/cancel");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/** The ONE record-consistent cancel implementation shared by every cancel door (this guarded API/MCP
|
|
59
|
+
* door and the unguarded page door `/actions/cancel-pr`, operations/cancelPrRun.ts), so both stamp
|
|
60
|
+
* termination provenance (issue #943, PR #944 review) and neither can drift. Auth stays per door. */
|
|
61
|
+
export async function cancelTrackedRun(
|
|
62
|
+
app: AppApi,
|
|
63
|
+
body: unknown,
|
|
64
|
+
door: string,
|
|
65
|
+
): Promise<{ status: number; body: CancelInstanceResponse }> {
|
|
52
66
|
if (!app.data) {
|
|
53
67
|
app.log.warn("cancelInstance: no data source configured — cannot reconcile the record");
|
|
54
68
|
return { status: 503, body: { error: "no data source configured" } };
|
|
@@ -58,7 +72,7 @@ export default defineOperation("cancelInstance", async ({ req, body }, app) => {
|
|
|
58
72
|
// so a numeric JSON value would already have lost precision before it reached us — accepting it
|
|
59
73
|
// (and coercing it back to a string) would silently cancel the wrong instance and also contradicts
|
|
60
74
|
// the OpenAPI `string` contract. Reject a non-string with a 400 rather than coerce.
|
|
61
|
-
const raw = body && typeof body === "object" ? body.processInstanceKey : undefined;
|
|
75
|
+
const raw = body && typeof body === "object" && "processInstanceKey" in body ? body.processInstanceKey : undefined;
|
|
62
76
|
const processInstanceKey = typeof raw === "string" ? raw.trim() : "";
|
|
63
77
|
if (!processInstanceKey) {
|
|
64
78
|
return { status: 400, body: { error: "processInstanceKey is required and must be a string" } };
|
|
@@ -124,6 +138,17 @@ export default defineOperation("cancelInstance", async ({ req, body }, app) => {
|
|
|
124
138
|
// delta lies. Re-read the resolved record and count it reconciled only when its ADR-0065
|
|
125
139
|
// `derived_status` has LEFT its `activeStatuses` (become terminal → resubmittable). When we cannot
|
|
126
140
|
// read the record (no default source), trust the primitive's own count.
|
|
141
|
+
// Termination provenance (issue #943): a deliberate operator cancel of a PR loop is NOT an orphan —
|
|
142
|
+
// stamp the reason (as soon as the ENGINE confirms termination, before
|
|
143
|
+
// the record re-read — a 502 on an unreconciled record must not lose it; PR #944 review) on the owning PR row so `/status` does not flag it `orphaned`. Best-effort.
|
|
144
|
+
if (canResolveRecord) {
|
|
145
|
+
try {
|
|
146
|
+
await recordOperatorCancelProvenance(app.data, processInstanceKey, door);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
app.log.warn("cancelInstance: could not record termination reason", { processInstanceKey, error: String(err) });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
127
152
|
let reconciled = result.reconciled;
|
|
128
153
|
let recordReconciled = true;
|
|
129
154
|
if (canResolveRecord) {
|
|
@@ -163,4 +188,5 @@ export default defineOperation("cancelInstance", async ({ req, body }, app) => {
|
|
|
163
188
|
reconciled,
|
|
164
189
|
});
|
|
165
190
|
return { status: 200, body: responseBody };
|
|
166
|
-
}
|
|
191
|
+
}
|
|
192
|
+
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// POST /app/api/actions/cancel-pr → operationId `cancelPrRun` (issue #943, PR #944 review).
|
|
2
|
+
//
|
|
3
|
+
// The page-facing twin of `cancelInstance` for the per-row Cancel button. The runtime's built-in
|
|
4
|
+
// `/app/actions/cancel` page action bypassed the app's termination-provenance write, so a deliberate
|
|
5
|
+
// UI cancel left an in-flight base status with no reason and `/status` reported it `orphaned`. Both
|
|
6
|
+
// doors now route through ONE implementation (`cancelTrackedRun`), which stamps provenance.
|
|
7
|
+
//
|
|
8
|
+
// Auth posture is preserved: like the built-in page action it replaces (and `retryIncident`), this
|
|
9
|
+
// door is INTENTIONALLY UNGUARDED by NANO_PR_WEBHOOK_SECRET — the `@nanobpm/urban` page runtime cannot
|
|
10
|
+
// attach x-hook-secret, so a guard would 401 every button press on a secured deployment. The guarded
|
|
11
|
+
// `/actions/cancel` stays the API/MCP door; this one is excluded from MCP (`x-mcp`).
|
|
12
|
+
|
|
13
|
+
import { defineOperation } from "../nano-generated/operations.ts";
|
|
14
|
+
import { cancelTrackedRun } from "./cancelInstance.ts";
|
|
15
|
+
|
|
16
|
+
export default defineOperation("cancelPrRun", async ({ body }, app) => cancelTrackedRun(app, body, "/actions/cancel-pr"));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.208.
|
|
3
|
+
"version": "0.208.4",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
package/pages/home.page.json
CHANGED