@nanobpm/nano-workforce 0.208.2 → 0.208.4
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/AGENTS.md +17 -0
- package/CHANGELOG.md +12 -0
- package/app/contracts.ts +1 -1
- package/app/feature.ts +5 -2
- package/app/implementBranchContract.test.ts +168 -0
- package/app/implementReconcile.ts +8 -2
- package/app/loopTermination.test.ts +412 -0
- package/app/mcpExclusions.test.ts +1 -1
- package/app/mergeBlockedThreads.test.ts +62 -2
- package/app/pullRequestReadModel.test.ts +1 -1
- package/app/repoEnvelope.test.ts +11 -75
- package/app/repoEnvelope.ts +122 -0
- package/app/service.ts +300 -30
- package/app/terminalReaderBehaviour.test.ts +15 -4
- package/build-info.json +4 -4
- package/db/migrations/130_pull_requests_termination_reason.sql +17 -0
- package/db/migrations/131_pull_requests_read_model_termination_reason.sql +48 -0
- package/e2e/feature-preflight.e2e.ts +2 -2
- package/e2e/plan-fanout.e2e.ts +105 -9
- package/nano.app.json +4 -0
- package/openapi.yaml +53 -0
- package/operations/cancelInstance.test.ts +9 -0
- package/operations/cancelInstance.ts +28 -2
- package/operations/cancelPrRun.ts +16 -0
- package/package.json +1 -1
- package/pages/home.page.json +1 -1
- package/resources/processes/implement-cell.bpmn +80 -65
- package/workers/caps-prepare/worker.test.ts +11 -0
- package/workers/slice-branch/worker.test.ts +31 -0
- package/workers/slice-branch/worker.ts +39 -0
package/app/repoEnvelope.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// rebase) and the PRE-PR implementation dispatch (`feature.ts`: `startFeature`; `plan.ts`:
|
|
9
9
|
// `startPlan` → the epic's `implement-cell`) derive from this single implementation without a
|
|
10
10
|
// `plan.ts ↔ service.ts` import cycle (AGENTS.md "derivation over duplication — no drift surfaces").
|
|
11
|
+
import { isPlausibleBranchName } from "./baseBranch.ts";
|
|
11
12
|
import { readEnvOr } from "./contracts.ts";
|
|
12
13
|
import { isCommitSha } from "./world/index.ts";
|
|
13
14
|
|
|
@@ -34,6 +35,43 @@ export function isResolvableRepo(repo: unknown): repo is string {
|
|
|
34
35
|
return typeof repo === "string" && isPlainOwnerRepo(repo.trim());
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/** The FLAT, dotted process-variable NAME a per-slice worker emits to attach the deterministic
|
|
39
|
+
* `branch.create` to the implement-cell's agent-job envelope (issue #945). The c8ctl harness's
|
|
40
|
+
* `collectEnvelopeFrom` reassembles the reserved namespace from BOTH the whole-object
|
|
41
|
+
* `io.nanobpm.agentTask` variable (the whole-epic seed, which carries `repository` + `task` but no
|
|
42
|
+
* per-slice `branch`) AND any dotted `io.nanobpm.agentTask.*` variables in scope, deep-merging them —
|
|
43
|
+
* so a dotted-name variable set in the MI-child scope lands on the SAME envelope the seed started,
|
|
44
|
+
* and `normalizeTaskEnvelope` then reads `branch.create` from the merged top level. A
|
|
45
|
+
* `<zeebe:ioMapping>` target with dots canNOT produce this: it builds a NESTED
|
|
46
|
+
* `{io:{nanobpm:{agentTask:…}}}` object the flat-key harness never reads (see {@link
|
|
47
|
+
* flattenAgentTaskEnvelope}). Only a worker result key literally NAMED with this dotted string (the
|
|
48
|
+
* engine propagates worker result variables verbatim, mapped or not) materialises the dotted
|
|
49
|
+
* variable. */
|
|
50
|
+
export const AGENT_TASK_BRANCH_CREATE_VAR = `${AGENT_TASK_NS}.branch.create`;
|
|
51
|
+
|
|
52
|
+
/** The ONE canonical derivation of an implementation slice's deterministic pre-PR branch —
|
|
53
|
+
* `feat/<taskId>` (the agent-guide's `feat/*` convention, resources/prompts/feature.md). This is the
|
|
54
|
+
* single source of truth for BOTH the whole-run feature seed (`feature.ts` `startFeature`, where the
|
|
55
|
+
* branch is known at seed time and rides the envelope's `branch.create` directly) AND the epic
|
|
56
|
+
* plan-fanout's per-slice emission (`workers/slice-branch`, run inside each implement-cell instance, which stamps it onto the implement-cell
|
|
57
|
+
* envelope via {@link AGENT_TASK_BRANCH_CREATE_VAR} because each slice's `task.id` differs per child
|
|
58
|
+
* and can't be named at the whole-epic seed). Issue #945: the plan-fanout implement-cell previously
|
|
59
|
+
* carried NO `branch.create`, so every c8ctl retry cut a random `nano/agent-work/*` fallback branch
|
|
60
|
+
* and the durable WIP checkpoint (keyed on `feat/<task.id>`) was never restored — each retry discarded
|
|
61
|
+
* the prior session's work. Deriving it here once keeps the envelope and the prompt's branch
|
|
62
|
+
* instruction (which tells the agent to use `feat/<task.id>`) from drifting into two sources.
|
|
63
|
+
*
|
|
64
|
+
* Returns `null` for a `taskId` whose derived branch is not a plausible git ref (mirrors the
|
|
65
|
+
* delivery-runner's `feat/<node.id>` guard, #776): an ill-formed `branch.create` would turn the
|
|
66
|
+
* isolation guarantee into a launch/checkout failure, so a blank/implausible id degrades to omission
|
|
67
|
+
* (the pre-#945 agent-cuts-its-own-branch behaviour) rather than emit an unusable value. */
|
|
68
|
+
export function sliceBranchCreate(taskId: string | null | undefined): string | null {
|
|
69
|
+
const id = typeof taskId === "string" ? taskId.trim() : "";
|
|
70
|
+
if (id === "") return null;
|
|
71
|
+
const branch = `feat/${id}`;
|
|
72
|
+
return isPlausibleBranchName(branch) ? branch : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
37
75
|
/** Flatten a repository-isolation envelope object (as built by {@link repoEnvelopeVars} —
|
|
38
76
|
* `{ "io.nanobpm.agentTask": { repository: { … } } }`) into the FLAT, dotted `io.nanobpm.agentTask.*`
|
|
39
77
|
* key → string-value pairs the c8ctl worker harness reads from a service task's `<zeebe:taskHeaders>`
|
|
@@ -66,6 +104,90 @@ export function flattenAgentTaskEnvelope(envelope: Record<string, unknown>): Rec
|
|
|
66
104
|
return out;
|
|
67
105
|
}
|
|
68
106
|
|
|
107
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
108
|
+
return v != null && typeof v === "object" && !Array.isArray(v);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function deepMerge(base: unknown, over: unknown): unknown {
|
|
112
|
+
if (!isPlainObject(base)) return isPlainObject(over) ? deepMerge({}, over) : over;
|
|
113
|
+
const out: Record<string, unknown> = { ...base };
|
|
114
|
+
if (!isPlainObject(over)) return out;
|
|
115
|
+
for (const [k, v] of Object.entries(over)) {
|
|
116
|
+
if (v === undefined) continue;
|
|
117
|
+
out[k] = isPlainObject(v) && isPlainObject(out[k]) ? deepMerge(out[k], v) : v;
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function setPath(obj: Record<string, unknown>, path: string[], value: unknown): void {
|
|
123
|
+
let cur = obj;
|
|
124
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
125
|
+
const k = path[i];
|
|
126
|
+
const next = cur[k];
|
|
127
|
+
// After the ensure-object step the slot is always a plain object; narrow via the type guard
|
|
128
|
+
// (no `as` — the ban-`as` gate) so the descent stays type-safe.
|
|
129
|
+
if (!isPlainObject(next)) {
|
|
130
|
+
const fresh: Record<string, unknown> = {};
|
|
131
|
+
cur[k] = fresh;
|
|
132
|
+
cur = fresh;
|
|
133
|
+
} else {
|
|
134
|
+
cur = next;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
cur[path[path.length - 1]] = value;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Mirror of the c8ctl harness's `collectEnvelopeFrom`: reassemble the reserved namespace out of BOTH
|
|
141
|
+
* a whole-object/JSON `io.nanobpm.agentTask` value AND flattened dotted `io.nanobpm.agentTask.*` keys
|
|
142
|
+
* (the channel a per-slice worker's dotted-name result variable uses, issue #945), deep-merging the
|
|
143
|
+
* two. This is the ONE consumer-side mirror shared by the round-trip contract tests
|
|
144
|
+
* (`app/repoEnvelope.test.ts`) and the e2e fan-out proof (`e2e/plan-fanout.e2e.ts`) so producer and
|
|
145
|
+
* consumer can never drift into two readings of where `branch.create` lives. */
|
|
146
|
+
function collectEnvelopeFrom(source: Record<string, unknown>): Record<string, unknown> {
|
|
147
|
+
const out: Record<string, unknown> = {};
|
|
148
|
+
if (!isPlainObject(source)) return out;
|
|
149
|
+
const whole = source[AGENT_TASK_NS];
|
|
150
|
+
if (whole != null) {
|
|
151
|
+
let val: unknown = whole;
|
|
152
|
+
if (typeof whole === "string") {
|
|
153
|
+
try {
|
|
154
|
+
val = JSON.parse(whole);
|
|
155
|
+
} catch {
|
|
156
|
+
val = undefined;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (isPlainObject(val)) Object.assign(out, deepMerge(out, val));
|
|
160
|
+
}
|
|
161
|
+
const prefix = `${AGENT_TASK_NS}.`;
|
|
162
|
+
for (const [key, value] of Object.entries(source)) {
|
|
163
|
+
if (!key.startsWith(prefix)) continue;
|
|
164
|
+
const rest = key.slice(prefix.length);
|
|
165
|
+
if (!rest) continue;
|
|
166
|
+
setPath(out, rest.split("."), value);
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Mirror of the c8ctl harness's `normalizeTaskEnvelope` branch slice: deep-merge the envelope
|
|
172
|
+
* recovered from `customHeaders` under that recovered from `variables` (variables win), then read
|
|
173
|
+
* `branch` from the TOP LEVEL of the merged envelope (`isPlainObject(raw.branch)`), NEVER from
|
|
174
|
+
* `raw.repository.branch`. Returns the consumer's recovered `branch.create` (`undefined` when the
|
|
175
|
+
* producer put it somewhere the consumer doesn't look, or omitted it). This is the ONE place tests
|
|
176
|
+
* assert "the harness sees this branch" — keeping producer (`repoEnvelopeVars` / the per-slice dotted
|
|
177
|
+
* variable) and consumer pinned together (issues #882/#945). */
|
|
178
|
+
export function recoverBranchCreate(
|
|
179
|
+
customHeaders: Record<string, unknown>,
|
|
180
|
+
variables: Record<string, unknown>,
|
|
181
|
+
): string | undefined {
|
|
182
|
+
const merged = deepMerge(collectEnvelopeFrom(customHeaders), collectEnvelopeFrom(variables));
|
|
183
|
+
// deepMerge of two plain-object envelope slices is always a plain object; narrow via the type guard
|
|
184
|
+
// (no `as` — the ban-`as` gate). A non-object merge (impossible here) reads as "no branch".
|
|
185
|
+
const raw = isPlainObject(merged) ? merged : {};
|
|
186
|
+
const branch = isPlainObject(raw.branch) ? raw.branch : {};
|
|
187
|
+
const create = branch.create;
|
|
188
|
+
return create == null ? undefined : String(create);
|
|
189
|
+
}
|
|
190
|
+
|
|
69
191
|
/** Raised by `requireRepoEnvelopeVars` when the repository-isolation envelope is REQUIRED on a fan-out
|
|
70
192
|
* path but its inputs are unresolved (a blank base/head `ref`, or a `repo` that is not a plain
|
|
71
193
|
* `owner/repo`). Issue #729: the fan-out dispatch paths must fail loudly here rather than let
|
package/app/service.ts
CHANGED
|
@@ -416,6 +416,21 @@ export interface PullRequest {
|
|
|
416
416
|
// Opt-in escalation steward (120_escalation_auto_resolutions.sql, issue #889): 1 ⇒ this PR's open
|
|
417
417
|
// escalations are eligible for the steward to auto-resolve (recorded reviewably). Default 0.
|
|
418
418
|
auto_resolve_escalations: number;
|
|
419
|
+
// Loop-termination provenance (130_pull_requests_termination_reason.sql, issue #943): the reason the
|
|
420
|
+
// app last cancelled this PR's convergence/merge loop, stamped by {@link terminatePrLoop}. NULL when
|
|
421
|
+
// the app never cancelled the current run.
|
|
422
|
+
termination_reason: string | null;
|
|
423
|
+
// The engine instance `termination_reason` describes (issue #943, PR #944 review): provenance is
|
|
424
|
+
// bound to the CANCELLED instance, so a predecessor's reason can never explain away the orphaning of
|
|
425
|
+
// a successor that later owns the row. Honoured only while it equals `process_key`.
|
|
426
|
+
termination_process_key: string | null;
|
|
427
|
+
// A handed-off predecessor merge loop whose retirement (engine cancel) has not yet succeeded (PR #944
|
|
428
|
+
// review). Durable so {@link retryPendingRetirements} re-attempts it on every poll pass instead of
|
|
429
|
+
// dropping the key after a bounded in-memory retry. NULL when nothing is pending.
|
|
430
|
+
retire_pending_key?: string | null;
|
|
431
|
+
// Operator Dismiss stamp (app/acknowledge.ts). Run-scoped: cleared on reopen so a dismissal of a
|
|
432
|
+
// PRIOR run never hides an orphaning of the current one.
|
|
433
|
+
acknowledged_at?: string | null;
|
|
419
434
|
}
|
|
420
435
|
|
|
421
436
|
interface PrDependency {
|
|
@@ -963,6 +978,13 @@ async function submitPrCritical(
|
|
|
963
978
|
// watermark could drop the new run's early rounds from the churn window (or, after the new
|
|
964
979
|
// run passes that number, silently expire). Reset it so the new run's churn clock starts clean.
|
|
965
980
|
churn_escalated_round: null,
|
|
981
|
+
// Run-scoped termination provenance (issue #943): a prior run's cancel reason must not mask a
|
|
982
|
+
// later orphaning of THIS run in `/status`.
|
|
983
|
+
termination_reason: null,
|
|
984
|
+
termination_process_key: null,
|
|
985
|
+
// The operator Dismiss is run-scoped too (PR #944 review): a dismissal of the PRIOR run must not
|
|
986
|
+
// count as acknowledging a later unexplained termination of THIS run.
|
|
987
|
+
acknowledged_at: null,
|
|
966
988
|
outcome: null,
|
|
967
989
|
converged_at: null,
|
|
968
990
|
merged_at: null,
|
|
@@ -1191,6 +1213,9 @@ export interface ActivePr {
|
|
|
1191
1213
|
/** The ACTIVE engine incident parking this PR's instance (issue #931), mirrored by the poller's
|
|
1192
1214
|
* `pollIncidents` pass; null when none. While set, `status` reads `incident`. */
|
|
1193
1215
|
incident: StatusIncident | null;
|
|
1216
|
+
/** Why the app last cancelled this PR's loop (issue #943), naming the successor/outcome; null when
|
|
1217
|
+
* never. An `orphaned` status means the loop ended with no reason, successor, or escalation. */
|
|
1218
|
+
terminationReason: string | null;
|
|
1194
1219
|
}
|
|
1195
1220
|
|
|
1196
1221
|
/** Every tracked PR not in a terminal state (converged/abandoned), newest-updated first. Backs
|
|
@@ -1218,7 +1243,7 @@ export async function activePrs(
|
|
|
1218
1243
|
// `status`/worker-owned transient columns are still surfaced on each row below (the view re-exports
|
|
1219
1244
|
// `base.*`), and `pull_requests` has no `onWaitingHuman` edge, so for a still-active PR
|
|
1220
1245
|
// `derived_status === status`.
|
|
1221
|
-
.filter((p) => !TERMINAL_STATUSES.includes(p.derived_status))
|
|
1246
|
+
.filter((p) => !TERMINAL_STATUSES.includes(p.derived_status) || isOrphanedPr(p))
|
|
1222
1247
|
.sort((a, b) => (a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : 0));
|
|
1223
1248
|
// Derive the STRUCTURED open-escalation pointer from the ONE `user_tasks` read model (issue #666) —
|
|
1224
1249
|
// the same user-task surface `listEscalations` and the Convergence/Tasks page consume — so `/status`
|
|
@@ -1256,13 +1281,14 @@ export async function activePrs(
|
|
|
1256
1281
|
// kept reading `converging` for hours while nothing ran. Overlay the derived `incident` status
|
|
1257
1282
|
// (the base `status` column is never touched) and carry the mirrored incident block.
|
|
1258
1283
|
const incident = statusIncidentFor(p);
|
|
1284
|
+
const orphaned = isOrphanedPr(p);
|
|
1259
1285
|
return {
|
|
1260
1286
|
prKey: p.pr_key,
|
|
1261
1287
|
repo: p.repo,
|
|
1262
1288
|
number: p.number,
|
|
1263
1289
|
url: p.url,
|
|
1264
1290
|
title: p.title ?? null,
|
|
1265
|
-
status: incident ? INCIDENT_STATUS : p.status,
|
|
1291
|
+
status: orphaned ? ORPHANED_PR_STATUS : incident ? INCIDENT_STATUS : p.status,
|
|
1266
1292
|
round: p.current_round,
|
|
1267
1293
|
processKey: p.process_key ?? null,
|
|
1268
1294
|
waitingSince: p.waiting_since ?? null,
|
|
@@ -1273,10 +1299,57 @@ export async function activePrs(
|
|
|
1273
1299
|
autoResolveEscalations: p.auto_resolve_escalations === 1,
|
|
1274
1300
|
merge: mergeByPr.get(p.pr_key) ?? mergePolicyDefault(),
|
|
1275
1301
|
incident,
|
|
1302
|
+
terminationReason: p.termination_reason ?? null,
|
|
1276
1303
|
};
|
|
1277
1304
|
});
|
|
1278
1305
|
}
|
|
1279
1306
|
|
|
1307
|
+
/** `/status` overlay for an ORPHANED PR (issue #943): its loop instance ended (derived tracking edge
|
|
1308
|
+
* folded to `abandoned`) while the base row still sits at an in-flight stage — no worker or human
|
|
1309
|
+
* settled it — and no termination reason was recorded (every app-initiated cancel records one via
|
|
1310
|
+
* {@link terminatePrLoop}, and the operator cancel door records one too), no merge landed, and no
|
|
1311
|
+
* successor took over. Cleared by an operator Dismiss (`acknowledged_at`). That is exactly the
|
|
1312
|
+
* silently-dropped nanobpm/nano-bpm#1452 shape; it must be visible, not filtered out. */
|
|
1313
|
+
export const ORPHANED_PR_STATUS = "orphaned";
|
|
1314
|
+
|
|
1315
|
+
/** Whether a tracked PR row is orphaned (see {@link ORPHANED_PR_STATUS}). Exported for tests. */
|
|
1316
|
+
export function isOrphanedPr(p: {
|
|
1317
|
+
status: string;
|
|
1318
|
+
derived_status: string;
|
|
1319
|
+
process_key?: string | null;
|
|
1320
|
+
termination_reason?: string | null;
|
|
1321
|
+
termination_process_key?: string | null;
|
|
1322
|
+
merged_at: string | null;
|
|
1323
|
+
acknowledged_at?: string | null;
|
|
1324
|
+
}): boolean {
|
|
1325
|
+
return (
|
|
1326
|
+
TERMINAL_STATUSES.includes(p.derived_status) &&
|
|
1327
|
+
// An operator Dismiss (`acknowledged_at`) clears the overlay once a human has seen it.
|
|
1328
|
+
!p.acknowledged_at &&
|
|
1329
|
+
// The BASE row is still at an in-flight stage of the loop (the manifest's active set): nothing
|
|
1330
|
+
// settled it. This base-vs-derived divergence IS the orphan signal.
|
|
1331
|
+
activeStatusesFor("pull_requests").includes(p.status) &&
|
|
1332
|
+
// Provenance only explains the CURRENT owner's termination: a reason bound to a different
|
|
1333
|
+
// (predecessor) instance never masks an orphaned successor.
|
|
1334
|
+
!hasOwnTerminationReason(p) &&
|
|
1335
|
+
p.merged_at == null
|
|
1336
|
+
);
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
/** Whether the row carries a termination reason recorded for the instance that currently owns it. */
|
|
1340
|
+
function hasOwnTerminationReason(p: {
|
|
1341
|
+
process_key?: string | null;
|
|
1342
|
+
termination_reason?: string | null;
|
|
1343
|
+
termination_process_key?: string | null;
|
|
1344
|
+
}): boolean {
|
|
1345
|
+
return (
|
|
1346
|
+
!!p.termination_reason &&
|
|
1347
|
+
p.termination_process_key != null &&
|
|
1348
|
+
p.process_key != null &&
|
|
1349
|
+
String(p.termination_process_key) === String(p.process_key)
|
|
1350
|
+
);
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1280
1353
|
/** The default merge policy when no instance variables are readable (mid-handoff row, engine
|
|
1281
1354
|
* unreachable, or a legacy loop carrying neither variable): the pre-#916 behaviour merged by
|
|
1282
1355
|
* default, so the projection must NOT read such a PR as review-only. */
|
|
@@ -1592,7 +1665,7 @@ export async function retireTerminalReviewWait(
|
|
|
1592
1665
|
if (pr.process_key != null) {
|
|
1593
1666
|
const key = String(pr.process_key);
|
|
1594
1667
|
try {
|
|
1595
|
-
await engine.
|
|
1668
|
+
await terminatePrLoop(data, engine, pr.pr_key, key, `review wait retired: PR ${liveness} out-of-band`, true);
|
|
1596
1669
|
} catch (err) {
|
|
1597
1670
|
// Only finalize the row once the instance is confirmed gone/terminal: a transiently-failed
|
|
1598
1671
|
// cancel would otherwise drop the row from `waiting_review` scans while the live instance's
|
|
@@ -2016,36 +2089,75 @@ export async function maybeEnsureFreshHeadRun(
|
|
|
2016
2089
|
* defaults `autoResolveEscalations` to false and overwrites the row's flag on reopen, so an opted-in PR
|
|
2017
2090
|
* would silently lose automated escalation resolution merely because late review threads appeared.
|
|
2018
2091
|
*
|
|
2019
|
-
* RETRYABLE on failure (PR #
|
|
2020
|
-
* row is restored to `waiting_merge`
|
|
2021
|
-
*
|
|
2022
|
-
*
|
|
2023
|
-
|
|
2024
|
-
* unfinished handoff is re-detected next pass by the dead-owner guard in `pollMerges` (which keys off
|
|
2025
|
-
* the terminal `derived_status`, independently of the GitHub verdict). Best-effort throughout: a failed
|
|
2026
|
-
* cancel or restore only logs, leaving the row for the next pass. */
|
|
2027
|
-
async function handoffWaitingMergeToConvergence(
|
|
2092
|
+
* RETRYABLE on failure, with creation and retirement recovered SEPARATELY (PR #944 review): if no
|
|
2093
|
+
* successor is created, the row is restored to `waiting_merge` naming the still-LIVE merge owner, so
|
|
2094
|
+
* the next pass simply retries. Once a successor is published the row is never demoted; retiring the
|
|
2095
|
+
* captured merge key is retried by {@link retireHandedOffMergeLoop}. Exported for tests. */
|
|
2096
|
+
export async function handoffWaitingMergeToConvergence(
|
|
2028
2097
|
data: DataLayer,
|
|
2029
2098
|
engine: EngineClient,
|
|
2030
2099
|
pr: PullRequest,
|
|
2031
2100
|
logContext: string,
|
|
2032
2101
|
) {
|
|
2033
2102
|
const prKey = pr.pr_key;
|
|
2103
|
+
const mergeKey = pr.process_key != null ? String(pr.process_key) : null;
|
|
2034
2104
|
try {
|
|
2035
2105
|
// `waiting_since` bounds the review the fresh loop collects: only a review submitted at or after
|
|
2036
2106
|
// the handoff counts, so a pre-merge review (submitted before this flip) can never satisfy the new
|
|
2037
2107
|
// run's wait.
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2108
|
+
// FENCE the staging write on the captured owner (PR #944 review): if a cancel/resubmit replaced the
|
|
2109
|
+
// owner while this pass awaited GitHub, the replacement owns the row (possibly parked on a stage such
|
|
2110
|
+
// as `waiting_deps`) — re-staging it to `waiting_review` would strand it outside its stage's scan.
|
|
2111
|
+
// The STAGE side of the fence is an allowlist (PR #944 round-6 review), not "unchanged since this
|
|
2112
|
+
// pass read the row": a restart mid-handoff can strand the row at a handoff-transient stage
|
|
2113
|
+
// (`waiting_review`, or keyless at `converging` after a phantom null-key publish) outside the
|
|
2114
|
+
// `waiting_merge` recovery scan, and an unchanged-stage fence would skip it forever. The fence
|
|
2115
|
+
// admits (a) the LIVE merge stages the merge poller scans (`waiting_merge`, `waiting_lane`) and
|
|
2116
|
+
// (b) a KEYLESS row at a handoff-transient stage — there is no live owner to protect, and the
|
|
2117
|
+
// rollback below restores the live merge stage so the next pass re-drives the handoff. A
|
|
2118
|
+
// successor-owned row (`converging` naming the fresh convergence instance) still fails the OWNER
|
|
2119
|
+
// check and is never demoted; a predecessor-owned `waiting_review` row is mid-handoff and is left
|
|
2120
|
+
// for `retryPendingRetirements`, not re-staged.
|
|
2121
|
+
const fresh = await prs(data).get(prKey);
|
|
2122
|
+
const freshOwner = fresh?.process_key != null ? String(fresh.process_key) : null;
|
|
2123
|
+
const HANDOFFABLE_STAGES = new Set(["waiting_merge", "waiting_lane"]);
|
|
2124
|
+
const HANDOFF_TRANSIENT_STAGES = new Set(["waiting_review", "converging"]);
|
|
2125
|
+
const stageOk =
|
|
2126
|
+
HANDOFFABLE_STAGES.has(String(fresh?.status)) ||
|
|
2127
|
+
(freshOwner == null && HANDOFF_TRANSIENT_STAGES.has(String(fresh?.status)));
|
|
2128
|
+
if (!fresh || freshOwner !== mergeKey || !stageOk) {
|
|
2129
|
+
console.log(`[poller] ${logContext} handoff ${prKey}: owner/stage changed (${freshOwner}/${fresh?.status}); skipping`);
|
|
2130
|
+
return;
|
|
2131
|
+
}
|
|
2132
|
+
// ONE pending predecessor retirement at a time (PR #944 review): while an earlier predecessor's
|
|
2133
|
+
// retirement is unconfirmed, never start another handoff — a second key would overwrite the first
|
|
2134
|
+
// and leave that live loop untracked. `retryPendingRetirements` clears it first. A KEYLESS owner
|
|
2135
|
+
// row is exempt (PR #944 round-6 review): there is no live owner to protect — the pending key names
|
|
2136
|
+
// the predecessor the ORIGINAL handoff stranded, and this recovery handoff re-stages against THAT
|
|
2137
|
+
// SAME key, so nothing is overwritten.
|
|
2138
|
+
const pendingKey = fresh.retire_pending_key != null ? String(fresh.retire_pending_key) : "";
|
|
2139
|
+
if (pendingKey !== "" && mergeKey != null && pendingKey !== mergeKey) {
|
|
2140
|
+
console.log(`[poller] ${logContext} handoff ${prKey}: predecessor ${pendingKey} retirement pending; deferring`);
|
|
2141
|
+
return;
|
|
2047
2142
|
}
|
|
2048
|
-
|
|
2143
|
+
const handoffTs = now();
|
|
2144
|
+
// Persist the retirement INTENT in the same write that stages the handoff (PR #944 review), so a
|
|
2145
|
+
// restart after successor publication can never lose the predecessor's key. The retry pass only
|
|
2146
|
+
// acts once the row's owner is no longer this key (i.e. the successor is published); a rollback
|
|
2147
|
+
// clears it.
|
|
2148
|
+
await prs(data).update(prKey, {
|
|
2149
|
+
status: "waiting_review",
|
|
2150
|
+
waiting_since: handoffTs,
|
|
2151
|
+
updated_at: handoffTs,
|
|
2152
|
+
...(mergeKey != null ? { retire_pending_key: mergeKey } : {}),
|
|
2153
|
+
});
|
|
2154
|
+
// SUCCESSOR FIRST, then retire (issue #943). The old order — cancel the merge instance, THEN
|
|
2155
|
+
// `submitPr` — orphaned nanobpm/nano-bpm#1452: the derived tracking status folds the cancel in
|
|
2156
|
+
// asynchronously, so `submitPr` still read the row's `process_key` as a LIVE owner, returned
|
|
2157
|
+
// `alreadyRunning`, and started NOTHING — the merge loop was gone, no convergence loop took over,
|
|
2158
|
+
// no human was told. Now the live merge instance hands back as the exact owner (`handoffFrom`, the
|
|
2159
|
+
// #939 channel), so the successor exists before the predecessor is retired.
|
|
2160
|
+
const res = await submitPr(
|
|
2049
2161
|
data,
|
|
2050
2162
|
engine,
|
|
2051
2163
|
{ repo: pr.repo, number: pr.number, url: pr.url, prKey },
|
|
@@ -2055,25 +2167,183 @@ async function handoffWaitingMergeToConvergence(
|
|
|
2055
2167
|
null,
|
|
2056
2168
|
false,
|
|
2057
2169
|
pr.auto_resolve_escalations === 1,
|
|
2170
|
+
undefined,
|
|
2171
|
+
mergeKey,
|
|
2058
2172
|
);
|
|
2059
|
-
|
|
2173
|
+
const successor = "processKey" in res ? res.processKey : null;
|
|
2174
|
+
if (successor == null) {
|
|
2175
|
+
// Deduped against a REPLACEMENT owner (a cancel/resubmit landed while this pass awaited GitHub):
|
|
2176
|
+
// the live replacement owns the row now — never overwrite its key with the stale merge key.
|
|
2177
|
+
const current = await prs(data).get(prKey);
|
|
2178
|
+
if (current?.process_key != null && String(current.process_key) !== mergeKey) {
|
|
2179
|
+
console.log(`[poller] ${logContext} handoff ${prKey}: replaced by ${current.process_key}; leaving it as owner`);
|
|
2180
|
+
return;
|
|
2181
|
+
}
|
|
2182
|
+
// No successor was started — never retire the merge loop into nothing.
|
|
2183
|
+
throw new Error("convergence hand-off started no successor instance");
|
|
2184
|
+
}
|
|
2185
|
+
// The successor is PUBLISHED: from here on the row is convergence-owned and must never be demoted
|
|
2186
|
+
// back to `waiting_merge` (PR #944 review) — a retirement failure is recovered separately, against
|
|
2187
|
+
// the CAPTURED merge key, without touching the successor's row.
|
|
2188
|
+
if (mergeKey != null) await retireHandedOffMergeLoop(data, engine, prKey, mergeKey, successor, logContext);
|
|
2189
|
+
console.log(`[poller] ${logContext} -> handed back into convergence (${successor}) -> ${prKey}`);
|
|
2060
2190
|
} catch (err) {
|
|
2191
|
+
// CREATION failed (submitPr threw or started nothing): roll back to the prior owner. The merge
|
|
2192
|
+
// instance was NOT retired, so it stays LIVE and keeps owning the PR — restore BOTH the stage and
|
|
2193
|
+
// its `process_key` (a null-successor reopen may already have overwritten the key with NULL), so
|
|
2194
|
+
// the next pass re-attempts the handoff against the right instance. A row that had no owner key
|
|
2195
|
+
// (an earlier unfinished handoff) stays keyless: the NULL key is the durable "unfinished handoff"
|
|
2196
|
+
// marker the dead-owner guard in `pollMerges` keys on. Best-effort: a failed restore is only logged.
|
|
2061
2197
|
console.error(`[poller] ${logContext} handoff ${prKey}: ${err}`);
|
|
2062
|
-
// Restore the row to `waiting_merge` so the next pass re-attempts the handoff, and NULL the
|
|
2063
|
-
// `process_key`: the merge instance was retired above (or never lived), so the row genuinely has
|
|
2064
|
-
// NO live owner. The null key is the durable "unfinished handoff" marker the dead-owner guard in
|
|
2065
|
-
// `pollMerges` keys on — and it is what distinguishes this retryable shape from a HUMAN cancel of a
|
|
2066
|
-
// parked merge wait (which leaves `process_key` naming the terminated instance and must NOT be
|
|
2067
|
-
// resurrected). Best-effort: a failed restore still leaves the original error logged.
|
|
2068
2198
|
try {
|
|
2069
|
-
|
|
2199
|
+
// FENCE the rollback on the captured owner (PR #944 review): if a replacement took the row while
|
|
2200
|
+
// we awaited, it owns the PR — restoring the stale merge key would point tracking at a retired loop.
|
|
2201
|
+
const current = await prs(data).get(prKey);
|
|
2202
|
+
if (current?.process_key != null && String(current.process_key) !== mergeKey) {
|
|
2203
|
+
console.log(`[poller] ${logContext} handoff ${prKey}: owner changed to ${current.process_key}; no rollback`);
|
|
2204
|
+
return;
|
|
2205
|
+
}
|
|
2206
|
+
await prs(data).update(prKey, {
|
|
2207
|
+
status: "waiting_merge",
|
|
2208
|
+
...(mergeKey != null ? { process_key: mergeKey, retire_pending_key: null } : {}),
|
|
2209
|
+
updated_at: now(),
|
|
2210
|
+
});
|
|
2070
2211
|
} catch (restoreErr) {
|
|
2071
2212
|
console.error(`[poller] ${logContext} handoff ${prKey}: restore waiting_merge: ${restoreErr}`);
|
|
2072
2213
|
}
|
|
2073
2214
|
}
|
|
2074
2215
|
}
|
|
2075
2216
|
|
|
2217
|
+
/** Retire the predecessor merge loop after its convergence successor is published (issue #943, PR #944
|
|
2218
|
+
* review). Bounded retry against the CAPTURED merge key; a persistent failure is logged loudly but never
|
|
2219
|
+
* rolls the successor-owned row back. Exported for tests. */
|
|
2220
|
+
export async function retireHandedOffMergeLoop(
|
|
2221
|
+
data: DataLayer,
|
|
2222
|
+
engine: Pick<EngineClient, "cancelInstance">,
|
|
2223
|
+
prKey: string,
|
|
2224
|
+
mergeKey: string,
|
|
2225
|
+
successor: string,
|
|
2226
|
+
logContext: string,
|
|
2227
|
+
attempts = 2,
|
|
2228
|
+
): Promise<boolean> {
|
|
2229
|
+
const reason = `merge loop handed back to convergence (${logContext}) -> successor ${successor}`;
|
|
2230
|
+
for (let i = 1; i <= attempts; i++) {
|
|
2231
|
+
try {
|
|
2232
|
+
await terminatePrLoop(data, engine, prKey, mergeKey, reason, true);
|
|
2233
|
+
await setRetirePending(data, prKey, mergeKey, false);
|
|
2234
|
+
return true;
|
|
2235
|
+
} catch (err) {
|
|
2236
|
+
console.error(`[poller] ${logContext} handoff ${prKey}: retire merge ${mergeKey} attempt ${i}/${attempts}: ${err}`);
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
// Persist the pending retirement so later passes keep retrying it (never drop the predecessor key —
|
|
2240
|
+
// a live merge loop could fire its timeout and overwrite the successor-owned row).
|
|
2241
|
+
await setRetirePending(data, prKey, mergeKey, true);
|
|
2242
|
+
return false;
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
/** Durably mark/clear a pending predecessor retirement. Clearing is fenced on the key so a newer
|
|
2246
|
+
* pending retirement is never wiped by an older one's success. Best-effort (logged). */
|
|
2247
|
+
async function setRetirePending(data: DataLayer, prKey: string, mergeKey: string, pending: boolean): Promise<void> {
|
|
2248
|
+
try {
|
|
2249
|
+
const row = await prs(data).get(prKey);
|
|
2250
|
+
if (!row) return;
|
|
2251
|
+
if (pending) await prs(data).update(prKey, { retire_pending_key: mergeKey, updated_at: now() });
|
|
2252
|
+
else if (row.retire_pending_key != null && String(row.retire_pending_key) === mergeKey)
|
|
2253
|
+
await prs(data).update(prKey, { retire_pending_key: null, updated_at: now() });
|
|
2254
|
+
} catch (err) {
|
|
2255
|
+
console.error(`[poller] ${prKey}: persist retire-pending ${mergeKey}: ${err}`);
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
/** Poll pass (PR #944 review): re-attempt every persisted pending predecessor retirement. Never touches
|
|
2260
|
+
* the row's stage or owner — only the predecessor instance and the pending marker. Exported for tests. */
|
|
2261
|
+
export async function retryPendingRetirements(data: DataLayer, engine: Pick<EngineClient, "cancelInstance">) {
|
|
2262
|
+
for (const pr of await prs(data).all()) {
|
|
2263
|
+
const key = pr.retire_pending_key;
|
|
2264
|
+
if (key == null || key === "") continue;
|
|
2265
|
+
// A pending key that is still the row's OWNER means the handoff never published a successor (e.g. a
|
|
2266
|
+
// restart mid-handoff): the merge loop is live and owning — never retire it into nothing.
|
|
2267
|
+
if (pr.process_key != null && String(pr.process_key) === String(key)) continue;
|
|
2268
|
+
// A KEYLESS owner row is the same unpublished-handoff shape (PR #944 round-5 review): `submitPr`
|
|
2269
|
+
// can publish a null `process_key`, and if the app stops before the rollback lands (or the rollback
|
|
2270
|
+
// write fails) the pending key survives with NO successor. Retiring here would cancel the still-live
|
|
2271
|
+
// merge loop with successor "none" — the ownerless termination this PR exists to prevent. Leave the
|
|
2272
|
+
// pending marker in place: the dead-owner guard in `pollMerges` re-drives the handoff from the
|
|
2273
|
+
// NULL-key row, and the next pass after a successor is published retires the predecessor against it.
|
|
2274
|
+
if (pr.process_key == null) {
|
|
2275
|
+
console.log(`[poller] retire-retry ${pr.pr_key}: no successor published (process_key null); deferring retirement of ${key}`);
|
|
2276
|
+
continue;
|
|
2277
|
+
}
|
|
2278
|
+
await retireHandedOffMergeLoop(data, engine, pr.pr_key, String(key), String(pr.process_key), "retire-retry", 1);
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
/** Record WHY a PR loop instance was terminated (issue #943), bound to THAT instance. The ONE writer of
|
|
2283
|
+
* termination provenance, shared by {@link terminatePrLoop} (app-initiated) and every operator cancel
|
|
2284
|
+
* door (`/actions/cancel`, `/actions/cancel-pr`). The reason is stamped only when `processKey` is the
|
|
2285
|
+
* row's CURRENT owner: once a successor owns the row (the convergence hand-off publishes it before
|
|
2286
|
+
* retiring the predecessor), the predecessor's reason has nothing to explain on that row and must not
|
|
2287
|
+
* mask a later orphaning of the successor (PR #944 review). Exported for the cancel doors + tests. */
|
|
2288
|
+
export async function recordTerminationProvenance(
|
|
2289
|
+
data: DataLayer,
|
|
2290
|
+
prKey: string,
|
|
2291
|
+
processKey: string,
|
|
2292
|
+
reason: string,
|
|
2293
|
+
): Promise<boolean> {
|
|
2294
|
+
const row = await prs(data).get(prKey);
|
|
2295
|
+
if (!row || row.process_key == null || String(row.process_key) !== String(processKey)) return false;
|
|
2296
|
+
await prs(data).update(prKey, {
|
|
2297
|
+
termination_reason: reason,
|
|
2298
|
+
termination_process_key: String(processKey),
|
|
2299
|
+
updated_at: now(),
|
|
2300
|
+
});
|
|
2301
|
+
return true;
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
/** Record operator-cancel provenance on every PR row a cancelled instance owns (issue #943). */
|
|
2305
|
+
export async function recordOperatorCancelProvenance(data: DataLayer, processKey: string, door: string): Promise<void> {
|
|
2306
|
+
for (const row of await prs(data).find({ process_key: processKey })) {
|
|
2307
|
+
await recordTerminationProvenance(data, row.pr_key, processKey, `operator cancel (${door})`);
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
/** THE canonical door for an app-initiated cancel of a PR's convergence/merge loop (issue #943).
|
|
2312
|
+
* Every in-app termination must say WHY and what took over: the reason (which names the successor
|
|
2313
|
+
* instance or the terminal GitHub outcome) is stamped on the row's `termination_reason` BEFORE the
|
|
2314
|
+
* engine cancel, so even a crash between the two leaves provenance. `strict` rethrows a failed cancel
|
|
2315
|
+
* (callers that must confirm the instance is gone); otherwise a failed cancel is logged. The
|
|
2316
|
+
* `cancel-sites` guard test (app/loopTermination.test.ts) fails if any new `cancelInstance` call site
|
|
2317
|
+
* targeting a PR loop bypasses this door. Exported for tests. */
|
|
2318
|
+
export async function terminatePrLoop(
|
|
2319
|
+
data: DataLayer,
|
|
2320
|
+
engine: Pick<EngineClient, "cancelInstance">,
|
|
2321
|
+
prKey: string,
|
|
2322
|
+
processKey: string,
|
|
2323
|
+
reason: string,
|
|
2324
|
+
strict: boolean,
|
|
2325
|
+
): Promise<void> {
|
|
2326
|
+
try {
|
|
2327
|
+
await recordTerminationProvenance(data, prKey, processKey, reason);
|
|
2328
|
+
} catch (err) {
|
|
2329
|
+
// Provenance is diagnostic: a failed write must never keep a loop alive that must be retired.
|
|
2330
|
+
console.error(`[loop] ${prKey}: record termination reason for ${processKey} failed: ${err}`);
|
|
2331
|
+
}
|
|
2332
|
+
try {
|
|
2333
|
+
await engine.cancelInstance({ processInstanceKey: processKey });
|
|
2334
|
+
} catch (err) {
|
|
2335
|
+
if (strict) throw err;
|
|
2336
|
+
console.error(`[loop] ${prKey}: cancel ${processKey} (${reason}) failed: ${err}`);
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2076
2340
|
export async function pollMerges(data: DataLayer, engine: EngineClient, token: string) {
|
|
2341
|
+
// 0) Pending predecessor retirements from earlier hand-offs (PR #944 review).
|
|
2342
|
+
try {
|
|
2343
|
+
await retryPendingRetirements(data, engine);
|
|
2344
|
+
} catch (err) {
|
|
2345
|
+
console.error(`[poller] retire-retry: ${err}`);
|
|
2346
|
+
}
|
|
2077
2347
|
// 1) Dependencies merged?
|
|
2078
2348
|
for (const pr of await prs(data).find({ status: "waiting_deps" })) {
|
|
2079
2349
|
const prKey = pr.pr_key;
|
|
@@ -104,6 +104,10 @@ test("submitPr re-opens a derive-only-terminated PR (base 'converging', derived
|
|
|
104
104
|
status: "converging", // base transient FROZEN — reconciler no longer writes the terminal
|
|
105
105
|
derived_status: "abandoned", // ADR-0065 derive-only terminal
|
|
106
106
|
current_round: 3,
|
|
107
|
+
// A PRIOR run's operator Dismiss + provenance must not survive the reopen (PR #944 review).
|
|
108
|
+
acknowledged_at: "2024-01-05",
|
|
109
|
+
termination_reason: "operator cancel",
|
|
110
|
+
termination_process_key: "PI-1",
|
|
107
111
|
}],
|
|
108
112
|
key: "pr_key",
|
|
109
113
|
},
|
|
@@ -126,24 +130,31 @@ test("submitPr re-opens a derive-only-terminated PR (base 'converging', derived
|
|
|
126
130
|
assertEquals(pr.status, "converging");
|
|
127
131
|
assertEquals(pr.current_round, 1); // re-opened for a fresh run
|
|
128
132
|
assertEquals(pr.process_key, "PI-9");
|
|
133
|
+
assertEquals(pr.acknowledged_at, null, "a prior run's Dismiss never acknowledges this run's orphaning");
|
|
134
|
+
assertEquals(pr.termination_reason, null);
|
|
135
|
+
assertEquals(pr.termination_process_key, null);
|
|
129
136
|
});
|
|
130
137
|
});
|
|
131
138
|
|
|
132
|
-
// #503 / #497 phantom: `activePrs`
|
|
133
|
-
//
|
|
134
|
-
|
|
139
|
+
// #503 / #497 phantom: `activePrs` classifies on `derived_status`, so a derive-only-terminated PR never
|
|
140
|
+
// reads as its frozen in-flight base status. Issue #943: if NO reason was recorded for the termination
|
|
141
|
+
// (an orphan) it is surfaced as `orphaned` rather than silently dropped; a recorded reason or an
|
|
142
|
+
// operator Dismiss (`acknowledged_at`) excludes it.
|
|
143
|
+
test("activePrs never reports a derive-only-terminated PR as active — orphaned if unexplained, excluded if explained/dismissed", async () => {
|
|
135
144
|
const stores: Stores = {
|
|
136
145
|
pull_requests: {
|
|
137
146
|
rows: [
|
|
138
147
|
{ pr_key: "o/r#1", repo: "o/r", number: 1, url: "u1", status: "converging", derived_status: "abandoned", current_round: 2, updated_at: "2024-01-02" },
|
|
139
148
|
{ pr_key: "o/r#2", repo: "o/r", number: 2, url: "u2", status: "converging", derived_status: "converging", current_round: 1, updated_at: "2024-01-01" },
|
|
149
|
+
{ pr_key: "o/r#3", repo: "o/r", number: 3, url: "u3", status: "converging", derived_status: "abandoned", process_key: "PI-3", termination_reason: "operator cancel", termination_process_key: "PI-3", current_round: 1, updated_at: "2024-01-03" },
|
|
150
|
+
{ pr_key: "o/r#4", repo: "o/r", number: 4, url: "u4", status: "converging", derived_status: "abandoned", acknowledged_at: "2024-01-05", current_round: 1, updated_at: "2024-01-04" },
|
|
140
151
|
],
|
|
141
152
|
key: "pr_key",
|
|
142
153
|
},
|
|
143
154
|
escalations: { rows: [], key: "id" },
|
|
144
155
|
};
|
|
145
156
|
const active = await activePrs(memData(stores));
|
|
146
|
-
assertEquals(active.map((p) => p.prKey), ["o/r#2"]);
|
|
157
|
+
assertEquals(active.map((p) => [p.prKey, p.status]), [["o/r#1", "orphaned"], ["o/r#2", "converging"]]);
|
|
147
158
|
});
|
|
148
159
|
|
|
149
160
|
// #503: `pollIncidentsImpl` classifies a dead instance on `derived_status`, so a derive-only-terminated
|
package/build-info.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.208.
|
|
3
|
-
"gitSha": "
|
|
4
|
-
"gitTag": "v0.208.
|
|
5
|
-
"builtAt": "2026-11-
|
|
2
|
+
"version": "0.208.4",
|
|
3
|
+
"gitSha": "097724344a2f3942af5b1796e2e0a7e8363b44f9",
|
|
4
|
+
"gitTag": "v0.208.4",
|
|
5
|
+
"builtAt": "2026-11-11T03:16:35.107Z"
|
|
6
6
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
-- Loop-termination provenance (issue #943). A merge loop was terminated in-app (the waiting-merge →
|
|
2
|
+
-- convergence hand-off) with no successor and no escalation, and nothing on the row said why — the PR
|
|
3
|
+
-- silently dropped off `/status`. Every APP-INITIATED cancel of a PR's convergence/merge loop now
|
|
4
|
+
-- stamps a one-line reason here (via `terminatePrLoop` in app/service.ts), naming the outcome it
|
|
5
|
+
-- led to (a successor convergence instance, or the PR's terminal GitHub state). NULL when the current
|
|
6
|
+
-- run was never cancelled by the app. `/status` surfaces it, and an `orphaned` overlay flags a PR
|
|
7
|
+
-- whose loop ended with NO recorded reason, no successor and no escalation.
|
|
8
|
+
ALTER TABLE pull_requests ADD COLUMN termination_reason TEXT;
|
|
9
|
+
|
|
10
|
+
-- The instance the reason describes (PR #944 review): provenance is bound to the CANCELLED instance and
|
|
11
|
+
-- only explains a termination while it equals the row's `process_key`, so a predecessor's reason can
|
|
12
|
+
-- never mask the later orphaning of a successor.
|
|
13
|
+
ALTER TABLE pull_requests ADD COLUMN termination_process_key TEXT;
|
|
14
|
+
|
|
15
|
+
-- A handed-off predecessor merge loop whose retirement has not yet succeeded (PR #944 review): durable
|
|
16
|
+
-- so the poller retries it every pass instead of dropping the key. NULL when nothing is pending.
|
|
17
|
+
ALTER TABLE pull_requests ADD COLUMN retire_pending_key TEXT;
|