@nanobpm/nano-workforce 0.200.7 → 0.200.9
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/SPEC.md +41 -0
- package/app/agentSla.test.ts +48 -0
- package/app/agentSla.ts +36 -11
- package/app/agentic/vocab/agent-sla-boundary.test.ts +170 -0
- package/app/agentic/vocab/job-types.ts +60 -0
- package/app/conformance.test.ts +114 -0
- package/app/conformance.ts +72 -0
- package/app/contracts.ts +2 -2
- package/app/deliveryRunner.ts +5 -0
- package/app/feature.ts +5 -0
- package/app/implementEscalationReason.test.ts +216 -0
- package/app/implementEscalationReason.ts +213 -0
- package/app/implementReconcile.test.ts +168 -11
- package/app/implementReconcile.ts +112 -18
- package/app/plan.ts +12 -0
- package/app/retro.test.ts +5 -0
- package/app/retro.ts +4 -0
- package/app/service.ts +27 -8
- package/e2e/agent-sla-boundary.e2e.ts +598 -0
- package/e2e/feature-run.e2e.ts +8 -1
- package/e2e/plan-fanout.e2e.ts +75 -2
- package/e2e/support/pin-agent-sla.ts +47 -0
- package/package.json +1 -1
- package/resources/processes/feature.bpmn +1 -0
- package/resources/processes/implement-cell.bpmn +122 -47
- package/resources/processes/merge-cell.bpmn +60 -12
- package/resources/processes/plan-fanout.bpmn +237 -211
- package/resources/processes/retro.bpmn +100 -3
- package/resources/prompts/feature.md +16 -1
- package/test/derivation-parity/derivation-parity.test.ts +35 -11
- package/test/derivation-parity/flows.ts +14 -1
- package/workers/conformance-record/worker.test.ts +87 -1
- package/workers/conformance-record/worker.ts +34 -7
- package/workers/reconcile-implement/worker.test.ts +31 -3
- package/workers/reconcile-implement/worker.ts +5 -0
- package/workers/record-feature-escalation/worker.test.ts +58 -3
- package/workers/record-feature-escalation/worker.ts +24 -10
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
implementCellBranch,
|
|
11
11
|
pickAdoptablePr,
|
|
12
12
|
reconcileImplement,
|
|
13
|
+
retryNudgeFor,
|
|
13
14
|
shouldReconcileImplement,
|
|
14
15
|
} from "./implementReconcile.ts";
|
|
15
16
|
|
|
@@ -20,16 +21,34 @@ const openPr = (number: number, base = "main"): HeadPr => ({
|
|
|
20
21
|
baseRef: base,
|
|
21
22
|
});
|
|
22
23
|
|
|
24
|
+
// The non-adopt, non-retry tail every escalate/adopt assertion carries (issue #865 widened the result
|
|
25
|
+
// shape with the auto-retry fields; #865 review added `deliveryVerified`). Spread it into each expected
|
|
26
|
+
// object so the tests stay focused on the reconcile/pr decision. `deliveryVerified: false` is the
|
|
27
|
+
// fall-through default (no lookup ran / it threw / transport unavailable); the adopt and
|
|
28
|
+
// verified-absence cases override it to `true`.
|
|
29
|
+
const tail = { retry: false, retryNudge: null, retried: false, deliveryVerified: false } as const;
|
|
30
|
+
// The tail for a SUCCESSFUL lookup: delivery state was confirmed (an adoptable PR, or a verified
|
|
31
|
+
// absence), so `deliveryVerified` is `true`.
|
|
32
|
+
const verified = { ...tail, deliveryVerified: true } as const;
|
|
33
|
+
|
|
23
34
|
test("implementCellBranch: the deterministic feat/<task.id> branch", () => {
|
|
24
35
|
assertEquals(implementCellBranch("issue-796"), "feat/issue-796");
|
|
25
36
|
});
|
|
26
37
|
|
|
27
|
-
test("shouldReconcileImplement:
|
|
38
|
+
test("shouldReconcileImplement: blank or a claimed-completion (off-vocabulary) status reconciles; a recognised status or an answerable question does not", () => {
|
|
28
39
|
assertEquals(shouldReconcileImplement(null), true);
|
|
29
40
|
assertEquals(shouldReconcileImplement(undefined), true);
|
|
30
41
|
assertEquals(shouldReconcileImplement(" "), true);
|
|
42
|
+
// Off-vocabulary "claimed completion" (issue #865) — look for a delivered PR before escalating.
|
|
43
|
+
assertEquals(shouldReconcileImplement("completed"), true);
|
|
44
|
+
assertEquals(shouldReconcileImplement("done"), true);
|
|
45
|
+
// Recognised statuses are the agent's own clean terminal / escalation — never auto-reconciled.
|
|
31
46
|
assertEquals(shouldReconcileImplement("escalated"), false);
|
|
32
47
|
assertEquals(shouldReconcileImplement("opened"), false);
|
|
48
|
+
assertEquals(shouldReconcileImplement("blocked"), false);
|
|
49
|
+
assertEquals(shouldReconcileImplement("skipped"), false);
|
|
50
|
+
// An off-vocabulary status carrying an answerable question is a GENUINE escalation — honour it.
|
|
51
|
+
assertEquals(shouldReconcileImplement("completed", "Which API should I use?"), false);
|
|
33
52
|
});
|
|
34
53
|
|
|
35
54
|
test("pickAdoptablePr: the first OPEN PR wins; merged/closed are not adoptable", () => {
|
|
@@ -62,7 +81,7 @@ test("reconcileImplement: blank status + open PR on feat/<task.id> → adopt (st
|
|
|
62
81
|
lookup,
|
|
63
82
|
"token",
|
|
64
83
|
);
|
|
65
|
-
assertEquals(res, { reconciled: true, status: "opened", pr: "nanobpm/nano-workforce#800" });
|
|
84
|
+
assertEquals(res, { reconciled: true, status: "opened", pr: "nanobpm/nano-workforce#800", ...verified });
|
|
66
85
|
assertEquals(calls, [{ repo: "nanobpm/nano-workforce", branch: "feat/issue-796" }]);
|
|
67
86
|
});
|
|
68
87
|
|
|
@@ -72,7 +91,7 @@ test("reconcileImplement: blank status but NO branch/PR → escalate (unchanged
|
|
|
72
91
|
async () => [],
|
|
73
92
|
"token",
|
|
74
93
|
);
|
|
75
|
-
assertEquals(res, { reconciled: false, status: null, pr: null });
|
|
94
|
+
assertEquals(res, { reconciled: false, status: null, pr: null, ...verified });
|
|
76
95
|
});
|
|
77
96
|
|
|
78
97
|
test("reconcileImplement: a genuine escalation status is honoured, GitHub never consulted", async () => {
|
|
@@ -85,7 +104,7 @@ test("reconcileImplement: a genuine escalation status is honoured, GitHub never
|
|
|
85
104
|
},
|
|
86
105
|
"token",
|
|
87
106
|
);
|
|
88
|
-
assertEquals(res, { reconciled: false, status: "escalated", pr: null });
|
|
107
|
+
assertEquals(res, { reconciled: false, status: "escalated", pr: null, ...tail });
|
|
89
108
|
assertEquals(consulted, false);
|
|
90
109
|
});
|
|
91
110
|
|
|
@@ -106,7 +125,7 @@ test("reconcileImplement: a lookup transport failure falls through to escalate (
|
|
|
106
125
|
},
|
|
107
126
|
"token",
|
|
108
127
|
);
|
|
109
|
-
assertEquals(res, { reconciled: false, status: null, pr: null });
|
|
128
|
+
assertEquals(res, { reconciled: false, status: null, pr: null, ...tail });
|
|
110
129
|
});
|
|
111
130
|
|
|
112
131
|
test("reconcileImplement: an existing pr is carried through unchanged on fall-through (never wiped)", async () => {
|
|
@@ -116,7 +135,7 @@ test("reconcileImplement: an existing pr is carried through unchanged on fall-th
|
|
|
116
135
|
async () => [],
|
|
117
136
|
"token",
|
|
118
137
|
);
|
|
119
|
-
assertEquals(escalated, { reconciled: false, status: "escalated", pr: "owner/repo#42" });
|
|
138
|
+
assertEquals(escalated, { reconciled: false, status: "escalated", pr: "owner/repo#42", ...tail });
|
|
120
139
|
|
|
121
140
|
// Blank status but no adoptable PR → escalate; an existing pr still survives.
|
|
122
141
|
const noAdopt = await reconcileImplement(
|
|
@@ -124,7 +143,7 @@ test("reconcileImplement: an existing pr is carried through unchanged on fall-th
|
|
|
124
143
|
async () => [],
|
|
125
144
|
"token",
|
|
126
145
|
);
|
|
127
|
-
assertEquals(noAdopt, { reconciled: false, status: null, pr: "owner/repo#42" });
|
|
146
|
+
assertEquals(noAdopt, { reconciled: false, status: null, pr: "owner/repo#42", ...verified });
|
|
128
147
|
});
|
|
129
148
|
|
|
130
149
|
test("reconcileImplement: a successful adoption overwrites any existing pr with the adopted key", async () => {
|
|
@@ -133,7 +152,7 @@ test("reconcileImplement: a successful adoption overwrites any existing pr with
|
|
|
133
152
|
async () => [openPr(99)],
|
|
134
153
|
"token",
|
|
135
154
|
);
|
|
136
|
-
assertEquals(res, { reconciled: true, status: "opened", pr: "owner/repo#99" });
|
|
155
|
+
assertEquals(res, { reconciled: true, status: "opened", pr: "owner/repo#99", ...verified });
|
|
137
156
|
});
|
|
138
157
|
|
|
139
158
|
test("reconcileImplement: with a pinned baseBranch, only a PR targeting it is adopted", async () => {
|
|
@@ -143,15 +162,16 @@ test("reconcileImplement: with a pinned baseBranch, only a PR targeting it is ad
|
|
|
143
162
|
async () => [openPr(50, "stale-base"), openPr(51, "epic/feat-x")],
|
|
144
163
|
"token",
|
|
145
164
|
);
|
|
146
|
-
assertEquals(adopt, { reconciled: true, status: "opened", pr: "owner/repo#51" });
|
|
165
|
+
assertEquals(adopt, { reconciled: true, status: "opened", pr: "owner/repo#51", ...verified });
|
|
147
166
|
|
|
148
|
-
// Only a wrong-base PR exists → escalate rather than converge the wrong branch.
|
|
167
|
+
// Only a wrong-base PR exists → escalate rather than converge the wrong branch. The lookup SUCCEEDED
|
|
168
|
+
// and confirmed no PR targets the pinned base — a verified absence.
|
|
149
169
|
const escalate = await reconcileImplement(
|
|
150
170
|
{ status: null, subjectKey: "owner/repo#7", taskId: "issue-7", baseBranch: "epic/feat-x", pr: "owner/repo#42" },
|
|
151
171
|
async () => [openPr(52, "stale-base")],
|
|
152
172
|
"token",
|
|
153
173
|
);
|
|
154
|
-
assertEquals(escalate, { reconciled: false, status: null, pr: "owner/repo#42" });
|
|
174
|
+
assertEquals(escalate, { reconciled: false, status: null, pr: "owner/repo#42", ...verified });
|
|
155
175
|
});
|
|
156
176
|
|
|
157
177
|
test("reconcileImplement: a missing taskId or unparseable subjectKey → escalate, no lookup", async () => {
|
|
@@ -164,3 +184,140 @@ test("reconcileImplement: a missing taskId or unparseable subjectKey → escalat
|
|
|
164
184
|
assertEquals((await reconcileImplement({ status: null, subjectKey: "not-a-key", taskId: "issue-7" }, lookup, "t")).reconciled, false);
|
|
165
185
|
assertEquals(consulted, false);
|
|
166
186
|
});
|
|
187
|
+
|
|
188
|
+
// Issue #865 — a claimed-completion status (an off-vocabulary `completed`) is no longer dead-ended at a
|
|
189
|
+
// false "no result" escalation: it reconciles from GitHub (adopting a delivered PR), and when none
|
|
190
|
+
// exists it is auto-retried ONCE before escalating.
|
|
191
|
+
test("reconcileImplement: claimed-completion status + an open PR on the branch → adopt (status=opened)", async () => {
|
|
192
|
+
const calls: Array<{ repo: string; branch: string }> = [];
|
|
193
|
+
const res = await reconcileImplement(
|
|
194
|
+
{ status: "completed", subjectKey: "owner/repo#41", taskId: "issue-41" },
|
|
195
|
+
async (repo, branch) => {
|
|
196
|
+
calls.push({ repo, branch });
|
|
197
|
+
return [openPr(900)];
|
|
198
|
+
},
|
|
199
|
+
"token",
|
|
200
|
+
);
|
|
201
|
+
assertEquals(res, { reconciled: true, status: "opened", pr: "owner/repo#900", retry: false, retryNudge: null, retried: false, deliveryVerified: true });
|
|
202
|
+
assertEquals(calls, [{ repo: "owner/repo", branch: "feat/issue-41" }]);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("reconcileImplement: claimed-completion + NO PR + not yet retried → auto-retry once with a nudge", async () => {
|
|
206
|
+
const res = await reconcileImplement(
|
|
207
|
+
{ status: "completed", subjectKey: "owner/repo#41", taskId: "issue-41" },
|
|
208
|
+
async () => [],
|
|
209
|
+
"token",
|
|
210
|
+
);
|
|
211
|
+
assertEquals(res.reconciled, false);
|
|
212
|
+
assertEquals(res.retry, true);
|
|
213
|
+
assertEquals(res.retried, true);
|
|
214
|
+
assertEquals(res.status, "completed");
|
|
215
|
+
assertEquals(typeof res.retryNudge === "string" && res.retryNudge.includes("completed"), true);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// Issue #865 review ("previously missed"): the retry nudge must not over-assert "pushed NO branch, so
|
|
219
|
+
// your work was discarded" — it also fires when an open PR merely targets the WRONG base (a verified
|
|
220
|
+
// absence ON THIS BASE), where the pushed work was NOT discarded. The nudge must describe the missing
|
|
221
|
+
// ADOPTABLE PR and tell the agent to recover pushed work / retarget an existing PR (feature.md resume
|
|
222
|
+
// guidance), not claim the work is gone.
|
|
223
|
+
test("retryNudgeFor: describes the missing ADOPTABLE PR and points at recovery/retarget, never a false 'work was discarded' (#865 review)", () => {
|
|
224
|
+
const nudge = retryNudgeFor("completed");
|
|
225
|
+
// It names the reported status.
|
|
226
|
+
assertEquals(nudge.includes('"completed"'), true);
|
|
227
|
+
// It does NOT over-assert that no branch was pushed / the work was discarded.
|
|
228
|
+
assertEquals(nudge.includes("your work was discarded"), false);
|
|
229
|
+
assertEquals(nudge.includes("pushed NO branch"), false);
|
|
230
|
+
// It describes the missing adoptable PR (none adoptable on the run's base) and the recovery path:
|
|
231
|
+
// recover an already-pushed branch / retarget an existing PR (consistent with feature.md resume).
|
|
232
|
+
assertEquals(nudge.includes("no adoptable pull request"), true);
|
|
233
|
+
assertEquals(/recover|retarget/i.test(nudge), true);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("reconcileImplement: claimed-completion + NO PR but ALREADY retried → escalate (retry is bounded once)", async () => {
|
|
237
|
+
const res = await reconcileImplement(
|
|
238
|
+
{ status: "completed", subjectKey: "owner/repo#41", taskId: "issue-41", retried: true },
|
|
239
|
+
async () => [],
|
|
240
|
+
"token",
|
|
241
|
+
);
|
|
242
|
+
assertEquals(res, { reconciled: false, status: "completed", pr: null, retry: false, retryNudge: null, retried: true, deliveryVerified: true });
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("reconcileImplement: claimed-completion carrying an answerable question → escalate, no lookup, no retry", async () => {
|
|
246
|
+
let consulted = false;
|
|
247
|
+
const res = await reconcileImplement(
|
|
248
|
+
{ status: "completed", subjectKey: "owner/repo#41", taskId: "issue-41", question: "Which design?" },
|
|
249
|
+
async () => {
|
|
250
|
+
consulted = true;
|
|
251
|
+
return [];
|
|
252
|
+
},
|
|
253
|
+
"token",
|
|
254
|
+
);
|
|
255
|
+
assertEquals(res, { reconciled: false, status: "completed", pr: null, retry: false, retryNudge: null, retried: false, deliveryVerified: false });
|
|
256
|
+
assertEquals(consulted, false);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("reconcileImplement: a true no-result (blank status) with no PR escalates — it is NOT auto-retried", async () => {
|
|
260
|
+
const res = await reconcileImplement(
|
|
261
|
+
{ status: null, subjectKey: "owner/repo#7", taskId: "issue-7" },
|
|
262
|
+
async () => [],
|
|
263
|
+
"token",
|
|
264
|
+
);
|
|
265
|
+
assertEquals(res.retry, false);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// Issue #865 review — a non-affirmative off-vocabulary status (a reported failure / input-required /
|
|
269
|
+
// unknown) is NOT a claimed completion: it is never reconciled/adopted as success, never auto-retried.
|
|
270
|
+
test("shouldReconcileImplement: a reported non-completion off-vocabulary status does NOT reconcile (#865 review)", () => {
|
|
271
|
+
assertEquals(shouldReconcileImplement("failed"), false);
|
|
272
|
+
assertEquals(shouldReconcileImplement("needs_input"), false);
|
|
273
|
+
assertEquals(shouldReconcileImplement("error"), false);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("reconcileImplement: a reported 'failed' status + a branch PR → escalate, NOT adopted as success (#865 review)", async () => {
|
|
277
|
+
let consulted = false;
|
|
278
|
+
const res = await reconcileImplement(
|
|
279
|
+
{ status: "failed", subjectKey: "owner/repo#7", taskId: "issue-7" },
|
|
280
|
+
async () => {
|
|
281
|
+
consulted = true;
|
|
282
|
+
return [openPr(9)];
|
|
283
|
+
},
|
|
284
|
+
"token",
|
|
285
|
+
);
|
|
286
|
+
// The open branch PR is never adopted as a success for a reported failure; GitHub is never consulted.
|
|
287
|
+
assertEquals(res, { reconciled: false, status: "failed", pr: null, ...tail });
|
|
288
|
+
assertEquals(consulted, false);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// Issue #865 review — a failed/unavailable GitHub lookup does NOT establish that delivery is missing,
|
|
292
|
+
// so it must NEVER consume the one automatic retry. Only a SUCCESSFUL lookup confirming no adoptable PR
|
|
293
|
+
// may retry a claimed completion.
|
|
294
|
+
test("reconcileImplement: claimed-completion + a THROWN lookup → escalate, the retry is NOT consumed (#865 review)", async () => {
|
|
295
|
+
const res = await reconcileImplement(
|
|
296
|
+
{ status: "completed", subjectKey: "owner/repo#41", taskId: "issue-41" },
|
|
297
|
+
async () => {
|
|
298
|
+
throw new Error("github 502");
|
|
299
|
+
},
|
|
300
|
+
"token",
|
|
301
|
+
);
|
|
302
|
+
// Escalate, retry untouched — the next pass can still retry/adopt once GitHub recovers. The lookup
|
|
303
|
+
// THREW, so delivery is UNVERIFIED (deliveryVerified: false).
|
|
304
|
+
assertEquals(res, { reconciled: false, status: "completed", pr: null, retry: false, retryNudge: null, retried: false, deliveryVerified: false });
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("reconcileImplement: claimed-completion + a NULL listing (no transport) → escalate, the retry is NOT consumed (#865 review)", async () => {
|
|
308
|
+
const res = await reconcileImplement(
|
|
309
|
+
{ status: "completed", subjectKey: "owner/repo#41", taskId: "issue-41" },
|
|
310
|
+
async () => null,
|
|
311
|
+
"token",
|
|
312
|
+
);
|
|
313
|
+
assertEquals(res, { reconciled: false, status: "completed", pr: null, retry: false, retryNudge: null, retried: false, deliveryVerified: false });
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("reconcileImplement: a blank status + a NULL listing → escalate (unavailable transport, unchanged)", async () => {
|
|
317
|
+
const res = await reconcileImplement(
|
|
318
|
+
{ status: null, subjectKey: "owner/repo#7", taskId: "issue-7", pr: "owner/repo#42" },
|
|
319
|
+
async () => null,
|
|
320
|
+
"token",
|
|
321
|
+
);
|
|
322
|
+
assertEquals(res, { reconciled: false, status: null, pr: "owner/repo#42", ...tail });
|
|
323
|
+
});
|
|
@@ -8,14 +8,19 @@
|
|
|
8
8
|
// escalation question literally asked a human to go and check (#796's implement-stage twin). Dead-
|
|
9
9
|
// ending that at a person is the defect.
|
|
10
10
|
//
|
|
11
|
-
// This is the CANONICAL, pure decision for the cell's reconcile step: on a blank/absent `status
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
11
|
+
// This is the CANONICAL, pure decision for the cell's reconcile step: on a blank/absent `status` — OR
|
|
12
|
+
// an off-vocabulary status such as `completed` that CLAIMED completion without being a recognised
|
|
13
|
+
// terminal (issue #865) — look for an OPEN PR opened from the cell's deterministic branch
|
|
14
|
+
// (`feat/<task.id>`, the agent-guide convention every implement-cell caller shares — see
|
|
15
|
+
// resources/prompts/feature.md) and, when one exists, ADOPT it (derive `status = "opened"` + a `pr`
|
|
16
|
+
// key) so the run converges exactly as if the agent had reported it — no human escalation. When no PR
|
|
17
|
+
// is observable, a claimed-completion-without-delivery is AUTO-RETRIED once with a nudge (#865) before
|
|
18
|
+
// it escalates; a true no-result escalates as today. The GitHub read is injected (the canonical
|
|
19
|
+
// `listPrsForHead`) so this stays a pure, exhaustively testable mirror of the `ic_reconcile_gw`
|
|
20
|
+
// gateway — no second GitHub reconciler.
|
|
21
|
+
import { hasAnswerableQuestion } from "./escalationTaxonomy.ts";
|
|
18
22
|
import type { HeadPr } from "./github.ts";
|
|
23
|
+
import { isClaimedCompletion } from "./implementEscalationReason.ts";
|
|
19
24
|
import { parsePr } from "./prParse.ts";
|
|
20
25
|
|
|
21
26
|
/** The escalate-arm inputs the reconcile step reads from the implement-cell scope. `subjectKey` is the
|
|
@@ -33,15 +38,32 @@ export interface ReconcileImplementInput {
|
|
|
33
38
|
taskId: unknown;
|
|
34
39
|
pr?: unknown;
|
|
35
40
|
baseBranch?: unknown;
|
|
41
|
+
/** The agent's own question. An answerable question means a GENUINE escalation even under an
|
|
42
|
+
* off-vocabulary status — honour it (escalate), never auto-reconcile/auto-retry over it. */
|
|
43
|
+
question?: unknown;
|
|
44
|
+
/** Whether this slice has ALREADY consumed its one automatic retry (the `implementRetried` process
|
|
45
|
+
* variable). A claimed-completion-without-delivery is auto-retried ONCE (issue #865) before it
|
|
46
|
+
* escalates; this flag bounds that race to a single retry. */
|
|
47
|
+
retried?: unknown;
|
|
36
48
|
}
|
|
37
49
|
|
|
38
50
|
/** The reconcile decision. `reconciled` is the `ic_reconcile_gw` gate: true → adopt-and-converge (with
|
|
39
|
-
* `status = "opened"` + `pr` set); false → escalate
|
|
40
|
-
*
|
|
51
|
+
* `status = "opened"` + `pr` set); false → retry-or-escalate. `retry` is the auto-retry arm (issue
|
|
52
|
+
* #865): true → re-dispatch the implement agent ONCE with `retryNudge` appended to its prompt; false →
|
|
53
|
+
* escalate as today. `retried` is the bounded-once marker re-emitted into `implementRetried` so the
|
|
54
|
+
* second pass can never retry again. `status`/`pr` are re-emitted so the cell (and its caller) route on
|
|
55
|
+
* the resolved values. `deliveryVerified` is the EVIDENCE the escalation reason needs (issue #865
|
|
56
|
+
* review): `true` ONLY when a GitHub lookup actually ran and CONFIRMED the delivery state (an adoptable
|
|
57
|
+
* PR, or a verified absence); `false` when no lookup ran, it threw, or the transport was unavailable —
|
|
58
|
+
* so the reason never asserts an UNVERIFIED "no pull request / none was found". */
|
|
41
59
|
export interface ReconcileImplementResult {
|
|
42
60
|
reconciled: boolean;
|
|
43
61
|
status: string | null;
|
|
44
62
|
pr: string | null;
|
|
63
|
+
retry: boolean;
|
|
64
|
+
retryNudge: string | null;
|
|
65
|
+
retried: boolean;
|
|
66
|
+
deliveryVerified: boolean;
|
|
45
67
|
}
|
|
46
68
|
|
|
47
69
|
/** The injected GitHub read — the canonical `listPrsForHead(repo, headBranch, token)` (so this module
|
|
@@ -51,11 +73,30 @@ export type OpenPrLookup = (repo: string, branch: string, token: string) => Prom
|
|
|
51
73
|
const str = (v: unknown): string | undefined =>
|
|
52
74
|
typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
|
|
53
75
|
|
|
54
|
-
/** Reconcile
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
|
|
58
|
-
|
|
76
|
+
/** Reconcile (look for a delivered PR) when the agent left no CLEAN, intentional result to route on:
|
|
77
|
+
* a blank/absent status (#796/#801's no-result), OR an AFFIRMATIVE-completion alias such as `completed`
|
|
78
|
+
* that CLAIMED completion without being a recognised terminal (#865) — both may nonetheless have pushed
|
|
79
|
+
* the branch and opened a green PR. A genuine escalation (`status = "escalated"`, or ANY status carrying
|
|
80
|
+
* an answerable `question`), and a reported non-completion outcome (`failed`/`needs_input`/unknown), are
|
|
81
|
+
* honoured as the agent's own decision and escalate accurately — never silently reconciled/adopted as a
|
|
82
|
+
* success by a branch PR that may be unrelated (#865 review). */
|
|
83
|
+
export function shouldReconcileImplement(status: unknown, question?: unknown): boolean {
|
|
84
|
+
if (hasAnswerableQuestion(typeof question === "string" ? question : null)) return false;
|
|
85
|
+
return str(status) === undefined || isClaimedCompletion(status);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The nudge appended to the implement agent's prompt on its one automatic retry (issue #865), naming
|
|
89
|
+
* exactly what was missing (an ADOPTABLE PR on the run's base) and the workspace-confinement rule.
|
|
90
|
+
* `status` is the off-vocabulary status it reported.
|
|
91
|
+
*
|
|
92
|
+
* It does NOT over-assert "no branch was pushed / your work was discarded" (#865 review): this nudge
|
|
93
|
+
* also fires when an open PR merely targets the WRONG base (a verified absence ON the run's base), where
|
|
94
|
+
* the pushed work was NOT discarded. So it describes the missing ADOPTABLE PR and points the agent at
|
|
95
|
+
* recovering an already-pushed branch or retargeting an existing PR — consistent with the resume
|
|
96
|
+
* instructions in `resources/prompts/feature.md` (check `git ls-remote`, continue from an existing
|
|
97
|
+
* branch/PR rather than restarting). */
|
|
98
|
+
export function retryNudgeFor(status: string): string {
|
|
99
|
+
return `\n\n---\n\n**Automatic retry — your previous attempt claimed completion but no adoptable pull request was found.** You reported status "${status}", yet a GitHub lookup confirmed there is no adoptable pull request for this slice on its base branch. Your work may NOT be lost: if you already pushed the \`feat/<task.id>\` branch, recover it — check the remote with \`git ls-remote --heads origin feat/<task.id>\`, and if a pull request exists but targets the WRONG base, retarget it onto this run's base branch with \`gh pr edit --base <base>\`. Otherwise you likely worked OUTSIDE your run workspace (e.g. \`cd /tmp\`), and those edits were discarded on teardown. This is your FINAL automatic retry; the next failure escalates to a human. Work ONLY inside your run workspace, then \`git commit -s\`, push \`feat/<task.id>\`, and open a PR on the correct base with \`gh pr create\`. Report \`status: "opened"\` with the \`pr\` key — not "${status}".`;
|
|
59
100
|
}
|
|
60
101
|
|
|
61
102
|
/** The cell's deterministic implement branch — `feat/<task.id>` (resources/prompts/feature.md). */
|
|
@@ -86,6 +127,7 @@ export async function reconcileImplement(
|
|
|
86
127
|
lookup: OpenPrLookup,
|
|
87
128
|
token: string,
|
|
88
129
|
): Promise<ReconcileImplementResult> {
|
|
130
|
+
const alreadyRetried = input.retried === true;
|
|
89
131
|
const escalate: ReconcileImplementResult = {
|
|
90
132
|
reconciled: false,
|
|
91
133
|
status: str(input.status) ?? null,
|
|
@@ -93,8 +135,15 @@ export async function reconcileImplement(
|
|
|
93
135
|
// into the process variable, so returning a bare `null` here would wipe a `pr` the implement
|
|
94
136
|
// harness already set. Only a successful adoption below overwrites it.
|
|
95
137
|
pr: str(input.pr) ?? null,
|
|
138
|
+
retry: false,
|
|
139
|
+
retryNudge: null,
|
|
140
|
+
retried: alreadyRetried,
|
|
141
|
+
// No lookup has CONFIRMED the delivery state on any fall-through path (no lookup ran, it threw, or
|
|
142
|
+
// the transport was unavailable) — the escalation reason must not assert an unverified absence
|
|
143
|
+
// (#865 review). Only the successful-lookup paths below flip this to `true`.
|
|
144
|
+
deliveryVerified: false,
|
|
96
145
|
};
|
|
97
|
-
if (!shouldReconcileImplement(input.status)) return escalate;
|
|
146
|
+
if (!shouldReconcileImplement(input.status, input.question)) return escalate;
|
|
98
147
|
const taskId = str(input.taskId);
|
|
99
148
|
// `subjectKey` shares the `owner/repo#N` shape parsePr validates; we use only its `repo` half.
|
|
100
149
|
const parsed = parsePr(input.subjectKey);
|
|
@@ -104,9 +153,54 @@ export async function reconcileImplement(
|
|
|
104
153
|
try {
|
|
105
154
|
prs = await lookup(parsed.repo, branch, token);
|
|
106
155
|
} catch {
|
|
107
|
-
|
|
156
|
+
// GitHub is unavailable — a failed lookup does NOT establish that delivery is missing, so it must
|
|
157
|
+
// never consume the one automatic retry (issue #865 review). Escalate (best-effort, unchanged from
|
|
158
|
+
// the pre-#865 no-result behaviour); the next reconcile pass can retry/adopt once GitHub recovers.
|
|
159
|
+
return escalate;
|
|
108
160
|
}
|
|
161
|
+
// A `null` listing is an unavailable transport (no token), not a confirmed "no PR" — same reasoning as
|
|
162
|
+
// the thrown case: escalate rather than retry on unconfirmed state.
|
|
163
|
+
if (prs === null) return escalate;
|
|
109
164
|
const adopt = pickAdoptablePr(prs, str(input.baseBranch));
|
|
110
|
-
if (
|
|
111
|
-
|
|
165
|
+
if (adopt) {
|
|
166
|
+
return {
|
|
167
|
+
reconciled: true,
|
|
168
|
+
status: "opened",
|
|
169
|
+
pr: `${parsed.repo}#${adopt.number}`,
|
|
170
|
+
retry: false,
|
|
171
|
+
retryNudge: null,
|
|
172
|
+
retried: alreadyRetried,
|
|
173
|
+
// The lookup SUCCEEDED and found the adoptable PR — delivery is verified (present).
|
|
174
|
+
deliveryVerified: true,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
// The lookup SUCCEEDED and confirmed no adoptable PR — delivery is verified (absent), so the
|
|
178
|
+
// escalation reason may say "none was found"; only now may a claimed completion auto-retry.
|
|
179
|
+
return maybeRetry(input, alreadyRetried, { ...escalate, deliveryVerified: true });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** No adoptable PR was found on a SUCCESSFUL lookup. A claimed-completion-without-delivery (an
|
|
183
|
+
* affirmative-completion alias such as `completed`) is auto-retried ONCE with a nudge (issue #865)
|
|
184
|
+
* before it escalates; everything else — a true no-result, a reported non-completion status, or a slice
|
|
185
|
+
* that has already consumed its retry — escalates as today. */
|
|
186
|
+
function maybeRetry(
|
|
187
|
+
input: ReconcileImplementInput,
|
|
188
|
+
alreadyRetried: boolean,
|
|
189
|
+
escalate: ReconcileImplementResult,
|
|
190
|
+
): ReconcileImplementResult {
|
|
191
|
+
const status = str(input.status);
|
|
192
|
+
if (!alreadyRetried && status !== undefined && isClaimedCompletion(status)) {
|
|
193
|
+
return {
|
|
194
|
+
// Spread the (successful-lookup) escalate base so `deliveryVerified: true` is preserved — this
|
|
195
|
+
// arm is only reached after a lookup CONFIRMED no adoptable PR.
|
|
196
|
+
...escalate,
|
|
197
|
+
reconciled: false,
|
|
198
|
+
status: escalate.status,
|
|
199
|
+
pr: escalate.pr,
|
|
200
|
+
retry: true,
|
|
201
|
+
retryNudge: retryNudgeFor(status),
|
|
202
|
+
retried: true,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
return escalate;
|
|
112
206
|
}
|
package/app/plan.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// the process. Data access goes through the record gateway (`data.table`), never
|
|
11
11
|
// hand-written SQL — matching app/service.ts.
|
|
12
12
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
13
|
+
import { AGENT_SLA_TIMEOUT } from "./agentSla.ts";
|
|
13
14
|
import {
|
|
14
15
|
InvalidBaseBranchError,
|
|
15
16
|
isPlausibleBranchName,
|
|
@@ -1021,6 +1022,17 @@ export async function startPlan(
|
|
|
1021
1022
|
// `operators` candidate group); an operator/agent can claim/reassign via the task inbox.
|
|
1022
1023
|
escalationSlaTimeout: ESCALATION_SLA_TIMEOUT,
|
|
1023
1024
|
escalationAssignee: null,
|
|
1025
|
+
// Agent-task liveness SLA (issue #849): PRE-SEED the canonical bound for the fan-out's
|
|
1026
|
+
// external agent tasks. Each slice's implementation agent (via the implement-cell
|
|
1027
|
+
// callActivity's explicit `agentSlaTimeout` input mapping) is bounded today; the wave's own
|
|
1028
|
+
// inline `trial-merge` agent (`plan-fanout.bpmn:524-542`) is NOT — it is a separate external
|
|
1029
|
+
// task from the standalone `merge-cell` process (which has no callActivity caller), sits on a
|
|
1030
|
+
// rerun back-edge, and its SLA boundary is deferred to #868 alongside the planner (`plan`) and
|
|
1031
|
+
// plan reviewer (`review-plan`), whose back-edge loop the layouter cannot yet route (layouter
|
|
1032
|
+
// bug #867). For those three this seed is preparation, not yet an armed bound. Each bounded
|
|
1033
|
+
// task's interrupting timer boundary evaluates `=agentSlaTimeout` at timer creation, so a hung
|
|
1034
|
+
// or looping agent escalates to a human instead of parking forever.
|
|
1035
|
+
agentSlaTimeout: AGENT_SLA_TIMEOUT,
|
|
1024
1036
|
// Capability-barrier bound (#289): the validated ISO-8601 duration read by the
|
|
1025
1037
|
// `wait-caps-timeout` timer arm of the `wait-caps-resolved` event-based gateway. A task whose
|
|
1026
1038
|
// declared cross-repo capabilities never resolve (most acutely an unresolvable capabilityRef the
|
package/app/retro.test.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { assert, assertEquals, assertStringIncludes } from "#test-assert";
|
|
|
4
4
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
5
5
|
import { memBlackboardSource } from "../test/blackboardDb.ts";
|
|
6
6
|
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
7
|
+
import { AGENT_SLA_TIMEOUT } from "./agentSla.ts";
|
|
7
8
|
import { appendEntry } from "./blackboard.ts";
|
|
8
9
|
import { recordTaskDelta } from "./taskDelta.ts";
|
|
9
10
|
import {
|
|
@@ -308,6 +309,10 @@ test("maybeStartRetro: starts the retro exactly once when the last PR lands with
|
|
|
308
309
|
assertEquals(started.length, 1);
|
|
309
310
|
assertEquals(started[0].processDefinitionId, "retro");
|
|
310
311
|
assertEquals(started[0].variables.planKey, PLAN);
|
|
312
|
+
// The retro seed must carry the canonical agent-SLA bound through its PRODUCTION start path
|
|
313
|
+
// (not just the e2e, which supplies agentSlaTimeout manually) — else the two external agent
|
|
314
|
+
// tasks (`conformance` / `synthesize`) would run unbounded (Previously-missed advisory, PR #864).
|
|
315
|
+
assertEquals(started[0].variables.agentSlaTimeout, AGENT_SLA_TIMEOUT);
|
|
311
316
|
assert(stores["plans"][0].retro_started_at, "retro_started_at must be stamped");
|
|
312
317
|
assertEquals(stores["plan_retro_starts"].length, 1);
|
|
313
318
|
|
package/app/retro.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// Data access goes through the record gateway (`data.table`), never hand-written SQL — matching
|
|
15
15
|
// app/plan.ts, app/blackboard.ts, and app/taskDelta.ts.
|
|
16
16
|
import type { DataLayer, EngineClient, Logger } from "@nanobpm/urban";
|
|
17
|
+
import { AGENT_SLA_TIMEOUT } from "./agentSla.ts";
|
|
17
18
|
import { type BlackboardEntry, isUniqueViolation, readBlackboard } from "./blackboard.ts";
|
|
18
19
|
import { hasDeliveredImplementationForPlan } from "./conformance.ts";
|
|
19
20
|
import { TERMINAL_STATUSES } from "./delivery.ts";
|
|
@@ -355,6 +356,9 @@ export async function maybeStartRetro(
|
|
|
355
356
|
planKey,
|
|
356
357
|
repo: digest.repo,
|
|
357
358
|
issueUrl: digest.issueUrl,
|
|
359
|
+
// Agent-task liveness SLA (issue #849): bounds the retro's two external agent tasks
|
|
360
|
+
// (`conformance`, `synthesize`) — a hung agent escalates to a human instead of parking.
|
|
361
|
+
agentSlaTimeout: AGENT_SLA_TIMEOUT,
|
|
358
362
|
},
|
|
359
363
|
}));
|
|
360
364
|
} catch (err) {
|
package/app/service.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
|
11
11
|
import { ABANDONED_STATUS, abandonUrl, mintAbandonToken, renderAbandonBrief } from "./abandon.ts";
|
|
12
12
|
import { matchAdjudication, prAdjudications, resetAdjudications } from "./adjudications.ts";
|
|
13
13
|
import { completeEscalationAutoApplied, escalationFormId } from "./agentCompletion.ts";
|
|
14
|
-
import {
|
|
14
|
+
import { AGENT_SLA_TIMEOUT } from "./agentSla.ts";
|
|
15
15
|
import {
|
|
16
16
|
CAPS_RESOLVED_MESSAGE,
|
|
17
17
|
type CapabilityNeed,
|
|
@@ -214,13 +214,25 @@ export const MAX_ADVERSARIAL_PASSES = clampCiFixBudget(
|
|
|
214
214
|
* (allows 0 = escalate immediately, ceiling-capped). */
|
|
215
215
|
export const MAX_MERGE_STALL_ROUNDS = clampCiFixBudget(process.env.NANO_PR_MAX_MERGE_STALL_ROUNDS, 3);
|
|
216
216
|
|
|
217
|
-
/** How long
|
|
218
|
-
*
|
|
219
|
-
* `agentSlaTimeout` process variable at
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
|
|
217
|
+
/** How long an external AGENT service task may sit without completing before its interrupting
|
|
218
|
+
* timer boundary fires and the process escalates for human attention. Seeded as the
|
|
219
|
+
* `agentSlaTimeout` process variable at every process start that hosts an external agent task
|
|
220
|
+
* (issue #849), and evaluated by the BOUNDED subset's boundary timers
|
|
221
|
+
* (`<bpmn:timeDuration>=agentSlaTimeout`, FEEL at timer creation): the implement-cell, the
|
|
222
|
+
* merge-cell's trial-merge, the merge-loop's rebase / fix-ci, and retro's conformance / synthesize.
|
|
223
|
+
* The convergence-loop (review-round / adversarial-review / classify-scope) and plan-fanout (plan /
|
|
224
|
+
* review-plan) tasks are only PRE-SEEDED here — their boundaries are intentionally deferred to #868
|
|
225
|
+
* (a bpmn-auto-layout back-edge routing limitation, #867). Plan-fanout's inline wave `trial-merge`
|
|
226
|
+
* (`plan-fanout.bpmn:524-542`) is likewise deferred: it is a separate external agent task from the
|
|
227
|
+
* standalone `merge-cell` (which has no callActivity caller) on a rerun back-edge with no boundary
|
|
228
|
+
* yet. For all of these this seed is preparation, not yet an armed bound. Unlike a human-decision
|
|
229
|
+
* escalation (PT24H), an agent task has no human in
|
|
230
|
+
* the loop — if its capability is unstaffed or the agent hangs/crashes without failing the job, the
|
|
231
|
+
* token would otherwise park forever. Override with `NANO_PR_AGENT_SLA_TIMEOUT` (ISO-8601 duration).
|
|
232
|
+
* The canonical validated constant lives in the leaf module `app/agentSla.ts` (re-exported here) so
|
|
233
|
+
* `app/feature.ts`, `app/plan.ts`, `app/retro.ts`, and `app/deliveryRunner.ts` seed it without an
|
|
234
|
+
* import cycle. */
|
|
235
|
+
export { AGENT_SLA_TIMEOUT } from "./agentSla.ts";
|
|
224
236
|
|
|
225
237
|
/** How long the convergence loop waits for a fresh review before escalating to a human. Seeded as
|
|
226
238
|
* the `reviewWaitTimeout` process variable at submit and evaluated by the process's
|
|
@@ -761,6 +773,13 @@ async function submitPrCritical(
|
|
|
761
773
|
round: 1,
|
|
762
774
|
maxRounds: clampRounds(maxRounds, MAX_ROUNDS),
|
|
763
775
|
reviewWaitTimeout: REVIEW_WAIT_TIMEOUT,
|
|
776
|
+
// Agent-task liveness SLA (issue #849): PRE-SEED the bound for the loop's external agent
|
|
777
|
+
// tasks (review-round / adversarial-review / classify-scope). Those tasks sit on a
|
|
778
|
+
// back-edge loop whose boundary the layouter cannot yet route, so their SLA boundaries are
|
|
779
|
+
// intentionally deferred to #868 (layouter bug #867) — this seed is preparation for that
|
|
780
|
+
// follow-up, not yet an armed bound on this loop. (The merge-loop's rebase / fix-ci, seeded
|
|
781
|
+
// from the same constant, ARE bounded today.)
|
|
782
|
+
agentSlaTimeout: AGENT_SLA_TIMEOUT,
|
|
764
783
|
// Bounded agent auto-ack (issue #796): the convergence loop re-dispatches the review-round
|
|
765
784
|
// agent up to `ackRetryMax` times to ack suppressed advisories when the converge-gate blocks
|
|
766
785
|
// solely on unacked ones, before escalating to a human. `ackRetryRound` counts those passes.
|