@nanobpm/nano-workforce 0.201.1 → 0.201.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/agentActivationLedger.test.ts +109 -0
- package/app/agentActivationLedger.ts +144 -0
- package/app/agentActivationsPoll.test.ts +180 -0
- package/app/agentSlaEscalation.test.ts +137 -0
- package/app/agentSlaEscalation.ts +169 -0
- package/app/agentSlaMarkerGuard.test.ts +172 -0
- package/app/contracts.ts +2 -2
- package/app/deliveryRunner.test.ts +14 -11
- package/app/feature.test.ts +5 -3
- package/app/mergeLoopBehaviour.test.ts +46 -1
- package/app/plan.test.ts +5 -2
- package/app/repoEnvelope.test.ts +139 -0
- package/app/repoEnvelope.ts +24 -15
- package/app/service.test.ts +21 -16
- package/app/service.ts +94 -0
- package/db/migrations/119_agent_job_activations.sql +50 -0
- package/e2e/agent-sla-boundary.e2e.ts +51 -8
- package/e2e/feature-run.e2e.ts +6 -4
- package/e2e/plan-fanout.e2e.ts +6 -4
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +10 -0
- package/resources/processes/implement-cell.bpmn +4 -1
- package/resources/processes/merge-cell.bpmn +4 -1
- package/resources/processes/merge-loop.bpmn +7 -0
- package/resources/processes/plan-fanout.bpmn +5 -0
- package/resources/processes/retro.bpmn +5 -2
- package/test/derivation-parity/flows.ts +5 -3
- package/workers/conformance-record/worker.ts +15 -1
- package/workers/persist-escalation/worker.ts +22 -3
- package/workers/record-feature-escalation/worker.ts +28 -12
- package/workers/record-trial-merge/worker.ts +16 -1
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Agent-task SLA-escalation reason builder (issue #879) — the single canonical, pure text builder for
|
|
2
|
+
// the human-facing escalation raised when an agent service task's interrupting SLA timer boundary
|
|
3
|
+
// fires. It distinguishes the two outcomes the old wording conflated:
|
|
4
|
+
//
|
|
5
|
+
// • NEVER OBSERVED ACTIVATED — no worker was ever observed to pick the job up (almost certainly
|
|
6
|
+
// queue starvation / no capacity for the job type). The job sat CREATED for ~the whole SLA budget
|
|
7
|
+
// and was cancelled by the boundary. Telling the human it is "hung or looping" and to "check its
|
|
8
|
+
// progress" is a false diagnosis — the likely fix is CAPACITY, not intervention. The wording is
|
|
9
|
+
// hedged (a worker could lease it in the final moment before the boundary fires, between activation
|
|
10
|
+
// polls, unrecorded), so it reports the likely cause without over-claiming certainty.
|
|
11
|
+
// • STARTED BUT EXCEEDED — a worker leased the job (an agent ran) but did not complete within the
|
|
12
|
+
// budget — it may genuinely be hung or looping, and intervening / checking its progress is apt.
|
|
13
|
+
//
|
|
14
|
+
// The distinction is read from the durable activation ledger (`app/agentActivationLedger.ts`), which
|
|
15
|
+
// the poller populates while the job is live. Every escalation names the JOB TYPE and HOW LONG it
|
|
16
|
+
// waited, per the issue's acceptance criteria.
|
|
17
|
+
//
|
|
18
|
+
// Derivation over duplication: this is the ONE builder every agent-SLA recorder (implement-cell,
|
|
19
|
+
// merge-cell, retro) routes through, so the never-started/hung wording can never drift between sites.
|
|
20
|
+
// The pure `buildAgentSlaEscalationReason` is exhaustively testable; `agentSlaEscalationQuestion` is
|
|
21
|
+
// the thin async wrapper that reads the ledger and calls it.
|
|
22
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
23
|
+
import { type AgentJobActivation, getAgentJobActivation } from "./agentActivationLedger.ts";
|
|
24
|
+
import { isoDurationToMs } from "./reviewWait.ts";
|
|
25
|
+
|
|
26
|
+
/** The activation facts the builder needs (a slice of the ledger row), or `null` when the poller never
|
|
27
|
+
* observed the job (activation unknown). */
|
|
28
|
+
export interface AgentSlaActivation {
|
|
29
|
+
/** ISO ts a leasing worker was first observed; `null`/absent ⇒ never activated. */
|
|
30
|
+
activatedAt?: string | null;
|
|
31
|
+
/** ISO ts the job was first observed queued. */
|
|
32
|
+
firstSeenAt?: string | null;
|
|
33
|
+
/** The leasing worker's name, when known. */
|
|
34
|
+
worker?: string | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AgentSlaEscalationInput {
|
|
38
|
+
/** The agent job type that timed out, e.g. `senior:feature`. Named in the reason. */
|
|
39
|
+
jobType: string;
|
|
40
|
+
/** The SLA budget (ISO-8601 duration) the boundary armed, e.g. `PT2H`. */
|
|
41
|
+
sla: string;
|
|
42
|
+
/** The activation record for this job, or `null` when the poller never observed it. */
|
|
43
|
+
activation: AgentSlaActivation | null;
|
|
44
|
+
/** The escalation moment (ISO ts) — the elapsed wait is measured against it. */
|
|
45
|
+
now: string;
|
|
46
|
+
/** How the human resumes after answering — appended verbatim so each caller keeps its own
|
|
47
|
+
* routing hint (e.g. "answer to resume … or abandon it"). */
|
|
48
|
+
recovery: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Humanise a millisecond span to a compact, human-facing string (e.g. `2h 5m`, `45m`, `30s`). A
|
|
52
|
+
* non-positive/NaN span renders `less than a minute`. */
|
|
53
|
+
export function humanizeMs(ms: number): string {
|
|
54
|
+
if (!Number.isFinite(ms) || ms <= 0) return "less than a minute";
|
|
55
|
+
const totalMinutes = Math.floor(ms / 60_000);
|
|
56
|
+
if (totalMinutes < 1) return `${Math.max(1, Math.round(ms / 1000))}s`;
|
|
57
|
+
const days = Math.floor(totalMinutes / 1440);
|
|
58
|
+
const hours = Math.floor((totalMinutes % 1440) / 60);
|
|
59
|
+
const minutes = totalMinutes % 60;
|
|
60
|
+
const parts: string[] = [];
|
|
61
|
+
if (days > 0) parts.push(`${days}d`);
|
|
62
|
+
if (hours > 0) parts.push(`${hours}h`);
|
|
63
|
+
if (minutes > 0) parts.push(`${minutes}m`);
|
|
64
|
+
return parts.join(" ") || "less than a minute";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Humanise an ISO-8601 duration (the SLA budget) to the same compact form, via the canonical parser. */
|
|
68
|
+
export function humanizeIsoDuration(iso: string): string {
|
|
69
|
+
return humanizeMs(isoDurationToMs(iso, iso));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const elapsedMs = (fromIso: string | null | undefined, toIso: string): number | null => {
|
|
73
|
+
if (!fromIso) return null;
|
|
74
|
+
const from = Date.parse(fromIso);
|
|
75
|
+
const to = Date.parse(toIso);
|
|
76
|
+
if (!Number.isFinite(from) || !Number.isFinite(to)) return null;
|
|
77
|
+
return to - from;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** Build the accurate, self-contained agent-SLA escalation reason. Pure — distinguishes never-started
|
|
81
|
+
* (queue starvation) from started-but-hung, names the job type and how long it waited, then appends
|
|
82
|
+
* the caller's recovery hint. */
|
|
83
|
+
export function buildAgentSlaEscalationReason(input: AgentSlaEscalationInput): string {
|
|
84
|
+
const { jobType, sla, activation, now, recovery } = input;
|
|
85
|
+
const slaHuman = humanizeIsoDuration(sla);
|
|
86
|
+
const activatedAt = activation?.activatedAt ?? null;
|
|
87
|
+
|
|
88
|
+
if (activatedAt) {
|
|
89
|
+
// STARTED BUT EXCEEDED: a worker leased the job, so it may genuinely be hung or looping.
|
|
90
|
+
const queueMs = elapsedMs(activation?.firstSeenAt, activatedAt);
|
|
91
|
+
const queuedClause = queueMs !== null && queueMs > 0
|
|
92
|
+
? ` (after waiting ~${humanizeMs(queueMs)} in the queue)`
|
|
93
|
+
: "";
|
|
94
|
+
const ranMs = elapsedMs(activatedAt, now);
|
|
95
|
+
const ranClause = ranMs !== null && ranMs > 0 ? ` and then ran for ~${humanizeMs(ranMs)}` : "";
|
|
96
|
+
const workerClause = activation?.worker ? ` (worker "${activation.worker}")` : "";
|
|
97
|
+
return (
|
|
98
|
+
`The ${jobType} agent started${queuedClause}${workerClause}${ranClause} but exceeded its time budget ` +
|
|
99
|
+
`(SLA ${sla} ≈ ${slaHuman}) without completing — it may be hung or looping. ${recovery}`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Measure the wait: from first-seen to now when the poller observed the queued job, else the full
|
|
104
|
+
// SLA budget (the boundary fired exactly one budget after the task was entered).
|
|
105
|
+
const waitedMs = elapsedMs(activation?.firstSeenAt, now);
|
|
106
|
+
const waited = waitedMs !== null && waitedMs > 0 ? humanizeMs(waitedMs) : slaHuman;
|
|
107
|
+
|
|
108
|
+
if (activation === null) {
|
|
109
|
+
// ACTIVATION UNKNOWN: the poller never observed the job (e.g. the activation poll was unavailable).
|
|
110
|
+
// Report the ambiguity honestly rather than guessing "hung" or "never started".
|
|
111
|
+
return (
|
|
112
|
+
`The ${jobType} agent did not complete within its time budget (SLA ${sla} ≈ ${slaHuman}), and no ` +
|
|
113
|
+
`worker-activation was ever recorded for it, so we cannot confirm whether a worker ever picked it ` +
|
|
114
|
+
`up — it may have been starved of capacity for ${jobType}, or started and hung. ${recovery}`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// NEVER OBSERVED ACTIVATED: seen queued, never seen leased. This is the best-effort observation of
|
|
119
|
+
// queue starvation — hedged, because a worker could in principle lease the job in the final moment
|
|
120
|
+
// before the boundary cancels it, between activation polls, and that would not be recorded. We report
|
|
121
|
+
// the likely diagnosis (and its capacity fix) without over-claiming certainty.
|
|
122
|
+
return (
|
|
123
|
+
`No worker was ever observed to pick up the ${jobType} agent's job for its entire SLA budget ` +
|
|
124
|
+
`(SLA ${sla} ≈ ${slaHuman}): it was seen queued for ~${waited}, then the SLA boundary cancelled it. ` +
|
|
125
|
+
`This is almost certainly queue starvation (no capacity for ${jobType}) rather than a hung or ` +
|
|
126
|
+
`looping agent, so the likely fix is to add capacity for ${jobType} (enrol a worker that serves it, ` +
|
|
127
|
+
`or reduce concurrent demand) rather than to check a non-existent agent's progress. (If a worker ` +
|
|
128
|
+
`leased it in the final moment before the boundary fired, between activation polls, that would not ` +
|
|
129
|
+
`have been recorded here.) ${recovery}`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Read the activation ledger for this job and build the escalation reason — the thin async wrapper the
|
|
134
|
+
* recorder workers call. `processInstanceKey` is the instance the agent job AND its SLA recorder share,
|
|
135
|
+
* so the lookup is by (instance, jobType). An absent ledger row ⇒ activation unknown. */
|
|
136
|
+
export async function agentSlaEscalationQuestion(
|
|
137
|
+
data: DataLayer,
|
|
138
|
+
input: {
|
|
139
|
+
processInstanceKey: string;
|
|
140
|
+
jobType: string;
|
|
141
|
+
sla: string;
|
|
142
|
+
recovery: string;
|
|
143
|
+
now?: string;
|
|
144
|
+
},
|
|
145
|
+
): Promise<string> {
|
|
146
|
+
const now = input.now ?? new Date().toISOString();
|
|
147
|
+
let activation: AgentSlaActivation | null = null;
|
|
148
|
+
if (input.processInstanceKey && input.processInstanceKey.length > 0) {
|
|
149
|
+
const row: AgentJobActivation | undefined = await getAgentJobActivation(
|
|
150
|
+
data,
|
|
151
|
+
input.processInstanceKey,
|
|
152
|
+
input.jobType,
|
|
153
|
+
);
|
|
154
|
+
if (row) {
|
|
155
|
+
activation = {
|
|
156
|
+
activatedAt: row.activated_at,
|
|
157
|
+
firstSeenAt: row.first_seen_at,
|
|
158
|
+
worker: row.worker,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return buildAgentSlaEscalationReason({
|
|
163
|
+
jobType: input.jobType,
|
|
164
|
+
sla: input.sla,
|
|
165
|
+
activation,
|
|
166
|
+
now,
|
|
167
|
+
recovery: input.recovery,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// DEFECT-CLASS GUARD (PR #881 review, thread r4214170285): `agentSlaJobType` is the marker that
|
|
2
|
+
// selects the agent-SLA escalation builder in the four recorder workers (`pr.persist-escalation`,
|
|
3
|
+
// `pr.record-feature-escalation`, `pr.record-trial-merge`, `pr.conformance-record`). It is set
|
|
4
|
+
// ONLY by the BPMN SLA arm (an interrupting timer boundary's route), never by the agent — but the
|
|
5
|
+
// c8ctl harness HOISTS the agent's result-JSON keys into the SAME process-variable namespace the
|
|
6
|
+
// recorders read it from. So a NORMAL (non-SLA) agent result that happens to carry an
|
|
7
|
+
// `agentSlaJobType` key would wrongly select the SLA builder and replace a genuine question or
|
|
8
|
+
// audit summary with a timeout diagnosis.
|
|
9
|
+
//
|
|
10
|
+
// This is the exact defect class PR #864 (review r4181010853) already closed for the boolean
|
|
11
|
+
// SLA-control flags `agentSlaElapsed` / `preserveConformance` — by pinning each to a LITERAL on
|
|
12
|
+
// every recorder task's ioMapping so hoisted agent output can never select the internal control
|
|
13
|
+
// path. `agentSlaJobType` was left unpinned. This guard closes that gap: every NORMAL-path recorder
|
|
14
|
+
// (one that does NOT set the marker to a real job-type literal) must pin `agentSlaJobType` to the
|
|
15
|
+
// `=null` literal, so a hoisted agent-supplied value is overridden to null on the normal path.
|
|
16
|
+
// SLA-arm recorders legitimately pin it to a job-type literal (e.g. `="senior:feature"`); those
|
|
17
|
+
// are the trusted source and are NOT required to pin null.
|
|
18
|
+
//
|
|
19
|
+
// The guard scans the deployed corpus and fails if any recorder task reads `agentSlaJobType`
|
|
20
|
+
// without pinning it to a literal (null on the normal path, a job-type on an SLA arm) — so a
|
|
21
|
+
// newly-added or edited recorder can never re-open the hole.
|
|
22
|
+
//
|
|
23
|
+
// EXCEPTION — merge-loop's SHARED SLA-sink recorders (PR #881 review, adversarial round 3): unlike
|
|
24
|
+
// the other processes, merge-loop has NO dedicated `*-sla` recorder task. Its fix-ci / rebase
|
|
25
|
+
// interrupting-SLA arms (`end_ci_sla` → "senior:fix-ci", `end_reb_sla` → "senior:rebase") and its
|
|
26
|
+
// NON-SLA escalation arms CONVERGE on the SAME two recorders (`merge-esc-attempt`,
|
|
27
|
+
// `merge-esc-conflict`). So those two recorders MUST READ `agentSlaJobType` from process scope
|
|
28
|
+
// (set by the trusted SLA end event) — a blanket `=null` input pin there would clobber the
|
|
29
|
+
// legitimate SLA job type and silently regress the issue-#879 never-started-vs-hung diagnosis. The
|
|
30
|
+
// hoisted-agent-output hole is closed on these shared sinks a DIFFERENT way: every path that can
|
|
31
|
+
// carry a hoisted (spoofable) value CLEARS `agentSlaJobType` to null via an OUTPUT mapping before
|
|
32
|
+
// it reaches the recorder — at the loop head (`arm-merge`, scrubbing any stale/cross-iteration
|
|
33
|
+
// value), on the two non-SLA escalate end events that bypass the loop head (`end_ci_esc`,
|
|
34
|
+
// `end_reb_blocked`), and on the CI reconcile re-check exit (`end_ci_recheck`, which re-enters the
|
|
35
|
+
// merge wait via `gw-merge-entry` without passing through `arm-merge`). Only the trusted SLA arms
|
|
36
|
+
// leave it set. This guard therefore (a) requires the shared sinks to NOT pin, and (b) requires
|
|
37
|
+
// those clears + the SLA-arm literals to exist.
|
|
38
|
+
import { readFileSync } from "node:fs";
|
|
39
|
+
import { test } from "node:test";
|
|
40
|
+
import { dirname, join } from "node:path";
|
|
41
|
+
import { fileURLToPath } from "node:url";
|
|
42
|
+
import { assert } from "#test-assert";
|
|
43
|
+
|
|
44
|
+
const PROCESSES_DIR = join(dirname(fileURLToPath(import.meta.url)), "../resources/processes");
|
|
45
|
+
const PROCESS_FILES = [
|
|
46
|
+
"convergence-loop.bpmn",
|
|
47
|
+
"merge-loop.bpmn",
|
|
48
|
+
"implement-cell.bpmn",
|
|
49
|
+
"merge-cell.bpmn",
|
|
50
|
+
"plan-fanout.bpmn",
|
|
51
|
+
"retro.bpmn",
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
// The recorder job types that read `agentSlaJobType` to select the SLA builder.
|
|
55
|
+
const RECORDER_TYPES = [
|
|
56
|
+
"pr.persist-escalation",
|
|
57
|
+
"pr.record-feature-escalation",
|
|
58
|
+
"pr.record-trial-merge",
|
|
59
|
+
"pr.conformance-record",
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const SERVICE_TASK = /<bpmn:serviceTask\b[\s\S]*?<\/bpmn:serviceTask>/g;
|
|
63
|
+
// A trusted pin is a LITERAL input: `=null` (normal path) or `="..."`/`="..."` (SLA arm's
|
|
64
|
+
// job-type). A process-variable REFERENCE (e.g. `=agentSlaJobType`) is NOT a pin — it would read
|
|
65
|
+
// hoisted agent output, which is exactly the hole being closed.
|
|
66
|
+
const LITERAL_PIN =
|
|
67
|
+
/<zeebe:input\s+source="=(?:null|&#[0-9]+;[^"]*&#[0-9]+;|"[^"]*")"\s+target="agentSlaJobType"\s*\/>/;
|
|
68
|
+
|
|
69
|
+
// Any INPUT mapping on `agentSlaJobType` (literal OR reference) — used to assert the shared SLA-sink
|
|
70
|
+
// recorders carry NONE (they must read the SLA arm's value from process scope, not shadow it).
|
|
71
|
+
const ANY_INPUT_PIN = /<zeebe:input\s+source="=[^"]*"\s+target="agentSlaJobType"\s*\/>/;
|
|
72
|
+
// A `=null` OUTPUT clear (writes process scope) — the fail-closed scrub on merge-loop's spoofable
|
|
73
|
+
// non-SLA paths.
|
|
74
|
+
const NULL_OUTPUT_CLEAR = /<zeebe:output\s+source="=null"\s+target="agentSlaJobType"\s*\/>/;
|
|
75
|
+
|
|
76
|
+
// merge-loop's two SHARED SLA-sink recorders: both SLA and non-SLA arms converge on them, so they
|
|
77
|
+
// MUST read `agentSlaJobType` from scope and MUST NOT pin it (see header).
|
|
78
|
+
const SHARED_SLA_SINKS = new Set(["merge-esc-attempt", "merge-esc-conflict"]);
|
|
79
|
+
|
|
80
|
+
/** Extract the XML block for a BPMN element by id (any element tag). */
|
|
81
|
+
function elementBlock(xml: string, id: string): string | undefined {
|
|
82
|
+
const re = new RegExp(`<bpmn:(\\w+)\\b[^>]*\\bid="${id}"[\\s\\S]*?</bpmn:\\1>`);
|
|
83
|
+
return xml.match(re)?.[0];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
test("DEFECT-CLASS GUARD: every SLA-builder recorder pins agentSlaJobType to a literal (agent output cannot select the SLA path)", () => {
|
|
87
|
+
let recorderCount = 0;
|
|
88
|
+
for (const file of PROCESS_FILES) {
|
|
89
|
+
const xml = readFileSync(join(PROCESSES_DIR, file), "utf8");
|
|
90
|
+
for (const [block] of xml.matchAll(SERVICE_TASK)) {
|
|
91
|
+
if (!RECORDER_TYPES.some((t) => block.includes(`type="${t}"`))) continue;
|
|
92
|
+
const id = block.match(/<bpmn:serviceTask\b[^>]*\bid="([^"]*)"/)?.[1] ?? "(unknown)";
|
|
93
|
+
// The shared SLA-sink recorders read the marker from scope — they must NOT pin it, else a
|
|
94
|
+
// `=null` pin would clobber the trusted SLA job type (issue #879 regression). They are
|
|
95
|
+
// protected by the OUTPUT clears asserted below, not by an input pin.
|
|
96
|
+
if (SHARED_SLA_SINKS.has(id)) {
|
|
97
|
+
assert(
|
|
98
|
+
!ANY_INPUT_PIN.test(block),
|
|
99
|
+
`${file}: shared SLA-sink recorder "${id}" must NOT pin agentSlaJobType — both the SLA ` +
|
|
100
|
+
`and non-SLA arms converge on it, so it must READ the SLA end event's value from ` +
|
|
101
|
+
`process scope. A =null pin here clobbers the trusted SLA job type and regresses the ` +
|
|
102
|
+
`never-started-vs-hung diagnosis (PR #881 adversarial review). Remove the pin; the ` +
|
|
103
|
+
`hole is closed by the =null OUTPUT clears on arm-merge / end_ci_esc / end_reb_blocked.`,
|
|
104
|
+
);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
recorderCount++;
|
|
108
|
+
assert(
|
|
109
|
+
LITERAL_PIN.test(block),
|
|
110
|
+
`${file}: recorder task "${id}" reads agentSlaJobType but does not pin it to a literal ` +
|
|
111
|
+
`(=null on the normal path, a job-type literal on an SLA arm). A hoisted agent result ` +
|
|
112
|
+
`carrying agentSlaJobType would wrongly select the SLA escalation builder (PR #881 review ` +
|
|
113
|
+
`r4214170285). Pin it: <zeebe:input source="=null" target="agentSlaJobType" /> on the ` +
|
|
114
|
+
`normal path.`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// Sanity: the guard is not vacuously green — the corpus really has these recorders (the
|
|
119
|
+
// normal-path recorders + the SLA arms across the six processes).
|
|
120
|
+
assert(recorderCount >= 11, `expected >=11 SLA-builder recorder tasks, found ${recorderCount}`);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("DEFECT-CLASS GUARD: merge-loop's shared SLA sinks are fed a trusted agentSlaJobType (SLA arms set it; spoofable non-SLA paths clear it)", () => {
|
|
124
|
+
const xml = readFileSync(join(PROCESSES_DIR, "merge-loop.bpmn"), "utf8");
|
|
125
|
+
|
|
126
|
+
// The two shared sinks really exist and read from scope (no input pin) — guards against the file
|
|
127
|
+
// being renamed/removed and silently passing.
|
|
128
|
+
for (const id of SHARED_SLA_SINKS) {
|
|
129
|
+
const block = elementBlock(xml, id);
|
|
130
|
+
assert(block, `merge-loop.bpmn: expected shared SLA-sink recorder "${id}" to exist`);
|
|
131
|
+
assert(
|
|
132
|
+
!ANY_INPUT_PIN.test(block),
|
|
133
|
+
`merge-loop.bpmn: "${id}" must read agentSlaJobType from scope, not pin it`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// The trusted SLA arms SET the job-type literal (this is the value the shared sinks must receive).
|
|
138
|
+
for (const [id, literal] of [
|
|
139
|
+
["end_ci_sla", "senior:fix-ci"],
|
|
140
|
+
["end_reb_sla", "senior:rebase"],
|
|
141
|
+
] as const) {
|
|
142
|
+
const block = elementBlock(xml, id);
|
|
143
|
+
assert(block, `merge-loop.bpmn: expected SLA arm "${id}" to exist`);
|
|
144
|
+
const re = new RegExp(
|
|
145
|
+
`<zeebe:output\\s+source="="${literal.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}""\\s+target="agentSlaJobType"\\s*/>`,
|
|
146
|
+
);
|
|
147
|
+
assert(
|
|
148
|
+
re.test(block),
|
|
149
|
+
`merge-loop.bpmn: SLA arm "${id}" must set agentSlaJobType to the "${literal}" literal via ` +
|
|
150
|
+
`an output mapping (the trusted source the shared sink reads).`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Every spoofable path that can carry a hoisted agentSlaJobType into a shared sink CLEARS it to
|
|
155
|
+
// null via output BEFORE the recorder: the loop head (stale/cross-iteration), the two non-SLA
|
|
156
|
+
// escalate end events that bypass the loop head (same-iteration agent spoof), and the CI
|
|
157
|
+
// reconcile re-check exit (`end_ci_recheck`), which re-enters the merge wait via `gw-merge-entry`
|
|
158
|
+
// — bypassing `arm-merge` — so a fix-ci result with status:"blocked"/pushed:false carrying a
|
|
159
|
+
// hoisted marker would otherwise reach `merge-esc-conflict` on a later draft/budget-exhausted
|
|
160
|
+
// merge with no SLA timer having fired (PR #881 review, "Previously missed" r5451284362).
|
|
161
|
+
for (const id of ["arm-merge", "end_ci_esc", "end_reb_blocked", "end_ci_recheck"]) {
|
|
162
|
+
const block = elementBlock(xml, id);
|
|
163
|
+
assert(block, `merge-loop.bpmn: expected "${id}" to exist`);
|
|
164
|
+
assert(
|
|
165
|
+
NULL_OUTPUT_CLEAR.test(block),
|
|
166
|
+
`merge-loop.bpmn: "${id}" must clear agentSlaJobType to null via ` +
|
|
167
|
+
`<zeebe:output source="=null" target="agentSlaJobType" /> so a hoisted (spoofable) agent ` +
|
|
168
|
+
`value can never reach the shared SLA-sink recorder and fabricate a never-started/hung ` +
|
|
169
|
+
`SLA diagnosis (PR #881 adversarial review).`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
});
|
package/app/contracts.ts
CHANGED
|
@@ -448,9 +448,9 @@ export const WIRE_CONTRACTS = {
|
|
|
448
448
|
name: "io.nanobpm.agentTask.repository",
|
|
449
449
|
owner: "app/repoEnvelope.ts",
|
|
450
450
|
semantics:
|
|
451
|
-
"Repo-provisioning envelope the app emits as a `createInstance` process variable (`repoEnvelopeVars`, app/repoEnvelope.ts) and the c8ctl worker harness consumes to provision an isolated clone — instead of the agent inheriting the worker's launch dir (issue #684). `ref` is the branch checked out: the PR HEAD branch on the PR-based paths (review-round / fix-ci / rebase), or — on the PRE-PR implementation path (feature.bpmn / plan-fanout's `implement-cell`, issue #684; the delivery-graph runner's agent cells, issue #686) — the BASE branch, off which the harness cuts a new feature branch named by the optional `branch.create`. On the PRE-PR path `branch.create` is the deterministic `feat/<task.id>` (emitted for a single-task feature run AND — per issue #776 — the deterministic `feat/<node.id>` for each single-instance delivery-graph agent cell, injected per-cell by `agentNodeRepoEnvelope`/`app/deliveryRunner.ts` so a forgetful agent can never be left committing on the base branch and stranding its run on a non-ff push; the epic plan-fanout seed still omits it because its MI children each cut a per-child `feat/<task.id>` the app can't name at compile time, so those agents branch themselves). On the PR-based paths `branch.create` is set to the PR HEAD branch itself (equal to `ref`, jwulf/c8ctl-plugin-nano#231): without it the harness's base-branch guard treats the checkout as base-like and cuts a throwaway `nano/agent-work/<base>-<run>` fallback, stranding the round's commits OFF the PR; with `ref == create == head` the harness does a no-op `checkout -B <head>` and commits/pushes to the PR head branch. So `branch.create` is present on both the pre-PR and PR-based paths (only a blank/omitted value drops the key). Beyond `{provider,url,ref}`, it carries clone-shaping fields for large monorepos (issue #287): `singleBranch:true` + `filter:\"blob:none\"` (a branch-scoped, blobless partial clone — trees fetched up-front, blobs lazily, no `--depth 1` so the merge-base/3-dot diff stays valid) and an optional `baseRef` (the PR base branch, emitted only when resolvable, so the harness fetches its tip and keeps `origin/<base>` reachable) and a `cloneTimeoutMs` (from `NANO_PR_CLONE_TIMEOUT_MS`, default 600000 = 10 min) that raises the harness's 120s default so a large monorepo's blobless single-branch clone provisions instead of dying at 120s (issue #694). World-restore (issue #324, ADR 0062 Slice 4/5): an optional `sha` — the last durable push-checkpoint — is emitted so a REPLACEMENT activation on a fresh worktree reconstructs the tree to the EXACT pushed SHA (inverting the round's `git push` into `git fetch && git checkout <sha>`), omitted when the PR has no checkpoint yet. The field is named `sha` because that is the field the c8ctl harness's `provisionRepo` reads to drive the checkout — an earlier `commitSha` key was a silent no-op (issue #695). Alongside the `repository` slice the envelope carries a sibling `task.allowPr: true` (issue #770): c8ctl-plugin-nano (≥1.60.2) only resolves the git credential (GITHUB_TOKEN, or the `gh` default) for repo provisioning behind that flag, so every repo-backed envelope sets it or the clone dies with `unable to get password from user`; the repoless path emits no envelope and so no `task`. Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91, branch-cut guard jwulf/c8ctl-plugin-nano#231).",
|
|
451
|
+
"Repo-provisioning envelope the app emits as a `createInstance` process variable (`repoEnvelopeVars`, app/repoEnvelope.ts) and the c8ctl worker harness consumes to provision an isolated clone — instead of the agent inheriting the worker's launch dir (issue #684). `ref` is the branch checked out: the PR HEAD branch on the PR-based paths (review-round / fix-ci / rebase), or — on the PRE-PR implementation path (feature.bpmn / plan-fanout's `implement-cell`, issue #684; the delivery-graph runner's agent cells, issue #686) — the BASE branch, off which the harness cuts a new feature branch named by the optional `branch.create`. On the PRE-PR path `branch.create` is the deterministic `feat/<task.id>` (emitted for a single-task feature run AND — per issue #776 — the deterministic `feat/<node.id>` for each single-instance delivery-graph agent cell, injected per-cell by `agentNodeRepoEnvelope`/`app/deliveryRunner.ts` so a forgetful agent can never be left committing on the base branch and stranding its run on a non-ff push; the epic plan-fanout seed still omits it because its MI children each cut a per-child `feat/<task.id>` the app can't name at compile time, so those agents branch themselves). On the PR-based paths `branch.create` is set to the PR HEAD branch itself (equal to `ref`, jwulf/c8ctl-plugin-nano#231): without it the harness's base-branch guard treats the checkout as base-like and cuts a throwaway `nano/agent-work/<base>-<run>` fallback, stranding the round's commits OFF the PR; with `ref == create == head` the harness does a no-op `checkout -B <head>` and commits/pushes to the PR head branch. So `branch.create` is present on both the pre-PR and PR-based paths (only a blank/omitted value drops the key). Beyond `{provider,url,ref}`, it carries clone-shaping fields for large monorepos (issue #287): `singleBranch:true` + `filter:\"blob:none\"` (a branch-scoped, blobless partial clone — trees fetched up-front, blobs lazily, no `--depth 1` so the merge-base/3-dot diff stays valid) and an optional `baseRef` (the PR base branch, emitted only when resolvable, so the harness fetches its tip and keeps `origin/<base>` reachable) and a `cloneTimeoutMs` (from `NANO_PR_CLONE_TIMEOUT_MS`, default 600000 = 10 min) that raises the harness's 120s default so a large monorepo's blobless single-branch clone provisions instead of dying at 120s (issue #694). World-restore (issue #324, ADR 0062 Slice 4/5): an optional `sha` — the last durable push-checkpoint — is emitted so a REPLACEMENT activation on a fresh worktree reconstructs the tree to the EXACT pushed SHA (inverting the round's `git push` into `git fetch && git checkout <sha>`), omitted when the PR has no checkpoint yet. The field is named `sha` because that is the field the c8ctl harness's `provisionRepo` reads to drive the checkout — an earlier `commitSha` key was a silent no-op (issue #695). Alongside the `repository` slice the envelope carries a sibling `task.allowPr: true` (issue #770): c8ctl-plugin-nano (≥1.60.2) only resolves the git credential (GITHUB_TOKEN, or the `gh` default) for repo provisioning behind that flag, so every repo-backed envelope sets it or the clone dies with `unable to get password from user`; the repoless path emits no envelope and so no `task`. Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91, branch-cut guard jwulf/c8ctl-plugin-nano#231). PLACEMENT (issue #882): `branch` is emitted as a TOP-LEVEL sibling of `repository` (and of `task`) under the namespace — the harness `normalizeTaskEnvelope` reads `branch` from the envelope ROOT (`isPlainObject(raw.branch)`), never from `repository.branch`, so the producer must not nest it there (nesting was the #227 two-sides-of-one-contract drift that left `branch.create` silently unread — nanobpm/nano-bpm#1344). The round-trip contract test `app/repoEnvelope.test.ts` pins producer and consumer together.",
|
|
452
452
|
shape:
|
|
453
|
-
'io.nanobpm.agentTask: { repository: { provider: "github", url: string, ref?: string, singleBranch: true, filter: "blob:none", cloneTimeoutMs: number, baseRef?: string, sha?: string, branch?: { create: string }
|
|
453
|
+
'io.nanobpm.agentTask: { repository: { provider: "github", url: string, ref?: string, singleBranch: true, filter: "blob:none", cloneTimeoutMs: number, baseRef?: string, sha?: string }, branch?: { create: string }, task: { allowPr: true } } — `branch` is a TOP-LEVEL sibling of `repository` (issue #882): the harness `normalizeTaskEnvelope` reads it from the envelope root (`isPlainObject(raw.branch)`), NOT from `repository.branch`, so the flattened header is `io.nanobpm.agentTask.branch.create` (never `…repository.branch.create`).',
|
|
454
454
|
},
|
|
455
455
|
"epicSet.submit": {
|
|
456
456
|
category: "wire",
|
|
@@ -533,7 +533,7 @@ test("prepareDeliveryGraph injects the repository envelope PER agent cell from t
|
|
|
533
533
|
assert(headers.some((h) => h.includes('task.allowPr" value="true"')), `expected a task.allowPr header, got ${JSON.stringify(headers)}`);
|
|
534
534
|
// The deterministic per-node isolation branch (issue #776): the harness cuts `feat/<node.id>` itself,
|
|
535
535
|
// so the agent can never be left committing on the base branch (a non-ff push that strands the run).
|
|
536
|
-
assert(headers.some((h) => h.includes('
|
|
536
|
+
assert(headers.some((h) => h.includes('io.nanobpm.agentTask.branch.create" value="feat/open-b"')), `expected a per-node branch.create header, got ${JSON.stringify(headers)}`);
|
|
537
537
|
// No `__repoSpec` marker survives injection — it is the compiler's digest-stable anchor only.
|
|
538
538
|
assert(!p.bpmn.includes("__repoSpec"), "the __repoSpec marker is fully replaced");
|
|
539
539
|
// No run-root `io.nanobpm.agentTask` variable — the envelope rides headers now, not a run variable.
|
|
@@ -590,7 +590,7 @@ test("a declared repository WITHOUT a base branch omits ref/baseRef — the harn
|
|
|
590
590
|
assert(!headers.some((h) => h.includes("repository.baseRef")), "no baseRef either");
|
|
591
591
|
// Even without a known base, the deterministic isolation branch is still emitted (issue #776): the
|
|
592
592
|
// harness cuts `feat/<node.id>` off the cloned default tip, so the agent never commits on the default.
|
|
593
|
-
assert(headers.some((h) => h.includes('
|
|
593
|
+
assert(headers.some((h) => h.includes('io.nanobpm.agentTask.branch.create" value="feat/a"')), `expected branch.create even without a base, got ${JSON.stringify(headers)}`);
|
|
594
594
|
});
|
|
595
595
|
|
|
596
596
|
test("each agent cell gets its OWN deterministic feat/<node.id> branch.create — never a shared branch (#776)", async () => {
|
|
@@ -603,7 +603,7 @@ test("each agent cell gets its OWN deterministic feat/<node.id> branch.create
|
|
|
603
603
|
edges: [{ from: "impl-a", to: "impl-b" }],
|
|
604
604
|
};
|
|
605
605
|
const p = await prepareOk(graph, {});
|
|
606
|
-
const branches = agentHeaders(p.bpmn).filter((h) => h.includes("
|
|
606
|
+
const branches = agentHeaders(p.bpmn).filter((h) => h.includes("io.nanobpm.agentTask.branch.create"));
|
|
607
607
|
// Two distinct cells → two distinct per-node branches, so no two agents can collide on one branch.
|
|
608
608
|
assert(branches.some((h) => h.includes('value="feat/impl-a"')), `expected feat/impl-a, got ${JSON.stringify(branches)}`);
|
|
609
609
|
assert(branches.some((h) => h.includes('value="feat/impl-b"')), `expected feat/impl-b, got ${JSON.stringify(branches)}`);
|
|
@@ -612,7 +612,7 @@ test("each agent cell gets its OWN deterministic feat/<node.id> branch.create
|
|
|
612
612
|
|
|
613
613
|
test("an explicit repoless run emits NO branch.create — no envelope at all (#776/#729)", async () => {
|
|
614
614
|
const p = await prepareOk(GRAPH, { repoless: true });
|
|
615
|
-
assert(!p.bpmn.includes("
|
|
615
|
+
assert(!p.bpmn.includes("io.nanobpm.agentTask.branch.create"), "a repoless run carries no isolation branch either");
|
|
616
616
|
});
|
|
617
617
|
|
|
618
618
|
test("a node id that derives an invalid git ref degrades to NO branch.create — never emits an unusable feat/... (#776)", async () => {
|
|
@@ -630,7 +630,7 @@ test("a node id that derives an invalid git ref degrades to NO branch.create —
|
|
|
630
630
|
edges: [{ from: "a..b", to: "a.lock" }, { from: "a.lock", to: "ok" }],
|
|
631
631
|
};
|
|
632
632
|
const p = await prepareOk(graph, {});
|
|
633
|
-
const branches = agentHeaders(p.bpmn).filter((h) => h.includes("
|
|
633
|
+
const branches = agentHeaders(p.bpmn).filter((h) => h.includes("io.nanobpm.agentTask.branch.create"));
|
|
634
634
|
// Only the valid-id cell keeps a branch.create; the two invalid-ref ids emit none (their agents still
|
|
635
635
|
// provision an isolated clone, they just cut their own branch inside it — the pre-#776 fallback).
|
|
636
636
|
assertEquals(branches.length, 1, `only the valid id keeps a branch.create, got ${JSON.stringify(branches)}`);
|
|
@@ -638,16 +638,19 @@ test("a node id that derives an invalid git ref degrades to NO branch.create —
|
|
|
638
638
|
});
|
|
639
639
|
|
|
640
640
|
test("agentNodeRepoEnvelope emits branch.create off the base when known, off the default when not, and omits it when blank (#776)", () => {
|
|
641
|
-
|
|
642
|
-
|
|
641
|
+
// `branch` is a TOP-LEVEL sibling of `repository` (issue #882) — the harness reads it from the
|
|
642
|
+
// envelope root, NOT from `repository.branch`.
|
|
643
|
+
const withBase = (agentNodeRepoEnvelope("acme/one", "main", "feat/n1") as any)["io.nanobpm.agentTask"];
|
|
644
|
+
assertEquals(withBase.repository.ref, "main", "known base → checked-out ref");
|
|
643
645
|
assertEquals(withBase.branch.create, "feat/n1", "the harness cuts feat/<node.id> off the base");
|
|
644
|
-
|
|
645
|
-
|
|
646
|
+
assertEquals("branch" in withBase.repository, false, "branch must NOT be nested under repository");
|
|
647
|
+
const noBase = (agentNodeRepoEnvelope("acme/one", null, "feat/n2") as any)["io.nanobpm.agentTask"];
|
|
648
|
+
assertEquals("ref" in noBase.repository, false, "no base → clone the default branch");
|
|
646
649
|
assertEquals(noBase.branch.create, "feat/n2", "branch.create is still emitted off the default tip");
|
|
647
650
|
// A whitespace-tainted branch is trimmed; a blank/absent one omits the `branch` key (pre-#776 behaviour).
|
|
648
|
-
assertEquals((agentNodeRepoEnvelope("acme/one", "main", " feat/n3 ") as any)["io.nanobpm.agentTask"].
|
|
651
|
+
assertEquals((agentNodeRepoEnvelope("acme/one", "main", " feat/n3 ") as any)["io.nanobpm.agentTask"].branch.create, "feat/n3");
|
|
649
652
|
for (const blank of [null, undefined, "", " "]) {
|
|
650
|
-
const r = (agentNodeRepoEnvelope("acme/one", "main", blank as any) as any)["io.nanobpm.agentTask"]
|
|
653
|
+
const r = (agentNodeRepoEnvelope("acme/one", "main", blank as any) as any)["io.nanobpm.agentTask"];
|
|
651
654
|
assertEquals("branch" in r, false, `expected no branch key for ${JSON.stringify(blank)}`);
|
|
652
655
|
}
|
|
653
656
|
});
|
package/app/feature.test.ts
CHANGED
|
@@ -196,11 +196,13 @@ test("startFeature: seeds the pre-PR repository envelope so the harness provisio
|
|
|
196
196
|
// shared launch dir; with it, the harness clones a throwaway workspace. The implementation path is
|
|
197
197
|
// PRE-PR, so it checks out the BASE branch (`ref`) and the harness cuts the deterministic
|
|
198
198
|
// `feat/<task.id>` feature branch off it (`branch.create`).
|
|
199
|
-
const
|
|
199
|
+
const ns = (captured.variables as Record<string, any>)["io.nanobpm.agentTask"];
|
|
200
|
+
const repo = ns.repository;
|
|
200
201
|
assertEquals(repo.url, "https://github.com/owner/repo.git");
|
|
201
202
|
assertEquals(repo.ref, "epic/x");
|
|
202
|
-
|
|
203
|
-
assertEquals(
|
|
203
|
+
// `branch` is a TOP-LEVEL sibling of `repository` (issue #882) — the harness reads it from the root.
|
|
204
|
+
assertEquals(ns.branch.create, "feat/issue-42");
|
|
205
|
+
assertEquals(ns.branch.create, `feat/${featureTaskId(PARSED.number)}`);
|
|
204
206
|
// The blobless/single-branch monorepo shaping rides along, exactly like the PR-based envelope.
|
|
205
207
|
assertEquals(repo.singleBranch, true);
|
|
206
208
|
assertEquals(repo.filter, "blob:none");
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
// Plus the terminate semantics the refactor had to preserve: `MergeAbandoned`
|
|
29
29
|
// terminates the whole instance (it stays at the root, not inside a sub-process).
|
|
30
30
|
import { after, test } from "node:test";
|
|
31
|
-
import { assert, assertStringIncludes } from "#test-assert";
|
|
31
|
+
import { assert, assertEquals, assertStringIncludes } from "#test-assert";
|
|
32
32
|
import { readFileSync } from "node:fs";
|
|
33
33
|
import {
|
|
34
34
|
assertThatInstance,
|
|
@@ -562,6 +562,10 @@ test("the fix-ci agent SLA interrupts the sub-process and escalates", async () =
|
|
|
562
562
|
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
563
563
|
assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-esc-attempt");
|
|
564
564
|
assertStringIncludes(String(escalation(engine).question ?? ""), "time budget (SLA)", "the SLA escalation must name the SLA trigger");
|
|
565
|
+
// #879 regression: the trusted fix-ci SLA arm must deliver its job-type marker to the shared
|
|
566
|
+
// persist-escalation sink so the worker builds the never-started-vs-hung diagnosis — a =null
|
|
567
|
+
// input pin on the shared recorder silently clobbered this (PR #881 adversarial review).
|
|
568
|
+
assertEquals(escalation(engine).agentSlaJobType, "senior:fix-ci", "the fix-ci SLA arm must reach the recorder with its job-type marker");
|
|
565
569
|
});
|
|
566
570
|
|
|
567
571
|
// ---------------------------------------------------------------------------
|
|
@@ -613,8 +617,49 @@ test("the rebase agent SLA interrupts the sub-process and escalates as not-merge
|
|
|
613
617
|
await engine.advanceTime(AGENT_SLA_MS + 1);
|
|
614
618
|
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
615
619
|
assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-esc-conflict");
|
|
620
|
+
// #879 regression: the trusted rebase SLA arm must deliver its job-type marker to the shared sink.
|
|
621
|
+
assertEquals(escalation(engine).agentSlaJobType, "senior:rebase", "the rebase SLA arm must reach the recorder with its job-type marker");
|
|
616
622
|
});
|
|
617
623
|
|
|
624
|
+
test("a non-SLA escalation carrying a spoofed agentSlaJobType is scrubbed before the recorder (#879 fail-closed)", async () => {
|
|
625
|
+
// The c8ctl harness hoists an agent's result-JSON keys into process scope, so a NON-SLA rebase
|
|
626
|
+
// agent that returns a bogus `agentSlaJobType` must NOT be able to fabricate an SLA diagnosis on
|
|
627
|
+
// the shared persist-escalation sink. The loop head / non-SLA escalate end events clear it.
|
|
628
|
+
const engine = await boot({ responses: { "senior:rebase": { status: "blocked", agentSlaJobType: "senior:evil" } } });
|
|
629
|
+
await driveToRebase(engine);
|
|
630
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
631
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-esc-attempt");
|
|
632
|
+
const spoofed = escalation(engine).agentSlaJobType;
|
|
633
|
+
assert(spoofed === null || spoofed === undefined, `a spoofed agentSlaJobType must be scrubbed, got ${JSON.stringify(spoofed)}`);
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
test("a spoofed agentSlaJobType surviving the CI-reconcile re-check route is scrubbed before a later merge-esc-conflict (#881 Previously-missed)", async () => {
|
|
637
|
+
// The CI reconcile route (fix-ci blocked/pushed:false → ci-reconcile → end_ci_recheck →
|
|
638
|
+
// gw-merge-entry) re-enters the merge wait WITHOUT passing through the loop head `arm-merge` or
|
|
639
|
+
// the non-SLA escalate clears. A hoisted (spoofable) agentSlaJobType from that fix-ci result
|
|
640
|
+
// would otherwise survive into a later draft / budget-exhausted merge and fabricate an SLA
|
|
641
|
+
// never-started/hung diagnosis on the shared merge-esc-conflict sink with NO SLA timer having
|
|
642
|
+
// fired. `end_ci_recheck` must clear it (PR #881 review, "Previously missed" r5451284362).
|
|
643
|
+
const engine = await boot({
|
|
644
|
+
responses: { "senior:fix-ci": { status: "blocked", pushed: false, agentSlaJobType: "senior:evil" } },
|
|
645
|
+
});
|
|
646
|
+
await driveToCiFix(engine); // blocked/pushed:false → ci-reconcile → end_ci_recheck → re-check mergeable
|
|
647
|
+
assertThatInstance(engine, byProcessId("merge-loop"))
|
|
648
|
+
.isActive()
|
|
649
|
+
.hasActiveElement("wait-mergeable")
|
|
650
|
+
.hasCompletedElements("ci-reconcile")
|
|
651
|
+
.hasVariable("ciBlockedReconciled", true);
|
|
652
|
+
assert(!completedElementIds(engine).has("merge-esc-conflict"), "the reconcile pass must not escalate yet");
|
|
653
|
+
// A later merge-ready reports the PR as draft → f_m_mDraft → merge-esc-conflict reads the marker
|
|
654
|
+
// directly (the fix-ci agent does NOT run again on this route).
|
|
655
|
+
await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "draft" } });
|
|
656
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
657
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-esc-conflict");
|
|
658
|
+
const spoofed = escalation(engine).agentSlaJobType;
|
|
659
|
+
assert(spoofed === null || spoofed === undefined, `the reconcile-route spoof must be scrubbed, got ${JSON.stringify(spoofed)}`);
|
|
660
|
+
// The human gets the genuine draft question, not a fabricated SLA diagnosis.
|
|
661
|
+
assertStringIncludes(String(escalation(engine).question ?? ""), "draft", "the escalation must be the genuine draft question, not an SLA diagnosis");
|
|
662
|
+
});
|
|
618
663
|
// ---------------------------------------------------------------------------
|
|
619
664
|
// Escalation user task (mergeEscalationUserTask #256, mergeEscalationQuestion #329/#454)
|
|
620
665
|
// ---------------------------------------------------------------------------
|
package/app/plan.test.ts
CHANGED
|
@@ -244,10 +244,13 @@ test("startPlan pins the base branch: persisted on the row + seeded as baseBranc
|
|
|
244
244
|
// Host-git provisioning (#684): the whole-epic repository envelope so each slice's implementation
|
|
245
245
|
// agent gets an isolated clone. The epic seed checks out the BASE branch (`ref`) — each slice's
|
|
246
246
|
// `feat/<task.id>` branch differs per MI child, so NO `branch.create` here (the agent branches).
|
|
247
|
-
const
|
|
247
|
+
const ns = seen["io.nanobpm.agentTask"];
|
|
248
|
+
const repo = ns.repository;
|
|
248
249
|
assertEquals(repo.url, "https://github.com/owner/repo.git");
|
|
249
250
|
assertEquals(repo.ref, "epic/agent-protocol");
|
|
250
|
-
|
|
251
|
+
// No isolation branch on the epic seed — `branch` is a top-level sibling of `repository` (#882),
|
|
252
|
+
// and it is omitted entirely here.
|
|
253
|
+
assertEquals("branch" in ns, false);
|
|
251
254
|
assertEquals(repo.singleBranch, true);
|
|
252
255
|
assertEquals(repo.filter, "blob:none");
|
|
253
256
|
});
|