@nanobpm/nano-workforce 0.200.8 → 0.201.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/SPEC.md +35 -0
- package/app/deliveryGraph.test.ts +10 -0
- package/app/deliveryGraphCompiler.test.ts +354 -10
- package/app/deliveryGraphCompiler.ts +296 -40
- package/app/deliveryRunner.ts +14 -5
- 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/pullRequestReadModel.test.ts +1 -1
- package/app/roundChurn.test.ts +388 -0
- package/app/roundChurn.ts +265 -0
- package/app/roundProgress.test.ts +224 -1
- package/app/service.test.ts +7 -0
- package/app/service.ts +12 -0
- package/db/migrations/117_pull_requests_churn_escalated_round.sql +12 -0
- package/db/migrations/118_pull_requests_read_model_churn_escalated_round.sql +55 -0
- package/docs/agent-guide.md +10 -7
- package/e2e/delivery-graph.e2e.ts +570 -1
- package/e2e/feature-run.e2e.ts +8 -1
- package/e2e/plan-fanout.e2e.ts +75 -2
- package/package.json +1 -1
- package/resources/processes/implement-cell.bpmn +42 -17
- package/resources/processes/plan-fanout.bpmn +236 -211
- package/resources/prompts/feature.md +16 -1
- package/workers/progress-check/worker.ts +98 -1
- package/workers/readiness-probe/worker.test.ts +72 -1
- package/workers/readiness-probe/worker.ts +39 -0
- 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
|
}
|
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
const MIG = (name: string) => readFileSync(fileURLToPath(new URL(`../db/migrations/${name}`, import.meta.url)), "utf8");
|
|
34
34
|
const PAGE = (name: string) => JSON.parse(readFileSync(fileURLToPath(new URL(`../pages/${name}`, import.meta.url)), "utf8"));
|
|
35
35
|
|
|
36
|
-
const READ_MODEL_MIGRATION = "
|
|
36
|
+
const READ_MODEL_MIGRATION = "118_pull_requests_read_model_churn_escalated_round.sql";
|
|
37
37
|
|
|
38
38
|
// The real base `pull_requests` columns, in schema order — DERIVED from the migration chain (not a
|
|
39
39
|
// hand-kept list that could silently omit one), used by both the drift guard and the e2e stand-in.
|