@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
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// Unit coverage for the implement-stage escalation reason builder (issue #865). The defect: the
|
|
2
|
+
// implement-cell synthesised ONE generic "no machine-readable result (no status was reported)" reason
|
|
3
|
+
// for every non-clean-terminal outcome — a FALSE diagnosis when the agent reported `status: "completed"`
|
|
4
|
+
// with a summary but opened no PR. These tests pin the accurate, self-contained reason.
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import { assertEquals } from "#test-assert";
|
|
7
|
+
import {
|
|
8
|
+
AFFIRMATIVE_COMPLETION_STATUSES,
|
|
9
|
+
classifyImplementEscalation,
|
|
10
|
+
implementEscalationQuestion,
|
|
11
|
+
isClaimedCompletion,
|
|
12
|
+
NO_RESULT_QUESTION,
|
|
13
|
+
RECOGNIZED_IMPLEMENT_STATUSES,
|
|
14
|
+
} from "./implementEscalationReason.ts";
|
|
15
|
+
|
|
16
|
+
test("RECOGNIZED_IMPLEMENT_STATUSES is exactly the agent result vocabulary", () => {
|
|
17
|
+
assertEquals([...RECOGNIZED_IMPLEMENT_STATUSES].sort(), ["blocked", "escalated", "opened", "skipped"]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("isClaimedCompletion: ONLY an affirmative-completion alias is a claimed completion (#865 review)", () => {
|
|
21
|
+
// Affirmative-completion aliases → claimed completion.
|
|
22
|
+
assertEquals(isClaimedCompletion("completed"), true);
|
|
23
|
+
assertEquals(isClaimedCompletion("done"), true);
|
|
24
|
+
assertEquals(isClaimedCompletion("success"), true);
|
|
25
|
+
// Case-insensitive — an agent shouting `DONE`/`Completed` still claims completion.
|
|
26
|
+
assertEquals(isClaimedCompletion("Completed"), true);
|
|
27
|
+
assertEquals(isClaimedCompletion("DONE"), true);
|
|
28
|
+
// Non-affirmative off-vocabulary (failure / input-required / unknown) is NOT a completion claim:
|
|
29
|
+
// it must escalate accurately, never be adopted as success or retried with a false diagnosis.
|
|
30
|
+
assertEquals(isClaimedCompletion("failed"), false);
|
|
31
|
+
assertEquals(isClaimedCompletion("needs_input"), false);
|
|
32
|
+
assertEquals(isClaimedCompletion("error"), false);
|
|
33
|
+
assertEquals(isClaimedCompletion("wibble"), false);
|
|
34
|
+
// Recognised statuses and blanks are never claimed completions.
|
|
35
|
+
assertEquals(isClaimedCompletion("opened"), false);
|
|
36
|
+
assertEquals(isClaimedCompletion("escalated"), false);
|
|
37
|
+
assertEquals(isClaimedCompletion(null), false);
|
|
38
|
+
assertEquals(isClaimedCompletion(" "), false);
|
|
39
|
+
// The alias set is non-empty and stable.
|
|
40
|
+
assertEquals(AFFIRMATIVE_COMPLETION_STATUSES.has("completed"), true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("classifyImplementEscalation: blank → no-result; affirmative → completed-without-delivery; else → reported-status", () => {
|
|
44
|
+
assertEquals(classifyImplementEscalation(null), "no-result");
|
|
45
|
+
assertEquals(classifyImplementEscalation(" "), "no-result");
|
|
46
|
+
assertEquals(classifyImplementEscalation("completed"), "completed-without-delivery");
|
|
47
|
+
assertEquals(classifyImplementEscalation("done"), "completed-without-delivery");
|
|
48
|
+
// A reported `escalated` with no answerable question is NOT a no-result — it is a reported status
|
|
49
|
+
// (#865 review: the false "no status was reported" diagnosis is retired).
|
|
50
|
+
assertEquals(classifyImplementEscalation("escalated"), "reported-status");
|
|
51
|
+
// A non-affirmative off-vocabulary failure/input-required is a reported status, never a completion.
|
|
52
|
+
assertEquals(classifyImplementEscalation("failed"), "reported-status");
|
|
53
|
+
assertEquals(classifyImplementEscalation("needs_input"), "reported-status");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("NO_RESULT_QUESTION: the blank-status reason names a true no-result, not a completion", () => {
|
|
57
|
+
assertEquals(NO_RESULT_QUESTION.includes("without a machine-readable result"), true);
|
|
58
|
+
assertEquals(NO_RESULT_QUESTION.includes('reported status "'), false);
|
|
59
|
+
// No summary/transcript folded in when none supplied.
|
|
60
|
+
assertEquals(NO_RESULT_QUESTION.includes("summary"), false);
|
|
61
|
+
assertEquals(NO_RESULT_QUESTION.includes("Transcript:"), false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("implementEscalationQuestion: a claimed completion names the exact status it reported", () => {
|
|
65
|
+
const q = implementEscalationQuestion({ status: "completed" });
|
|
66
|
+
assertEquals(q.includes('reported status "completed"'), true);
|
|
67
|
+
// It claims completion — but with no delivery evidence supplied the reason must NOT assert the
|
|
68
|
+
// unverified "...without delivering any work" (#865 review round 3).
|
|
69
|
+
assertEquals(q.includes("claimed completion"), true);
|
|
70
|
+
// Distinct from the generic no-result lead.
|
|
71
|
+
assertEquals(q.includes("without a machine-readable result"), false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("implementEscalationQuestion: a reported non-completion status names it accurately, never 'no status' or 'claimed completion' (#865 review)", () => {
|
|
75
|
+
// A reported `escalated` whose question was blank reaches the builder — it must NOT claim "no status
|
|
76
|
+
// was reported", and must NOT claim completion.
|
|
77
|
+
const esc = implementEscalationQuestion({ status: "escalated" });
|
|
78
|
+
assertEquals(esc.includes('reported status "escalated"'), true);
|
|
79
|
+
assertEquals(esc.includes("gave no answerable question"), true);
|
|
80
|
+
assertEquals(esc.includes("without a machine-readable result"), false);
|
|
81
|
+
assertEquals(esc.includes("claimed completion"), false);
|
|
82
|
+
// A non-affirmative off-vocabulary failure is reported accurately, not adopted as a completion claim.
|
|
83
|
+
const failed = implementEscalationQuestion({ status: "failed" });
|
|
84
|
+
assertEquals(failed.includes('reported status "failed"'), true);
|
|
85
|
+
assertEquals(failed.includes("claimed completion"), false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("implementEscalationQuestion: an agent's answerable question leads and PRESERVES its summary + transcript context (#865 review)", () => {
|
|
89
|
+
// The #865-review defect: an `escalated` result carrying a question + summary + transcript persisted
|
|
90
|
+
// ONLY the question, losing the supporting decision context pollUserTasks surfaces.
|
|
91
|
+
const q = implementEscalationQuestion({
|
|
92
|
+
status: "escalated",
|
|
93
|
+
question: "Should I target the epic base or main?",
|
|
94
|
+
summary: "drafted the migration but the base is ambiguous",
|
|
95
|
+
transcriptUrl: "http://merlin.local:3000/t?stream=job%3A777",
|
|
96
|
+
});
|
|
97
|
+
// The agent's own question leads.
|
|
98
|
+
assertEquals(q.startsWith("Should I target the epic base or main?"), true);
|
|
99
|
+
// Its summary and transcript are preserved as context.
|
|
100
|
+
assertEquals(q.includes('The agent\'s own summary: "drafted the migration but the base is ambiguous".'), true);
|
|
101
|
+
assertEquals(q.includes("Transcript: http://merlin.local:3000/t?stream=job%3A777"), true);
|
|
102
|
+
// No synthesised lead / recovery tail when the agent raised its own genuine escalation.
|
|
103
|
+
assertEquals(q.includes("without a machine-readable result"), false);
|
|
104
|
+
assertEquals(q.includes("/tmp/<repo>"), false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("implementEscalationQuestion: a blank/whitespace question does NOT lead — it synthesises accurately", () => {
|
|
108
|
+
const q = implementEscalationQuestion({ status: "completed", question: " " });
|
|
109
|
+
assertEquals(q.includes('reported status "completed"'), true);
|
|
110
|
+
assertEquals(q.includes("/tmp/<repo>"), true); // recovery tail present on the synthesised path
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("implementEscalationQuestion: folds in the agent's own summary and the transcript link (#863)", () => {
|
|
114
|
+
const q = implementEscalationQuestion({
|
|
115
|
+
status: "completed",
|
|
116
|
+
summary: "implemented src/pin.rs and supporting changes",
|
|
117
|
+
transcriptUrl: "http://merlin.local:3000/app/api/agentic/transcripts?stream=job%3A123",
|
|
118
|
+
});
|
|
119
|
+
assertEquals(q.includes('The agent\'s own summary: "implemented src/pin.rs and supporting changes".'), true);
|
|
120
|
+
assertEquals(q.includes("Transcript: http://merlin.local:3000/app/api/agentic/transcripts?stream=job%3A123"), true);
|
|
121
|
+
// The workspace-confinement hint (#865 ask 3) is present.
|
|
122
|
+
assertEquals(q.includes("/tmp/<repo>"), true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("implementEscalationQuestion: a blank status with a summary surfaces the summary on the no-result lead", () => {
|
|
126
|
+
const q = implementEscalationQuestion({ status: null, summary: "did some work" });
|
|
127
|
+
assertEquals(q.includes("without a machine-readable result"), true);
|
|
128
|
+
assertEquals(q.includes('The agent\'s own summary: "did some work".'), true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// --- Issue #865 review (round 3): never assert UNVERIFIED delivery -------------------------------
|
|
132
|
+
// The synthesised reason must not claim "no adoptable pull request was found" from status alone. Two
|
|
133
|
+
// unverified cases must be worded as unverified:
|
|
134
|
+
// (a) a preserved PR is still in scope (an `escalated`/`failed` with a blank question retains its PR
|
|
135
|
+
// without any GitHub lookup) — the builder must NOT say "opened no pull request";
|
|
136
|
+
// (b) the GitHub lookup never confirmed delivery state (it threw, or the transport was unavailable)
|
|
137
|
+
// — the builder must NOT assert a verified absence.
|
|
138
|
+
// Only a SUCCESSFUL lookup that found no adoptable PR may assert "no adoptable pull request was found".
|
|
139
|
+
//
|
|
140
|
+
// --- Issue #865 review (round 4): a VERIFIED absence is only ever about an ADOPTABLE PR --------------
|
|
141
|
+
// `deliveryVerified === true` means the reconcile lookup ran and found no open PR on the required base.
|
|
142
|
+
// It does NOT check branch existence — a PR targeting the WRONG base leaves the `feat/<task.id>` branch
|
|
143
|
+
// pushed. So the verified-absence wording must NEVER claim "pushed no branch" / "no branch" — only the
|
|
144
|
+
// absence of an adoptable PR.
|
|
145
|
+
|
|
146
|
+
test("implementEscalationQuestion: a preserved PR is surfaced, never the false 'opened no pull request' (#865 review)", () => {
|
|
147
|
+
// An `escalated` result with a blank question retains its PR (reconcile carries it through without a
|
|
148
|
+
// lookup). The reason must name that PR, not assert none was opened.
|
|
149
|
+
const q = implementEscalationQuestion({ status: "escalated", pr: "owner/repo#42" });
|
|
150
|
+
assertEquals(q.includes("owner/repo#42"), true);
|
|
151
|
+
assertEquals(q.includes("opened no pull request"), false);
|
|
152
|
+
assertEquals(q.includes("no adoptable pull request"), false);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("implementEscalationQuestion: an unverified lookup (thrown / unavailable) does NOT assert a verified absence (#865 review)", () => {
|
|
156
|
+
// `completed` reached the builder after a thrown/unavailable lookup — delivery state is UNVERIFIED,
|
|
157
|
+
// so the reason must not claim a verified absence.
|
|
158
|
+
const q = implementEscalationQuestion({ status: "completed", deliveryVerified: false });
|
|
159
|
+
assertEquals(q.includes('reported status "completed"'), true);
|
|
160
|
+
assertEquals(q.includes("no adoptable pull request"), false);
|
|
161
|
+
// It still tells the human the delivery could not be confirmed.
|
|
162
|
+
assertEquals(q.includes("could not be confirmed"), true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("implementEscalationQuestion: a SUCCESSFUL lookup with no adoptable PR asserts the absence — but NEVER 'pushed no branch' (#865 review round 4)", () => {
|
|
166
|
+
// Only when the lookup succeeded and confirmed no adoptable PR may the reason say so. But the lookup
|
|
167
|
+
// checks only for an ADOPTABLE PR (open, on the required base) — it does NOT check branch existence,
|
|
168
|
+
// so a wrong-base PR leaves the branch pushed. The verified-absence wording must describe ONLY the
|
|
169
|
+
// missing adoptable PR, never claim the branch was not pushed.
|
|
170
|
+
const q = implementEscalationQuestion({ status: "completed", deliveryVerified: true });
|
|
171
|
+
assertEquals(q.includes("no adoptable pull request"), true);
|
|
172
|
+
// The regression the round-4 review cited: verified absence must not overstate the evidence.
|
|
173
|
+
assertEquals(q.includes("pushed no branch"), false);
|
|
174
|
+
assertEquals(q.includes("(and pushed no branch)"), false);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("implementEscalationQuestion: an UNVERIFIED delivery never promises automatic adoption (#865 review round 6)", () => {
|
|
178
|
+
// For `failed`/`needs_input`/`escalated` with no answerable question and no reported PR,
|
|
179
|
+
// `shouldReconcileImplement()` skips the GitHub lookup entirely — so `deliveryVerified` is false and NO
|
|
180
|
+
// adoption attempt ran. The recovery text must NOT claim a delivered PR "would have been adopted
|
|
181
|
+
// automatically" (it was never even looked for, so a valid PR can sit unadopted). Instead it must ask
|
|
182
|
+
// the operator to check for an existing PR before re-running. This covers every non-affirmative
|
|
183
|
+
// reported status that bypasses reconciliation.
|
|
184
|
+
for (const status of ["failed", "needs_input", "escalated"]) {
|
|
185
|
+
const q = implementEscalationQuestion({ status, deliveryVerified: false });
|
|
186
|
+
assertEquals(q.includes("would have been adopted automatically"), false, `status=${status} must not promise automatic adoption`);
|
|
187
|
+
assertEquals(q.includes("check"), true, `status=${status} must direct the operator to check for an existing PR`);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("implementEscalationQuestion: an UNVERIFIED delivery never falsely claims 'no lookup ran' (#865 review round 6b)", () => {
|
|
192
|
+
// `deliveryVerified === false` covers THREE paths in reconcileImplement: the reconciliation bypass
|
|
193
|
+
// (no lookup ran), the catch where the lookup THREW, and a `null` listing (transport unavailable). On
|
|
194
|
+
// the latter two a lookup WAS attempted, so the recovery text must say a lookup never CONFIRMED
|
|
195
|
+
// delivery — never the false blanket "no lookup ran" for an outcome where one did run.
|
|
196
|
+
for (const status of ["failed", "needs_input", "escalated", "completed"]) {
|
|
197
|
+
const q = implementEscalationQuestion({ status, deliveryVerified: false });
|
|
198
|
+
assertEquals(q.includes("No GitHub lookup ran"), false, `status=${status} must not claim no lookup ran`);
|
|
199
|
+
assertEquals(q.includes("No GitHub lookup confirmed delivery"), true, `status=${status} must frame the absence as unconfirmed`);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("implementEscalationQuestion: a VERIFIED absence still promises automatic adoption (the lookup ran) (#865 review round 6)", () => {
|
|
204
|
+
// The verified branch is the one place the adoption promise IS accurate: a lookup ran and confirmed no
|
|
205
|
+
// adoptable PR, so a delivered PR on the right base genuinely would have been adopted. Guard the
|
|
206
|
+
// round-6 fix from over-correcting the verified wording away.
|
|
207
|
+
const q = implementEscalationQuestion({ status: "completed", deliveryVerified: true });
|
|
208
|
+
assertEquals(q.includes("adopted automatically"), true);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("implementEscalationQuestion: default (no evidence supplied) stays unverified — no false verified absence", () => {
|
|
212
|
+
// Back-compat / safest default: with no delivery evidence the reason must not assert a verified
|
|
213
|
+
// absence. (The builder is pure; a caller that has not run a lookup passes no evidence.)
|
|
214
|
+
const q = implementEscalationQuestion({ status: "completed" });
|
|
215
|
+
assertEquals(q.includes("no adoptable pull request"), false);
|
|
216
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// Implement-stage escalation reason — the single canonical builder for the human-facing escalation
|
|
2
|
+
// text when an implement-cell slice reaches the escalation arm with no adoptable PR (issue #865).
|
|
3
|
+
//
|
|
4
|
+
// The defect #865 fixed: the shared `implement-cell` synthesised ONE generic reason — "finished
|
|
5
|
+
// without a machine-readable result (no status was reported)" — for EVERY non-clean-terminal outcome.
|
|
6
|
+
// But an agent that reported `status: "completed"` (an off-vocabulary status) with a summary, yet
|
|
7
|
+
// opened no PR, DID report a status: the generic text was a false diagnosis the human had to untangle.
|
|
8
|
+
// This module classifies the escalation precisely and always carries the agent's own `summary` and the
|
|
9
|
+
// run's transcript link (cf. #863) so the human sees an accurate, self-contained reason.
|
|
10
|
+
//
|
|
11
|
+
// It is a pure, exhaustively-testable builder shared by `record-feature-escalation` (the escalation
|
|
12
|
+
// recorder) so the reason can never drift between raise sites — the "one canonical implementation"
|
|
13
|
+
// rule applied to the escalation text.
|
|
14
|
+
import { hasAnswerableQuestion } from "./escalationTaxonomy.ts";
|
|
15
|
+
|
|
16
|
+
/** The agent result vocabulary the implement-cell recognises (resources/prompts/feature.md). Any other
|
|
17
|
+
* reported status is OFF-vocabulary: the agent reported an outcome the cell cannot route on.
|
|
18
|
+
* `opened`/`blocked`/`skipped` are clean terminals the `ic_gw` "clean terminal?" gate converges before
|
|
19
|
+
* the escalation arm; `escalated` is the agent's own answerable escalation. */
|
|
20
|
+
export const RECOGNIZED_IMPLEMENT_STATUSES: ReadonlySet<string> = new Set([
|
|
21
|
+
"opened",
|
|
22
|
+
"blocked",
|
|
23
|
+
"skipped",
|
|
24
|
+
"escalated",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
/** The AFFIRMATIVE-completion aliases an agent reports off-vocabulary when it believes it SUCCEEDED
|
|
28
|
+
* (feature.md names `completed`/`done`/`success` as the canonical examples of this mistake). ONLY these
|
|
29
|
+
* off-vocabulary words claim completion. A non-affirmative off-vocabulary status — `failed`,
|
|
30
|
+
* `needs_input`, `error`, or any unknown word — reports a FAILURE / INPUT-REQUIRED / UNKNOWN outcome,
|
|
31
|
+
* NOT a completion: treating it as one would adopt a failure as success or auto-retry it with a false
|
|
32
|
+
* "claimed completion" diagnosis (issue #865 review). This is the shared source of truth for "did the
|
|
33
|
+
* agent CLAIM COMPLETION?" — `implementReconcile` reads it to decide whether to look for / retry a
|
|
34
|
+
* delivered PR, and this module reads it to word the escalation accurately. Matched case-insensitively
|
|
35
|
+
* so `Completed`/`DONE` are caught too. */
|
|
36
|
+
export const AFFIRMATIVE_COMPLETION_STATUSES: ReadonlySet<string> = new Set([
|
|
37
|
+
"completed",
|
|
38
|
+
"complete",
|
|
39
|
+
"done",
|
|
40
|
+
"success",
|
|
41
|
+
"succeeded",
|
|
42
|
+
"finished",
|
|
43
|
+
"finish",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
const str = (v: unknown): string | undefined =>
|
|
47
|
+
typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
|
|
48
|
+
|
|
49
|
+
/** True iff `status` is a non-blank AFFIRMATIVE-completion alias (e.g. `completed`/`done`/`success`) —
|
|
50
|
+
* the "claimed-completion-without-delivery" signal. A blank/absent status is NOT a claimed completion
|
|
51
|
+
* (it is a no-result); a recognised status (`opened`/`escalated`/…) is the agent's own routed outcome;
|
|
52
|
+
* and a non-affirmative off-vocabulary status (`failed`/`needs_input`/unknown) is a reported
|
|
53
|
+
* failure/input-required/unknown outcome, NOT a completion claim (issue #865 review). */
|
|
54
|
+
export function isClaimedCompletion(status: unknown): boolean {
|
|
55
|
+
const s = str(status);
|
|
56
|
+
return s !== undefined && AFFIRMATIVE_COMPLETION_STATUSES.has(s.toLowerCase());
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The outcome the escalation reason distinguishes:
|
|
60
|
+
* - `no-result` = blank/absent status — the agent returned nothing we can read;
|
|
61
|
+
* - `completed-without-delivery` = an affirmative-completion alias was reported (the agent CLAIMED
|
|
62
|
+
* completion) but no PR/branch was delivered;
|
|
63
|
+
* - `reported-status` = some OTHER non-blank status reached the escalation arm with no answerable
|
|
64
|
+
* question — a recognised status such as `escalated` whose question was blank, or a non-affirmative
|
|
65
|
+
* off-vocabulary failure/input-required/unknown. It names the reported status accurately rather than
|
|
66
|
+
* falsely claiming "no status was reported" (#865 review) or "claimed completion". */
|
|
67
|
+
export type ImplementEscalationKind = "no-result" | "completed-without-delivery" | "reported-status";
|
|
68
|
+
|
|
69
|
+
export function classifyImplementEscalation(status: unknown): ImplementEscalationKind {
|
|
70
|
+
const s = str(status);
|
|
71
|
+
if (s === undefined) return "no-result";
|
|
72
|
+
return isClaimedCompletion(s) ? "completed-without-delivery" : "reported-status";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** What the escalation builder needs from the implement-step outcome. `question` is the agent's OWN
|
|
76
|
+
* answerable question (when it raised a genuine escalation) — used as the lead when present; `summary`
|
|
77
|
+
* is the agent's own one-line result (always surfaced when present); `transcriptUrl` is the run's
|
|
78
|
+
* transcript link (cf. #863).
|
|
79
|
+
*
|
|
80
|
+
* Delivery EVIDENCE (issue #865 review — never assert an UNVERIFIED delivery claim):
|
|
81
|
+
* - `pr` is any PR key still in scope. An `escalated`/`failed` result with a blank question RETAINS its
|
|
82
|
+
* PR (the reconcile step carries it through without a GitHub lookup), so when `pr` is set the reason
|
|
83
|
+
* must NAME it — never the false "opened no pull request".
|
|
84
|
+
* - `deliveryVerified` is whether a GitHub lookup actually CONFIRMED the delivery state. It is `true`
|
|
85
|
+
* only when the reconcile step's lookup SUCCEEDED and found no adoptable PR — the one case where the
|
|
86
|
+
* reason may say "no adoptable PR was found". It is `false`/absent when no lookup ran, the lookup
|
|
87
|
+
* threw, or the transport was unavailable (a `null` listing): there the delivery state is UNVERIFIED,
|
|
88
|
+
* so the reason says the delivery "could not be confirmed" rather than asserting an absence it never
|
|
89
|
+
* checked. Note `deliveryVerified === true` verifies only the absence of an ADOPTABLE PR (an open PR
|
|
90
|
+
* on the required base) — it does NOT check branch existence, so a wrong-base PR means the branch may
|
|
91
|
+
* still be pushed; the reason never claims "pushed no branch" (#865 review). */
|
|
92
|
+
export interface ImplementEscalationReasonInput {
|
|
93
|
+
status?: unknown;
|
|
94
|
+
question?: unknown;
|
|
95
|
+
summary?: unknown;
|
|
96
|
+
transcriptUrl?: unknown;
|
|
97
|
+
pr?: unknown;
|
|
98
|
+
deliveryVerified?: unknown;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// The lead sentence for a genuine no-result (blank status) — the agent returned nothing we can read.
|
|
102
|
+
const NO_RESULT_LEAD =
|
|
103
|
+
"The implementation agent finished without a machine-readable result (no status was reported), so we cannot tell whether the slice succeeded.";
|
|
104
|
+
|
|
105
|
+
// The delivery clause qualifies the lead with what is actually KNOWN about delivery (issue #865 review):
|
|
106
|
+
// - a preserved PR (`pr`) is NAMED — the agent may have delivered, so we never claim it opened none;
|
|
107
|
+
// - a VERIFIED absence (`deliveryVerified === true`: the reconcile lookup succeeded and found no
|
|
108
|
+
// adoptable PR) is the one case that may assert "no adoptable pull request was found". The lookup
|
|
109
|
+
// checks only for an ADOPTABLE PR (an open PR on the required base) — it does NOT check branch
|
|
110
|
+
// existence, so a wrong-base PR leaves the branch pushed; the clause never claims "pushed no branch";
|
|
111
|
+
// - anything else (no lookup ran, the lookup threw, or the transport was unavailable) is UNVERIFIED —
|
|
112
|
+
// the clause says delivery "could not be confirmed" rather than asserting an absence never checked.
|
|
113
|
+
function deliveryClause(pr: string | undefined, deliveryVerified: boolean): string {
|
|
114
|
+
if (pr) return `it may already have a pull request open as ${pr} (preserved from the run, not re-verified)`;
|
|
115
|
+
if (deliveryVerified) return "a GitHub lookup confirmed no adoptable pull request was found for this slice on its base branch";
|
|
116
|
+
return "its delivery could not be confirmed (no GitHub lookup verified whether it opened a pull request or pushed a branch)";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// The lead sentence when the agent reported an affirmative-completion alias (e.g. `completed`) — it
|
|
120
|
+
// claimed completion. The delivery clause states only what is verified about delivery (never an
|
|
121
|
+
// unverified "opened no pull request"). Names the exact status so the human sees this is distinct from
|
|
122
|
+
// a true no-result.
|
|
123
|
+
const completedWithoutDeliveryLead = (status: string, delivery: string): string =>
|
|
124
|
+
`The implementation agent reported status "${status}" — it claimed completion — but ${delivery}.`;
|
|
125
|
+
|
|
126
|
+
// The lead sentence when the agent reported SOME other status (a recognised `escalated` with a blank
|
|
127
|
+
// question, or a non-affirmative off-vocabulary failure/input-required/unknown) and left no answerable
|
|
128
|
+
// question. Names the exact reported status — never the false "no status was reported" diagnosis
|
|
129
|
+
// (#865 review) — and does not claim completion. The delivery clause states only what is verified.
|
|
130
|
+
const reportedStatusLead = (status: string, delivery: string): string =>
|
|
131
|
+
`The implementation agent reported status "${status}" and gave no answerable question, but ${delivery}, so we cannot tell whether the slice succeeded.`;
|
|
132
|
+
|
|
133
|
+
// The shared tail: how recovery works, the workspace-confinement hint (#865 ask 3 — edits outside the
|
|
134
|
+
// run workspace are discarded on teardown), and the two answers the cell's `ic_gw_answer` gateway routes
|
|
135
|
+
// on. The delivery-absence clause is EVIDENCE-BASED (issue #865 review): only a verified lookup says
|
|
136
|
+
// "no adoptable PR was found on the base branch" (it does NOT verify branch existence — a wrong-base PR
|
|
137
|
+
// leaves the branch pushed, so it never claims "no branch"); an unverified delivery says so instead of
|
|
138
|
+
// asserting an absence never checked. The automatic-adoption promise is likewise evidence-gated (#865
|
|
139
|
+
// review round 6): only a VERIFIED lookup may say a delivered PR "would have been adopted automatically".
|
|
140
|
+
// An UNVERIFIED outcome — the SAME three `deliveryVerified === false` cases `deliveryClause` names (a
|
|
141
|
+
// reported non-completion status with no answerable question bypasses reconciliation so no lookup ran,
|
|
142
|
+
// OR the lookup threw, OR the transport was unavailable) — must not promise an adoption that never
|
|
143
|
+
// happened. Because the thrown/unavailable paths DID attempt a lookup, the wording says a lookup never
|
|
144
|
+
// CONFIRMED delivery (not the false "no lookup ran"), and asks the operator to check for an existing PR
|
|
145
|
+
// before re-running (#865 review round 6).
|
|
146
|
+
function recoveryTail(pr: string | undefined, deliveryVerified: boolean): string {
|
|
147
|
+
const absence = pr
|
|
148
|
+
? `A pull request may already be open as ${pr} — check and retarget or adopt it before re-running`
|
|
149
|
+
: deliveryVerified
|
|
150
|
+
? "A delivered PR on the slice's `feat/<task.id>` branch targeting this run's base would have been adopted automatically; no adoptable PR was found on the base branch (the branch may still be pushed — e.g. a PR targeting a different base — so check and retarget it before re-running)"
|
|
151
|
+
: "No GitHub lookup confirmed delivery for this outcome, so a delivered PR on the slice's `feat/<task.id>` branch would NOT have been adopted automatically — check whether one already exists (and retarget or adopt it) before re-running";
|
|
152
|
+
return `${absence} (if the agent worked OUTSIDE its run workspace — e.g. \`cd /tmp/<repo>\` — those edits were discarded on teardown). Choose "Answer" and give guidance to re-run the slice — or choose "Abandon" to skip it and continue.`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Build the accurate, self-contained escalation reason for an implement-cell slice that reached the
|
|
156
|
+
* escalation arm. When the agent raised a GENUINE escalation (an answerable `question`), its own
|
|
157
|
+
* question is the lead and its `summary`/transcript are folded in as the supporting decision context
|
|
158
|
+
* (`pollUserTasks` surfaces this — #865 review). Otherwise it synthesises an accurate reason —
|
|
159
|
+
* distinguishing a true no-result from a claimed-completion-without-delivery and from a reported
|
|
160
|
+
* non-completion status — folds in the agent's own `summary` and the transcript link when present
|
|
161
|
+
* (#863), and appends the recovery tail. Pure — the single canonical implementation
|
|
162
|
+
* `record-feature-escalation` uses so the reason can never drift. */
|
|
163
|
+
export function implementEscalationQuestion(input: ImplementEscalationReasonInput): string {
|
|
164
|
+
const question = str(input.question);
|
|
165
|
+
const summary = str(input.summary);
|
|
166
|
+
const transcriptUrl = str(input.transcriptUrl);
|
|
167
|
+
|
|
168
|
+
// The agent declared a genuine, answerable escalation: lead with its own question, then preserve its
|
|
169
|
+
// summary and transcript so the human has the full supporting context (#865 review — otherwise an
|
|
170
|
+
// `escalated` result's summary/transcript were dropped and only the bare question survived). No
|
|
171
|
+
// recovery tail here — this is the agent's own decision, not a missing-delivery recovery.
|
|
172
|
+
if (question && hasAnswerableQuestion(question)) {
|
|
173
|
+
return withContext([question], summary, transcriptUrl).join(" ");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// No answerable question — synthesise an accurate reason by outcome, fold in the context, then append
|
|
177
|
+
// the recovery tail (how a human recovers the slice). The delivery evidence (`pr` still in scope, and
|
|
178
|
+
// whether a lookup VERIFIED the delivery state) qualifies every delivery claim so the reason never
|
|
179
|
+
// asserts an UNVERIFIED absence (#865 review).
|
|
180
|
+
const status = str(input.status);
|
|
181
|
+
const pr = str(input.pr);
|
|
182
|
+
const deliveryVerified = input.deliveryVerified === true;
|
|
183
|
+
const delivery = deliveryClause(pr, deliveryVerified);
|
|
184
|
+
const parts = withContext([leadForKind(classifyImplementEscalation(status), status, delivery)], summary, transcriptUrl);
|
|
185
|
+
parts.push(recoveryTail(pr, deliveryVerified));
|
|
186
|
+
return parts.join(" ");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The lead sentence for a synthesised (no-answerable-question) escalation, by classified kind. The
|
|
190
|
+
* `delivery` clause (built from the preserved PR + the verified-lookup flag) states only what is
|
|
191
|
+
* actually known about delivery — never an unverified "opened no pull request" (#865 review). */
|
|
192
|
+
function leadForKind(kind: ImplementEscalationKind, status: string | undefined, delivery: string): string {
|
|
193
|
+
switch (kind) {
|
|
194
|
+
case "no-result":
|
|
195
|
+
return NO_RESULT_LEAD;
|
|
196
|
+
case "completed-without-delivery":
|
|
197
|
+
return completedWithoutDeliveryLead(status ?? "", delivery);
|
|
198
|
+
case "reported-status":
|
|
199
|
+
return reportedStatusLead(status ?? "", delivery);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Append the agent's own summary and transcript link to an escalation reason when present (#863). */
|
|
204
|
+
function withContext(parts: string[], summary: string | undefined, transcriptUrl: string | undefined): string[] {
|
|
205
|
+
if (summary) parts.push(`The agent's own summary: "${summary}".`);
|
|
206
|
+
if (transcriptUrl) parts.push(`Transcript: ${transcriptUrl}`);
|
|
207
|
+
return parts;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The generic no-result question (blank status, no summary, no transcript) — the stable default the
|
|
211
|
+
* escalation recorder emits when the agent left nothing to read. Exported so the recorder and its tests
|
|
212
|
+
* share one constant. */
|
|
213
|
+
export const NO_RESULT_QUESTION: string = implementEscalationQuestion({});
|