@nanobpm/nano-workforce 0.191.1 → 0.192.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 +6 -0
- package/app/github.test.ts +223 -1
- package/app/github.ts +122 -0
- package/app/roundProgress.test.ts +108 -1
- package/app/service.test.ts +132 -3
- package/app/service.ts +122 -52
- package/app/world/checkpoint.ts +5 -0
- package/app/world/selfHeal.test.ts +296 -0
- package/app/world/selfHeal.ts +194 -0
- package/app/world/store.test.ts +49 -0
- package/app/world/store.ts +37 -1
- package/db/migrations/115_world_checkpoint_process_key.sql +22 -0
- package/package.json +1 -1
- package/test/worldDb.ts +1 -1
- package/workers/persist-round/worker.ts +6 -0
- package/workers/progress-check/worker.ts +117 -18
package/app/service.test.ts
CHANGED
|
@@ -338,7 +338,136 @@ test("re-submit advances process_key BEFORE resetting adjudications (fence order
|
|
|
338
338
|
});
|
|
339
339
|
});
|
|
340
340
|
|
|
341
|
-
// Red/green regression (Copilot review): the
|
|
341
|
+
// Red/green regression (Copilot review of #819): the reopen must publish the fresh run's identity
|
|
342
|
+
// (`process_key`) in the SAME write that flips the row to `converging`/round 1. If the status flip
|
|
343
|
+
// landed while `process_key` still named the OLD instance, a delayed old-run `pr.progress-check`
|
|
344
|
+
// straggler would read its own key as the current owner, pass `isSuperseded`/`stillOwns`, and
|
|
345
|
+
// fast-forward the PR head onto its stale checkpoint under the fresh run's identity. Asserting the
|
|
346
|
+
// atomic write closes that "converging-but-old-owner" window.
|
|
347
|
+
test("re-submit flips status to converging and advances process_key in the SAME write (no old-owner window, #819)", async () => {
|
|
348
|
+
await withGithubOff(async () => {
|
|
349
|
+
const PR_KEY = "owner/repo#42";
|
|
350
|
+
const patches: Array<Record<string, unknown>> = [];
|
|
351
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
352
|
+
pull_requests: {
|
|
353
|
+
rows: [{ pr_key: PR_KEY, repo: "owner/repo", number: 42, url: "https://github.com/owner/repo/pull/42", title: "t", status: "converged", process_key: "PI-OLD" }],
|
|
354
|
+
key: "pr_key",
|
|
355
|
+
},
|
|
356
|
+
escalations: { rows: [], key: "id" },
|
|
357
|
+
pr_adjudications: { rows: [], key: "id" },
|
|
358
|
+
pr_dependencies: { rows: [], key: "pr_key" },
|
|
359
|
+
};
|
|
360
|
+
const wrap = (name: string, key: string) => {
|
|
361
|
+
const t = memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key);
|
|
362
|
+
return {
|
|
363
|
+
...t,
|
|
364
|
+
update: (k: any, patch: any) => {
|
|
365
|
+
if (name === "pull_requests") patches.push({ ...patch });
|
|
366
|
+
return t.update(k, patch);
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
};
|
|
370
|
+
const data = { table: withTrackingViews(wrap), open: () => memOpen(stores) } as any;
|
|
371
|
+
const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-NEW" }) } as any;
|
|
372
|
+
|
|
373
|
+
await submitPr(data, engine, { repo: "owner/repo", number: 42, url: "https://github.com/owner/repo/pull/42", prKey: PR_KEY });
|
|
374
|
+
|
|
375
|
+
// No update may set status to "converging" while leaving process_key at (or below) the old key.
|
|
376
|
+
const convergingPatch = patches.find((p) => p.status === "converging");
|
|
377
|
+
assertEquals(convergingPatch !== undefined, true, "the reopen flips the row to converging");
|
|
378
|
+
assertEquals(convergingPatch?.process_key, "PI-NEW", "the SAME write that flips to converging advances the owner to the new run");
|
|
379
|
+
assertEquals(
|
|
380
|
+
patches.some((p) => p.status === "converging" && p.process_key === "PI-OLD"),
|
|
381
|
+
false,
|
|
382
|
+
"the row never presents as the fresh run while still owned by the old instance",
|
|
383
|
+
);
|
|
384
|
+
const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
|
|
385
|
+
assertEquals(pr.status, "converging");
|
|
386
|
+
assertEquals(pr.current_round, 1);
|
|
387
|
+
assertEquals(pr.process_key, "PI-NEW");
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// Red/green regression (Copilot review of #819, follow-up): deferring the terminal→`converging`
|
|
392
|
+
// transition until AFTER `createInstance()` (the atomic-reopen fix above) left the row in its OLD
|
|
393
|
+
// terminal shape across the create await. A second `submitPr` for the same PR racing that await would
|
|
394
|
+
// read the still-terminal row, pass the RESUBMITTABLE idempotency gate, and create a SECOND
|
|
395
|
+
// convergence instance for one PR. `submitPr` serializes its create→reopen critical section per
|
|
396
|
+
// `prKey`, so the loser observes the winner's claim (`converging` + the new key, derived-active) and
|
|
397
|
+
// returns `alreadyRunning` — exactly one instance is created. Without the lock both concurrent calls
|
|
398
|
+
// enter `createInstance` (calls === 2) and this fails.
|
|
399
|
+
test("concurrent submitPr for one reopened PR mints only ONE convergence instance (#819 follow-up)", async () => {
|
|
400
|
+
await withGithubOff(async () => {
|
|
401
|
+
const PR_KEY = "owner/repo#42";
|
|
402
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
403
|
+
pull_requests: {
|
|
404
|
+
rows: [{ pr_key: PR_KEY, repo: "owner/repo", number: 42, url: "https://github.com/owner/repo/pull/42", title: "t", status: "converged", current_round: 3, process_key: "PI-OLD" }],
|
|
405
|
+
key: "pr_key",
|
|
406
|
+
},
|
|
407
|
+
escalations: { rows: [], key: "id" },
|
|
408
|
+
pr_adjudications: { rows: [], key: "id" },
|
|
409
|
+
pr_dependencies: { rows: [], key: "pr_key" },
|
|
410
|
+
};
|
|
411
|
+
const data = { table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)), open: () => memOpen(stores) } as any;
|
|
412
|
+
let calls = 0;
|
|
413
|
+
const engine = {
|
|
414
|
+
createInstance: async () => {
|
|
415
|
+
const id = ++calls;
|
|
416
|
+
// Hold the critical section open long enough for a racing submit to interleave at this await.
|
|
417
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
418
|
+
return { processInstanceKey: `PI-${id}` };
|
|
419
|
+
},
|
|
420
|
+
} as any;
|
|
421
|
+
const parsed = { repo: "owner/repo", number: 42, url: "https://github.com/owner/repo/pull/42", prKey: PR_KEY };
|
|
422
|
+
|
|
423
|
+
const results = await Promise.all([submitPr(data, engine, parsed), submitPr(data, engine, parsed)]);
|
|
424
|
+
|
|
425
|
+
assertEquals(calls, 1, "concurrent submits of the same PR create exactly one convergence instance");
|
|
426
|
+
assertEquals(
|
|
427
|
+
results.filter((r: any) => r.alreadyRunning === true).length,
|
|
428
|
+
1,
|
|
429
|
+
"the second concurrent submit observes the first's claim and short-circuits at the alreadyRunning gate",
|
|
430
|
+
);
|
|
431
|
+
const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
|
|
432
|
+
assertEquals(pr.status, "converging");
|
|
433
|
+
assertEquals(pr.process_key, "PI-1", "the single created instance owns the reopened row");
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
// A distinct key must never block another PR's submit — the per-`prKey` lock serializes only same-PR
|
|
438
|
+
// calls, so two DIFFERENT PRs submitted concurrently both proceed and each mints its own instance.
|
|
439
|
+
test("submitPr serialization is per-prKey — different PRs never block each other (#819 follow-up)", async () => {
|
|
440
|
+
await withGithubOff(async () => {
|
|
441
|
+
const mkStores = (n: number): Record<string, { rows: any[]; key: string }> => ({
|
|
442
|
+
pull_requests: { rows: [{ pr_key: `owner/repo#${n}`, repo: "owner/repo", number: n, url: `https://github.com/owner/repo/pull/${n}`, title: "t", status: "converged", current_round: 1, process_key: `PI-OLD-${n}` }], key: "pr_key" },
|
|
443
|
+
escalations: { rows: [], key: "id" },
|
|
444
|
+
pr_adjudications: { rows: [], key: "id" },
|
|
445
|
+
pr_dependencies: { rows: [], key: "pr_key" },
|
|
446
|
+
});
|
|
447
|
+
const s1 = mkStores(1);
|
|
448
|
+
const s2 = mkStores(2);
|
|
449
|
+
const mkData = (s: Record<string, { rows: any[]; key: string }>) => ({ table: withTrackingViews((name: string, key: string) => memTable(s[name]?.rows ?? [], s[name]?.key ?? key)), open: () => memOpen(s) } as any);
|
|
450
|
+
let calls = 0;
|
|
451
|
+
const engine = {
|
|
452
|
+
createInstance: async () => {
|
|
453
|
+
const id = ++calls;
|
|
454
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
455
|
+
return { processInstanceKey: `PI-${id}` };
|
|
456
|
+
},
|
|
457
|
+
} as any;
|
|
458
|
+
|
|
459
|
+
await Promise.all([
|
|
460
|
+
submitPr(mkData(s1), engine, { repo: "owner/repo", number: 1, url: "https://github.com/owner/repo/pull/1", prKey: "owner/repo#1" }),
|
|
461
|
+
submitPr(mkData(s2), engine, { repo: "owner/repo", number: 2, url: "https://github.com/owner/repo/pull/2", prKey: "owner/repo#2" }),
|
|
462
|
+
]);
|
|
463
|
+
|
|
464
|
+
assertEquals(calls, 2, "two different PRs each mint their own convergence instance");
|
|
465
|
+
assertEquals(s1.pull_requests.rows[0].status, "converging");
|
|
466
|
+
assertEquals(s2.pull_requests.rows[0].status, "converging");
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
|
|
342
471
|
// created and `process_key` is advanced. If the reset DELETE fails, the new convergence instance is
|
|
343
472
|
// already live while the OLD adjudications remain — and because the new instance is ACTIVE the
|
|
344
473
|
// `alreadyRunning` idempotency gate short-circuits every retry, so the reset is never re-run and the
|
|
@@ -1024,7 +1153,7 @@ test("repoEnvelopeVars emits branch.create only for a non-blank pre-PR branch (#
|
|
|
1024
1153
|
// so the round redrives from scratch (exactly as today). Proven against a REAL in-memory SQLite db
|
|
1025
1154
|
// with the world (049) + enrolment (052) schemas applied.
|
|
1026
1155
|
test("worldRestoreSha is gated on the durable-resume enrolment: participant → SHA, none → null", async () => {
|
|
1027
|
-
const { data } = memDataFor(["049_world_checkpoint.sql", "052_worker_durable_resume.sql"]);
|
|
1156
|
+
const { data } = memDataFor(["049_world_checkpoint.sql", "052_worker_durable_resume.sql", "115_world_checkpoint_process_key.sql"]);
|
|
1028
1157
|
const PR = "owner/repo#7";
|
|
1029
1158
|
const sha = "77ee0993cc6ad4493da0f7551212ef16722135db";
|
|
1030
1159
|
await new WorldStore(data).recordCheckpoint({ prKey: PR, roundNo: 1, commitSha: sha });
|
|
@@ -1042,7 +1171,7 @@ test("worldRestoreSha is gated on the durable-resume enrolment: participant →
|
|
|
1042
1171
|
});
|
|
1043
1172
|
|
|
1044
1173
|
test("worldRestoreSha is null when a participant is enrolled but the PR has no checkpoint yet", async () => {
|
|
1045
|
-
const { data } = memDataFor(["049_world_checkpoint.sql", "052_worker_durable_resume.sql"]);
|
|
1174
|
+
const { data } = memDataFor(["049_world_checkpoint.sql", "052_worker_durable_resume.sql", "115_world_checkpoint_process_key.sql"]);
|
|
1046
1175
|
await new DurableResumeRegistry(data).recordEnrolment("modern-1", true);
|
|
1047
1176
|
assertEquals(await worldRestoreSha(data, "owner/repo#8"), null, "nothing to reconstruct on a first activation");
|
|
1048
1177
|
});
|
package/app/service.ts
CHANGED
|
@@ -514,9 +514,45 @@ export async function isPrSettled(data: DataLayer, prKey: string): Promise<boole
|
|
|
514
514
|
return !!existing && TERMINAL_STATUSES.includes(existing.derived_status);
|
|
515
515
|
}
|
|
516
516
|
|
|
517
|
+
// Per-`prKey` in-process serialization for `submitPr` (Copilot review of #819). `submitPr` reads the
|
|
518
|
+
// PR row, decides `alreadyRunning`, `createInstance`s, and reopens the row across several `await`s.
|
|
519
|
+
// Two `submitPr` calls for the SAME PR (a poll pass racing a delivery-connector redelivery, a
|
|
520
|
+
// record-wave enrollment, …) could otherwise interleave at the `createInstance` await: both read the
|
|
521
|
+
// pre-submit terminal row, both pass the idempotency gate (a terminal row is RESUBMITTABLE), and both
|
|
522
|
+
// create a convergence instance for one PR. Serializing the whole create→reopen critical section per
|
|
523
|
+
// `prKey` makes the second caller observe the row the first already claimed (`converging` + the new
|
|
524
|
+
// `process_key`, derived-active) and short-circuit at the `alreadyRunning` gate instead of minting a
|
|
525
|
+
// duplicate instance. The lock is deliberately IN-PROCESS, never a durable DB claim: every `submitPr`
|
|
526
|
+
// caller — the poller, record-wave, delivery-connector, converge-feature — runs in THIS one Node
|
|
527
|
+
// process (main.ts hosts the poller and the job workers on a single engine client) against one SQLite
|
|
528
|
+
// store, so an in-memory chain fully serializes them; and an in-memory lock evaporates on crash, so a
|
|
529
|
+
// half-done submit recovers through the existing terminal→resubmittable derive path. A durable non-null
|
|
530
|
+
// claim would instead strand a keyed `converging` row `alreadyRunning` FOREVER on a create-instance
|
|
531
|
+
// crash — exactly the #704/#497 phantom the keyless-row guard above exists to avoid. Distinct keys
|
|
532
|
+
// never block each other. This composes with the atomic reopen below (which fences the old-run
|
|
533
|
+
// straggler): the lock removes the duplicate-instance race, the atomic write removes the stale-owner
|
|
534
|
+
// race, and neither reintroduces the other.
|
|
535
|
+
const submitChains = new Map<string, Promise<unknown>>();
|
|
536
|
+
function withPrSubmitLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
|
537
|
+
const prev = submitChains.get(key) ?? Promise.resolve();
|
|
538
|
+
// Chain after the previous holder SETTLES (fulfilled OR rejected — both handlers are `fn`), so one
|
|
539
|
+
// failed/throwing submit never wedges the key for the PR's next submit.
|
|
540
|
+
const run = prev.then(fn, fn);
|
|
541
|
+
// The stored tail is error-swallowed so the chain survives a rejection; bound the map by dropping
|
|
542
|
+
// the key once this tail settles and no later caller has replaced it as the chain's end.
|
|
543
|
+
const tail = run.then(() => undefined, () => undefined);
|
|
544
|
+
submitChains.set(key, tail);
|
|
545
|
+
tail.then(() => {
|
|
546
|
+
if (submitChains.get(key) === tail) submitChains.delete(key);
|
|
547
|
+
});
|
|
548
|
+
return run;
|
|
549
|
+
}
|
|
550
|
+
|
|
517
551
|
/** Register a PR row (if new) and start the convergence process. Idempotent on prKey. Optional
|
|
518
552
|
* `dependsOn` (explicit refs) is unioned with any `Depends-on:` line parsed from the PR body and
|
|
519
|
-
* recorded as the PR's merge-stage dependency set.
|
|
553
|
+
* recorded as the PR's merge-stage dependency set. Concurrent calls for the SAME PR are serialized
|
|
554
|
+
* (see {@link withPrSubmitLock}) so a poll pass racing a worker redelivery can't mint two convergence
|
|
555
|
+
* instances for one PR; the loser observes the winner's claim and returns `alreadyRunning`. */
|
|
520
556
|
export async function submitPr(
|
|
521
557
|
data: DataLayer,
|
|
522
558
|
engine: EngineClient,
|
|
@@ -525,6 +561,20 @@ export async function submitPr(
|
|
|
525
561
|
maxRounds: number = MAX_ROUNDS,
|
|
526
562
|
convergeOnly = false,
|
|
527
563
|
rootRequestKey: string | null = null,
|
|
564
|
+
) {
|
|
565
|
+
return withPrSubmitLock(parsed.prKey, () =>
|
|
566
|
+
submitPrCritical(data, engine, parsed, dependsOn, maxRounds, convergeOnly, rootRequestKey),
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async function submitPrCritical(
|
|
571
|
+
data: DataLayer,
|
|
572
|
+
engine: EngineClient,
|
|
573
|
+
parsed: ParsedPr,
|
|
574
|
+
dependsOn: string[] = [],
|
|
575
|
+
maxRounds: number = MAX_ROUNDS,
|
|
576
|
+
convergeOnly = false,
|
|
577
|
+
rootRequestKey: string | null = null,
|
|
528
578
|
) {
|
|
529
579
|
const table = prs(data);
|
|
530
580
|
const existing = await table.get(parsed.prKey);
|
|
@@ -604,44 +654,16 @@ export async function submitPr(
|
|
|
604
654
|
// while `process_key` still names the OLD instance leaves a window where a delayed old-instance
|
|
605
655
|
// answer job still passes the worker's staleness gate and reinserts its adjudication into the fresh
|
|
606
656
|
// run (Copilot review of #806). Advancing the run identity FIRST, then clearing, fences that job.
|
|
607
|
-
// Re-open a previously converged/abandoned/merged PR for a fresh convergence run.
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
waiting_since: null,
|
|
618
|
-
last_review_id: null,
|
|
619
|
-
last_nudge_at: null,
|
|
620
|
-
// Clear the no-progress head baseline: it is scoped to the PRIOR convergence run, and a fresh
|
|
621
|
-
// run at round 1 must compare its first addressed round against a clean slate. Leaving a stale
|
|
622
|
-
// `last_round_head` lets a resubmission whose branch changed read `currentHead !== previousHead`
|
|
623
|
-
// on its first husked round, mis-route it as progress, and bypass the bounded husk retry (#786).
|
|
624
|
-
last_round_head: null,
|
|
625
|
-
// Clear the attempt watermark too: it is scoped to the prior convergence run's `review-round`
|
|
626
|
-
// instances (Copilot #789). A fresh run mints new, higher-keyed instances so a carried-over
|
|
627
|
-
// watermark would still be below them, but clearing keeps the per-run husk-correlation state
|
|
628
|
-
// unambiguous and self-contained.
|
|
629
|
-
last_progress_agent_watermark: null,
|
|
630
|
-
// Clear the at-least-once REPLAY stamp too (Copilot PR #789). The idempotency guard replays a
|
|
631
|
-
// recorded outcome whenever a redelivered job key matches this row; if the stamp survived a
|
|
632
|
-
// re-open, an OLD `pr.progress-check` delivery redelivered after the NEW convergence instance
|
|
633
|
-
// starts would still match its job key here and replay a stale escalation/progress effect into
|
|
634
|
-
// the fresh run. Clearing it makes the new run treat any such straggler as an unknown key (a
|
|
635
|
-
// normal, freshly-computed decision) rather than replaying the prior run's outcome.
|
|
636
|
-
last_progress_job_key: null,
|
|
637
|
-
last_progress_result: null,
|
|
638
|
-
outcome: null,
|
|
639
|
-
converged_at: null,
|
|
640
|
-
merged_at: null,
|
|
641
|
-
abandon_token: abandonToken,
|
|
642
|
-
root_request_key: effectiveRoot,
|
|
643
|
-
updated_at: ts,
|
|
644
|
-
});
|
|
657
|
+
// Re-open a previously converged/abandoned/merged PR for a fresh convergence run. The row-reset
|
|
658
|
+
// that flips the PR to `converging`/round 1 is DEFERRED and applied ATOMICALLY WITH the
|
|
659
|
+
// `process_key` advance below (after `createInstance`), never here. Flipping the row to the fresh
|
|
660
|
+
// run's shape while `process_key` still names the OLD (now-superseded) instance opens a window in
|
|
661
|
+
// which a delayed old-run `pr.progress-check` straggler reads its own key as the CURRENT owner,
|
|
662
|
+
// passes both `isSuperseded` and the late `stillOwns` guard, and fast-forwards the PR head onto
|
|
663
|
+
// its own stale checkpoint under the fresh run's identity (Copilot review of #819). Publishing the
|
|
664
|
+
// new run identity in the SAME write that resets the row eliminates that "converging-but-old-owner"
|
|
665
|
+
// window: the old generation is invalidated the instant the row becomes the new run's, so no
|
|
666
|
+
// straggler is ever accepted as owner of a row that already presents as the fresh run.
|
|
645
667
|
} else {
|
|
646
668
|
await table.insert({
|
|
647
669
|
pr_key: parsed.prKey,
|
|
@@ -701,18 +723,67 @@ export async function submitPr(
|
|
|
701
723
|
},
|
|
702
724
|
});
|
|
703
725
|
const processKey = processInstanceKey == null ? null : String(processInstanceKey);
|
|
704
|
-
// The `process_key` advance and the adjudication reset below are the
|
|
705
|
-
// run authoritative. If
|
|
706
|
-
// only half-committed — and a retry would short-circuit at the `alreadyRunning`
|
|
707
|
-
// (the new instance is ACTIVE, so `derived_status` is non-terminal), never
|
|
708
|
-
// failed reset would then leave the fresh run replaying STALE adjudication memory
|
|
709
|
-
// (Copilot review). So roll the just-created run back on failure: terminate it and
|
|
710
|
-
// submission is NOT treated as started. Terminating flips the PR's derived tracking
|
|
711
|
-
// terminal edge (`abandoned`) via the `instanceTracking` reconciler, making the PR
|
|
712
|
-
// a retry re-creates a fresh instance and re-runs
|
|
713
|
-
// stale decisions
|
|
726
|
+
// The reopen row-reset, the `process_key` advance, and the adjudication reset below are the writes
|
|
727
|
+
// that MAKE the new run authoritative. If ANY throws, the newly created instance is already live but
|
|
728
|
+
// the reopen is only half-committed — and a retry would short-circuit at the `alreadyRunning`
|
|
729
|
+
// idempotency gate (the new instance is ACTIVE, so `derived_status` is non-terminal), never
|
|
730
|
+
// re-running them. A failed reset would then leave the fresh run replaying STALE adjudication memory
|
|
731
|
+
// indefinitely (Copilot review). So roll the just-created run back on failure: terminate it and
|
|
732
|
+
// rethrow, so the submission is NOT treated as started. Terminating flips the PR's derived tracking
|
|
733
|
+
// status to a terminal edge (`abandoned`) via the `instanceTracking` reconciler, making the PR
|
|
734
|
+
// resubmittable so a retry re-creates a fresh instance and re-runs everything cleanly — no orphaned
|
|
735
|
+
// run auto-applies stale decisions, and (crucially) the row stays in its OLD terminal shape until
|
|
736
|
+
// the atomic reopen write lands, so no straggler ever sees a fresh-run row still owned by the old
|
|
737
|
+
// instance.
|
|
714
738
|
try {
|
|
715
|
-
if (
|
|
739
|
+
if (existing) {
|
|
740
|
+
// ATOMIC REOPEN (Copilot review of #819): reset the row to the fresh run's shape AND publish the
|
|
741
|
+
// new run identity (`process_key`) in a SINGLE write, so the row never presents as `converging`
|
|
742
|
+
// round 1 while still owned by the OLD instance. A delayed old-run straggler is therefore never
|
|
743
|
+
// accepted as the current owner of a row that already belongs to the fresh run.
|
|
744
|
+
await table.update(parsed.prKey, {
|
|
745
|
+
status: "converging",
|
|
746
|
+
current_round: 1,
|
|
747
|
+
url: parsed.url,
|
|
748
|
+
// Coalesce to the key so `pull_requests.title` stays non-blank for the title-led grids
|
|
749
|
+
// (issue #248): a fresh fetch wins, else the prior title, else the `owner/repo#N` key.
|
|
750
|
+
// A blank/whitespace title counts as missing (matches the 036 backfill), so an empty
|
|
751
|
+
// external title never lands as an unlabeled row.
|
|
752
|
+
title: coalesceTitle(title, existing.title, parsed.prKey),
|
|
753
|
+
waiting_since: null,
|
|
754
|
+
last_review_id: null,
|
|
755
|
+
last_nudge_at: null,
|
|
756
|
+
// Clear the no-progress head baseline: it is scoped to the PRIOR convergence run, and a fresh
|
|
757
|
+
// run at round 1 must compare its first addressed round against a clean slate. Leaving a stale
|
|
758
|
+
// `last_round_head` lets a resubmission whose branch changed read `currentHead !== previousHead`
|
|
759
|
+
// on its first husked round, mis-route it as progress, and bypass the bounded husk retry (#786).
|
|
760
|
+
last_round_head: null,
|
|
761
|
+
// Clear the attempt watermark too: it is scoped to the prior convergence run's `review-round`
|
|
762
|
+
// instances (Copilot #789). A fresh run mints new, higher-keyed instances so a carried-over
|
|
763
|
+
// watermark would still be below them, but clearing keeps the per-run husk-correlation state
|
|
764
|
+
// unambiguous and self-contained.
|
|
765
|
+
last_progress_agent_watermark: null,
|
|
766
|
+
// Clear the at-least-once REPLAY stamp too (Copilot PR #789). The idempotency guard replays a
|
|
767
|
+
// recorded outcome whenever a redelivered job key matches this row; if the stamp survived a
|
|
768
|
+
// re-open, an OLD `pr.progress-check` delivery redelivered after the NEW convergence instance
|
|
769
|
+
// starts would still match its job key here and replay a stale escalation/progress effect into
|
|
770
|
+
// the fresh run. Clearing it makes the new run treat any such straggler as an unknown key (a
|
|
771
|
+
// normal, freshly-computed decision) rather than replaying the prior run's outcome.
|
|
772
|
+
last_progress_job_key: null,
|
|
773
|
+
last_progress_result: null,
|
|
774
|
+
outcome: null,
|
|
775
|
+
converged_at: null,
|
|
776
|
+
merged_at: null,
|
|
777
|
+
abandon_token: abandonToken,
|
|
778
|
+
root_request_key: effectiveRoot,
|
|
779
|
+
// Publish the fresh run's identity in the SAME write as the status flip (may be null if the
|
|
780
|
+
// instance could not be created — a phantom row a retry re-enrolls, as on first submit).
|
|
781
|
+
process_key: processKey,
|
|
782
|
+
updated_at: ts,
|
|
783
|
+
});
|
|
784
|
+
} else if (processKey != null) {
|
|
785
|
+
// First submit: the row was INSERTED keyless before `createInstance` (the phantom-row pattern,
|
|
786
|
+
// #704/#497); publish its `process_key` now that the instance exists.
|
|
716
787
|
await table.update(parsed.prKey, { process_key: processKey });
|
|
717
788
|
}
|
|
718
789
|
// Invalidate this PR's durable adjudication memory for the fresh run (issue #806, Copilot review) —
|
|
@@ -721,8 +792,7 @@ export async function submitPr(
|
|
|
721
792
|
// rejected by the worker's staleness gate (its `processInstanceKey` no longer matches the advanced
|
|
722
793
|
// `process_key`), so it cannot reinsert a stale adjudication after the reset; and the worker reads
|
|
723
794
|
// `process_key` as late as possible so it observes this advance. The insert-if-absent record then
|
|
724
|
-
// re-learns the operator's new answer for the new run.
|
|
725
|
-
// null: the memory must still be clean for the fresh run). The wipe is a SINGLE atomic `DELETE`
|
|
795
|
+
// re-learns the operator's new answer for the new run. The wipe is a SINGLE atomic `DELETE`
|
|
726
796
|
// (`resetAdjudications`), never a row-by-row loop, so a crash mid-reset cannot leave a partially
|
|
727
797
|
// cleared memory (Copilot review of #806).
|
|
728
798
|
if (existing) {
|
package/app/world/checkpoint.ts
CHANGED
|
@@ -45,6 +45,10 @@ export interface WorldCheckpointInput {
|
|
|
45
45
|
/** The round's irreversible effects, in order. Defaults to a single `push` effect keyed by the
|
|
46
46
|
* commit SHA. */
|
|
47
47
|
readonly effects?: readonly Effect[];
|
|
48
|
+
/** The convergence RUN (process instance) recording this checkpoint — persisted so the no-advance
|
|
49
|
+
* self-heal can prove the checkpoint belongs to THIS run and never heals onto a prior run's stale
|
|
50
|
+
* checkpoint (issue #819). `null`/omitted when the recording job has no process instance. */
|
|
51
|
+
readonly processKey?: string | null;
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
/** The result of recording a world checkpoint: the shared offset (mind + world commit at it) and the
|
|
@@ -73,6 +77,7 @@ export async function recordWorldCheckpoint(
|
|
|
73
77
|
roundNo: input.roundNo,
|
|
74
78
|
commitSha: input.commitSha,
|
|
75
79
|
effects,
|
|
80
|
+
processKey: input.processKey ?? null,
|
|
76
81
|
});
|
|
77
82
|
// The JOIN: hand the mind the identical checkpoint the world just persisted, at the same boundary.
|
|
78
83
|
// Advisory — a sink failure must not undo the durable world record (the world is authoritative for
|