@nanobpm/nano-workforce 0.173.0 → 0.174.1
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/github.test.ts +2 -0
- package/app/github.ts +113 -1
- package/app/mergeQueueEviction.test.ts +223 -0
- package/app/queuedVerdict.test.ts +38 -2
- package/app/readiness.test.ts +1 -0
- package/app/readiness.ts +1 -0
- package/app/reconcile.test.ts +234 -4
- package/app/reconcile.ts +299 -44
- package/app/service.ts +37 -18
- package/main.ts +8 -6
- package/openapi.yaml +10 -3
- package/package.json +1 -1
package/app/reconcile.ts
CHANGED
|
@@ -29,12 +29,24 @@
|
|
|
29
29
|
// engine is a no-op too: reconcile NEVER orphans when it could not confirm a reset (a 401/5xx or
|
|
30
30
|
// a network error yields `reachable:false`, not a false "engine missing").
|
|
31
31
|
//
|
|
32
|
+
// A second, complementary pass covers the "instance absent/unknown" gap (issue #630) the epoch signal
|
|
33
|
+
// alone cannot: an inflight run whose engine instance has VANISHED from the read model — its
|
|
34
|
+
// `keyField` (process_key) has NO `_urban_instance_state` row at all (engine clean-reset, cluster
|
|
35
|
+
// rebuild, or read-model pruning removed it), so the derived tracking edge has no `TERMINATED` row to
|
|
36
|
+
// match and the run freezes at its last worker-owned status, wedging Active forever with no
|
|
37
|
+
// reconciliation path (the observed pre-reset orphan `Magikcraft/nano-bpm#1051`, process_key 71506).
|
|
38
|
+
// `reconcileVanishedInstances` drives every such row — active, dispatched, its key absent from
|
|
39
|
+
// `_urban_instance_state`, and PAST A GRACE WINDOW (so a still-starting run not yet projected is
|
|
40
|
+
// spared) — to the same `orphaned` terminal, with a DISTINCT provenance reason so an operator can
|
|
41
|
+
// tell a vanished-instance orphan apart from an epoch-regression one. `runEngineReconcile` runs BOTH
|
|
42
|
+
// passes, so startup and the operator command converge both failure modes in one call.
|
|
43
|
+
//
|
|
32
44
|
// The provenance is app-owned (not urban's `_urban_write_provenance`, which is a domain-free
|
|
33
45
|
// insert-join sidecar written only inside a job): reconcile runs at boot / over HTTP, outside any
|
|
34
46
|
// job, and needs to record the REASON + epoch + run id — which the app-owned `reconcile_provenance`
|
|
35
47
|
// table carries, and the existing `app.db` backup convention makes the whole mutation reversible.
|
|
36
48
|
|
|
37
|
-
import type { DataLayer, GatewayDataSource as DataSource } from "@nanobpm/urban";
|
|
49
|
+
import type { DataLayer, GatewayDataSource as DataSource, InstanceTracking } from "@nanobpm/urban";
|
|
38
50
|
import type { TopologyProbe } from "./enginePreflight.ts";
|
|
39
51
|
import { activeStatusesFor, baseStatusFieldFor, engineBackedBindings, keyFieldFor } from "./instanceTracking.ts";
|
|
40
52
|
|
|
@@ -49,6 +61,28 @@ export const ORPHANED_STATUS = "orphaned";
|
|
|
49
61
|
* recorded incarnation epoch regressed (the #1065 signature). */
|
|
50
62
|
export const RECONCILE_ORPHAN_REASON = "engine-reset/epoch-regression";
|
|
51
63
|
|
|
64
|
+
/** The provenance reason stamped when a row is orphaned because its engine instance VANISHED from the
|
|
65
|
+
* read model — the run's `keyField` (process_key) has NO `_urban_instance_state` row at all, so the
|
|
66
|
+
* instance is absent/unknown in engine truth (engine clean-reset, cluster rebuild, or read-model
|
|
67
|
+
* pruning removed the instance-state row entirely — issue #630). Deliberately DISTINCT from
|
|
68
|
+
* {@link RECONCILE_ORPHAN_REASON} so an operator can tell an epoch-regression orphan apart from a
|
|
69
|
+
* vanished-instance orphan, even though both land on the same `orphaned` terminal. */
|
|
70
|
+
export const RECONCILE_VANISHED_REASON = "engine-instance/vanished";
|
|
71
|
+
|
|
72
|
+
/** The default grace window (ms) a dispatched-but-instance-less row is spared before it is considered
|
|
73
|
+
* vanished. A run dispatched moments ago (its `process_key` set) has not yet been polled into
|
|
74
|
+
* `_urban_instance_state` by the instanceTracking reconciler (`pollMs` 5s + engine search latency),
|
|
75
|
+
* so it transiently looks "vanished". This window (comfortably larger than a poll cycle) keeps a
|
|
76
|
+
* legitimately-still-starting run from being folded to terminal prematurely (issue #630 AC #2). */
|
|
77
|
+
export const DEFAULT_VANISHED_GRACE_MS = 5 * 60_000;
|
|
78
|
+
|
|
79
|
+
/** The framework's canonical per-instance engine-lifecycle projection table (urban's
|
|
80
|
+
* `_urban_instance_state`, keyed by `process_instance_key`). The vanished-instance reconcile joins
|
|
81
|
+
* each engine-backed row's `keyField` against it: a run whose key has NO row here has no backing
|
|
82
|
+
* instance in engine truth. `_urban_` prefixed (framework bookkeeping) so it is provisioned by the
|
|
83
|
+
* runtime, not our migrations — the reconcile guards on its existence before acting. */
|
|
84
|
+
const INSTANCE_STATE_TABLE = "_urban_instance_state";
|
|
85
|
+
|
|
52
86
|
/** The single-row epoch ledger + its append-only run/provenance sidecars (migration 092). */
|
|
53
87
|
const INCARNATION_TABLE = "engine_incarnation";
|
|
54
88
|
const RUNS_TABLE = "reconcile_runs";
|
|
@@ -71,7 +105,12 @@ export interface EngineEpochObservation {
|
|
|
71
105
|
}
|
|
72
106
|
|
|
73
107
|
/** Why a reconcile pass acted (or did not). */
|
|
74
|
-
export type ReconcileReason =
|
|
108
|
+
export type ReconcileReason =
|
|
109
|
+
| "epoch-regression"
|
|
110
|
+
| "seed-epoch"
|
|
111
|
+
| "no-op"
|
|
112
|
+
| "engine-unreachable"
|
|
113
|
+
| "instance-vanished";
|
|
75
114
|
|
|
76
115
|
/** One orphaned engine-backed row. */
|
|
77
116
|
export interface OrphanedRow {
|
|
@@ -107,6 +146,14 @@ export interface ReconcileOptions {
|
|
|
107
146
|
log?: ReconcileLog;
|
|
108
147
|
}
|
|
109
148
|
|
|
149
|
+
/** Options for the vanished-instance reconcile pass ({@link reconcileVanishedInstances}). */
|
|
150
|
+
export interface VanishedReconcileOptions extends ReconcileOptions {
|
|
151
|
+
/** How long (ms) a dispatched-but-instance-less row is spared before it is folded to terminal, so a
|
|
152
|
+
* still-starting run (not yet projected into `_urban_instance_state`) is not orphaned prematurely.
|
|
153
|
+
* Defaults to {@link DEFAULT_VANISHED_GRACE_MS}. */
|
|
154
|
+
graceMs?: number;
|
|
155
|
+
}
|
|
156
|
+
|
|
110
157
|
/** Read the incarnation epoch out of a `/v2/topology` body — `nano.incarnation` (or its `epoch`
|
|
111
158
|
* alias), coerced from a number or a numeric string. Any other shape (absent, non-numeric) yields
|
|
112
159
|
* null: "the engine exposes no epoch". A null is a no-op ONLY when no epoch was previously recorded;
|
|
@@ -175,6 +222,78 @@ async function persistEpoch(src: DataSource, epoch: number, at: string): Promise
|
|
|
175
222
|
);
|
|
176
223
|
}
|
|
177
224
|
|
|
225
|
+
/** The resolved schema + tracking selectors for one engine-backed binding, or `null` when the binding
|
|
226
|
+
* carries no `activeStatuses` selector (it cannot classify "in-flight", so it is skipped). */
|
|
227
|
+
interface BindingShape {
|
|
228
|
+
table: string;
|
|
229
|
+
active: readonly string[];
|
|
230
|
+
statusField: string;
|
|
231
|
+
keyField: string;
|
|
232
|
+
pkCol: string;
|
|
233
|
+
hasUpdatedAt: boolean;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The shape of a row selected for possible orphaning. */
|
|
237
|
+
interface OrphanCandidate {
|
|
238
|
+
__pk: unknown;
|
|
239
|
+
__key: unknown;
|
|
240
|
+
__status: unknown;
|
|
241
|
+
__updated?: unknown;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Resolve a binding's tracking selectors + physical schema, or `null` to skip a selector-less
|
|
245
|
+
* binding (activeStatusesFor would throw; we tolerate it here). */
|
|
246
|
+
async function resolveShape(src: DataSource, binding: InstanceTracking): Promise<BindingShape | null> {
|
|
247
|
+
if (!binding.activeStatuses?.length) return null;
|
|
248
|
+
const table = binding.table;
|
|
249
|
+
const { pkCol, hasUpdatedAt } = await tableShape(src, table);
|
|
250
|
+
return {
|
|
251
|
+
table,
|
|
252
|
+
active: activeStatusesFor(table),
|
|
253
|
+
statusField: baseStatusFieldFor(table),
|
|
254
|
+
keyField: keyFieldFor(table),
|
|
255
|
+
pkCol,
|
|
256
|
+
hasUpdatedAt,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Drive ONE candidate row to `orphaned` with provenance, GUARDED: the UPDATE re-asserts the exact
|
|
261
|
+
* status read AND a populated key (plus any `extraGuardSql`, e.g. the still-vanished re-check), so a
|
|
262
|
+
* writer that flipped the row to a newer terminal status (or an instance that reappeared) between the
|
|
263
|
+
* SELECT and this UPDATE wins the race — we never clobber that history back to `orphaned`. Only a row
|
|
264
|
+
* we actually transitioned (`res.changed > 0`) gets provenance and is returned. `updated_at` is
|
|
265
|
+
* stamped (when the table has one) so the transition refreshes the row's timestamp like every other
|
|
266
|
+
* status transition in the codebase. Runs inside the caller's transaction. */
|
|
267
|
+
async function orphanRow(
|
|
268
|
+
src: DataSource,
|
|
269
|
+
shape: BindingShape,
|
|
270
|
+
row: OrphanCandidate,
|
|
271
|
+
reason: string,
|
|
272
|
+
observedEpoch: number | null,
|
|
273
|
+
runId: string,
|
|
274
|
+
at: string,
|
|
275
|
+
extraGuardSql = "",
|
|
276
|
+
): Promise<OrphanedRow | null> {
|
|
277
|
+
const { table, pkCol, statusField, keyField, hasUpdatedAt } = shape;
|
|
278
|
+
const pk = String(row.__pk);
|
|
279
|
+
const key = row.__key == null ? null : String(row.__key);
|
|
280
|
+
const fromStatus = String(row.__status);
|
|
281
|
+
const res = await src.exec(
|
|
282
|
+
`UPDATE ${q(table)} SET ${q(statusField)} = ?` +
|
|
283
|
+
(hasUpdatedAt ? `, ${q(UPDATED_AT_COLUMN)} = ?` : "") +
|
|
284
|
+
` WHERE ${q(pkCol)} = ? AND ${q(statusField)} = ? AND ${q(keyField)} IS NOT NULL${extraGuardSql}`,
|
|
285
|
+
hasUpdatedAt ? [ORPHANED_STATUS, at, row.__pk, fromStatus] : [ORPHANED_STATUS, row.__pk, fromStatus],
|
|
286
|
+
);
|
|
287
|
+
if (res.changed <= 0) return null;
|
|
288
|
+
await src.exec(
|
|
289
|
+
`INSERT INTO ${PROVENANCE_TABLE} ` +
|
|
290
|
+
`(run_id, source_table, pk_value, key_value, from_status, to_status, reason, observed_epoch, at) ` +
|
|
291
|
+
`VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
292
|
+
[runId, table, pk, key, fromStatus, ORPHANED_STATUS, reason, observedEpoch, at],
|
|
293
|
+
);
|
|
294
|
+
return { table, pk, key, fromStatus };
|
|
295
|
+
}
|
|
296
|
+
|
|
178
297
|
/** Orphan every NON-terminal, engine-backed row across all instanceTracking bindings, recording one
|
|
179
298
|
* `reconcile_provenance` row per transition. Runs inside the caller's transaction. */
|
|
180
299
|
async function orphanEngineBackedRows(
|
|
@@ -185,52 +304,87 @@ async function orphanEngineBackedRows(
|
|
|
185
304
|
): Promise<OrphanedRow[]> {
|
|
186
305
|
const orphaned: OrphanedRow[] = [];
|
|
187
306
|
for (const binding of engineBackedBindings()) {
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const { pkCol, hasUpdatedAt } = await tableShape(src, table);
|
|
196
|
-
const placeholders = active.map(() => "?").join(", ");
|
|
197
|
-
const rows = await src.query<{ __pk: unknown; __key: unknown; __status: unknown }>(
|
|
198
|
-
`SELECT ${q(pkCol)} AS __pk, ${q(keyField)} AS __key, ${q(statusField)} AS __status ` +
|
|
199
|
-
`FROM ${q(table)} WHERE ${q(statusField)} IN (${placeholders}) AND ${q(keyField)} IS NOT NULL`,
|
|
200
|
-
[...active],
|
|
307
|
+
const shape = await resolveShape(src, binding);
|
|
308
|
+
if (!shape) continue;
|
|
309
|
+
const placeholders = shape.active.map(() => "?").join(", ");
|
|
310
|
+
const rows = await src.query<OrphanCandidate>(
|
|
311
|
+
`SELECT ${q(shape.pkCol)} AS __pk, ${q(shape.keyField)} AS __key, ${q(shape.statusField)} AS __status ` +
|
|
312
|
+
`FROM ${q(shape.table)} WHERE ${q(shape.statusField)} IN (${placeholders}) AND ${q(shape.keyField)} IS NOT NULL`,
|
|
313
|
+
[...shape.active],
|
|
201
314
|
);
|
|
202
315
|
for (const row of rows) {
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
const fromStatus = String(row.__status);
|
|
206
|
-
// GUARDED update: re-assert the exact status we read AND a populated key, so a writer that
|
|
207
|
-
// flipped the row to a newer terminal status (or cleared its key) between the SELECT above and
|
|
208
|
-
// this UPDATE wins the race — we never clobber that terminal history back to `orphaned`. Only a
|
|
209
|
-
// row we actually transitioned (`res.changed > 0`) gets provenance and is counted. We also stamp
|
|
210
|
-
// `updated_at` (when the table has one) so the transition to `orphaned` refreshes the row's
|
|
211
|
-
// timestamp the same way every other status transition in the codebase does — leaving it stale
|
|
212
|
-
// would misrepresent the orphaning moment to the UI/audits.
|
|
213
|
-
const res = await src.exec(
|
|
214
|
-
`UPDATE ${q(table)} SET ${q(statusField)} = ?` +
|
|
215
|
-
(hasUpdatedAt ? `, ${q(UPDATED_AT_COLUMN)} = ?` : "") +
|
|
216
|
-
` WHERE ${q(pkCol)} = ? AND ${q(statusField)} = ? AND ${q(keyField)} IS NOT NULL`,
|
|
217
|
-
hasUpdatedAt
|
|
218
|
-
? [ORPHANED_STATUS, at, row.__pk, fromStatus]
|
|
219
|
-
: [ORPHANED_STATUS, row.__pk, fromStatus],
|
|
220
|
-
);
|
|
221
|
-
if (res.changed <= 0) continue;
|
|
222
|
-
await src.exec(
|
|
223
|
-
`INSERT INTO ${PROVENANCE_TABLE} ` +
|
|
224
|
-
`(run_id, source_table, pk_value, key_value, from_status, to_status, reason, observed_epoch, at) ` +
|
|
225
|
-
`VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
226
|
-
[runId, table, pk, key, fromStatus, ORPHANED_STATUS, RECONCILE_ORPHAN_REASON, observedEpoch, at],
|
|
227
|
-
);
|
|
228
|
-
orphaned.push({ table, pk, key, fromStatus });
|
|
316
|
+
const o = await orphanRow(src, shape, row, RECONCILE_ORPHAN_REASON, observedEpoch, runId, at);
|
|
317
|
+
if (o) orphaned.push(o);
|
|
229
318
|
}
|
|
230
319
|
}
|
|
231
320
|
return orphaned;
|
|
232
321
|
}
|
|
233
322
|
|
|
323
|
+
/** Whether a row's `updated_at` is younger than the grace window — i.e. it was (re)dispatched too
|
|
324
|
+
* recently to have been projected into `_urban_instance_state` yet, so it must NOT be folded. A
|
|
325
|
+
* null/unparseable timestamp is treated as "within grace" (spared): when we cannot establish a row's
|
|
326
|
+
* age we must NOT orphan it — a nullable `updated_at` (e.g. `delivery_units.updated_at`,
|
|
327
|
+
* db/migrations/088_delivery_units.sql) would otherwise fold a live row. Erring toward sparing at
|
|
328
|
+
* worst leaves a genuinely-vanished ageless row for a later pass once it carries a usable timestamp;
|
|
329
|
+
* erring the other way wedges/destroys a live run, so we choose the safe default. */
|
|
330
|
+
function withinGrace(updated: unknown, nowMs: number, graceMs: number): boolean {
|
|
331
|
+
if (updated == null) return true;
|
|
332
|
+
const t = Date.parse(String(updated));
|
|
333
|
+
if (!Number.isFinite(t)) return true;
|
|
334
|
+
return nowMs - t < graceMs;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Orphan every NON-terminal, engine-backed row whose `keyField` (process instance key) has NO
|
|
338
|
+
* `_urban_instance_state` row — the instance is absent/unknown in engine truth (vanished, issue
|
|
339
|
+
* #630) — and whose last transition is older than the grace window. Records one
|
|
340
|
+
* `reconcile_provenance` row per transition (reason {@link RECONCILE_VANISHED_REASON}). Runs inside
|
|
341
|
+
* the caller's transaction. */
|
|
342
|
+
async function orphanVanishedRows(
|
|
343
|
+
src: DataSource,
|
|
344
|
+
runId: string,
|
|
345
|
+
at: string,
|
|
346
|
+
nowMs: number,
|
|
347
|
+
graceMs: number,
|
|
348
|
+
): Promise<OrphanedRow[]> {
|
|
349
|
+
const orphaned: OrphanedRow[] = [];
|
|
350
|
+
for (const binding of engineBackedBindings()) {
|
|
351
|
+
const shape = await resolveShape(src, binding);
|
|
352
|
+
if (!shape) continue;
|
|
353
|
+
const placeholders = shape.active.map(() => "?").join(", ");
|
|
354
|
+
const updatedSel = shape.hasUpdatedAt ? `, ${q(UPDATED_AT_COLUMN)} AS __updated` : "";
|
|
355
|
+
// Active, dispatched (key populated) rows whose engine instance key has NO matching
|
|
356
|
+
// `_urban_instance_state` row — absent/unknown in engine truth.
|
|
357
|
+
const rows = await src.query<OrphanCandidate>(
|
|
358
|
+
`SELECT ${q(shape.pkCol)} AS __pk, ${q(shape.keyField)} AS __key, ${q(shape.statusField)} AS __status${updatedSel} ` +
|
|
359
|
+
`FROM ${q(shape.table)} b WHERE ${q(shape.statusField)} IN (${placeholders}) AND ${q(shape.keyField)} IS NOT NULL ` +
|
|
360
|
+
`AND NOT EXISTS (SELECT 1 FROM ${q(INSTANCE_STATE_TABLE)} s WHERE s.process_instance_key = b.${q(shape.keyField)})`,
|
|
361
|
+
[...shape.active],
|
|
362
|
+
);
|
|
363
|
+
// Re-assert "still no instance-state row" in the guarded UPDATE too, so an instance that reappears
|
|
364
|
+
// (the poller records it) between the SELECT above and the UPDATE wins the race.
|
|
365
|
+
const stillVanishedGuard =
|
|
366
|
+
` AND NOT EXISTS (SELECT 1 FROM ${q(INSTANCE_STATE_TABLE)} s ` +
|
|
367
|
+
`WHERE s.process_instance_key = ${q(shape.table)}.${q(shape.keyField)})`;
|
|
368
|
+
for (const row of rows) {
|
|
369
|
+
if (shape.hasUpdatedAt && withinGrace(row.__updated, nowMs, graceMs)) continue;
|
|
370
|
+
const o = await orphanRow(src, shape, row, RECONCILE_VANISHED_REASON, null, runId, at, stillVanishedGuard);
|
|
371
|
+
if (o) orphaned.push(o);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return orphaned;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Whether the framework `_urban_instance_state` projection exists in this source. When it does NOT,
|
|
378
|
+
* the vanished-instance pass is a hard no-op: without the projection every dispatched row would look
|
|
379
|
+
* "vanished", so we must never orphan on its absence. */
|
|
380
|
+
async function instanceStateTableExists(src: DataSource): Promise<boolean> {
|
|
381
|
+
const rows = await src.query<{ n: number }>(
|
|
382
|
+
`SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name = ?`,
|
|
383
|
+
[INSTANCE_STATE_TABLE],
|
|
384
|
+
);
|
|
385
|
+
return rows.length > 0 && Number(rows[0].n) > 0;
|
|
386
|
+
}
|
|
387
|
+
|
|
234
388
|
/**
|
|
235
389
|
* Reconcile the app's engine-backed projections against one epoch observation. Pure of I/O beyond the
|
|
236
390
|
* data layer (the topology probe is {@link probeEngineEpoch}, injected as `observation`), so the
|
|
@@ -300,6 +454,75 @@ export async function reconcileEngineBackedWork(
|
|
|
300
454
|
return { runId, reason: result.reason, observedEpoch, recordedEpoch, orphanedCount: result.orphaned.length, orphaned: result.orphaned };
|
|
301
455
|
}
|
|
302
456
|
|
|
457
|
+
/**
|
|
458
|
+
* Reconcile engine-backed inflight work against the framework's canonical per-instance projection
|
|
459
|
+
* (`_urban_instance_state`) — the "instance absent/unknown" gap (issue #630), DISTINCT from the
|
|
460
|
+
* epoch-regression reset the {@link reconcileEngineBackedWork} pass handles.
|
|
461
|
+
*
|
|
462
|
+
* When an engine instance VANISHES from the read model — engine clean-reset, cluster rebuild, or
|
|
463
|
+
* read-model pruning removes the `_urban_instance_state` row entirely — there is no `TERMINATED` row
|
|
464
|
+
* for the derived tracking edge to match, so the run freezes at its last worker-owned status
|
|
465
|
+
* (`escalated`/`awaiting_operator`) and wedges the Active list forever with no reconciliation path.
|
|
466
|
+
* "The instance backing this run no longer exists in engine truth" is a terminal condition: this pass
|
|
467
|
+
* drives every such row (active, dispatched, its `keyField` absent from `_urban_instance_state`, and
|
|
468
|
+
* past the grace window) to the defined `orphaned` terminal WITH PROVENANCE.
|
|
469
|
+
*
|
|
470
|
+
* Safety:
|
|
471
|
+
* • GRACE WINDOW — a just-dispatched run has not yet been polled into `_urban_instance_state`; only
|
|
472
|
+
* rows whose last transition is older than `graceMs` are folded, so a still-starting run is never
|
|
473
|
+
* prematurely orphaned (AC #2).
|
|
474
|
+
* • PROJECTION-PRESENT — if `_urban_instance_state` does not exist (the runtime has not provisioned
|
|
475
|
+
* it), every dispatched row would look vanished, so the pass is a hard no-op.
|
|
476
|
+
* • GUARDED — the same status-re-assert as the epoch pass, plus a still-vanished re-check, so a
|
|
477
|
+
* concurrent terminal write or a reappearing instance wins the race.
|
|
478
|
+
* • This pass reads the app's OWN last-known projection, not a live probe, so it acts correctly on a
|
|
479
|
+
* genuinely-vanished instance regardless of transient engine reachability (a live instance keeps
|
|
480
|
+
* its persisted ACTIVE row across a restart, so it is never mistaken for vanished).
|
|
481
|
+
*/
|
|
482
|
+
export async function reconcileVanishedInstances(
|
|
483
|
+
data: DataLayer,
|
|
484
|
+
opts: VanishedReconcileOptions = {},
|
|
485
|
+
): Promise<ReconcileResult> {
|
|
486
|
+
const src = data.open(opts.sourceName);
|
|
487
|
+
const clock = opts.now?.() ?? new Date();
|
|
488
|
+
const at = clock.toISOString();
|
|
489
|
+
const nowMs = clock.getTime();
|
|
490
|
+
const runId = opts.runId ?? crypto.randomUUID();
|
|
491
|
+
const graceMs = opts.graceMs ?? DEFAULT_VANISHED_GRACE_MS;
|
|
492
|
+
|
|
493
|
+
// Without the framework projection we cannot tell a vanished instance from a live one — every
|
|
494
|
+
// dispatched row would look vanished. NO-OP rather than orphan live work.
|
|
495
|
+
if (!(await instanceStateTableExists(src))) {
|
|
496
|
+
await recordRun(src, { runId, at, observedEpoch: null, recordedEpoch: null, reason: "no-op", orphanedCount: 0 });
|
|
497
|
+
opts.log?.info(`reconcile(vanished): instance-state projection absent — no-op [run ${runId}].`);
|
|
498
|
+
return { runId, reason: "no-op", observedEpoch: null, recordedEpoch: null, orphanedCount: 0, orphaned: [] };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const orphaned = await src.tx(async (t) => {
|
|
502
|
+
const rows = await orphanVanishedRows(t, runId, at, nowMs, graceMs);
|
|
503
|
+
const reason: ReconcileReason = rows.length > 0 ? "instance-vanished" : "no-op";
|
|
504
|
+
await recordRun(t, { runId, at, observedEpoch: null, recordedEpoch: null, reason, orphanedCount: rows.length });
|
|
505
|
+
return rows;
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
if (orphaned.length > 0) {
|
|
509
|
+
opts.log?.warn(
|
|
510
|
+
`reconcile(vanished): orphaned ${orphaned.length} inflight row(s) whose engine instance ` +
|
|
511
|
+
`vanished from the read model [run ${runId}].`,
|
|
512
|
+
);
|
|
513
|
+
} else {
|
|
514
|
+
opts.log?.info(`reconcile(vanished): no vanished instances — no-op [run ${runId}].`);
|
|
515
|
+
}
|
|
516
|
+
return {
|
|
517
|
+
runId,
|
|
518
|
+
reason: orphaned.length > 0 ? "instance-vanished" : "no-op",
|
|
519
|
+
observedEpoch: null,
|
|
520
|
+
recordedEpoch: null,
|
|
521
|
+
orphanedCount: orphaned.length,
|
|
522
|
+
orphaned,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
303
526
|
async function recordRun(
|
|
304
527
|
src: DataSource,
|
|
305
528
|
run: { runId: string; at: string; observedEpoch: number | null; recordedEpoch: number | null; reason: ReconcileReason; orphanedCount: number },
|
|
@@ -312,15 +535,47 @@ async function recordRun(
|
|
|
312
535
|
}
|
|
313
536
|
|
|
314
537
|
/** Probe the engine's incarnation epoch, then reconcile — the wiring both startup (main.ts) and the
|
|
315
|
-
* `reconcileEngineState` operator command share, so the two paths can never diverge.
|
|
538
|
+
* `reconcileEngineState` operator command share, so the two paths can never diverge. Runs BOTH the
|
|
539
|
+
* epoch-regression pass (engine reset/rewind) and the vanished-instance pass (an inflight run whose
|
|
540
|
+
* instance is absent/unknown in `_urban_instance_state` — issue #630), returning ONE merged result.
|
|
541
|
+
* Reason precedence: `epoch-regression` always wins; otherwise, if the vanished pass orphaned any
|
|
542
|
+
* rows the reason is `instance-vanished` (even when the epoch pass reported a non-regression state
|
|
543
|
+
* such as `engine-unreachable`); otherwise the epoch pass's reason stands. `orphanedCount` /
|
|
544
|
+
* `orphaned` cover both passes. The two passes never double-fold a row: once the epoch pass orphans a
|
|
545
|
+
* row it leaves `activeStatuses`, so the vanished pass no longer selects it. */
|
|
316
546
|
export async function runEngineReconcile(
|
|
317
547
|
data: DataLayer,
|
|
318
548
|
engineRest: { restAddress: string; token?: string },
|
|
319
|
-
opts:
|
|
549
|
+
opts: VanishedReconcileOptions & { fetchImpl?: typeof fetch } = {},
|
|
320
550
|
): Promise<ReconcileResult> {
|
|
321
551
|
const observation = await probeEngineEpoch(engineRest.restAddress, {
|
|
322
552
|
token: engineRest.token,
|
|
323
553
|
fetchImpl: opts.fetchImpl,
|
|
324
554
|
});
|
|
325
|
-
|
|
555
|
+
const epoch = await reconcileEngineBackedWork(data, observation, opts);
|
|
556
|
+
// A distinct run id so the vanished pass's `reconcile_runs`/provenance rows never collide with the
|
|
557
|
+
// epoch pass's (run_id is a PRIMARY KEY). DERIVE it from the epoch pass's resolved run id — which is
|
|
558
|
+
// also the merged result's `runId` — so it is `<runId>-vanished` on EVERY path, including the boot
|
|
559
|
+
// path where `opts.runId` is omitted (a bare random UUID here would be non-correlatable to the
|
|
560
|
+
// returned `runId`). Operators can always locate the vanished pass's provenance from the reported id.
|
|
561
|
+
const vanished = await reconcileVanishedInstances(data, {
|
|
562
|
+
...opts,
|
|
563
|
+
runId: `${epoch.runId}-vanished`,
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
const orphaned = [...epoch.orphaned, ...vanished.orphaned];
|
|
567
|
+
const reason: ReconcileReason =
|
|
568
|
+
epoch.reason === "epoch-regression"
|
|
569
|
+
? "epoch-regression"
|
|
570
|
+
: vanished.orphanedCount > 0
|
|
571
|
+
? "instance-vanished"
|
|
572
|
+
: epoch.reason;
|
|
573
|
+
return {
|
|
574
|
+
runId: epoch.runId,
|
|
575
|
+
reason,
|
|
576
|
+
observedEpoch: epoch.observedEpoch,
|
|
577
|
+
recordedEpoch: epoch.recordedEpoch,
|
|
578
|
+
orphanedCount: orphaned.length,
|
|
579
|
+
orphaned,
|
|
580
|
+
};
|
|
326
581
|
}
|
package/app/service.ts
CHANGED
|
@@ -1316,35 +1316,41 @@ export async function pollMerges(data: DataLayer, engine: EngineClient, token: s
|
|
|
1316
1316
|
//
|
|
1317
1317
|
// A PR enqueued by `attempt-merge` (mergeStatus="queued") parks the process at `wait-landed`.
|
|
1318
1318
|
// Two things can end that wait: the queue lands the PR (→ `merge-landed`), or the PR is EVICTED
|
|
1319
|
-
// from the queue
|
|
1320
|
-
//
|
|
1321
|
-
// `
|
|
1322
|
-
//
|
|
1323
|
-
//
|
|
1324
|
-
//
|
|
1325
|
-
//
|
|
1326
|
-
// auto-rebase
|
|
1319
|
+
// from the queue. An eviction has two observable flavours: a merge CONFLICT after its base moved
|
|
1320
|
+
// (`DIRTY` — how #727/instance 729 wedged), OR — the #702 wedge — required checks FAILED on the
|
|
1321
|
+
// speculative `merge_group` commit (the ALLGREEN-batch invalidation), which leaves the
|
|
1322
|
+
// conflict-free head NOT `DIRTY` (it reverts to BLOCKED/UNSTABLE/CLEAN). The latter is invisible to
|
|
1323
|
+
// `mergeStateStatus` alone, so we read GROUND-TRUTH native-queue membership (`withMergeQueue: true`
|
|
1324
|
+
// → GraphQL `mergeQueueEntry`) and let `queuedVerdict` evict on a definitive drop. Without an
|
|
1325
|
+
// eviction path an evicted PR waits out the full `landedWaitTimeout` (default PT1H) then escalates
|
|
1326
|
+
// to a human instead of auto-re-driving `fix-ci`/`rebase`. We must NOT treat a merely "not yet
|
|
1327
|
+
// landed" PR as evicted: while it is legitimately queuing GitHub reports it BLOCKED/UNSTABLE and
|
|
1328
|
+
// `mergeQueueEntry` stays `true`, so `queuedVerdict` keeps waiting. Eviction re-arms the merge
|
|
1329
|
+
// poller (`merge-evicted` → `arm-merge`), re-running the mergeable gate so the existing
|
|
1330
|
+
// auto-rebase / fix-ci / re-enqueue machinery resolves whatever dropped it.
|
|
1327
1331
|
for (const pr of await prs(data).find({ status: "queued" })) {
|
|
1328
1332
|
const { repo, number, pr_key: prKey } = pr;
|
|
1329
1333
|
try {
|
|
1330
|
-
const st = await fetchPrState(repo, number, token);
|
|
1334
|
+
const st = await fetchPrState(repo, number, token, { withMergeQueue: true });
|
|
1331
1335
|
if (st === null) continue; // no transport → skip this PR (others may still advance)
|
|
1332
1336
|
// Out-of-band terminal FIRST (reusing `st`): a queued PR merged out-of-band lands
|
|
1333
1337
|
// (`merge-landed` → mark-merged); one CLOSED out-of-band without merging can never land, so the
|
|
1334
1338
|
// pre-check re-arms it (`merge-evicted` → arm-merge) and block 2 abandons it — a closed queued
|
|
1335
1339
|
// PR would otherwise wedge, since `queuedVerdict` calls a non-DIRTY closed PR merely "waiting"
|
|
1336
|
-
// (#368). The
|
|
1340
|
+
// (#368). The eviction check below still handles a live queue drop (conflict or CI-on-merge_group).
|
|
1337
1341
|
if (await advanceIfTerminalOutOfBand(data, engine, pr, token, st)) continue;
|
|
1338
1342
|
// Terminal states (merged/closed) are handled by the shared pre-check above; here the PR is
|
|
1339
|
-
// still open, so the only remaining reason to leave `wait-landed` is a live queue DROP — a
|
|
1340
|
-
//
|
|
1343
|
+
// still open, so the only remaining reason to leave `wait-landed` is a live queue DROP — a merge
|
|
1344
|
+
// CONFLICT (`DIRTY`) or a ground-truth `mergeQueueEntry === false`. `queuedVerdict` is the
|
|
1345
|
+
// canonical classifier for both.
|
|
1341
1346
|
if (queuedVerdict(st) === "evicted") {
|
|
1342
1347
|
await flipToMergingThenPublish(data, engine, prKey, "queued", {
|
|
1343
1348
|
name: "merge-evicted",
|
|
1344
1349
|
correlationKey: prKey,
|
|
1345
1350
|
variables: {},
|
|
1346
1351
|
});
|
|
1347
|
-
|
|
1352
|
+
const reason = st.mergeStateStatus === "DIRTY" ? "conflict" : "dropped from queue";
|
|
1353
|
+
console.log(`[poller] queued PR evicted (${reason}) -> ${prKey}`);
|
|
1348
1354
|
}
|
|
1349
1355
|
// otherwise: still legitimately in the queue — keep waiting.
|
|
1350
1356
|
} catch (err) {
|
|
@@ -1356,14 +1362,27 @@ export async function pollMerges(data: DataLayer, engine: EngineClient, token: s
|
|
|
1356
1362
|
/** Decide what to do with a PR the process enqueued (parked at `wait-landed`), from its current
|
|
1357
1363
|
* GitHub merge state:
|
|
1358
1364
|
* • `landed` — the queue merged it → publish `merge-landed` (advance to mark-merged).
|
|
1359
|
-
* • `evicted` — it fell out of the queue
|
|
1360
|
-
*
|
|
1361
|
-
*
|
|
1362
|
-
*
|
|
1363
|
-
*
|
|
1365
|
+
* • `evicted` — it fell out of the queue → publish `merge-evicted` so the process re-arms the
|
|
1366
|
+
* merge poller and the mergeable gate re-runs (auto-rebase for a conflict, `fix-ci` for a red
|
|
1367
|
+
* required check, re-enqueue otherwise). Two independent signals mean "evicted": a live merge
|
|
1368
|
+
* CONFLICT (`DIRTY`) — observable even in token mode — OR ground-truth `mergeQueueEntry === false`,
|
|
1369
|
+
* i.e. the base branch has a native GitHub merge queue but the PR is no longer enrolled in it.
|
|
1370
|
+
* The latter is what catches the #702 wedge: a PR evicted because required checks FAILED on the
|
|
1371
|
+
* speculative `merge_group` commit is NOT `DIRTY` (its head reverts to BLOCKED/UNSTABLE/CLEAN),
|
|
1372
|
+
* so inferring from `mergeStateStatus` alone kept it waiting out the full `landedWaitTimeout`
|
|
1373
|
+
* then pulled in a human, instead of auto-re-driving `fix-ci`.
|
|
1374
|
+
* • `waiting` — still legitimately in the queue. A queuing PR is frequently reported
|
|
1375
|
+
* BLOCKED/UNSTABLE (a pending queue check) — that is NOT eviction. `mergeQueueEntry` is only
|
|
1376
|
+
* `false` when the queue definitively dropped it; `null` (unprobed / token GraphQL error / a
|
|
1377
|
+
* Mergify/plain repo with no native queue) leaves the #556 `landedWaitTimeout` backstop to
|
|
1378
|
+
* handle a genuinely-never-lands wedge, so we never falsely evict there. */
|
|
1364
1379
|
export function queuedVerdict(st: PrState): "landed" | "evicted" | "waiting" {
|
|
1365
1380
|
if (st.merged) return "landed";
|
|
1381
|
+
// A live conflict is an eviction (works in token mode, where `mergeQueueEntry` is unprobed).
|
|
1366
1382
|
if (st.mergeStateStatus === "DIRTY") return "evicted";
|
|
1383
|
+
// Ground-truth: enrolled in a native merge queue and now gone → evicted for ANY reason (a red
|
|
1384
|
+
// `merge_group` build, base moved, manual dequeue), not just a conflict (#702).
|
|
1385
|
+
if (st.mergeQueueEntry === false) return "evicted";
|
|
1367
1386
|
return "waiting";
|
|
1368
1387
|
}
|
|
1369
1388
|
|
package/main.ts
CHANGED
|
@@ -103,12 +103,14 @@ if (httpServer instanceof Server) {
|
|
|
103
103
|
app.log.warn("agentic channel not mounted: app.httpServer is not a node:http Server on this host");
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
// Engine-reset reconciliation (
|
|
107
|
-
// the last-seen value; on a REGRESSION (the engine was reset/restored/rewound and re-minted
|
|
108
|
-
// Magikcraft/nano-bpm#1065) drive every dangling engine-backed inflight row to the defined
|
|
109
|
-
// terminal WITH PROVENANCE
|
|
110
|
-
//
|
|
111
|
-
//
|
|
106
|
+
// Engine-reset reconciliation (issues #622, #630). On boot, compare the engine's incarnation epoch
|
|
107
|
+
// against the last-seen value; on a REGRESSION (the engine was reset/restored/rewound and re-minted
|
|
108
|
+
// its keys, Magikcraft/nano-bpm#1065) drive every dangling engine-backed inflight row to the defined
|
|
109
|
+
// `orphaned` terminal WITH PROVENANCE. A second pass also folds any run whose engine instance has
|
|
110
|
+
// VANISHED from the read model (no `_urban_instance_state` row, past a grace window — issue #630),
|
|
111
|
+
// which the epoch signal alone can't catch — BEFORE the pollers below start projecting off stale,
|
|
112
|
+
// dead instances. Guarded: an unreachable engine / an absent projection is a no-op (never orphans
|
|
113
|
+
// live work), and any failure degrades to a warn so reconcile can never block boot.
|
|
112
114
|
if (app.data) {
|
|
113
115
|
try {
|
|
114
116
|
const reconciled = await runEngineReconcile(
|
package/openapi.yaml
CHANGED
|
@@ -1042,7 +1042,10 @@ components:
|
|
|
1042
1042
|
type: integer
|
|
1043
1043
|
ReconcileReport:
|
|
1044
1044
|
type: object
|
|
1045
|
-
description: The result of
|
|
1045
|
+
description: "The merged result of the engine-reconcile invocation — an aggregate of both the
|
|
1046
|
+
epoch-regression (engine reset/rewind) pass and the vanished-instance pass (issues #622 and
|
|
1047
|
+
#630). `runId` is the epoch pass's run id; the vanished pass records its own provenance under
|
|
1048
|
+
the derived id `<runId>-vanished`."
|
|
1046
1049
|
additionalProperties: false
|
|
1047
1050
|
required:
|
|
1048
1051
|
- runId
|
|
@@ -1054,15 +1057,19 @@ components:
|
|
|
1054
1057
|
properties:
|
|
1055
1058
|
runId:
|
|
1056
1059
|
type: string
|
|
1057
|
-
description: The reconcile run id
|
|
1060
|
+
description: The reconcile run id the epoch pass's orphaned-transition provenance is stamped
|
|
1061
|
+
with. The vanished-instance pass's provenance is stamped with the derived, deterministic id
|
|
1062
|
+
`<runId>-vanished`, so operators can locate the provenance rows for either pass from this id.
|
|
1058
1063
|
reason:
|
|
1059
1064
|
type: string
|
|
1060
|
-
description: Why this pass acted (or did not).
|
|
1065
|
+
description: Why this pass acted (or did not). `instance-vanished` — a run whose engine
|
|
1066
|
+
instance is absent/unknown in the read model was orphaned (issue #630).
|
|
1061
1067
|
enum:
|
|
1062
1068
|
- epoch-regression
|
|
1063
1069
|
- seed-epoch
|
|
1064
1070
|
- no-op
|
|
1065
1071
|
- engine-unreachable
|
|
1072
|
+
- instance-vanished
|
|
1066
1073
|
observedEpoch:
|
|
1067
1074
|
type: integer
|
|
1068
1075
|
nullable: true
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.174.1",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|