@nanobpm/nano-workforce 0.104.0 → 0.106.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 +14 -0
- package/README.md +1 -0
- package/app/abandon.ts +12 -3
- package/app/agentCompletion.test.ts +28 -0
- package/app/agentCompletion.ts +14 -2
- package/app/conformance.test.ts +313 -0
- package/app/conformance.ts +329 -0
- package/app/dbFence.ts +18 -0
- package/app/instance-tracking.test.ts +24 -0
- package/app/migration053.test.ts +84 -0
- package/app/plan.ts +6 -0
- package/app/pollUserTasks.test.ts +37 -0
- package/app/retro.test.ts +32 -2
- package/app/retro.ts +26 -10
- package/app/service.test.ts +191 -3
- package/app/service.ts +163 -2
- package/app/userTasks.test.ts +20 -0
- package/app/userTasks.ts +2 -0
- package/app/waves.test.ts +12 -0
- package/app/world/store.ts +6 -6
- package/db/migrations/004_planning.sql +1 -1
- package/db/migrations/052_plan_conformance.sql +28 -0
- package/db/migrations/053_merges_abandon_dedupe.sql +29 -0
- package/db/migrations/054_conformance_review_tracking.sql +27 -0
- package/nano.app.json +24 -1
- package/package.json +1 -1
- package/pages/tasks.page.json +84 -0
- package/resources/forms/conformance-escalation.form +17 -0
- package/resources/processes/retro.bpmn +123 -11
- package/resources/prompts/conformance.md +105 -0
- package/resources/prompts/retro.md +5 -0
- package/workers/conformance-ack/worker.test.ts +50 -0
- package/workers/conformance-ack/worker.ts +31 -0
- package/workers/conformance-record/worker.test.ts +241 -0
- package/workers/conformance-record/worker.ts +127 -0
- package/workers/merge/worker.test.ts +4 -0
- package/workers/merge/worker.ts +13 -18
- package/workers/retro-gather/worker.test.ts +6 -0
- package/workers/retro-gather/worker.ts +17 -5
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// Spec-conformance review — "did we build what the spec asked for?", examined against the ACTUAL
|
|
2
|
+
// implementation.
|
|
3
|
+
//
|
|
4
|
+
// It rides the existing `retro` process (app/retro.ts): when an epic's last PR lands, a
|
|
5
|
+
// `senior:conformance` agent runs BEFORE the lessons agent. Unlike retro — which reflects on what
|
|
6
|
+
// implementers *claimed* via `learning` blackboard entries and task deltas — conformance is
|
|
7
|
+
// deliberately grounded in the code: the digest it builds hands the agent the spec (the epic issue
|
|
8
|
+
// + every slice's `prompt`) and the set of PRs that actually LANDED, so the agent reads the real
|
|
9
|
+
// diffs/code/tests (`gh pr diff`, `git`) and verifies delivery rather than trusting the transcript.
|
|
10
|
+
//
|
|
11
|
+
// It surfaces two classes of deviation: those RAISED during implementation (`scope-change`
|
|
12
|
+
// blackboard entries — quoted here so the agent can reconcile them) and those it finds itself that
|
|
13
|
+
// were NEVER raised. The result is persisted to `plan_conformance` (052_plan_conformance.sql).
|
|
14
|
+
//
|
|
15
|
+
// Data access goes through the record gateway (`data.table`), never hand-written SQL — matching
|
|
16
|
+
// app/retro.ts, app/plan.ts, and app/blackboard.ts.
|
|
17
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
18
|
+
import { type BlackboardEntry, isUniqueViolation, readBlackboard } from "./blackboard.ts";
|
|
19
|
+
import { TERMINAL_STATUSES } from "./delivery.ts";
|
|
20
|
+
import { planTasks } from "./plan.ts";
|
|
21
|
+
|
|
22
|
+
const now = () => new Date().toISOString();
|
|
23
|
+
|
|
24
|
+
/** The BPMN `elementId` of the conformance escalation user task (retro.bpmn). The inbox reconciler
|
|
25
|
+
* (`pollUserTasks`) and the human completer (`HUMAN_COMPLETABLE_ELEMENTS`) key off this. */
|
|
26
|
+
export const CONFORMANCE_ESCALATION_ELEMENT = "conformance-escalation";
|
|
27
|
+
|
|
28
|
+
/** The `review_status` a `plan_conformance` row carries while its escalation ack task is OPEN — the
|
|
29
|
+
* only status `pollUserTasks` scans (migration 054). Every settled run is `reviewed`. */
|
|
30
|
+
export const CONFORMANCE_REVIEWING_STATUS = "reviewing";
|
|
31
|
+
|
|
32
|
+
/** A slice PR "landed" — its implementation is really in the tree and worth examining — when its
|
|
33
|
+
* PR reached a terminal state that isn't `abandoned`. In auto-merge mode that terminal is `merged`;
|
|
34
|
+
* in review-only mode it is `converged`. Derived from app/delivery.ts TERMINAL_STATUSES (the single
|
|
35
|
+
* source of truth for PR-terminal states) minus `abandoned`, so conformance and retro can't drift
|
|
36
|
+
* about what counts as landed. */
|
|
37
|
+
const LANDED_PR_STATUSES = new Set(TERMINAL_STATUSES.filter((s) => s !== "abandoned"));
|
|
38
|
+
|
|
39
|
+
interface PlanRow extends Record<string, unknown> {
|
|
40
|
+
plan_key: string;
|
|
41
|
+
repo: string;
|
|
42
|
+
issue_url: string;
|
|
43
|
+
title: string | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const plansTbl = (data: DataLayer) => data.table<PlanRow>("plans", "plan_key");
|
|
47
|
+
const prsTbl = (data: DataLayer) =>
|
|
48
|
+
data.table<{ pr_key: string; status: string }>("pull_requests", "pr_key");
|
|
49
|
+
|
|
50
|
+
/** A slice's PR "landed" iff it exists and reached a non-abandoned terminal status. The single
|
|
51
|
+
* predicate both {@link gatherConformance} and {@link hasDeliveredImplementationForPlan} apply, so
|
|
52
|
+
* the full digest and the cheap trigger check can't disagree about what counts as landed. */
|
|
53
|
+
async function isLanded(data: DataLayer, prKey: string | null | undefined): Promise<boolean> {
|
|
54
|
+
if (!prKey) return false;
|
|
55
|
+
const pr = await prsTbl(data).get(prKey);
|
|
56
|
+
return !!pr && LANDED_PR_STATUSES.has(pr.status);
|
|
57
|
+
}
|
|
58
|
+
const conformanceTbl = (data: DataLayer) =>
|
|
59
|
+
data.table<{ plan_key: string } & Record<string, unknown>>("plan_conformance", "plan_key");
|
|
60
|
+
|
|
61
|
+
/** A `plan_conformance` row viewed as a retro-run tracking record, for the inbox reconciler. */
|
|
62
|
+
export interface ConformanceReviewRow extends Record<string, unknown> {
|
|
63
|
+
plan_key: string;
|
|
64
|
+
process_key: string | null;
|
|
65
|
+
review_status: string;
|
|
66
|
+
summary: string | null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const conformanceReviewsTbl = (data: DataLayer) =>
|
|
70
|
+
data.table<ConformanceReviewRow>("plan_conformance", "plan_key");
|
|
71
|
+
|
|
72
|
+
/** The conformance runs whose escalation ack task is still open (`review_status = 'reviewing'`) —
|
|
73
|
+
* the set `pollUserTasks` scans for an open `conformance-escalation` user task. */
|
|
74
|
+
export async function activeConformanceReviews(data: DataLayer): Promise<ConformanceReviewRow[]> {
|
|
75
|
+
return await conformanceReviewsTbl(data).find({ review_status: CONFORMANCE_REVIEWING_STATUS });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The escalation question shown in the inbox row: the agent's conformance summary (which names the
|
|
79
|
+
* reduced / not-verified items and the unraised deviations). Best-effort — NULL when none recorded. */
|
|
80
|
+
export function conformanceEscalationQuestion(row: { summary?: unknown } | undefined): string | null {
|
|
81
|
+
const s = row?.summary;
|
|
82
|
+
return typeof s === "string" && s.trim() ? s.trim() : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** One item of the spec the agent must verify against the code: the slice's planner-supplied
|
|
86
|
+
* `prompt` (its acceptance brief), where it landed, and whether it landed at all. */
|
|
87
|
+
export interface ConformanceSlice {
|
|
88
|
+
taskId: string;
|
|
89
|
+
title: string | null;
|
|
90
|
+
prompt: string | null;
|
|
91
|
+
status: string;
|
|
92
|
+
prKey: string | null;
|
|
93
|
+
landed: boolean;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The material a conformance review examines. Unlike {@link RetroDigest}, this is spec + delivery
|
|
97
|
+
* pointers (not distilled claims) — the agent turns `deliveredPrs` into real diffs to inspect. */
|
|
98
|
+
export interface ConformanceDigest {
|
|
99
|
+
planKey: string;
|
|
100
|
+
repo: string;
|
|
101
|
+
issueUrl: string;
|
|
102
|
+
title: string | null;
|
|
103
|
+
slices: ConformanceSlice[];
|
|
104
|
+
/** The landed PR keys ("<owner>/<repo>#<n>") the agent must open and read the diff of. */
|
|
105
|
+
deliveredPrs: string[];
|
|
106
|
+
/** Deviations agents RAISED during implementation (`scope-change` blackboard entries). */
|
|
107
|
+
scopeChanges: { author_task: string; body: string; created_at: string }[];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Assemble the conformance material for a plan: the spec (issue + each slice's `prompt`), the set
|
|
111
|
+
* of PRs that actually landed (so the agent examines the real implementation), and the scope
|
|
112
|
+
* deviations raised during implementation. Reads only — no writes.
|
|
113
|
+
*
|
|
114
|
+
* `entries` lets a caller that has already scanned the blackboard for this plan (e.g.
|
|
115
|
+
* `pr.retro-gather`, which also runs {@link gatherRetro}) pass those entries in so the plan is
|
|
116
|
+
* scanned once, not once per gatherer — see workers/retro-gather. Omitted, it reads them itself. */
|
|
117
|
+
export async function gatherConformance(
|
|
118
|
+
data: DataLayer,
|
|
119
|
+
planKey: string,
|
|
120
|
+
entries?: BlackboardEntry[],
|
|
121
|
+
): Promise<ConformanceDigest> {
|
|
122
|
+
const plan = await plansTbl(data).get(planKey);
|
|
123
|
+
const tasks = (await planTasks(data).find({ plan_key: planKey }))
|
|
124
|
+
.slice()
|
|
125
|
+
.sort((a, b) => (a.task_index ?? 0) - (b.task_index ?? 0));
|
|
126
|
+
|
|
127
|
+
const slices: ConformanceSlice[] = [];
|
|
128
|
+
const deliveredPrs: string[] = [];
|
|
129
|
+
for (const t of tasks) {
|
|
130
|
+
const landed = await isLanded(data, t.pr_key);
|
|
131
|
+
if (landed && t.pr_key) deliveredPrs.push(t.pr_key);
|
|
132
|
+
slices.push({
|
|
133
|
+
taskId: t.task_id,
|
|
134
|
+
title: t.title ?? null,
|
|
135
|
+
prompt: t.prompt ?? null,
|
|
136
|
+
status: t.status,
|
|
137
|
+
prKey: t.pr_key ?? null,
|
|
138
|
+
landed,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const scopeChanges = (entries ?? (await readBlackboard(data, planKey)))
|
|
143
|
+
.filter((e) => e.kind === "scope-change")
|
|
144
|
+
.map((e) => ({ author_task: e.author_task, body: e.body, created_at: e.created_at }));
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
planKey,
|
|
148
|
+
repo: plan?.repo ?? planKey.split("#")[0] ?? "",
|
|
149
|
+
issueUrl: plan?.issue_url ?? "",
|
|
150
|
+
title: plan?.title ?? null,
|
|
151
|
+
slices,
|
|
152
|
+
deliveredPrs,
|
|
153
|
+
scopeChanges,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** True when there is real, landed implementation to examine. A plan whose slices were all
|
|
158
|
+
* skipped/blocked/abandoned shipped nothing, so there is nothing to check for conformance — the
|
|
159
|
+
* retro trigger uses this to decide whether the conformance run is worthwhile even when the retro
|
|
160
|
+
* digest itself is empty. */
|
|
161
|
+
export function hasDeliveredImplementation(d: ConformanceDigest): boolean {
|
|
162
|
+
return d.deliveredPrs.length > 0;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Cheap trigger check for the retro gate: does this plan have ANY landed implementation to examine?
|
|
166
|
+
* Inspects only plan_tasks + pull_requests (short-circuiting on the first landed PR) and — unlike
|
|
167
|
+
* {@link gatherConformance} — performs no blackboard scan, so the empty-digest trigger in
|
|
168
|
+
* app/retro.ts doesn't pay to compute `scopeChanges` it would discard. Shares {@link isLanded} with
|
|
169
|
+
* the full digest so the two can't drift on what "landed" means. */
|
|
170
|
+
export async function hasDeliveredImplementationForPlan(
|
|
171
|
+
data: DataLayer,
|
|
172
|
+
planKey: string,
|
|
173
|
+
): Promise<boolean> {
|
|
174
|
+
const tasks = await planTasks(data).find({ plan_key: planKey });
|
|
175
|
+
for (const t of tasks) {
|
|
176
|
+
if (await isLanded(data, t.pr_key)) return true;
|
|
177
|
+
}
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Render the digest as the compact markdown brief handed to the conformance agent (rides
|
|
182
|
+
* `appendPrompt`, concatenated after the base `conformance.md` linked-resource prompt — so it owns
|
|
183
|
+
* its own leading separator). It deliberately gives POINTERS (the spec text + the PRs to open), not
|
|
184
|
+
* conclusions: the agent must reach the verdicts by reading the code. */
|
|
185
|
+
export function renderConformanceBrief(d: ConformanceDigest): string {
|
|
186
|
+
const lines: string[] = [
|
|
187
|
+
"",
|
|
188
|
+
"",
|
|
189
|
+
"---",
|
|
190
|
+
"",
|
|
191
|
+
`## Conformance input — epic ${d.planKey}`,
|
|
192
|
+
"",
|
|
193
|
+
`Target repo: **${d.repo}**${d.issueUrl ? ` · issue (the spec): ${d.issueUrl}` : ""}`,
|
|
194
|
+
d.title ? `Epic: ${d.title}` : "",
|
|
195
|
+
"",
|
|
196
|
+
"### Delivered PRs to examine",
|
|
197
|
+
];
|
|
198
|
+
if (d.deliveredPrs.length === 0) {
|
|
199
|
+
lines.push("_(none landed — no implementation to verify)_");
|
|
200
|
+
} else {
|
|
201
|
+
lines.push(
|
|
202
|
+
`Read the actual diff of each with \`gh pr diff <n> --repo ${d.repo}\` (and the code/tests it touches):`,
|
|
203
|
+
);
|
|
204
|
+
for (const pr of d.deliveredPrs) lines.push(`- ${pr}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
lines.push("", `### Spec — the ${d.slices.length} slice(s) planned`);
|
|
208
|
+
if (d.slices.length === 0) {
|
|
209
|
+
lines.push("_(no slices recorded — verify the epic issue body directly)_");
|
|
210
|
+
} else {
|
|
211
|
+
for (const s of d.slices) {
|
|
212
|
+
const where = s.landed && s.prKey ? `landed as ${s.prKey}` : `status: ${s.status}`;
|
|
213
|
+
lines.push("", `#### ${s.taskId}${s.title ? ` — ${s.title}` : ""} (${where})`);
|
|
214
|
+
lines.push(s.prompt ? s.prompt : "_(no per-slice prompt; verify against the epic issue body)_");
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
lines.push("", `### Deviations RAISED during implementation (${d.scopeChanges.length})`);
|
|
219
|
+
if (d.scopeChanges.length === 0) {
|
|
220
|
+
lines.push("_(none — no `scope-change` entries were posted; treat any deviation you find as UNRAISED)_");
|
|
221
|
+
} else {
|
|
222
|
+
lines.push("Reconcile each against the delivered code — a raised deviation is still a deviation:");
|
|
223
|
+
for (const c of d.scopeChanges) lines.push(`- **[${c.author_task}]** ${c.body}`);
|
|
224
|
+
}
|
|
225
|
+
return lines.join("\n");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The persisted conformance shape (written by pr.conformance-record from the agent's result). */
|
|
229
|
+
export interface ConformanceInput {
|
|
230
|
+
status: string; // filed | skipped | blocked
|
|
231
|
+
commentUrl?: string | null;
|
|
232
|
+
slicesMet?: number;
|
|
233
|
+
slicesReduced?: number;
|
|
234
|
+
slicesNotVerified?: number;
|
|
235
|
+
deviationsRaised?: number;
|
|
236
|
+
deviationsUnraised?: number;
|
|
237
|
+
hasDeviations?: boolean;
|
|
238
|
+
summary?: string | null;
|
|
239
|
+
report?: string | null;
|
|
240
|
+
/** The retro process instance this conformance ran in — the tracking key `pollUserTasks` reads to
|
|
241
|
+
* find an open escalation user task (migration 054). */
|
|
242
|
+
processKey?: string | null;
|
|
243
|
+
/** Escalation lifecycle: `reviewing` while the ack task is open (poller scans these), else
|
|
244
|
+
* `reviewed`. Defaults to `reviewed` — only an escalation flips it to `reviewing`. */
|
|
245
|
+
reviewStatus?: "reviewing" | "reviewed";
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Upsert a plan's conformance row (idempotent on plan_key, so a job retry overwrites in place).
|
|
249
|
+
*
|
|
250
|
+
* Insert-first, then fall back to update only on a verified unique/PK violation — mirrors
|
|
251
|
+
* {@link recordRetro}: a get-then-insert can race, so "row already exists" is the update path, but
|
|
252
|
+
* any non-duplicate constraint failure must propagate rather than be silently swallowed. */
|
|
253
|
+
export async function recordConformance(
|
|
254
|
+
data: DataLayer,
|
|
255
|
+
planKey: string,
|
|
256
|
+
input: ConformanceInput,
|
|
257
|
+
): Promise<void> {
|
|
258
|
+
const ts = now();
|
|
259
|
+
const processKey = input.processKey ?? null;
|
|
260
|
+
const reviewStatus = input.reviewStatus ?? "reviewed";
|
|
261
|
+
// Invariant: a `reviewing` row must be trackable. `pollUserTasks` skips rows without a
|
|
262
|
+
// `process_key` and the `instanceTracking` binding keys off `process_key`, so a `reviewing` row
|
|
263
|
+
// with a null key can never be surfaced to an operator nor cleared — it wedges forever. The
|
|
264
|
+
// conformance-record worker already guards its own call site, but `recordConformance` is a public
|
|
265
|
+
// API: reject the untrackable combination here too so no future caller can encode it.
|
|
266
|
+
if (reviewStatus === CONFORMANCE_REVIEWING_STATUS && processKey == null) {
|
|
267
|
+
throw new Error(
|
|
268
|
+
`recordConformance: ${planKey} would persist review_status='reviewing' with no process_key — ` +
|
|
269
|
+
"refusing to record an untrackable escalation that no poller or onTerminated binding can clear",
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
const fields = {
|
|
273
|
+
status: input.status,
|
|
274
|
+
comment_url: input.commentUrl ?? null,
|
|
275
|
+
slices_met: input.slicesMet ?? 0,
|
|
276
|
+
slices_reduced: input.slicesReduced ?? 0,
|
|
277
|
+
slices_not_verified: input.slicesNotVerified ?? 0,
|
|
278
|
+
deviations_raised: input.deviationsRaised ?? 0,
|
|
279
|
+
deviations_unraised: input.deviationsUnraised ?? 0,
|
|
280
|
+
has_deviations: input.hasDeviations ? 1 : 0,
|
|
281
|
+
summary: input.summary ?? null,
|
|
282
|
+
report: input.report ?? null,
|
|
283
|
+
process_key: processKey,
|
|
284
|
+
review_status: reviewStatus,
|
|
285
|
+
updated_at: ts,
|
|
286
|
+
};
|
|
287
|
+
try {
|
|
288
|
+
await conformanceTbl(data).insert({ plan_key: planKey, created_at: ts, ...fields });
|
|
289
|
+
} catch (err) {
|
|
290
|
+
if (!isUniqueViolation(err)) throw err;
|
|
291
|
+
await conformanceTbl(data).update(planKey, fields);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Settle a conformance run's escalation once the operator acknowledges it: flip `review_status` to
|
|
296
|
+
* `reviewed` so `pollUserTasks` stops scanning it (its inbox row is already gone once the ack task
|
|
297
|
+
* closes) and stamp the disposition note into `summary` for the audit trail. Needed because the
|
|
298
|
+
* `retro` instance COMPLETES normally after the ack — `instanceTracking.onTerminated` only fires on a
|
|
299
|
+
* TERMINATED (crashed) instance, never a completed one, so nothing else would clear `reviewing`. */
|
|
300
|
+
export async function acknowledgeConformance(
|
|
301
|
+
data: DataLayer,
|
|
302
|
+
planKey: string,
|
|
303
|
+
note?: string | null,
|
|
304
|
+
): Promise<void> {
|
|
305
|
+
const trimmed = typeof note === "string" && note.trim() ? note.trim() : null;
|
|
306
|
+
const existing = await conformanceTbl(data).get(planKey);
|
|
307
|
+
// Invariant: the ack task only fires after the escalation parked this exact `planKey` at
|
|
308
|
+
// `review_status='reviewing'`, so the row must exist. A missing row means a wrong/mismatched
|
|
309
|
+
// `planKey` (or unexpected DB state); silently returning would let the `retro` instance COMPLETE
|
|
310
|
+
// while the real conformance row stays stuck in `reviewing`, so `pollUserTasks` scans it forever.
|
|
311
|
+
// Fail loudly so the job retries/alerts instead of silently encoding the mismatch.
|
|
312
|
+
if (!existing) {
|
|
313
|
+
throw new Error(
|
|
314
|
+
`acknowledgeConformance: no plan_conformance row for ${planKey} — ` +
|
|
315
|
+
"refusing to settle a missing/mismatched escalation that would leave the real row stuck in 'reviewing'",
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
// At-least-once worker semantics can retry `pr.conformance-ack` after a successful DB update; the
|
|
319
|
+
// row is already settled at `reviewed`, so short-circuit to keep the operation idempotent (a retry
|
|
320
|
+
// must not re-append a duplicate `Operator ack: …` block to the audit trail).
|
|
321
|
+
if (existing.review_status === "reviewed") return;
|
|
322
|
+
const prior = typeof existing.summary === "string" ? existing.summary : null;
|
|
323
|
+
const summary = trimmed ? (prior ? `${prior}\n\nOperator ack: ${trimmed}` : `Operator ack: ${trimmed}`) : prior;
|
|
324
|
+
await conformanceTbl(data).update(planKey, {
|
|
325
|
+
review_status: "reviewed",
|
|
326
|
+
summary,
|
|
327
|
+
updated_at: now(),
|
|
328
|
+
});
|
|
329
|
+
}
|
package/app/dbFence.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// nano-workforce — the ONE canonical classifier for a SQLite UNIQUE-constraint fence collision.
|
|
2
|
+
//
|
|
3
|
+
// A durable fence is a DB-level UNIQUE constraint that a check-then-insert races against: two
|
|
4
|
+
// concurrent/duplicate writers both observe "no row" and both attempt the insert, so the loser hits
|
|
5
|
+
// `UNIQUE constraint failed`. Turning that collision into the SAME intended idempotent outcome
|
|
6
|
+
// (instead of a spurious job failure) is a recurring pattern across the app — the world store's
|
|
7
|
+
// checkpoint/effect ledger (`db/migrations/049_world_checkpoint.sql`) and the merges-audit abandon
|
|
8
|
+
// guard (`abandonClosedPr`, `db/migrations/053_merges_abandon_dedupe.sql`) both rely on it.
|
|
9
|
+
//
|
|
10
|
+
// This is the ONE place that classifies the collision so every catch site shares a single
|
|
11
|
+
// implementation rather than re-encoding the driver's error shape (AGENTS.md: "no drift surfaces").
|
|
12
|
+
// Matched on the message substring the RAD `Table` surface propagates verbatim — the same one the
|
|
13
|
+
// schema/migration tests assert on — because that surface hides the concrete driver error type.
|
|
14
|
+
|
|
15
|
+
/** True when `err` is a SQLite `UNIQUE constraint failed` — the durable fence firing. */
|
|
16
|
+
export function isUniqueConstraintFence(err: unknown): boolean {
|
|
17
|
+
return err instanceof Error && /UNIQUE constraint failed/i.test(err.message);
|
|
18
|
+
}
|
|
@@ -11,6 +11,7 @@ import { PR_ACTIVE_STATUSES, PLAN_ACTIVE_STATUSES, FEATURE_ACTIVE_STATUSES } fro
|
|
|
11
11
|
import { TERMINAL_STATUSES } from "./delivery.ts";
|
|
12
12
|
import { PLAN_TERMINAL_STATUSES } from "./plan.ts";
|
|
13
13
|
import { FEATURE_TERMINAL_STATUSES } from "./feature.ts";
|
|
14
|
+
import { CONFORMANCE_REVIEWING_STATUS } from "./conformance.ts";
|
|
14
15
|
|
|
15
16
|
interface Binding {
|
|
16
17
|
table: string;
|
|
@@ -117,3 +118,26 @@ test("FEATURE_ACTIVE_STATUSES is derived from the manifest binding (no drift)",
|
|
|
117
118
|
const b = bindingFor(await bindings(), "feature_runs");
|
|
118
119
|
assertEquals([...FEATURE_ACTIVE_STATUSES].sort(), [...(b.activeStatuses ?? [])].sort());
|
|
119
120
|
});
|
|
121
|
+
|
|
122
|
+
// The retro conformance-escalation lifecycle has exactly one in-flight `review_status` — `reviewing`
|
|
123
|
+
// (the only status `pollUserTasks` scans via `activeConformanceReviews`) — and settles to `reviewed`.
|
|
124
|
+
// Tie the manifest binding to the code's single source of truth (`CONFORMANCE_REVIEWING_STATUS`) so
|
|
125
|
+
// the two can't drift: if a future change adds a new in-flight status but forgets the manifest, a
|
|
126
|
+
// terminated retro instance would strand in `review_status='reviewing'` and never clear (issue #96
|
|
127
|
+
// class of drift — the exact gap Copilot flagged on this binding).
|
|
128
|
+
test("instanceTracking: plan_conformance activeStatuses is exactly the reviewing status (no drift)", async () => {
|
|
129
|
+
const b = bindingFor(await bindings(), "plan_conformance");
|
|
130
|
+
assertEquals([...(b.activeStatuses ?? [])].sort(), [CONFORMANCE_REVIEWING_STATUS]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// The settled status the reconciler flips a terminated row to (`onTerminated.set.review_status`)
|
|
134
|
+
// must NOT itself be listed active — otherwise `onTerminated` would leave the row scannable and the
|
|
135
|
+
// reconciler could clobber a settled run (mirrors the "excludes every terminal status" guards above).
|
|
136
|
+
test("instanceTracking: plan_conformance onTerminated status is not active", async () => {
|
|
137
|
+
const b = bindingFor(await bindings(), "plan_conformance");
|
|
138
|
+
const settled = b.onTerminated.set.review_status;
|
|
139
|
+
assert(
|
|
140
|
+
typeof settled === "string" && !b.activeStatuses?.includes(settled),
|
|
141
|
+
`onTerminated review_status "${String(settled)}" must not be listed active`,
|
|
142
|
+
);
|
|
143
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Regression guard for migration 053 (#352, PR #354 review — suppressed advisory on app/service.ts:867):
|
|
2
|
+
// the DB-level fence that makes `abandonClosedPr`'s terminal audit write TRULY idempotent under a
|
|
3
|
+
// concurrent race, not merely best-effort. The partial `UNIQUE INDEX ux_merges_abandon_pr_closed ON
|
|
4
|
+
// merges(pr_key) WHERE outcome='abandoned' AND method='pr-closed'` IS the fence: the merge worker and
|
|
5
|
+
// the wave-gate self-heal path can both observe "no row" between the guard's `find` and its `insert`
|
|
6
|
+
// and both attempt the write, and this index is what turns the loser's insert into a catchable
|
|
7
|
+
// `UNIQUE constraint failed` instead of a duplicate audit row.
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { DatabaseSync } from "node:sqlite";
|
|
10
|
+
import test from "node:test";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { assert, assertEquals, assertThrows } from "#test-assert";
|
|
13
|
+
|
|
14
|
+
// The `merges` audit table as created by 004_merge.sql, minus the `pull_requests` FK parent (this
|
|
15
|
+
// test proves the index behaviour in isolation, exactly as migration049.test.ts hosts the world
|
|
16
|
+
// tables FK-free). Then apply 053 on top.
|
|
17
|
+
function migratedDb(): DatabaseSync {
|
|
18
|
+
const db = new DatabaseSync(":memory:");
|
|
19
|
+
db.exec(`CREATE TABLE merges (
|
|
20
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
21
|
+
pr_key TEXT NOT NULL,
|
|
22
|
+
outcome TEXT NOT NULL,
|
|
23
|
+
method TEXT,
|
|
24
|
+
detail TEXT,
|
|
25
|
+
at TEXT NOT NULL
|
|
26
|
+
);`);
|
|
27
|
+
const sql = readFileSync(fileURLToPath(new URL("../db/migrations/053_merges_abandon_dedupe.sql", import.meta.url)), "utf8");
|
|
28
|
+
db.exec(sql);
|
|
29
|
+
return db;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const insertAbandon = (db: DatabaseSync, prKey: string) =>
|
|
33
|
+
db
|
|
34
|
+
.prepare("INSERT INTO merges (pr_key, outcome, method, detail, at) VALUES (?, 'abandoned', 'pr-closed', 'd', 't')")
|
|
35
|
+
.run(prKey);
|
|
36
|
+
|
|
37
|
+
test("migration 053 applies cleanly and enforces one abandoned/pr-closed row per pr_key", () => {
|
|
38
|
+
const db = migratedDb();
|
|
39
|
+
insertAbandon(db, "o/r#1");
|
|
40
|
+
// The race: a second observer inserting the SAME abandoned/pr-closed row now hits the fence.
|
|
41
|
+
assertThrows(() => insertAbandon(db, "o/r#1"), undefined, "UNIQUE constraint failed");
|
|
42
|
+
assertEquals(
|
|
43
|
+
Number((db.prepare("SELECT COUNT(*) c FROM merges WHERE pr_key='o/r#1'").get() as { c: number }).c),
|
|
44
|
+
1,
|
|
45
|
+
"the loser's duplicate was rejected — one terminal audit row survives",
|
|
46
|
+
);
|
|
47
|
+
// A DIFFERENT PR's abandon is independent — the index is per pr_key, not global.
|
|
48
|
+
insertAbandon(db, "o/r#2");
|
|
49
|
+
assertEquals(Number((db.prepare("SELECT COUNT(*) c FROM merges").get() as { c: number }).c), 2);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("migration 053 only fences abandoned/pr-closed rows — merged/queued/blocked still repeat freely", () => {
|
|
53
|
+
const db = migratedDb();
|
|
54
|
+
const insertMerged = () =>
|
|
55
|
+
db.prepare("INSERT INTO merges (pr_key, outcome, method, detail, at) VALUES ('o/r#3','merged','squash','d','t')").run();
|
|
56
|
+
// A PR can carry several `merged` audit rows (retry / already-merged short-circuit) — the partial
|
|
57
|
+
// index must NOT constrain them (mergesPerDay dedupes with COUNT(DISTINCT pr_key)).
|
|
58
|
+
insertMerged();
|
|
59
|
+
insertMerged();
|
|
60
|
+
assertEquals(Number((db.prepare("SELECT COUNT(*) c FROM merges WHERE outcome='merged'").get() as { c: number }).c), 2);
|
|
61
|
+
// An abandoned row with a DIFFERENT method is also outside the partial predicate.
|
|
62
|
+
db.prepare("INSERT INTO merges (pr_key, outcome, method, detail, at) VALUES ('o/r#3','abandoned','other','d','t')").run();
|
|
63
|
+
db.prepare("INSERT INTO merges (pr_key, outcome, method, detail, at) VALUES ('o/r#3','abandoned','other','d','t')").run();
|
|
64
|
+
assertEquals(Number((db.prepare("SELECT COUNT(*) c FROM merges WHERE method='other'").get() as { c: number }).c), 2);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("migration 053 collapses pre-existing duplicate abandoned/pr-closed rows, keeping the earliest", () => {
|
|
68
|
+
// Simulate a database where the pre-fence race already wrote duplicates, then apply the migration.
|
|
69
|
+
const db = new DatabaseSync(":memory:");
|
|
70
|
+
db.exec(`CREATE TABLE merges (
|
|
71
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, pr_key TEXT NOT NULL, outcome TEXT NOT NULL,
|
|
72
|
+
method TEXT, detail TEXT, at TEXT NOT NULL);`);
|
|
73
|
+
db.prepare("INSERT INTO merges (pr_key,outcome,method,detail,at) VALUES ('o/r#9','abandoned','pr-closed','first','t')").run();
|
|
74
|
+
db.prepare("INSERT INTO merges (pr_key,outcome,method,detail,at) VALUES ('o/r#9','abandoned','pr-closed','dup','t')").run();
|
|
75
|
+
db.prepare("INSERT INTO merges (pr_key,outcome,method,detail,at) VALUES ('o/r#8','abandoned','pr-closed','solo','t')").run();
|
|
76
|
+
const sql = readFileSync(fileURLToPath(new URL("../db/migrations/053_merges_abandon_dedupe.sql", import.meta.url)), "utf8");
|
|
77
|
+
db.exec(sql); // dedupe + create index; must not throw despite the pre-existing duplicate
|
|
78
|
+
const rows = db.prepare("SELECT pr_key, detail FROM merges ORDER BY pr_key").all() as { pr_key: string; detail: string }[];
|
|
79
|
+
assertEquals(rows.length, 2, "the duplicate for o/r#9 was collapsed");
|
|
80
|
+
assert(
|
|
81
|
+
rows.some((r) => r.pr_key === "o/r#9" && r.detail === "first"),
|
|
82
|
+
"the EARLIEST (MIN(id)) row survived the collapse",
|
|
83
|
+
);
|
|
84
|
+
});
|
package/app/plan.ts
CHANGED
|
@@ -181,6 +181,12 @@ export const PLAN_TASK_STATUSES = [
|
|
|
181
181
|
"skipped",
|
|
182
182
|
"escalated",
|
|
183
183
|
"waiting-for-lane",
|
|
184
|
+
// Terminal: the task's PR was closed on GitHub without merging (abandoned / superseded /
|
|
185
|
+
// perpetually conflicting). Set by the canonical abandon writer (`abandonClosedPr`, app/service.ts)
|
|
186
|
+
// reached from BOTH the merge stage and the wave-merge gate. An `abandoned` task drops out of
|
|
187
|
+
// `waveMergeTargets` (so a dead member never wedges the wave barrier — #352) and stops
|
|
188
|
+
// `isPlanComplete`/the Epics table counting a phantom open task.
|
|
189
|
+
"abandoned",
|
|
184
190
|
] as const;
|
|
185
191
|
export type PlanTaskStatus = typeof PLAN_TASK_STATUSES[number];
|
|
186
192
|
|
|
@@ -209,6 +209,43 @@ test("pollUserTasks: projects a blocked feature run (feature-blocked) with the d
|
|
|
209
209
|
assertEquals(byKey["ut-blocked"].question, "agent gave up: no PR");
|
|
210
210
|
});
|
|
211
211
|
|
|
212
|
+
test("pollUserTasks: projects a conformance-escalation ack (issue #216) keyed to the epic, question from summary", async () => {
|
|
213
|
+
// The advisory `retro` process parks on a native `conformance-escalation` user task when the
|
|
214
|
+
// spec-conformance audit found the epic did not cleanly meet its spec. retro is not a delivery
|
|
215
|
+
// aggregate, so its instance is tracked on `plan_conformance` (review_status = 'reviewing'); the
|
|
216
|
+
// poller scans those rows, reads the open ack task from the engine, and projects it under the epic
|
|
217
|
+
// (plan) subject with the audit `summary` as its question. A settled ('reviewed') row is skipped.
|
|
218
|
+
const { data, stores } = memData({
|
|
219
|
+
plan_conformance: [
|
|
220
|
+
{
|
|
221
|
+
plan_key: "o/r#70",
|
|
222
|
+
process_key: "cp-70",
|
|
223
|
+
review_status: "reviewing",
|
|
224
|
+
summary: "slice 2 reduced; auth cache never verified",
|
|
225
|
+
},
|
|
226
|
+
{ plan_key: "o/r#71", process_key: "cp-71", review_status: "reviewed", summary: "all clean" },
|
|
227
|
+
],
|
|
228
|
+
plans: [
|
|
229
|
+
{ plan_key: "o/r#70", status: "done", issue_url: "https://github.com/o/r/issues/70", title: "Ship the cache" },
|
|
230
|
+
],
|
|
231
|
+
});
|
|
232
|
+
const engine = fakeEngine({
|
|
233
|
+
"cp-70": [{ userTaskKey: "ut-conf", elementId: "conformance-escalation" }],
|
|
234
|
+
"cp-71": [{ userTaskKey: "ut-conf-settled", elementId: "conformance-escalation" }],
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
await pollUserTasks(data, engine);
|
|
238
|
+
|
|
239
|
+
const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
|
|
240
|
+
assertEquals(Object.keys(byKey), ["ut-conf"]);
|
|
241
|
+
assertEquals(byKey["ut-conf"].element_id, "conformance-escalation");
|
|
242
|
+
assertEquals(byKey["ut-conf"].kind_label, "Conformance review");
|
|
243
|
+
assertEquals(byKey["ut-conf"].subject_type, "plan");
|
|
244
|
+
assertEquals(byKey["ut-conf"].subject_key, "o/r#70");
|
|
245
|
+
assertEquals(byKey["ut-conf"].subject_title, "Ship the cache");
|
|
246
|
+
assertEquals(byKey["ut-conf"].question, "slice 2 reduced; auth cache never verified");
|
|
247
|
+
});
|
|
248
|
+
|
|
212
249
|
test("pollUserTasks: removes a row once its task is no longer open (completed / out-of-band)", async () => {
|
|
213
250
|
const { data, stores } = memData({
|
|
214
251
|
user_tasks: [
|
package/app/retro.test.ts
CHANGED
|
@@ -166,6 +166,17 @@ test("gatherRetro: separates learnings from notes and folds in deltas", async ()
|
|
|
166
166
|
assertEquals(d.repo, "acme/widgets");
|
|
167
167
|
});
|
|
168
168
|
|
|
169
|
+
test("gatherRetro: uses pre-fetched blackboard entries instead of re-scanning", async () => {
|
|
170
|
+
const { data, stores } = memData();
|
|
171
|
+
seedPlan(stores);
|
|
172
|
+
// A learning lives in the store, but the caller passes an EMPTY pre-fetched snapshot — gatherRetro
|
|
173
|
+
// must honour what it was handed and not re-read the store.
|
|
174
|
+
await appendEntry(data, PLAN, { author_task: "t1", kind: "learning", body: "should be ignored" });
|
|
175
|
+
const d = await gatherRetro(data, PLAN, []);
|
|
176
|
+
assertEquals(d.counts.learnings, 0);
|
|
177
|
+
assertEquals(d.notes.length, 0);
|
|
178
|
+
});
|
|
179
|
+
|
|
169
180
|
test("gatherRetro: folds in the plan-review trace and task-outcome shape", async () => {
|
|
170
181
|
const { data, stores } = memData();
|
|
171
182
|
seedPlan(stores);
|
|
@@ -382,11 +393,13 @@ test("maybeStartRetro: bails while the plan is incomplete", async () => {
|
|
|
382
393
|
assertEquals(stores["plans"][0].retro_started_at, null, "must not stamp an incomplete plan");
|
|
383
394
|
});
|
|
384
395
|
|
|
385
|
-
test("maybeStartRetro: complete but
|
|
396
|
+
test("maybeStartRetro: complete but nothing landed (PR abandoned) → records a skipped retro, does not start", async () => {
|
|
386
397
|
const { data, stores } = memData();
|
|
387
398
|
seedPlan(stores);
|
|
399
|
+
// The only task's PR was abandoned: the plan is complete (abandoned is terminal) but shipped no
|
|
400
|
+
// code, so there is neither reflection material nor an implementation to audit.
|
|
388
401
|
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
389
|
-
seedPr(stores, "acme/widgets#10", "
|
|
402
|
+
seedPr(stores, "acme/widgets#10", "abandoned");
|
|
390
403
|
const { engine, started } = fakeEngine();
|
|
391
404
|
|
|
392
405
|
const r = await maybeStartRetro(data, engine, "acme/widgets#10");
|
|
@@ -397,6 +410,23 @@ test("maybeStartRetro: complete but empty → records a skipped retro, does not
|
|
|
397
410
|
assertEquals(stores["plan_retros"][0].status, "skipped");
|
|
398
411
|
});
|
|
399
412
|
|
|
413
|
+
test("maybeStartRetro: complete with landed code but no learnings → still starts (conformance has something to verify)", async () => {
|
|
414
|
+
const { data, stores } = memData();
|
|
415
|
+
seedPlan(stores);
|
|
416
|
+
// A merged PR but zero learnings/deltas/notes: the retro digest is empty, but there IS delivered
|
|
417
|
+
// implementation to audit for conformance — so the process must still start.
|
|
418
|
+
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
419
|
+
seedPr(stores, "acme/widgets#10", "merged");
|
|
420
|
+
const { engine, started } = fakeEngine();
|
|
421
|
+
|
|
422
|
+
const r = await maybeStartRetro(data, engine, "acme/widgets#10");
|
|
423
|
+
assertEquals(r.started, true);
|
|
424
|
+
assertEquals(r.planKey, PLAN);
|
|
425
|
+
assertEquals(started.length, 1);
|
|
426
|
+
assertEquals(started[0].processDefinitionId, "retro");
|
|
427
|
+
assert(stores["plans"][0].retro_started_at, "retro_started_at must be stamped");
|
|
428
|
+
});
|
|
429
|
+
|
|
400
430
|
test("maybeStartRetro: a rejected review round alone is enough to fire the retro", async () => {
|
|
401
431
|
const { data, stores } = memData();
|
|
402
432
|
seedPlan(stores);
|
package/app/retro.ts
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
// Data access goes through the record gateway (`data.table`), never hand-written SQL — matching
|
|
15
15
|
// app/plan.ts, app/blackboard.ts, and app/taskDelta.ts.
|
|
16
16
|
import type { DataLayer, EngineClient, Logger } from "@nanobpm/urban";
|
|
17
|
-
import { isUniqueViolation, readBlackboard } from "./blackboard.ts";
|
|
17
|
+
import { type BlackboardEntry, isUniqueViolation, readBlackboard } from "./blackboard.ts";
|
|
18
|
+
import { hasDeliveredImplementationForPlan } from "./conformance.ts";
|
|
18
19
|
import { TERMINAL_STATUSES } from "./delivery.ts";
|
|
19
20
|
import { planReviews, planTasks } from "./plan.ts";
|
|
20
21
|
import { aggregateEpicDeltas } from "./taskDelta.ts";
|
|
@@ -110,14 +111,22 @@ export interface RetroDigest {
|
|
|
110
111
|
/** Gather a plan's reflection material: the `learning` blackboard entries (the headline), plus the
|
|
111
112
|
* task-delta rollup (contract changes, discovered constraints, cross-slice file touches), the
|
|
112
113
|
* plan-review trace (rounds + rejection findings), the task-outcome shape, and any other
|
|
113
|
-
* non-learning blackboard notes for colour. Reads only — no writes.
|
|
114
|
-
|
|
114
|
+
* non-learning blackboard notes for colour. Reads only — no writes.
|
|
115
|
+
*
|
|
116
|
+
* `entries` lets a caller that has already scanned the blackboard for this plan (e.g.
|
|
117
|
+
* `pr.retro-gather`, which also runs {@link gatherConformance}) pass those entries in so the plan is
|
|
118
|
+
* scanned once, not once per gatherer — see workers/retro-gather. Omitted, it reads them itself. */
|
|
119
|
+
export async function gatherRetro(
|
|
120
|
+
data: DataLayer,
|
|
121
|
+
planKey: string,
|
|
122
|
+
entries?: BlackboardEntry[],
|
|
123
|
+
): Promise<RetroDigest> {
|
|
115
124
|
const plan = await plansTbl(data).get(planKey);
|
|
116
|
-
const
|
|
117
|
-
const learnings =
|
|
125
|
+
const bbEntries = entries ?? (await readBlackboard(data, planKey));
|
|
126
|
+
const learnings = bbEntries
|
|
118
127
|
.filter((e) => e.kind === "learning")
|
|
119
128
|
.map((e) => ({ author_task: e.author_task, body: e.body, created_at: e.created_at }));
|
|
120
|
-
const notes =
|
|
129
|
+
const notes = bbEntries
|
|
121
130
|
.filter((e) => e.kind !== "learning")
|
|
122
131
|
.map((e) => ({ author_task: e.author_task, kind: e.kind, body: e.body }));
|
|
123
132
|
const deltas = await aggregateEpicDeltas(data, planKey);
|
|
@@ -310,12 +319,19 @@ export async function maybeStartRetro(
|
|
|
310
319
|
if (!(await isPlanComplete(data, planKey))) return { started: false, planKey, reason: "incomplete" };
|
|
311
320
|
|
|
312
321
|
const digest = await gatherRetro(data, planKey);
|
|
313
|
-
|
|
322
|
+
// The retro digest can be empty (no learnings/deltas/notes, cleanly-approved plan) yet the epic
|
|
323
|
+
// still shipped real code — in which case conformance has something to verify even though the
|
|
324
|
+
// lessons agent has nothing to distil. So run whenever there is EITHER reflection material OR
|
|
325
|
+
// landed implementation to audit; only truly skip when there is neither. The landed-implementation
|
|
326
|
+
// probe is gathered lazily (only when the digest is empty) and via the lightweight
|
|
327
|
+
// hasDeliveredImplementationForPlan — which inspects only plan_tasks + PR status, with no
|
|
328
|
+
// blackboard scan — so we avoid discarded DB work on every terminal-PR event.
|
|
329
|
+
if (isDigestEmpty(digest) && !(await hasDeliveredImplementationForPlan(data, planKey))) {
|
|
314
330
|
if (!(await claimRetroStart(data, planKey))) return { started: false, planKey, reason: "already-started" };
|
|
315
|
-
// Nothing to reflect on — stamp anyway so we don't re-check on
|
|
316
|
-
// (now settled) plan, and record a skipped retro for visibility.
|
|
331
|
+
// Nothing to reflect on and nothing shipped to verify — stamp anyway so we don't re-check on
|
|
332
|
+
// every future terminal PR of a (now settled) plan, and record a skipped retro for visibility.
|
|
317
333
|
await plansTbl(data).update(planKey, { retro_started_at: now(), updated_at: now() });
|
|
318
|
-
await recordRetro(data, planKey, { status: "skipped", summary: "No learnings, deltas, or
|
|
334
|
+
await recordRetro(data, planKey, { status: "skipped", summary: "No learnings, deltas, notes, or landed implementation to retrospect." });
|
|
319
335
|
return { started: false, planKey, reason: "nothing-to-retro" };
|
|
320
336
|
}
|
|
321
337
|
|