@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.
@@ -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 `="..."`/`=&#34;...&#34;` (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="=&#34;${literal.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}&#34;"\\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 } }, task: { allowPr: true } }',
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('repository.branch.create" value="feat/open-b"')), `expected a per-node branch.create header, got ${JSON.stringify(headers)}`);
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('repository.branch.create" value="feat/a"')), `expected branch.create even without a base, got ${JSON.stringify(headers)}`);
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("repository.branch.create"));
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("repository.branch.create"), "a repoless run carries no isolation branch either");
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("repository.branch.create"));
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
- const withBase = (agentNodeRepoEnvelope("acme/one", "main", "feat/n1") as any)["io.nanobpm.agentTask"].repository;
642
- assertEquals(withBase.ref, "main", "known base → checked-out ref");
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
- const noBase = (agentNodeRepoEnvelope("acme/one", null, "feat/n2") as any)["io.nanobpm.agentTask"].repository;
645
- assertEquals("ref" in noBase, false, "no base → clone the default branch");
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"].repository.branch.create, "feat/n3");
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"].repository;
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
  });
@@ -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 repo = (captured.variables as Record<string, any>)["io.nanobpm.agentTask"].repository;
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
- assertEquals(repo.branch.create, "feat/issue-42");
203
- assertEquals(repo.branch.create, `feat/${featureTaskId(PARSED.number)}`);
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 repo = seen["io.nanobpm.agentTask"].repository;
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
- assertEquals("branch" in repo, false);
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
  });