@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.
Files changed (33) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/SPEC.md +35 -0
  3. package/app/deliveryGraph.test.ts +10 -0
  4. package/app/deliveryGraphCompiler.test.ts +354 -10
  5. package/app/deliveryGraphCompiler.ts +296 -40
  6. package/app/deliveryRunner.ts +14 -5
  7. package/app/implementEscalationReason.test.ts +216 -0
  8. package/app/implementEscalationReason.ts +213 -0
  9. package/app/implementReconcile.test.ts +168 -11
  10. package/app/implementReconcile.ts +112 -18
  11. package/app/pullRequestReadModel.test.ts +1 -1
  12. package/app/roundChurn.test.ts +388 -0
  13. package/app/roundChurn.ts +265 -0
  14. package/app/roundProgress.test.ts +224 -1
  15. package/app/service.test.ts +7 -0
  16. package/app/service.ts +12 -0
  17. package/db/migrations/117_pull_requests_churn_escalated_round.sql +12 -0
  18. package/db/migrations/118_pull_requests_read_model_churn_escalated_round.sql +55 -0
  19. package/docs/agent-guide.md +10 -7
  20. package/e2e/delivery-graph.e2e.ts +570 -1
  21. package/e2e/feature-run.e2e.ts +8 -1
  22. package/e2e/plan-fanout.e2e.ts +75 -2
  23. package/package.json +1 -1
  24. package/resources/processes/implement-cell.bpmn +42 -17
  25. package/resources/processes/plan-fanout.bpmn +236 -211
  26. package/resources/prompts/feature.md +16 -1
  27. package/workers/progress-check/worker.ts +98 -1
  28. package/workers/readiness-probe/worker.test.ts +72 -1
  29. package/workers/readiness-probe/worker.ts +39 -0
  30. package/workers/reconcile-implement/worker.test.ts +31 -3
  31. package/workers/reconcile-implement/worker.ts +5 -0
  32. package/workers/record-feature-escalation/worker.test.ts +58 -3
  33. 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: only a blank/absent status reconciles", () => {
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
- // look for an OPEN PR opened from the cell's deterministic branch (`feat/<task.id>`, the agent-guide
13
- // convention every implement-cell caller shares — see resources/prompts/feature.md) and, when one
14
- // exists, ADOPT it (derive `status = "opened"` + a `pr` key) so the run converges exactly as if the
15
- // agent had reported it — no human escalation. Only when nothing is observable does the run escalate
16
- // as today. The GitHub read is injected (the canonical `listPrsForHead`) so this stays a pure,
17
- // exhaustively testable mirror of the `ic_reconcile_gw` gateway — no second GitHub reconciler.
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 as today. `status`/`pr` are re-emitted so the
40
- * cell (and its caller) route on the adopted values. */
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 ONLY when the agent left no machine-readable status (blank/absent) — the #796/#801
55
- * no-result condition. A genuine escalation that carries its own status string is honoured (escalate),
56
- * never silently overridden by a branch PR that may be unrelated to the agent's question. */
57
- export function shouldReconcileImplement(status: unknown): boolean {
58
- return str(status) === undefined;
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
- return escalate; // transport hiccup → escalate as today
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 (!adopt) return escalate;
111
- return { reconciled: true, status: "opened", pr: `${parsed.repo}#${adopt.number}` };
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 = "104_pull_requests_read_model_progress_idempotency.sql";
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.