@nanobpm/nano-workforce 0.193.1 → 0.194.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 +12 -0
- package/app/agentic/cockpit/agent-history-render.test.ts +24 -0
- package/app/agentic/cockpit/agent-history-render.ts +18 -4
- package/app/agentic/cockpit/agent-history-view.test.ts +16 -0
- package/app/agentic/cockpit/agent-history-view.ts +17 -0
- package/app/agentic/cockpit/mount.test.ts +12 -0
- package/app/persist-escalation.test.ts +56 -15
- package/app/persistEscalationElements.test.ts +40 -0
- package/app/pollUserTasks.test.ts +572 -3
- package/app/service.ts +158 -0
- package/app/userTasks.ts +34 -0
- package/package.json +2 -2
- package/pages/cockpit/cockpit.css +17 -1
- package/pages/cockpit/mount.js +27 -4
- package/workers/persist-escalation/worker.ts +29 -14
|
@@ -15,6 +15,34 @@ function memData(seed: Record<string, any[]> = {}): { data: DataLayer; stores: R
|
|
|
15
15
|
const stores: Record<string, any[]> = {};
|
|
16
16
|
for (const [k, v] of Object.entries(seed)) stores[k] = v.map((r) => ({ ...r }));
|
|
17
17
|
function tbl(name: string, pk = "id") {
|
|
18
|
+
// The ADR-0065 derived tracking VIEW (`<base>__tracking`) is modelled as a read-only projection
|
|
19
|
+
// over its base store, augmenting each row with `derived_status` (Copilot review of #829 — the PR
|
|
20
|
+
// self-heal now reads candidates through `prsTracking` and filters on `derived_status="escalated"`).
|
|
21
|
+
// Default `derived_status` to the base `status`, so a fixture that does NOT model out-of-band
|
|
22
|
+
// termination reads live (base status passes through). A test that wants a row the reconciler has
|
|
23
|
+
// already folded terminal seeds an explicit `derived_status` (e.g. `"abandoned"`) on the base row.
|
|
24
|
+
const trackingMatch = /^(.*)__tracking$/.exec(name);
|
|
25
|
+
if (trackingMatch) {
|
|
26
|
+
const base = (stores[trackingMatch[1]] ??= [] as any[]);
|
|
27
|
+
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
28
|
+
const project = (r: any) => ({ ...r, derived_status: r.derived_status ?? r.status });
|
|
29
|
+
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
30
|
+
const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
|
|
31
|
+
return {
|
|
32
|
+
async all() {
|
|
33
|
+
return base.map(project);
|
|
34
|
+
},
|
|
35
|
+
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
36
|
+
async get(id: any) {
|
|
37
|
+
const r = base.find((row) => row[pk] === id);
|
|
38
|
+
return r ? project(r) : undefined;
|
|
39
|
+
},
|
|
40
|
+
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
41
|
+
async find(where: any = {}) {
|
|
42
|
+
return base.map(project).filter((r) => match(r, where));
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
18
46
|
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
19
47
|
const rows = (stores[name] ??= [] as any[]);
|
|
20
48
|
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
@@ -48,26 +76,107 @@ function memData(seed: Record<string, any[]> = {}): { data: DataLayer; stores: R
|
|
|
48
76
|
},
|
|
49
77
|
};
|
|
50
78
|
}
|
|
51
|
-
const data =
|
|
79
|
+
const data = openableData(stores, tbl);
|
|
52
80
|
return { data, stores };
|
|
53
81
|
}
|
|
54
82
|
|
|
83
|
+
/** Wrap the in-memory `stores` as a `DataLayer` that ALSO exposes `open()` → a `DataSource` with the
|
|
84
|
+
* `exec` (guarded UPDATE) + `tx` (snapshot/rollback) surface the escalated-PR self-heal now uses for its
|
|
85
|
+
* conditional, atomic repair (Copilot review of #829). `exec` interprets ONLY the two guarded statements
|
|
86
|
+
* the heal issues — the snapshot-fenced `pull_requests` CAS and the by-id `escalations` retirement —
|
|
87
|
+
* mutating the same store objects `table()` reads, so both surfaces stay consistent within a test. The
|
|
88
|
+
* SQL shape itself is exercised against real SQLite by the app's integration/e2e suites; here the fake
|
|
89
|
+
* need only honour the guards so a race assertion can observe the CAS refusing a moved snapshot. */
|
|
90
|
+
// biome-ignore lint/suspicious/noExplicitAny: in-memory rows are untyped fixtures
|
|
91
|
+
function openableData(stores: Record<string, any[]>, tbl: (name: string, pk?: string) => any): DataLayer {
|
|
92
|
+
const norm = (v: unknown) => v ?? null;
|
|
93
|
+
const exec = async (sql: string, params: unknown[] = []) => {
|
|
94
|
+
const flip =
|
|
95
|
+
/UPDATE "pull_requests" SET "status" = 'converging', "updated_at" = \? WHERE "pr_key" = \? AND "status" = 'escalated' AND "process_key" IS \? AND "updated_at" IS \?/.exec(
|
|
96
|
+
sql,
|
|
97
|
+
);
|
|
98
|
+
if (flip) {
|
|
99
|
+
const [at, prKey, processKey, updatedAt] = params;
|
|
100
|
+
const row = (stores.pull_requests ?? []).find((r) => r.pr_key === prKey);
|
|
101
|
+
if (row && row.status === "escalated" && norm(row.process_key) === norm(processKey) && norm(row.updated_at) === norm(updatedAt)) {
|
|
102
|
+
row.status = "converging";
|
|
103
|
+
row.updated_at = at;
|
|
104
|
+
return { changed: 1 };
|
|
105
|
+
}
|
|
106
|
+
return { changed: 0 };
|
|
107
|
+
}
|
|
108
|
+
const retire = /UPDATE "escalations" SET "status" = 'stale' WHERE "id" = \? AND "status" = 'open'/.exec(sql);
|
|
109
|
+
if (retire) {
|
|
110
|
+
const row = (stores.escalations ?? []).find((r) => r.id === params[0]);
|
|
111
|
+
if (row && row.status === "open") {
|
|
112
|
+
row.status = "stale";
|
|
113
|
+
return { changed: 1 };
|
|
114
|
+
}
|
|
115
|
+
return { changed: 0 };
|
|
116
|
+
}
|
|
117
|
+
throw new Error(`unexpected exec sql: ${sql}`);
|
|
118
|
+
};
|
|
119
|
+
const source: any = {
|
|
120
|
+
exec,
|
|
121
|
+
table: (n: string, pk?: string) => tbl(n, pk),
|
|
122
|
+
tx: async (fn: (t: any) => Promise<unknown>) => {
|
|
123
|
+
const snap = JSON.parse(JSON.stringify(stores));
|
|
124
|
+
try {
|
|
125
|
+
return await fn(source);
|
|
126
|
+
} catch (e) {
|
|
127
|
+
for (const [n, rows] of Object.entries(stores)) {
|
|
128
|
+
rows.length = 0;
|
|
129
|
+
rows.push(...(snap[n] ?? []));
|
|
130
|
+
}
|
|
131
|
+
throw e;
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
return { table: (n: string, pk?: string) => tbl(n, pk), open: () => source } as unknown as DataLayer;
|
|
136
|
+
}
|
|
137
|
+
|
|
55
138
|
/** A single engine-reported user task in the fixture. `state` mirrors the engine lifecycle; it
|
|
56
139
|
* defaults to `"CREATED"` (the only open/answerable state) so existing fixtures read as live tasks.
|
|
57
140
|
* A looping instance holds multiple tasks for one element (COMPLETED from prior rounds + the live one). */
|
|
58
141
|
type FakeTask = { userTaskKey: string; elementId?: string; state?: "CREATED" | "COMPLETED" | "CANCELED"; formKey?: string };
|
|
59
142
|
|
|
143
|
+
/** A single engine-reported element instance in the fixture — the answer-recording service task the
|
|
144
|
+
* escalation self-heal probes for (issue #829). `state` mirrors the engine lifecycle; `"ACTIVE"` is the
|
|
145
|
+
* in-flight marker that an answer is being recorded. */
|
|
146
|
+
type FakeElement = { elementId: string; state: string };
|
|
147
|
+
|
|
60
148
|
/** A fake engine whose user tasks are keyed by processInstanceKey (the only field the poller queries on
|
|
61
149
|
* for plan / PR instances). It models the real engine's two accessors from ONE fixture so a test
|
|
62
150
|
* genuinely exercises the lifecycle-state filtering: `searchUserTasks` returns tasks in ANY state
|
|
63
|
-
* (COMPLETED first, as the live API does — issue #294), while `openUserTasks` pins `state:"CREATED"`.
|
|
64
|
-
|
|
151
|
+
* (COMPLETED first, as the live API does — issue #294), while `openUserTasks` pins `state:"CREATED"`.
|
|
152
|
+
* `searchElementInstances` is fed from an OPTIONAL second fixture (default empty) so the PR escalation
|
|
153
|
+
* self-heal can see an ACTIVE answer-recording task (issue #829). `searchProcessInstances` is fed from an
|
|
154
|
+
* OPTIONAL third fixture keyed by processInstanceKey: any key NOT listed defaults to reporting the
|
|
155
|
+
* instance `ACTIVE` (so existing heal fixtures, which model a genuinely-resumed live loop, stay green); a
|
|
156
|
+
* value of `null` models an instance ABSENT from the read model; a string overrides its lifecycle state
|
|
157
|
+
* (e.g. `"TERMINATED"`) — the positive-ACTIVE liveness gate the self-heal requires (issue #829). */
|
|
158
|
+
function fakeEngine(
|
|
159
|
+
byInstance: Record<string, FakeTask[]>,
|
|
160
|
+
elementsByInstance: Record<string, FakeElement[]> = {},
|
|
161
|
+
instanceStateByInstance: Record<string, string | null> = {},
|
|
162
|
+
): EngineClient {
|
|
65
163
|
const all = (filter?: { processInstanceKey?: string }) =>
|
|
66
164
|
filter?.processInstanceKey ? (byInstance[filter.processInstanceKey] ?? []) : [];
|
|
67
165
|
return {
|
|
68
166
|
searchUserTasks: (filter?: { processInstanceKey?: string }) => Promise.resolve(all(filter)),
|
|
69
167
|
openUserTasks: (filter?: { processInstanceKey?: string }) =>
|
|
70
168
|
Promise.resolve(all(filter).filter((t) => (t.state ?? "CREATED") === "CREATED")),
|
|
169
|
+
searchElementInstances: (filter?: { processInstanceKey?: string }) =>
|
|
170
|
+
Promise.resolve(filter?.processInstanceKey ? (elementsByInstance[filter.processInstanceKey] ?? []) : []),
|
|
171
|
+
searchProcessInstances: (filter?: { processInstanceKeys?: string[] }) =>
|
|
172
|
+
Promise.resolve(
|
|
173
|
+
(filter?.processInstanceKeys ?? [])
|
|
174
|
+
.map((k) => {
|
|
175
|
+
const state = k in instanceStateByInstance ? instanceStateByInstance[k] : "ACTIVE";
|
|
176
|
+
return state === null ? null : { processInstanceKey: k, state };
|
|
177
|
+
})
|
|
178
|
+
.filter((it): it is { processInstanceKey: string; state: string } => it != null),
|
|
179
|
+
),
|
|
71
180
|
} as unknown as EngineClient;
|
|
72
181
|
}
|
|
73
182
|
|
|
@@ -1072,3 +1181,463 @@ test("pollUserTasks (typed-seam fallback): self-heals an escalated run with no o
|
|
|
1072
1181
|
const byKey = Object.fromEntries((stores.feature_runs ?? []).map((r) => [r.feature_key, r]));
|
|
1073
1182
|
assertEquals(byKey["o/r#632"].status, "running", "the stranded escalated run is healed to running");
|
|
1074
1183
|
});
|
|
1184
|
+
|
|
1185
|
+
test("pollUserTasks (engine-first): self-heals an escalated PR stranded off its parked task, sparing a genuinely parked one (issue #828)", async () => {
|
|
1186
|
+
// The convergence-path twin of the #642 feature heal. A PR's `status="escalated"` must hold ONLY while
|
|
1187
|
+
// a `wait-answer` / `wait-merge-answer` task is open. A PR whose instance the engine no longer reports
|
|
1188
|
+
// parked (its escalation was answered, but the write-side `answer-escalation` flip was lost around an
|
|
1189
|
+
// app restart — the #828 tear) is reconciled to `converging`; a PR whose task IS still open is left
|
|
1190
|
+
// escalated. The engine's open set is the authority, not the raw `pull_requests.status` column. The
|
|
1191
|
+
// orphaned `open` escalation rows are retired to `stale` so the audit trail matches.
|
|
1192
|
+
const { data, stores } = memData({
|
|
1193
|
+
pull_requests: [
|
|
1194
|
+
{ pr_key: "o/r#828", status: "escalated", process_key: "rp-828", url: "https://github.com/o/r/pull/828", title: "stranded" },
|
|
1195
|
+
{ pr_key: "o/r#77", status: "escalated", process_key: "rp-77", url: "https://github.com/o/r/pull/77", title: "still parked" },
|
|
1196
|
+
],
|
|
1197
|
+
escalations: [
|
|
1198
|
+
{ id: 1, pr_key: "o/r#828", status: "open", question: "orphaned — never answered", answer: null, answered_at: null },
|
|
1199
|
+
{ id: 2, pr_key: "o/r#77", status: "open", question: "genuinely open", answer: null, answered_at: null },
|
|
1200
|
+
],
|
|
1201
|
+
});
|
|
1202
|
+
const restore = stubUserTaskSearch([
|
|
1203
|
+
// Only rp-77 is genuinely parked; the engine reports NO open task on rp-828.
|
|
1204
|
+
{ userTaskKey: "ut-parked", elementId: "wait-answer", processInstanceKey: "rp-77", state: "CREATED" },
|
|
1205
|
+
]);
|
|
1206
|
+
try {
|
|
1207
|
+
await pollUserTasks(data, fakeEngine({ "rp-77": [{ userTaskKey: "ut-parked", elementId: "wait-answer" }] }), REST);
|
|
1208
|
+
} finally {
|
|
1209
|
+
restore();
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
const byKey = Object.fromEntries((stores.pull_requests ?? []).map((r) => [r.pr_key, r]));
|
|
1213
|
+
assertEquals(byKey["o/r#828"].status, "converging", "the stranded escalated PR is healed to converging");
|
|
1214
|
+
assertEquals(byKey["o/r#77"].status, "escalated", "the genuinely parked PR stays escalated");
|
|
1215
|
+
const escById = Object.fromEntries((stores.escalations ?? []).map((r) => [r.id, r]));
|
|
1216
|
+
assertEquals(escById[1].status, "stale", "the healed PR's orphaned open escalation row is retired to stale");
|
|
1217
|
+
assertEquals(escById[2].status, "open", "the genuinely parked PR's escalation row stays open");
|
|
1218
|
+
});
|
|
1219
|
+
|
|
1220
|
+
test("pollUserTasks (engine-first): self-heals an escalated MERGE-loop PR off its parked wait-merge-answer task (issue #828)", async () => {
|
|
1221
|
+
// Both loops write `pr.persist-escalation` (`status="escalated"`) and are answered by the same
|
|
1222
|
+
// `answer-escalation` flip, so the heal must confirm against BOTH PR escalation elements — a merge
|
|
1223
|
+
// escalation parks on `wait-merge-answer`.
|
|
1224
|
+
const { data, stores } = memData({
|
|
1225
|
+
pull_requests: [
|
|
1226
|
+
{ pr_key: "o/r#828m", status: "escalated", process_key: "mp-828", url: "https://github.com/o/r/pull/828", title: "stranded merge" },
|
|
1227
|
+
],
|
|
1228
|
+
escalations: [{ id: 1, pr_key: "o/r#828m", status: "open", question: "not mergeable", answer: null, answered_at: null }],
|
|
1229
|
+
});
|
|
1230
|
+
const restore = stubUserTaskSearch([]); // engine reports NO open task on mp-828
|
|
1231
|
+
const engine = fakeEngine({ "mp-828": [] });
|
|
1232
|
+
try {
|
|
1233
|
+
await pollUserTasks(data, engine, REST);
|
|
1234
|
+
} finally {
|
|
1235
|
+
restore();
|
|
1236
|
+
}
|
|
1237
|
+
const byKey = Object.fromEntries((stores.pull_requests ?? []).map((r) => [r.pr_key, r]));
|
|
1238
|
+
assertEquals(byKey["o/r#828m"].status, "converging", "the stranded escalated merge-loop PR is healed to converging");
|
|
1239
|
+
});
|
|
1240
|
+
|
|
1241
|
+
test("pollUserTasks (engine-first): does NOT heal an escalated PR while its answer-recording job is in flight (issue #829)", async () => {
|
|
1242
|
+
// The dangerous race: both BPMN models flow the completed `wait-answer` / `wait-merge-answer` user task
|
|
1243
|
+
// DIRECTLY to the `record-answer` / `record-merge-answer` service task (`pr.answer-escalation`). Between
|
|
1244
|
+
// the operator completing the task and that job running, the user task is already gone (openUserTasks
|
|
1245
|
+
// returns none) yet the answer is being recorded RIGHT NOW. The escalation can be arbitrarily old (past
|
|
1246
|
+
// the raise-time grace), so the grace window does not cover this. Healing here would flip the row to
|
|
1247
|
+
// `converging` and retire the still-`open` escalation to `stale`, and the in-flight worker would then
|
|
1248
|
+
// find no target and silently drop the operator's answer. An ACTIVE answer-recording element instance is
|
|
1249
|
+
// positive evidence the answer is in flight, so the heal must skip it — exactly like an open task.
|
|
1250
|
+
const stale = new Date(Date.now() - 60 * 60_000).toISOString();
|
|
1251
|
+
const { data, stores } = memData({
|
|
1252
|
+
pull_requests: [
|
|
1253
|
+
{ pr_key: "o/r#829r", status: "escalated", process_key: "rp-829r", updated_at: stale, url: "https://github.com/o/r/pull/829", title: "answer in flight (review)" },
|
|
1254
|
+
{ pr_key: "o/r#829m", status: "escalated", process_key: "rp-829m", updated_at: stale, url: "https://github.com/o/r/pull/8290", title: "answer in flight (merge)" },
|
|
1255
|
+
],
|
|
1256
|
+
escalations: [
|
|
1257
|
+
{ id: 1, pr_key: "o/r#829r", status: "open", question: "answered, recording", answer: "do it", answered_at: stale },
|
|
1258
|
+
{ id: 2, pr_key: "o/r#829m", status: "open", question: "answered, recording", answer: "rebase", answered_at: stale },
|
|
1259
|
+
],
|
|
1260
|
+
});
|
|
1261
|
+
const restore = stubUserTaskSearch([]); // the user task has already completed on both instances
|
|
1262
|
+
// Each instance's answer-recording service task is ACTIVE — the operator's answer is being written.
|
|
1263
|
+
const engine = fakeEngine(
|
|
1264
|
+
{ "rp-829r": [], "rp-829m": [] },
|
|
1265
|
+
{
|
|
1266
|
+
"rp-829r": [{ elementId: "record-answer", state: "ACTIVE" }],
|
|
1267
|
+
"rp-829m": [{ elementId: "record-merge-answer", state: "ACTIVE" }],
|
|
1268
|
+
},
|
|
1269
|
+
);
|
|
1270
|
+
try {
|
|
1271
|
+
await pollUserTasks(data, engine, REST);
|
|
1272
|
+
} finally {
|
|
1273
|
+
restore();
|
|
1274
|
+
}
|
|
1275
|
+
const byKey = Object.fromEntries((stores.pull_requests ?? []).map((r) => [r.pr_key, r]));
|
|
1276
|
+
assertEquals(byKey["o/r#829r"].status, "escalated", "a PR with an in-flight review answer-recorder is spared the heal");
|
|
1277
|
+
assertEquals(byKey["o/r#829m"].status, "escalated", "a PR with an in-flight merge answer-recorder is spared the heal");
|
|
1278
|
+
const escById = Object.fromEntries((stores.escalations ?? []).map((r) => [r.id, r]));
|
|
1279
|
+
assertEquals(escById[1].status, "open", "the in-flight review escalation row is left for the worker to answer");
|
|
1280
|
+
assertEquals(escById[2].status, "open", "the in-flight merge escalation row is left for the worker to answer");
|
|
1281
|
+
});
|
|
1282
|
+
|
|
1283
|
+
test("pollUserTasks (engine-first): DOES heal an escalated PR once its answer-recorder has completed (issue #829)", async () => {
|
|
1284
|
+
// The genuine #828 lost-write tear: the answer-recording job COMPLETED (its element instance is no
|
|
1285
|
+
// longer ACTIVE) but the durable `status="converging"` flip was lost around an app restart. With no open
|
|
1286
|
+
// task AND no ACTIVE answer-recorder, the PR is genuinely stranded and must heal — the in-flight guard
|
|
1287
|
+
// must not over-fire and wedge it permanently.
|
|
1288
|
+
const stale = new Date(Date.now() - 60 * 60_000).toISOString();
|
|
1289
|
+
const { data, stores } = memData({
|
|
1290
|
+
pull_requests: [{ pr_key: "o/r#829c", status: "escalated", process_key: "rp-829c", updated_at: stale, url: "https://github.com/o/r/pull/8291", title: "recorder completed, write lost" }],
|
|
1291
|
+
escalations: [{ id: 1, pr_key: "o/r#829c", status: "open", question: "orphaned", answer: null, answered_at: null }],
|
|
1292
|
+
});
|
|
1293
|
+
const restore = stubUserTaskSearch([]);
|
|
1294
|
+
const engine = fakeEngine({ "rp-829c": [] }, { "rp-829c": [{ elementId: "record-answer", state: "COMPLETED" }] });
|
|
1295
|
+
try {
|
|
1296
|
+
await pollUserTasks(data, engine, REST);
|
|
1297
|
+
} finally {
|
|
1298
|
+
restore();
|
|
1299
|
+
}
|
|
1300
|
+
const byKey = Object.fromEntries((stores.pull_requests ?? []).map((r) => [r.pr_key, r]));
|
|
1301
|
+
assertEquals(byKey["o/r#829c"].status, "converging", "a stranded PR whose answer-recorder already completed is healed");
|
|
1302
|
+
const escById = Object.fromEntries((stores.escalations ?? []).map((r) => [r.id, r]));
|
|
1303
|
+
assertEquals(escById[1].status, "stale", "the orphaned open escalation row is retired to stale");
|
|
1304
|
+
});
|
|
1305
|
+
|
|
1306
|
+
test("pollUserTasks (engine-first): does NOT heal an escalated PR whose process instance is TERMINAL (issue #829)", async () => {
|
|
1307
|
+
// Copilot review of the #828 heal: "no open task and no ACTIVE escalation element" is ALSO true for a
|
|
1308
|
+
// TERMINAL instance (cancelled / completed / failed), whose loop never resumed. Flipping such a frozen
|
|
1309
|
+
// `escalated` row to `converging` and retiring its audit rows fabricates a live loop the engine will
|
|
1310
|
+
// never advance; terminal PRs are owned by tracking/reconciliation (they read `abandoned`/settled on
|
|
1311
|
+
// `derived_status`). The heal now requires POSITIVE engine-truth that the instance is `ACTIVE`, so a
|
|
1312
|
+
// TERMINATED (and, below, an absent) instance is spared. One case per known terminal state.
|
|
1313
|
+
const stale = new Date(Date.now() - 60 * 60_000).toISOString();
|
|
1314
|
+
const { data, stores } = memData({
|
|
1315
|
+
pull_requests: [
|
|
1316
|
+
{ pr_key: "o/r#829tt", status: "escalated", process_key: "rp-829tt", updated_at: stale, url: "https://github.com/o/r/pull/8297", title: "terminated instance" },
|
|
1317
|
+
{ pr_key: "o/r#829tc", status: "escalated", process_key: "rp-829tc", updated_at: stale, url: "https://github.com/o/r/pull/8298", title: "cancelled instance" },
|
|
1318
|
+
],
|
|
1319
|
+
escalations: [
|
|
1320
|
+
{ id: 1, pr_key: "o/r#829tt", status: "open", question: "loop gone", answer: null, answered_at: null },
|
|
1321
|
+
{ id: 2, pr_key: "o/r#829tc", status: "open", question: "loop gone", answer: null, answered_at: null },
|
|
1322
|
+
],
|
|
1323
|
+
});
|
|
1324
|
+
const restore = stubUserTaskSearch([]); // no open task — but the loop is dead, not merely mid-transition
|
|
1325
|
+
// No open task, no ACTIVE escalation element, but the instance itself is a known terminal state.
|
|
1326
|
+
const engine = fakeEngine(
|
|
1327
|
+
{ "rp-829tt": [], "rp-829tc": [] },
|
|
1328
|
+
{},
|
|
1329
|
+
{ "rp-829tt": "TERMINATED", "rp-829tc": "CANCELED" },
|
|
1330
|
+
);
|
|
1331
|
+
try {
|
|
1332
|
+
await pollUserTasks(data, engine, REST);
|
|
1333
|
+
} finally {
|
|
1334
|
+
restore();
|
|
1335
|
+
}
|
|
1336
|
+
const byKey = Object.fromEntries((stores.pull_requests ?? []).map((r) => [r.pr_key, r]));
|
|
1337
|
+
assertEquals(byKey["o/r#829tt"].status, "escalated", "a TERMINATED instance is left to tracking/reconciliation, not healed");
|
|
1338
|
+
assertEquals(byKey["o/r#829tc"].status, "escalated", "a CANCELED instance is left to tracking/reconciliation, not healed");
|
|
1339
|
+
const escById = Object.fromEntries((stores.escalations ?? []).map((r) => [r.id, r]));
|
|
1340
|
+
assertEquals(escById[1].status, "open", "the terminal instance's escalation row is not retired");
|
|
1341
|
+
assertEquals(escById[2].status, "open", "the cancelled instance's escalation row is not retired");
|
|
1342
|
+
});
|
|
1343
|
+
|
|
1344
|
+
test("pollUserTasks (engine-first): does NOT heal an escalated PR whose process instance is ABSENT / unknown-state (issue #829)", async () => {
|
|
1345
|
+
// The other halves of the tri-state (Copilot review of #828): an instance genuinely ABSENT from the
|
|
1346
|
+
// read model (engine answered, no match) is treated like a terminal — its loop is gone, so leave it to
|
|
1347
|
+
// tracking rather than fabricate a resumed loop. An instance present with an UNKNOWN/empty `state` is a
|
|
1348
|
+
// partial read this app cannot interpret and is SPARED (never healed off a wire shape we misread). Both
|
|
1349
|
+
// stay `escalated`. One case each.
|
|
1350
|
+
const stale = new Date(Date.now() - 60 * 60_000).toISOString();
|
|
1351
|
+
const { data, stores } = memData({
|
|
1352
|
+
pull_requests: [
|
|
1353
|
+
{ pr_key: "o/r#829ab", status: "escalated", process_key: "rp-829ab", updated_at: stale, url: "https://github.com/o/r/pull/8299", title: "absent instance" },
|
|
1354
|
+
{ pr_key: "o/r#829uk", status: "escalated", process_key: "rp-829uk", updated_at: stale, url: "https://github.com/o/r/pull/82990", title: "unknown-state instance" },
|
|
1355
|
+
],
|
|
1356
|
+
escalations: [
|
|
1357
|
+
{ id: 1, pr_key: "o/r#829ab", status: "open", question: "loop gone", answer: null, answered_at: null },
|
|
1358
|
+
{ id: 2, pr_key: "o/r#829uk", status: "open", question: "partial read", answer: null, answered_at: null },
|
|
1359
|
+
],
|
|
1360
|
+
});
|
|
1361
|
+
const restore = stubUserTaskSearch([]);
|
|
1362
|
+
// `null` models an instance absent from the read model; a blank string models an unknown/partial state.
|
|
1363
|
+
const engine = fakeEngine({ "rp-829ab": [], "rp-829uk": [] }, {}, { "rp-829ab": null, "rp-829uk": "" });
|
|
1364
|
+
try {
|
|
1365
|
+
await pollUserTasks(data, engine, REST);
|
|
1366
|
+
} finally {
|
|
1367
|
+
restore();
|
|
1368
|
+
}
|
|
1369
|
+
const byKey = Object.fromEntries((stores.pull_requests ?? []).map((r) => [r.pr_key, r]));
|
|
1370
|
+
assertEquals(byKey["o/r#829ab"].status, "escalated", "an ABSENT instance is left to tracking/reconciliation, not healed");
|
|
1371
|
+
assertEquals(byKey["o/r#829uk"].status, "escalated", "an UNKNOWN-state instance is spared, not healed off a misread wire shape");
|
|
1372
|
+
const escById = Object.fromEntries((stores.escalations ?? []).map((r) => [r.id, r]));
|
|
1373
|
+
assertEquals(escById[1].status, "open", "the absent instance's escalation row is not retired");
|
|
1374
|
+
assertEquals(escById[2].status, "open", "the unknown-state instance's escalation row is not retired");
|
|
1375
|
+
});
|
|
1376
|
+
|
|
1377
|
+
test("pollUserTasks (engine-first): a terminated-while-escalated PR is EXCLUDED by the derived tracking VIEW — never probed (issue #829)", async () => {
|
|
1378
|
+
// Copilot review of #829: the candidate scan reads through `prsTracking` and requires
|
|
1379
|
+
// `derived_status="escalated"`, so a PR TERMINATED while its base status was `escalated` — whose VIEW
|
|
1380
|
+
// folds `derived_status` to `abandoned` — is excluded BEFORE any per-instance probe. Without the VIEW
|
|
1381
|
+
// filter this historical row would be reselected every poll and burn engine RPCs forever. We seed an
|
|
1382
|
+
// explicit `derived_status: "abandoned"` (the reconciler's terminal fold) and assert the heal issues NO
|
|
1383
|
+
// engine RPC for it at all, and its frozen base row / escalation are left untouched.
|
|
1384
|
+
const stale = new Date(Date.now() - 60 * 60_000).toISOString();
|
|
1385
|
+
const { data, stores } = memData({
|
|
1386
|
+
pull_requests: [
|
|
1387
|
+
{ pr_key: "o/r#829term", status: "escalated", derived_status: "abandoned", process_key: "rp-829term", updated_at: stale, url: "https://github.com/o/r/pull/82991", title: "terminated-while-escalated (VIEW-folded)" },
|
|
1388
|
+
],
|
|
1389
|
+
escalations: [{ id: 1, pr_key: "o/r#829term", status: "open", question: "loop gone", answer: null, answered_at: null }],
|
|
1390
|
+
});
|
|
1391
|
+
const restore = stubUserTaskSearch([]);
|
|
1392
|
+
const probed: string[] = [];
|
|
1393
|
+
const engine = {
|
|
1394
|
+
searchUserTasks: () => Promise.resolve([]),
|
|
1395
|
+
openUserTasks: (f?: { processInstanceKey?: string }) => {
|
|
1396
|
+
if (f?.processInstanceKey) probed.push(f.processInstanceKey);
|
|
1397
|
+
return Promise.resolve([]);
|
|
1398
|
+
},
|
|
1399
|
+
searchElementInstances: (f?: { processInstanceKey?: string }) => {
|
|
1400
|
+
if (f?.processInstanceKey) probed.push(f.processInstanceKey);
|
|
1401
|
+
return Promise.resolve([]);
|
|
1402
|
+
},
|
|
1403
|
+
searchProcessInstances: (f?: { processInstanceKeys?: string[] }) => {
|
|
1404
|
+
for (const k of f?.processInstanceKeys ?? []) probed.push(k);
|
|
1405
|
+
return Promise.resolve([]);
|
|
1406
|
+
},
|
|
1407
|
+
} as unknown as EngineClient;
|
|
1408
|
+
try {
|
|
1409
|
+
await pollUserTasks(data, engine, REST);
|
|
1410
|
+
} finally {
|
|
1411
|
+
restore();
|
|
1412
|
+
}
|
|
1413
|
+
assertEquals(probed.includes("rp-829term"), false, "a VIEW-folded terminal row is excluded from the scan — no engine RPC is issued for it");
|
|
1414
|
+
const byKey = Object.fromEntries((stores.pull_requests ?? []).map((r) => [r.pr_key, r]));
|
|
1415
|
+
assertEquals(byKey["o/r#829term"].status, "escalated", "the terminated row's frozen base status is left to tracking, not rewritten");
|
|
1416
|
+
const escById = Object.fromEntries((stores.escalations ?? []).map((r) => [r.id, r]));
|
|
1417
|
+
assertEquals(escById[1].status, "open", "the terminated row's escalation is not retired");
|
|
1418
|
+
});
|
|
1419
|
+
|
|
1420
|
+
test("pollUserTasks (engine-first): does NOT heal an escalated PR while its persist-escalation producer is still ACTIVE (issue #829)", async () => {
|
|
1421
|
+
// The producer-side twin of the answer-recorder race. `pr.persist-escalation` COMMITS the `open` row and
|
|
1422
|
+
// `status="escalated"` BEFORE the engine creates the `wait-answer` user task, so there is a window with an
|
|
1423
|
+
// `escalated` PR, an `open` escalation, and NO user task yet. The raise-time grace normally covers it, but
|
|
1424
|
+
// if the producer service task stays ACTIVE past the grace (an app restart / lease delay between the DB
|
|
1425
|
+
// commit and the job completing) the row is old enough to heal while the escalation is being raised RIGHT
|
|
1426
|
+
// NOW. Healing here would retire the just-raised escalation to `stale` and the question would never surface.
|
|
1427
|
+
// An ACTIVE persist-escalation element is positive evidence, so the heal must skip it — one case per loop.
|
|
1428
|
+
const stale = new Date(Date.now() - 60 * 60_000).toISOString();
|
|
1429
|
+
const { data, stores } = memData({
|
|
1430
|
+
pull_requests: [
|
|
1431
|
+
{ pr_key: "o/r#829pr", status: "escalated", process_key: "rp-829pr", updated_at: stale, url: "https://github.com/o/r/pull/8293", title: "producer active (review)" },
|
|
1432
|
+
{ pr_key: "o/r#829pm", status: "escalated", process_key: "rp-829pm", updated_at: stale, url: "https://github.com/o/r/pull/8294", title: "producer active (merge)" },
|
|
1433
|
+
],
|
|
1434
|
+
escalations: [
|
|
1435
|
+
{ id: 1, pr_key: "o/r#829pr", status: "open", question: "being raised", answer: null, answered_at: null },
|
|
1436
|
+
{ id: 2, pr_key: "o/r#829pm", status: "open", question: "being raised", answer: null, answered_at: null },
|
|
1437
|
+
],
|
|
1438
|
+
});
|
|
1439
|
+
const restore = stubUserTaskSearch([]); // the user task does not exist yet — the producer hasn't completed
|
|
1440
|
+
const engine = fakeEngine(
|
|
1441
|
+
{ "rp-829pr": [], "rp-829pm": [] },
|
|
1442
|
+
{
|
|
1443
|
+
"rp-829pr": [{ elementId: "persist-escalation-blockedcomments", state: "ACTIVE" }],
|
|
1444
|
+
"rp-829pm": [{ elementId: "merge-esc-conflict", state: "ACTIVE" }],
|
|
1445
|
+
},
|
|
1446
|
+
);
|
|
1447
|
+
try {
|
|
1448
|
+
await pollUserTasks(data, engine, REST);
|
|
1449
|
+
} finally {
|
|
1450
|
+
restore();
|
|
1451
|
+
}
|
|
1452
|
+
const byKey = Object.fromEntries((stores.pull_requests ?? []).map((r) => [r.pr_key, r]));
|
|
1453
|
+
assertEquals(byKey["o/r#829pr"].status, "escalated", "a PR with an ACTIVE review persist-escalation producer is spared the heal");
|
|
1454
|
+
assertEquals(byKey["o/r#829pm"].status, "escalated", "a PR with an ACTIVE merge persist-escalation producer is spared the heal");
|
|
1455
|
+
const escById = Object.fromEntries((stores.escalations ?? []).map((r) => [r.id, r]));
|
|
1456
|
+
assertEquals(escById[1].status, "open", "the just-raised review escalation row is left open");
|
|
1457
|
+
assertEquals(escById[2].status, "open", "the just-raised merge escalation row is left open");
|
|
1458
|
+
});
|
|
1459
|
+
|
|
1460
|
+
test("pollUserTasks (engine-first): does NOT heal an escalated PR when the producer→user-task transition lands BETWEEN the two engine reads (issue #829)", async () => {
|
|
1461
|
+
// The producer→user-task TOCTOU the prior two guards miss. `openUserTasks()` (read 1) returns empty while
|
|
1462
|
+
// a long-running persist producer is still ACTIVE; that producer then COMPLETES and the engine creates the
|
|
1463
|
+
// `wait-answer` / `wait-merge-answer` user task BEFORE `searchElementInstances()` (read 2). Read 2 therefore
|
|
1464
|
+
// sees an ACTIVE wait element — NOT the producer. Because user-task creation does not re-stamp the PR row's
|
|
1465
|
+
// `updated_at` (only `pr.persist-escalation` does, before the task exists), the row is past the raise-time
|
|
1466
|
+
// grace, so neither the grace nor the producer/answer-recorder predicate covers it: a predicate that ignores
|
|
1467
|
+
// the wait elements lets the CAS win and stale a live escalation. Treat an ACTIVE `wait-answer` /
|
|
1468
|
+
// `wait-merge-answer` element as positive evidence in the second snapshot too — one case per loop.
|
|
1469
|
+
const stale = new Date(Date.now() - 60 * 60_000).toISOString();
|
|
1470
|
+
const { data, stores } = memData({
|
|
1471
|
+
pull_requests: [
|
|
1472
|
+
{ pr_key: "o/r#829wr", status: "escalated", process_key: "rp-829wr", updated_at: stale, url: "https://github.com/o/r/pull/8295", title: "producer→wait (review)" },
|
|
1473
|
+
{ pr_key: "o/r#829wm", status: "escalated", process_key: "rp-829wm", updated_at: stale, url: "https://github.com/o/r/pull/8296", title: "producer→wait (merge)" },
|
|
1474
|
+
],
|
|
1475
|
+
escalations: [
|
|
1476
|
+
{ id: 1, pr_key: "o/r#829wr", status: "open", question: "raised mid-read", answer: null, answered_at: null },
|
|
1477
|
+
{ id: 2, pr_key: "o/r#829wm", status: "open", question: "raised mid-read", answer: null, answered_at: null },
|
|
1478
|
+
],
|
|
1479
|
+
});
|
|
1480
|
+
// The typed `openUserTasks` seam reports NO open task (the producer was still ACTIVE at read 1), while the
|
|
1481
|
+
// element search (read 2) now sees the freshly-created wait element ACTIVE — the transition landed mid-read.
|
|
1482
|
+
const restore = stubUserTaskSearch([]);
|
|
1483
|
+
const engine = fakeEngine(
|
|
1484
|
+
{ "rp-829wr": [], "rp-829wm": [] },
|
|
1485
|
+
{
|
|
1486
|
+
"rp-829wr": [{ elementId: "wait-answer", state: "ACTIVE" }],
|
|
1487
|
+
"rp-829wm": [{ elementId: "wait-merge-answer", state: "ACTIVE" }],
|
|
1488
|
+
},
|
|
1489
|
+
);
|
|
1490
|
+
try {
|
|
1491
|
+
await pollUserTasks(data, engine, REST);
|
|
1492
|
+
} finally {
|
|
1493
|
+
restore();
|
|
1494
|
+
}
|
|
1495
|
+
const byKey = Object.fromEntries((stores.pull_requests ?? []).map((r) => [r.pr_key, r]));
|
|
1496
|
+
assertEquals(byKey["o/r#829wr"].status, "escalated", "a PR whose wait-answer appeared mid-read is spared the heal");
|
|
1497
|
+
assertEquals(byKey["o/r#829wm"].status, "escalated", "a PR whose wait-merge-answer appeared mid-read is spared the heal");
|
|
1498
|
+
const escById = Object.fromEntries((stores.escalations ?? []).map((r) => [r.id, r]));
|
|
1499
|
+
assertEquals(escById[1].status, "open", "the live review escalation row is left open");
|
|
1500
|
+
assertEquals(escById[2].status, "open", "the live merge escalation row is left open");
|
|
1501
|
+
});
|
|
1502
|
+
|
|
1503
|
+
test("pollUserTasks (engine-first): a re-escalation in the read→write window is NOT clobbered — the guarded CAS refuses the moved snapshot (issue #829)", async () => {
|
|
1504
|
+
// The TOCTOU race Copilot flagged: the heal reads the PR, then does two remote engine RPCs, THEN writes.
|
|
1505
|
+
// If the SAME instance re-escalates in that window (`pr.persist-escalation` re-stamps `updated_at` and
|
|
1506
|
+
// INSERTs a fresh open escalation), a blind write would clobber the fresh `escalated` status and stale
|
|
1507
|
+
// the fresh escalation — dropping the operator's next answer. We reproduce the concurrent re-escalation
|
|
1508
|
+
// by mutating the store from inside `searchElementInstances` (the last read before the write), then
|
|
1509
|
+
// assert the snapshot-fenced CAS makes NO change: the PR stays `escalated` and the fresh escalation open.
|
|
1510
|
+
// NB the mutation advances `updated_at` AND inserts the fresh escalation TOGETHER, modelling the now-atomic
|
|
1511
|
+
// `pr.persist-escalation` producer (its insert + PR re-stamp share one transaction, issue #829) — so a
|
|
1512
|
+
// captured snapshot can never see the new escalation under the OLD generation; the covering red/green for
|
|
1513
|
+
// that producer atomicity lives in app/persist-escalation.test.ts.
|
|
1514
|
+
const stale = new Date(Date.now() - 60 * 60_000).toISOString();
|
|
1515
|
+
const { data, stores } = memData({
|
|
1516
|
+
pull_requests: [{ pr_key: "o/r#829race", status: "escalated", process_key: "rp-829race", updated_at: stale, url: "https://github.com/o/r/pull/8292", title: "re-escalates mid-heal" }],
|
|
1517
|
+
escalations: [{ id: 1, pr_key: "o/r#829race", status: "open", question: "first", answer: null, answered_at: null }],
|
|
1518
|
+
});
|
|
1519
|
+
const restore = stubUserTaskSearch([]);
|
|
1520
|
+
let reEscalated = false;
|
|
1521
|
+
const engine = {
|
|
1522
|
+
searchUserTasks: () => Promise.resolve([]),
|
|
1523
|
+
openUserTasks: () => Promise.resolve([]),
|
|
1524
|
+
// Report the instance ACTIVE so the heal reaches its guarded CAS (Copilot review of #829): without a
|
|
1525
|
+
// `searchProcessInstances` response the positive-liveness probe throws, sets `queryErrored`, and the
|
|
1526
|
+
// row would be spared for the WRONG reason — never exercising the snapshot-fenced CAS this test asserts.
|
|
1527
|
+
searchProcessInstances: () => Promise.resolve([{ processInstanceKey: "rp-829race", state: "ACTIVE" }]),
|
|
1528
|
+
searchElementInstances: (filter?: { processInstanceKey?: string }) => {
|
|
1529
|
+
// Simulate the concurrent re-escalation landing AFTER the heal captured its snapshot but BEFORE its
|
|
1530
|
+
// write: the row's generation advances (`updated_at`) and a brand-new open escalation is inserted.
|
|
1531
|
+
if (filter?.processInstanceKey === "rp-829race" && !reEscalated) {
|
|
1532
|
+
reEscalated = true;
|
|
1533
|
+
const row = (stores.pull_requests ?? []).find((r) => r.pr_key === "o/r#829race");
|
|
1534
|
+
if (row) row.updated_at = new Date().toISOString();
|
|
1535
|
+
(stores.escalations ?? []).push({ id: 2, pr_key: "o/r#829race", status: "open", question: "second", answer: null, answered_at: null });
|
|
1536
|
+
}
|
|
1537
|
+
return Promise.resolve([{ elementId: "record-answer", state: "COMPLETED" }]);
|
|
1538
|
+
},
|
|
1539
|
+
} as unknown as EngineClient;
|
|
1540
|
+
try {
|
|
1541
|
+
await pollUserTasks(data, engine, REST);
|
|
1542
|
+
} finally {
|
|
1543
|
+
restore();
|
|
1544
|
+
}
|
|
1545
|
+
const byKey = Object.fromEntries((stores.pull_requests ?? []).map((r) => [r.pr_key, r]));
|
|
1546
|
+
assertEquals(byKey["o/r#829race"].status, "escalated", "the re-escalated PR is left escalated — the CAS refused the moved snapshot");
|
|
1547
|
+
const escById = Object.fromEntries((stores.escalations ?? []).map((r) => [r.id, r]));
|
|
1548
|
+
assertEquals(escById[2].status, "open", "the fresh escalation opened in the window is NOT retired");
|
|
1549
|
+
assertEquals(escById[1].status, "open", "the snapshot's own escalation is left untouched too — the whole tx rolled to a no-op");
|
|
1550
|
+
});
|
|
1551
|
+
|
|
1552
|
+
test("pollUserTasks (engine-first): skips the per-instance open-task RPC for an escalated PR already seen parked in this pass's sweep (issue #828)", async () => {
|
|
1553
|
+
// best-effort sweep may truncate (drop tasks) but never invents one. Re-confirming with a per-instance
|
|
1554
|
+
// `openUserTasks` RPC is a redundant N+1 query; the self-heal must skip it. Only a PR NOT confirmed
|
|
1555
|
+
// parked by the sweep still needs the per-instance check.
|
|
1556
|
+
const { data, stores } = memData({
|
|
1557
|
+
pull_requests: [
|
|
1558
|
+
{ pr_key: "o/r#parked", status: "escalated", process_key: "rp-parked", url: "https://github.com/o/r/pull/1", title: "parked" },
|
|
1559
|
+
{ pr_key: "o/r#stranded", status: "escalated", process_key: "rp-stranded", url: "https://github.com/o/r/pull/2", title: "stranded" },
|
|
1560
|
+
],
|
|
1561
|
+
escalations: [{ id: 1, pr_key: "o/r#parked", status: "open", question: "open", answer: null, answered_at: null }],
|
|
1562
|
+
});
|
|
1563
|
+
const restore = stubUserTaskSearch([
|
|
1564
|
+
{ userTaskKey: "ut-parked", elementId: "wait-answer", processInstanceKey: "rp-parked", state: "CREATED" },
|
|
1565
|
+
]);
|
|
1566
|
+
const openUserTasksCalls: string[] = [];
|
|
1567
|
+
const engine = {
|
|
1568
|
+
searchUserTasks: () => Promise.resolve([]),
|
|
1569
|
+
openUserTasks: (filter?: { processInstanceKey?: string }) => {
|
|
1570
|
+
if (filter?.processInstanceKey) openUserTasksCalls.push(filter.processInstanceKey);
|
|
1571
|
+
return Promise.resolve([]);
|
|
1572
|
+
},
|
|
1573
|
+
searchElementInstances: () => Promise.resolve([]),
|
|
1574
|
+
// The stranded instance genuinely resumed (ACTIVE) — so the sweep-miss falls through to the
|
|
1575
|
+
// per-instance confirm AND the positive-liveness gate, and heals (issue #829).
|
|
1576
|
+
searchProcessInstances: (filter?: { processInstanceKeys?: string[] }) =>
|
|
1577
|
+
Promise.resolve((filter?.processInstanceKeys ?? []).map((k) => ({ processInstanceKey: k, state: "ACTIVE" }))),
|
|
1578
|
+
} as unknown as EngineClient;
|
|
1579
|
+
try {
|
|
1580
|
+
await pollUserTasks(data, engine, REST);
|
|
1581
|
+
} finally {
|
|
1582
|
+
restore();
|
|
1583
|
+
}
|
|
1584
|
+
const byKey = Object.fromEntries((stores.pull_requests ?? []).map((r) => [r.pr_key, r]));
|
|
1585
|
+
assertEquals(byKey["o/r#parked"].status, "escalated", "the swept-parked PR stays escalated without a per-instance query");
|
|
1586
|
+
assertEquals(byKey["o/r#stranded"].status, "converging", "the PR absent from the sweep is still confirmed per-instance and healed");
|
|
1587
|
+
assertEquals(openUserTasksCalls.includes("rp-parked"), false, "no redundant per-instance RPC for the already-parked PR");
|
|
1588
|
+
assertEquals(openUserTasksCalls.includes("rp-stranded"), true, "the unconfirmed PR still needs the per-instance RPC");
|
|
1589
|
+
});
|
|
1590
|
+
|
|
1591
|
+
test("pollUserTasks (engine-first): does NOT heal an escalated PR when the per-instance open-task query errors (issue #828)", async () => {
|
|
1592
|
+
// A per-instance query error is not proof the PR is unparked — mutating on that negative evidence would
|
|
1593
|
+
// steal a parked escalation. On query error the PR must be left `escalated` for a later pass.
|
|
1594
|
+
const { data, stores } = memData({
|
|
1595
|
+
pull_requests: [{ pr_key: "o/r#err", status: "escalated", process_key: "rp-err", url: "https://github.com/o/r/pull/9", title: "query errors" }],
|
|
1596
|
+
escalations: [{ id: 1, pr_key: "o/r#err", status: "open", question: "open", answer: null, answered_at: null }],
|
|
1597
|
+
});
|
|
1598
|
+
const restore = stubUserTaskSearch([]);
|
|
1599
|
+
const engine = {
|
|
1600
|
+
searchUserTasks: () => Promise.resolve([]),
|
|
1601
|
+
openUserTasks: (filter?: { processInstanceKey?: string }) =>
|
|
1602
|
+
filter?.processInstanceKey === "rp-err" ? Promise.reject(new Error("engine down")) : Promise.resolve([]),
|
|
1603
|
+
searchElementInstances: () => Promise.resolve([]),
|
|
1604
|
+
} as unknown as EngineClient;
|
|
1605
|
+
try {
|
|
1606
|
+
await pollUserTasks(data, engine, REST);
|
|
1607
|
+
} finally {
|
|
1608
|
+
restore();
|
|
1609
|
+
}
|
|
1610
|
+
const byKey = Object.fromEntries((stores.pull_requests ?? []).map((r) => [r.pr_key, r]));
|
|
1611
|
+
assertEquals(byKey["o/r#err"].status, "escalated", "a failed per-instance query leaves the PR escalated");
|
|
1612
|
+
const escById = Object.fromEntries((stores.escalations ?? []).map((r) => [r.id, r]));
|
|
1613
|
+
assertEquals(escById[1].status, "open", "the escalation row is untouched when the heal is skipped");
|
|
1614
|
+
});
|
|
1615
|
+
|
|
1616
|
+
test("pollUserTasks (engine-first): does NOT heal a JUST-escalated PR inside the grace window before its user task exists (issue #828)", async () => {
|
|
1617
|
+
// `pr.persist-escalation` stamps `status="escalated"` (and `updated_at`) IMMEDIATELY BEFORE the engine
|
|
1618
|
+
// creates the `wait-answer` user task. A poll landing in that window sees `openUserTasks` return none
|
|
1619
|
+
// and would wrongly flip the fresh escalation back to `converging`. A short grace window on `updated_at`
|
|
1620
|
+
// spares a just-written escalation while still healing genuinely-stranded (old) rows.
|
|
1621
|
+
const fresh = new Date().toISOString();
|
|
1622
|
+
const stale = new Date(Date.now() - 60 * 60_000).toISOString();
|
|
1623
|
+
const { data, stores } = memData({
|
|
1624
|
+
pull_requests: [
|
|
1625
|
+
{ pr_key: "o/r#fresh", status: "escalated", process_key: "rp-fresh", updated_at: fresh, url: "https://github.com/o/r/pull/3", title: "just escalated" },
|
|
1626
|
+
{ pr_key: "o/r#old", status: "escalated", process_key: "rp-old", updated_at: stale, url: "https://github.com/o/r/pull/4", title: "genuinely stranded" },
|
|
1627
|
+
],
|
|
1628
|
+
escalations: [
|
|
1629
|
+
{ id: 1, pr_key: "o/r#fresh", status: "open", question: "fresh", answer: null, answered_at: null },
|
|
1630
|
+
{ id: 2, pr_key: "o/r#old", status: "open", question: "old", answer: null, answered_at: null },
|
|
1631
|
+
],
|
|
1632
|
+
});
|
|
1633
|
+
const restore = stubUserTaskSearch([]);
|
|
1634
|
+
const engine = fakeEngine({});
|
|
1635
|
+
try {
|
|
1636
|
+
await pollUserTasks(data, engine, REST);
|
|
1637
|
+
} finally {
|
|
1638
|
+
restore();
|
|
1639
|
+
}
|
|
1640
|
+
const byKey = Object.fromEntries((stores.pull_requests ?? []).map((r) => [r.pr_key, r]));
|
|
1641
|
+
assertEquals(byKey["o/r#fresh"].status, "escalated", "a just-escalated PR inside the grace window is spared the heal");
|
|
1642
|
+
assertEquals(byKey["o/r#old"].status, "converging", "a genuinely-stranded (old) PR is still healed");
|
|
1643
|
+
});
|