@allocator-one/rcl 4.5.4 → 4.5.6

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.
Files changed (64) hide show
  1. package/README.md +5 -4
  2. package/dist/converge/attempt-budget.d.ts +14 -0
  3. package/dist/converge/attempt-budget.js +216 -1
  4. package/dist/converge/attempt-budget.js.map +1 -1
  5. package/dist/converge/launch-guard.js +23 -6
  6. package/dist/converge/launch-guard.js.map +1 -1
  7. package/dist/converge/launch-record.d.ts +36 -0
  8. package/dist/converge/launch-record.js +9 -3
  9. package/dist/converge/launch-record.js.map +1 -1
  10. package/dist/converge/legacy-launch-health.d.ts +36 -0
  11. package/dist/converge/native-lock.d.ts +3 -1
  12. package/dist/converge/native-lock.js +4 -4
  13. package/dist/converge/native-lock.js.map +1 -1
  14. package/dist/converge/ordinary-pending-export.d.ts +3 -0
  15. package/dist/converge/ordinary-pending-export.js +64 -17
  16. package/dist/converge/ordinary-pending-export.js.map +1 -1
  17. package/dist/converge/ordinary-pending-package.d.ts +11 -0
  18. package/dist/converge/ordinary-pending-package.js +18 -4
  19. package/dist/converge/ordinary-pending-package.js.map +1 -1
  20. package/dist/converge/pending-finalization-receipt.d.ts +4 -0
  21. package/dist/converge/pending-finalization-receipt.js +102 -0
  22. package/dist/converge/pending-finalization-receipt.js.map +1 -0
  23. package/dist/converge/pending-legacy-resume.d.ts +48 -3
  24. package/dist/converge/pending-legacy-resume.js +215 -35
  25. package/dist/converge/pending-legacy-resume.js.map +1 -1
  26. package/dist/converge/pending-recovery-source.d.ts +114 -0
  27. package/dist/converge/pending-recovery-source.js +36 -1
  28. package/dist/converge/pending-recovery-source.js.map +1 -1
  29. package/dist/converge/process-identity.d.ts +51 -0
  30. package/dist/converge/process-identity.js +190 -0
  31. package/dist/converge/process-identity.js.map +1 -0
  32. package/dist/converge/recovery-launch.js +39 -17
  33. package/dist/converge/recovery-launch.js.map +1 -1
  34. package/dist/converge/recovery-state.d.ts +55 -0
  35. package/dist/converge/recovery-state.js +306 -0
  36. package/dist/converge/recovery-state.js.map +1 -0
  37. package/dist/converge/run-state.d.ts +39 -1
  38. package/dist/converge/run-state.js +246 -18
  39. package/dist/converge/run-state.js.map +1 -1
  40. package/dist/converge/semantic-state.d.ts +55 -0
  41. package/dist/converge/semantic-state.js +666 -0
  42. package/dist/converge/semantic-state.js.map +1 -0
  43. package/dist/converge/stale-report.d.ts +36 -0
  44. package/dist/dispatch/async-lane.d.ts +12 -0
  45. package/dist/dispatch/async-lane.js +98 -12
  46. package/dist/dispatch/async-lane.js.map +1 -1
  47. package/dist/evidence/claim-recovery/validation/native-state.d.ts +22 -0
  48. package/dist/evidence/claim-recovery/validation/native-state.js +91 -9
  49. package/dist/evidence/claim-recovery/validation/native-state.js.map +1 -1
  50. package/dist/evidence/claim-recovery/validation/obligations.js +2 -1
  51. package/dist/evidence/claim-recovery/validation/obligations.js.map +1 -1
  52. package/dist/evidence/claim-recovery/validation/semantic-validation.d.ts +11 -0
  53. package/dist/evidence/claim-recovery/validation/semantic-validation.js +105 -28
  54. package/dist/evidence/claim-recovery/validation/semantic-validation.js.map +1 -1
  55. package/dist/evidence/claim-recovery/validation/types.d.ts +10 -0
  56. package/dist/evidence/original-run/lock-scope.d.ts +12 -6
  57. package/dist/evidence/original-run/lock-scope.js +12 -9
  58. package/dist/evidence/original-run/lock-scope.js.map +1 -1
  59. package/dist/index.js +59 -13
  60. package/dist/index.js.map +1 -1
  61. package/dist/telemetry/recovery/files.d.ts +4 -0
  62. package/dist/telemetry/recovery/files.js +15 -5
  63. package/dist/telemetry/recovery/files.js.map +1 -1
  64. package/package.json +1 -1
@@ -10,6 +10,7 @@ import { claimConvergeAttempt, convergeAttemptStatePath, previewConvergeAttemptS
10
10
  import { withRecoveryTarget } from './target-ownership.js';
11
11
  import { bindOriginalCouncil } from '../dispatch/original-execution.js';
12
12
  import { createOriginalLaunch } from '../dispatch/original-launch.js';
13
+ import { consumeBoundAsyncHistory, snapshotAsyncHistory } from '../dispatch/async-lane.js';
13
14
  import { uuidv7 } from '../report/uuid.js';
14
15
  import { captureSupplementalAsync } from '../report/supplemental-async.js';
15
16
  import { stableStringify } from '../report/run-header.js';
@@ -17,8 +18,9 @@ import { readStable } from '../telemetry/recovery/files.js';
17
18
  import { serializeRecoveryDocument } from '../evidence/original-run/journal.js';
18
19
  import { prepareLockRoot } from '../evidence/original-run/lock-path.js';
19
20
  import { retainStaleFile } from './stale-report-storage.js';
20
- import { createPendingRecoverySource, createOrdinaryMigrationSource, pendingRecoverySourceSchema } from './pending-recovery-source.js';
21
+ import { createPendingRecoverySource, createOrdinaryMigrationSource, createCycleMigrationSource, pendingRecoverySourceSchema } from './pending-recovery-source.js';
21
22
  import { validateOrdinaryPendingPackage } from './ordinary-pending-package.js';
23
+ import { assertReviewCyclePair } from './fresh-review.js';
22
24
  const digestSchema = z.string().regex(/^[a-f0-9]{64}$/);
23
25
  const ordinaryPendingFinalizationReceiptBodySchema = z.object({
24
26
  version: z.literal(1),
@@ -41,20 +43,66 @@ const ordinaryPendingFinalizationReceiptBodySchema = z.object({
41
43
  sourceAttemptStateSha256: digestSchema,
42
44
  finalizedNativeStateSha256: digestSchema,
43
45
  }).strict();
44
- const ordinaryPendingFinalizationReceiptSchema = ordinaryPendingFinalizationReceiptBodySchema.extend({
45
- receiptDigest: digestSchema,
46
- }).strict().superRefine((receipt, context) => {
46
+ const cyclePendingFinalizationReceiptBodySchema = ordinaryPendingFinalizationReceiptBodySchema.omit({
47
+ version: true,
48
+ operation: true,
49
+ retainedAsyncSha256: true,
50
+ }).extend({
51
+ version: z.literal(2),
52
+ operation: z.literal('cycle-pending-finalize-only'),
53
+ retainedAsyncSha256: z.array(digestSchema),
54
+ cycleId: z.string().uuid(),
55
+ operationId: z.string().uuid(),
56
+ repo: z.string().min(1),
57
+ prNumber: z.number().int().positive().safe(),
58
+ attemptCap: z.number().int().positive().safe(),
59
+ roundCap: z.number().int().positive().safe(),
60
+ asyncAttribution: z.literal('cycle-history-unattributed'),
61
+ }).strict();
62
+ const pendingFinalizationReceiptBodySchema = z.union([
63
+ ordinaryPendingFinalizationReceiptBodySchema,
64
+ cyclePendingFinalizationReceiptBodySchema,
65
+ ]);
66
+ const ordinaryPendingFinalizationReceiptSchema = z.union([
67
+ ordinaryPendingFinalizationReceiptBodySchema.extend({ receiptDigest: digestSchema }).strict(),
68
+ cyclePendingFinalizationReceiptBodySchema.extend({ receiptDigest: digestSchema }).strict(),
69
+ ]).superRefine((receipt, context) => {
47
70
  const { receiptDigest, ...body } = receipt;
48
71
  if (receiptDigest !== createHash('sha256').update(stableStringify(body)).digest('hex')) {
49
72
  context.addIssue({ code: 'custom', message: 'Pending finalization receipt digest is invalid' });
50
73
  }
51
74
  if (receipt.nextFreeAttempt !== receipt.finalizedAttempt + 1 ||
52
75
  receipt.attemptsUsed !== receipt.finalizedAttempt || receipt.attemptsUsed > receipt.cap ||
53
- new Set(receipt.retainedAsyncSha256).size !== receipt.retainedAsyncSha256.length) {
76
+ (receipt.version === 1 && (receipt.retainedAsyncSha256.length === 0 ||
77
+ new Set(receipt.retainedAsyncSha256).size !== receipt.retainedAsyncSha256.length))) {
54
78
  context.addIssue({ code: 'custom', message: 'Pending finalization accounting is inconsistent' });
55
79
  }
80
+ if (receipt.version === 2 && (receipt.attemptCap !== receipt.cap || receipt.round > receipt.roundCap)) {
81
+ context.addIssue({ code: 'custom', message: 'Cycle pending finalization accounting is inconsistent' });
82
+ }
56
83
  });
57
84
  function fail(code) { throw new Error(`pending_legacy_resume_${code}`); }
85
+ function cyclePendingPreviewSha256(input) {
86
+ return createHash('sha256').update(stableStringify({
87
+ version: 1,
88
+ operation: 'cycle-pending-finalize-preview',
89
+ ...input,
90
+ retainedAsyncSha256: [...input.retainedAsyncSha256].sort(),
91
+ })).digest('hex');
92
+ }
93
+ function assertCyclePendingPreviewBinding(options, sourceDigest, migrationPackageSha256) {
94
+ if (options.currentPrHeadSha === undefined)
95
+ return;
96
+ const expected = cyclePendingPreviewSha256({ target: options.target, headSha: options.headSha,
97
+ baseSha: options.baseSha, currentPrHeadSha: options.currentPrHeadSha,
98
+ pendingInputSha256: options.pendingInputSha256,
99
+ nativeStateSha256: options.nativeStateSha256,
100
+ attemptStateSha256: options.attemptStateSha256,
101
+ retainedAsyncSha256: options.retainedAsyncSha256, maxAttempts: options.maxAttempts,
102
+ sourceDigest, migrationPackageSha256 });
103
+ if (options.previewSha256 !== expected)
104
+ fail('preview_binding_mismatch');
105
+ }
58
106
  function defaultOwnerAlive(pid) {
59
107
  try {
60
108
  process.kill(pid, 0);
@@ -90,12 +138,18 @@ function sourceFromFinalized(target, launch, attempts, migrationPackage) {
90
138
  const migrationPackageSha256 = createHash('sha256').update(stableStringify(migrationPackage)).digest('hex');
91
139
  if (migrationPackageSha256 !== recovery.migrationPackageSha256)
92
140
  fail('finalization_mismatch');
93
- const source = createOrdinaryMigrationSource({ version: 1, target, headSha: launch.headSha,
141
+ const common = { target, headSha: launch.headSha,
94
142
  inputSha256: launch.inputSha256, pendingAttempt: launch.attempt, round: launch.round,
95
143
  originalPid: launch.pid, startedAt: launch.startedAt, blockingOutcome: recovery.blockingOutcome,
96
144
  reason: recovery.reason, nativeStateSha256: recovery.nativeStateSha256,
97
145
  attemptStateSha256: recovery.attemptStateSha256, retainedAsyncSha256: recovery.retainedAsyncSha256,
98
- migrationPackageSha256 });
146
+ migrationPackageSha256 };
147
+ const source = migrationPackage.version === 2
148
+ ? createCycleMigrationSource({ version: 2, ...common, cycle: migrationPackage.cycle,
149
+ cycleId: migrationPackage.cycle.id, operationId: migrationPackage.cycle.operationId,
150
+ attemptCap: migrationPackage.attemptCap, roundCap: migrationPackage.roundCap,
151
+ attemptsUsed: migrationPackage.attemptsUsed, asyncAttribution: 'cycle-history-unattributed' })
152
+ : createOrdinaryMigrationSource({ version: 1, ...common });
99
153
  if (source.digest !== recovery.sourceDigest)
100
154
  fail('finalization_mismatch');
101
155
  return source;
@@ -112,40 +166,109 @@ function sourceFromFinalized(target, launch, attempts, migrationPackage) {
112
166
  digest: recovery.sourceDigest,
113
167
  });
114
168
  }
169
+ function packageExpected(state, attempts, current, input) {
170
+ return {
171
+ target: input.target, headSha: current.headSha, inputSha256: current.inputSha256,
172
+ baseSha: input.baseSha, attempt: current.attempt, round: current.round, pid: current.pid,
173
+ retainedAsyncSha256: input.retainedAsyncSha256,
174
+ ...(state.cycle ? { cycle: state.cycle, attemptCap: attempts.cap,
175
+ roundCap: state.roundCap, attemptsUsed: attempts.attemptsUsed } : {}),
176
+ };
177
+ }
178
+ async function assertCycleRecovery(options, state, attempts, packet) {
179
+ if (!state.cycle) {
180
+ if (packet.version === 2)
181
+ fail('cycle_mismatch');
182
+ return;
183
+ }
184
+ await assertReviewCyclePair(options.gitCommonDir, options.target, state.cycle);
185
+ if (packet.version !== 2 || !isDeepStrictEqual(packet.cycle, state.cycle))
186
+ fail('cycle_mismatch');
187
+ const remote = options.cycleRemote;
188
+ if (!remote || remote.repo.toLowerCase() !== state.cycle.repo ||
189
+ remote.prNumber !== state.cycle.prNumber || remote.url !== state.cycle.url)
190
+ fail('cycle_remote_mismatch');
191
+ if ((await remote.current())?.id !== state.cycle.id)
192
+ fail('cycle_superseded');
193
+ if (attempts.cap !== packet.attemptCap || state.roundCap !== packet.roundCap ||
194
+ attempts.attemptsUsed !== packet.attemptsUsed)
195
+ fail('cycle_accounting_mismatch');
196
+ }
115
197
  function validateOptions(input) {
116
198
  const hashes = input?.retainedAsyncSha256;
199
+ const cycleFinalizePreview = input?.migrationPackage?.version === 2 &&
200
+ input.previewMode === 'finalize-only';
201
+ const currentPrHeadValid = input?.currentPrHeadSha === undefined ||
202
+ (cycleFinalizePreview && /^[a-f0-9]{40}$/.test(input.currentPrHeadSha));
117
203
  if (!input || typeof input.gitCommonDir !== 'string' || typeof input.target !== 'string' || !input.target.trim() ||
118
204
  !/^[a-f0-9]{40}$/.test(input.headSha ?? '') ||
119
205
  (input.migrationPackage && !/^[a-f0-9]{40}$/.test(input.baseSha ?? '')) ||
120
206
  !/^[a-f0-9]{64}$/.test(input.pendingInputSha256 ?? '') ||
121
207
  !/^[a-f0-9]{64}$/.test(input.recoveryInputSha256 ?? '') ||
122
208
  typeof input.retryReason !== 'string' || !input.retryReason.trim() || input.retryReason.length > 500 ||
123
- !Array.isArray(hashes) || hashes.length === 0 || hashes.some(value => !/^[a-f0-9]{64}$/.test(value)) ||
124
- new Set(hashes).size !== hashes.length || typeof input.validate !== 'function' ||
209
+ !Array.isArray(hashes) || hashes.some(value => !/^[a-f0-9]{64}$/.test(value)) ||
210
+ (!cycleFinalizePreview && (hashes.length === 0 || new Set(hashes).size !== hashes.length)) ||
211
+ typeof input.validate !== 'function' ||
125
212
  typeof input.run !== 'function' || typeof input.loadRetainedAsync !== 'function' ||
126
213
  (!input.migrationPackage && !input.legacyRetry) ||
127
214
  !Number.isSafeInteger(input.maxAttempts) || input.maxAttempts < 1 ||
128
215
  !Number.isSafeInteger(input.maxPhysicalCalls) || input.maxPhysicalCalls < 1 ||
129
216
  !Number.isSafeInteger(input.maxAttemptsPerCell) || input.maxAttemptsPerCell < 1 ||
130
217
  !Number.isSafeInteger(input.maxDurationMs) || input.maxDurationMs < 1 || input.maxDurationMs > 2_147_483_647 ||
131
- (input.previewMode !== undefined && !['combined', 'finalize-only'].includes(input.previewMode))) {
218
+ (input.previewMode !== undefined && !['combined', 'finalize-only'].includes(input.previewMode)) ||
219
+ !currentPrHeadValid) {
132
220
  fail('invalid_input');
133
221
  }
134
222
  }
135
223
  function validateFinalizeOptions(input) {
136
224
  const hashes = input?.retainedAsyncSha256;
225
+ const cycleBacked = input?.migrationPackage?.version === 2;
226
+ const hasCurrentPrHead = input?.currentPrHeadSha !== undefined;
227
+ const hasPreviewSha = input?.previewSha256 !== undefined;
228
+ const hasStoreDir = typeof input?.cycleHistory?.storeDir === 'string' &&
229
+ input.cycleHistory.storeDir.trim().length > 0;
230
+ const hasStoreResolver = typeof input?.cycleHistory?.resolveStoreDir === 'function';
137
231
  if (!input || typeof input.gitCommonDir !== 'string' || typeof input.target !== 'string' || !input.target.trim() ||
138
232
  !/^[a-f0-9]{40}$/.test(input.headSha ?? '') || !/^[a-f0-9]{40}$/.test(input.baseSha ?? '') ||
139
233
  !/^[a-f0-9]{64}$/.test(input.pendingInputSha256 ?? '') ||
140
234
  !/^[a-f0-9]{64}$/.test(input.nativeStateSha256 ?? '') ||
141
235
  !/^[a-f0-9]{64}$/.test(input.attemptStateSha256 ?? '') ||
142
- !Array.isArray(hashes) || hashes.length === 0 || hashes.some(value => !/^[a-f0-9]{64}$/.test(value)) ||
143
- new Set(hashes).size !== hashes.length || typeof input.loadRetainedAsync !== 'function' ||
236
+ !Array.isArray(hashes) || hashes.some(value => !/^[a-f0-9]{64}$/.test(value)) ||
237
+ (!cycleBacked && (hashes.length === 0 || new Set(hashes).size !== hashes.length)) ||
238
+ typeof input.loadRetainedAsync !== 'function' ||
144
239
  (input.writeFinalizedState !== undefined && typeof input.writeFinalizedState !== 'function') ||
145
- !input.migrationPackage || !Number.isSafeInteger(input.maxAttempts) || input.maxAttempts < 1) {
240
+ (input.consumeCycleHistory !== undefined && typeof input.consumeCycleHistory !== 'function') ||
241
+ (cycleBacked && (!input.cycleHistory || hasStoreDir === hasStoreResolver ||
242
+ typeof input.cycleHistory.targetKey !== 'string' || !input.cycleHistory.targetKey.trim() ||
243
+ !Number.isSafeInteger(input.cycleHistory.maxResults) || input.cycleHistory.maxResults < 1)) ||
244
+ !input.migrationPackage || !Number.isSafeInteger(input.maxAttempts) || input.maxAttempts < 1 ||
245
+ hasCurrentPrHead !== hasPreviewSha ||
246
+ (hasCurrentPrHead && (!cycleBacked || !/^[a-f0-9]{40}$/.test(input.currentPrHeadSha) ||
247
+ !/^[a-f0-9]{64}$/.test(input.previewSha256)))) {
146
248
  fail('invalid_input');
147
249
  }
148
250
  }
251
+ async function reconcileFinalizedCycleHistory(options, mode) {
252
+ if (options.migrationPackage.version !== 2)
253
+ return;
254
+ const history = options.cycleHistory;
255
+ let storeDir;
256
+ try {
257
+ storeDir = history.storeDir ?? await history.resolveStoreDir();
258
+ }
259
+ catch (error) {
260
+ if (mode !== 'new-receipt' && error.code === 'ENOENT')
261
+ return;
262
+ throw error;
263
+ }
264
+ if (mode === 'advanced-replay') {
265
+ const live = await snapshotAsyncHistory(storeDir, history.targetKey, history.maxResults);
266
+ if (live.artifacts.length > 0)
267
+ fail('cycle_history_ambiguous_after_successor');
268
+ return;
269
+ }
270
+ await (options.consumeCycleHistory ?? consumeBoundAsyncHistory)(storeDir, history.targetKey, options.retainedAsyncSha256, history.maxResults);
271
+ }
149
272
  async function archiveAsync(common, source, artifacts, packet) {
150
273
  validateAsyncArtifacts(source, artifacts, packet);
151
274
  const directory = await prepareLockRoot(join(common, 'rcl-converge-pending-recovery', source.digest));
@@ -269,7 +392,7 @@ async function assertFinalizedStateMatchesReceipt(common, receipt) {
269
392
  return { native: native.raw, attempts: attempts.raw, exact: false };
270
393
  }
271
394
  function createFinalizationReceipt(input) {
272
- const body = ordinaryPendingFinalizationReceiptBodySchema.parse(input);
395
+ const body = pendingFinalizationReceiptBodySchema.parse(input);
273
396
  return ordinaryPendingFinalizationReceiptSchema.parse({
274
397
  ...body,
275
398
  receiptDigest: createHash('sha256').update(stableStringify(body)).digest('hex'),
@@ -284,10 +407,16 @@ function assertReceiptMatchesInput(receipt, input, migrationPackageSha256) {
284
407
  receipt.retainedAsyncSha256.join(',') !== [...input.retainedAsyncSha256].sort().join(',')) {
285
408
  fail('finalization_receipt_mismatch');
286
409
  }
410
+ if (receipt.version === 2 && (input.migrationPackage.cycle?.id !== receipt.cycleId ||
411
+ input.migrationPackage.cycle.operationId !== receipt.operationId))
412
+ fail('finalization_receipt_mismatch');
287
413
  return validateOrdinaryPendingPackage(input.migrationPackage, {
288
414
  target: receipt.target, headSha: receipt.headSha, inputSha256: receipt.inputSha256,
289
415
  baseSha: receipt.baseSha, attempt: receipt.finalizedAttempt, round: receipt.round,
290
416
  pid: receipt.originalPid, retainedAsyncSha256: receipt.retainedAsyncSha256,
417
+ ...(receipt.version === 2 ? { cycle: input.migrationPackage.cycle,
418
+ attemptCap: receipt.attemptCap, roundCap: receipt.roundCap,
419
+ attemptsUsed: receipt.attemptsUsed } : {}),
291
420
  });
292
421
  }
293
422
  async function readArchivedAsync(common, sourceDigest, retainedAsyncSha256) {
@@ -327,14 +456,26 @@ export async function finalizeOrdinaryPendingLaunch(input) {
327
456
  const existingReceipt = await readFinalizationReceipt(options.gitCommonDir, migrationPackageSha256);
328
457
  if (existingReceipt) {
329
458
  const packet = assertReceiptMatchesInput(existingReceipt, options, migrationPackageSha256);
459
+ assertCyclePendingPreviewBinding(options, existingReceipt.sourceDigest, migrationPackageSha256);
330
460
  const state = await assertFinalizedStateMatchesReceipt(options.gitCommonDir, existingReceipt);
461
+ const [currentRun, currentAttempts] = await Promise.all([
462
+ loadConvergeRunState(options.gitCommonDir, options.target),
463
+ previewConvergeAttemptState(options.gitCommonDir, options.target),
464
+ ]);
465
+ if (!currentRun || !currentAttempts)
466
+ fail('finalization_receipt_state_mismatch');
467
+ await assertCycleRecovery(options, currentRun, currentAttempts, packet.version === 2 ? { ...packet, attemptsUsed: currentAttempts.attemptsUsed } : packet);
331
468
  validateAsyncArtifacts({ retainedAsyncSha256: existingReceipt.retainedAsyncSha256 }, await readArchivedAsync(options.gitCommonDir, existingReceipt.sourceDigest, existingReceipt.retainedAsyncSha256), packet);
332
469
  const snapshotsPresent = await finalizationSnapshotsPresent(options.gitCommonDir, existingReceipt);
470
+ // Revalidate at the last supported boundary before replay can publish
471
+ // missing snapshots or consume retained cycle history.
472
+ await assertCycleRecovery(options, currentRun, currentAttempts, packet.version === 2 ? { ...packet, attemptsUsed: currentAttempts.attemptsUsed } : packet);
333
473
  if (!snapshotsPresent) {
334
474
  if (!state.exact)
335
475
  fail('finalization_receipt_state_mismatch');
336
476
  await retainFinalizationSnapshots(options.gitCommonDir, existingReceipt, state.attempts, state.native);
337
477
  }
478
+ await reconcileFinalizedCycleHistory(options, state.exact ? 'exact-replay' : 'advanced-replay');
338
479
  return { receipt: existingReceipt, reusedReceipt: true,
339
480
  snapshotBindingUpgraded: !snapshotsPresent };
340
481
  }
@@ -351,11 +492,8 @@ export async function finalizeOrdinaryPendingLaunch(input) {
351
492
  current.inputSha256 !== options.pendingInputSha256 || !['pending', 'failed'].includes(current.status)) {
352
493
  fail('launch_mismatch');
353
494
  }
354
- const packet = validateOrdinaryPendingPackage(options.migrationPackage, {
355
- target: options.target, headSha: current.headSha, inputSha256: current.inputSha256,
356
- baseSha: options.baseSha, attempt: current.attempt, round: current.round, pid: current.pid,
357
- retainedAsyncSha256: options.retainedAsyncSha256,
358
- });
495
+ const packet = validateOrdinaryPendingPackage(options.migrationPackage, packageExpected(state, attempts, current, options));
496
+ await assertCycleRecovery(options, state, attempts, packet);
359
497
  let source;
360
498
  let sourceAttemptStateBytes;
361
499
  if (current.status === 'pending') {
@@ -366,12 +504,18 @@ export async function finalizeOrdinaryPendingLaunch(input) {
366
504
  readStable(convergeAttemptStatePath(options.gitCommonDir, options.target)),
367
505
  ]);
368
506
  sourceAttemptStateBytes = attemptBytes.raw;
369
- source = createOrdinaryMigrationSource({ version: 1, target: options.target,
507
+ const commonSource = { target: options.target,
370
508
  headSha: current.headSha, inputSha256: current.inputSha256, pendingAttempt: current.attempt,
371
509
  round: current.round, originalPid: current.pid, startedAt: current.startedAt,
372
510
  blockingOutcome: 'unknown', reason: 'coordinator_exited_without_durable_blocking_receipts',
373
511
  nativeStateSha256: nativeBytes.sha256, attemptStateSha256: attemptBytes.sha256,
374
- retainedAsyncSha256: options.retainedAsyncSha256, migrationPackageSha256 });
512
+ retainedAsyncSha256: options.retainedAsyncSha256, migrationPackageSha256 };
513
+ source = packet.version === 2
514
+ ? createCycleMigrationSource({ version: 2, ...commonSource, cycle: state.cycle,
515
+ cycleId: state.cycle.id, operationId: state.cycle.operationId,
516
+ attemptCap: attempts.cap, roundCap: state.roundCap, attemptsUsed: attempts.attemptsUsed,
517
+ asyncAttribution: 'cycle-history-unattributed' })
518
+ : createOrdinaryMigrationSource({ version: 1, ...commonSource });
375
519
  }
376
520
  else {
377
521
  source = sourceFromFinalized(options.target, current, attempts, packet);
@@ -379,6 +523,7 @@ export async function finalizeOrdinaryPendingLaunch(input) {
379
523
  if (source.nativeStateSha256 !== options.nativeStateSha256 ||
380
524
  source.attemptStateSha256 !== options.attemptStateSha256)
381
525
  fail('source_digest_mismatch');
526
+ assertCyclePendingPreviewBinding(options, source.digest, migrationPackageSha256);
382
527
  // Once the failed/unknown native state exists, archive publication already
383
528
  // completed. Resume from those immutable bytes so a lost acknowledgment
384
529
  // does not depend on the original async store still being available.
@@ -399,7 +544,7 @@ export async function finalizeOrdinaryPendingLaunch(input) {
399
544
  // Construct and validate the exact receipt before the first write. The
400
545
  // native serializer is shared with writeState, so its post-state digest is
401
546
  // known without publishing a partially validated operation.
402
- const receipt = createFinalizationReceipt({ version: 1, operation: 'ordinary-pending-finalize-only',
547
+ const receiptCommon = {
403
548
  target: options.target, headSha: current.headSha, baseSha: options.baseSha,
404
549
  inputSha256: current.inputSha256, sourceDigest: source.digest, migrationPackageSha256,
405
550
  finalizedAttempt: current.attempt, round: current.round, originalPid: current.pid,
@@ -407,13 +552,24 @@ export async function finalizeOrdinaryPendingLaunch(input) {
407
552
  blockingOutcome: 'unknown', retainedAsyncSha256: options.retainedAsyncSha256,
408
553
  sourceNativeStateSha256: source.nativeStateSha256,
409
554
  sourceAttemptStateSha256: source.attemptStateSha256,
410
- finalizedNativeStateSha256 });
555
+ finalizedNativeStateSha256
556
+ };
557
+ const receipt = packet.version === 2
558
+ ? createFinalizationReceipt({ version: 2, operation: 'cycle-pending-finalize-only', ...receiptCommon,
559
+ cycleId: state.cycle.id, operationId: state.cycle.operationId,
560
+ repo: state.cycle.repo, prNumber: state.cycle.prNumber,
561
+ attemptCap: attempts.cap, roundCap: state.roundCap,
562
+ asyncAttribution: 'cycle-history-unattributed' })
563
+ : createFinalizationReceipt({ version: 1, operation: 'ordinary-pending-finalize-only', ...receiptCommon });
411
564
  if (current.status === 'pending') {
412
565
  await preflightConvergeRunStateWrite(options.gitCommonDir, state, ownership);
413
566
  }
414
567
  // Every fallible authentication and accounting check above precedes the
415
568
  // first durable write. Archive publication is idempotent if this process
416
569
  // stops before the native failed/unknown state or receipt is published.
570
+ // Revalidate remote cycle membership after those reads and immediately
571
+ // before archive publication so live PR or cycle drift fails closed.
572
+ await assertCycleRecovery(options, state, attempts, packet);
417
573
  await archiveAsync(options.gitCommonDir, source, artifacts, packet);
418
574
  if (current.status === 'pending') {
419
575
  await (options.writeFinalizedState ?? writeState)(options.gitCommonDir, state, ownership);
@@ -424,6 +580,10 @@ export async function finalizeOrdinaryPendingLaunch(input) {
424
580
  const finalizedAttempts = await readStable(convergeAttemptStatePath(options.gitCommonDir, options.target));
425
581
  sourceAttemptStateBytes ??= finalizedAttempts.raw;
426
582
  await retainFinalizationSnapshots(options.gitCommonDir, receipt, sourceAttemptStateBytes, finalizedNative.raw);
583
+ // Cycle history is consumed while the target remains recovery-locked and
584
+ // before the terminal receipt makes a successor eligible. Partial cleanup
585
+ // is safe to retry because consumption validates occurrence subsets.
586
+ await reconcileFinalizedCycleHistory(options, 'new-receipt');
427
587
  await retainFinalizationReceipt(options.gitCommonDir, receipt);
428
588
  return { receipt, reusedReceipt: false, snapshotBindingUpgraded: false };
429
589
  });
@@ -434,12 +594,12 @@ function validateAsyncArtifacts(source, artifacts, packet) {
434
594
  if (expected.length !== actual.length || expected.some((item, index) => item !== actual[index])) {
435
595
  fail('async_binding_mismatch');
436
596
  }
597
+ const actualDescriptors = [];
437
598
  for (const artifact of artifacts) {
438
599
  const bytes = Buffer.from(artifact.bytesBase64, 'base64');
439
600
  if (createHash('sha256').update(bytes).digest('hex') !== artifact.sha256)
440
601
  fail('async_binding_mismatch');
441
602
  if (packet) {
442
- const descriptor = packet.retainedAsync.find(item => item.sha256 === artifact.sha256);
443
603
  let row;
444
604
  try {
445
605
  row = JSON.parse(bytes.toString('utf8'));
@@ -447,9 +607,15 @@ function validateAsyncArtifacts(source, artifacts, packet) {
447
607
  catch {
448
608
  fail('async_binding_mismatch');
449
609
  }
450
- if (!descriptor || row.model !== descriptor.model || row.role !== descriptor.role ||
451
- row.provider !== descriptor.provider)
452
- fail('async_binding_mismatch');
610
+ actualDescriptors.push(JSON.stringify([artifact.sha256, row.model, row.role, row.provider]));
611
+ }
612
+ }
613
+ if (packet) {
614
+ const expectedDescriptors = packet.retainedAsync.map(item => JSON.stringify([item.sha256, item.model, item.role, item.provider])).sort();
615
+ actualDescriptors.sort();
616
+ if (expectedDescriptors.length !== actualDescriptors.length ||
617
+ expectedDescriptors.some((item, index) => item !== actualDescriptors[index])) {
618
+ fail('async_binding_mismatch');
453
619
  }
454
620
  }
455
621
  }
@@ -481,26 +647,40 @@ export async function previewOrdinaryPendingLaunch(input) {
481
647
  }
482
648
  if ((options.ownerAlive ?? defaultOwnerAlive)(current.pid))
483
649
  fail('owner_alive');
484
- const packet = validateOrdinaryPendingPackage(options.migrationPackage, {
485
- target: options.target, headSha: current.headSha, inputSha256: current.inputSha256,
486
- baseSha: options.baseSha, attempt: current.attempt, round: current.round, pid: current.pid,
487
- retainedAsyncSha256: options.retainedAsyncSha256,
488
- });
650
+ const packet = validateOrdinaryPendingPackage(options.migrationPackage, packageExpected(state, attempts, current, options));
651
+ await assertCycleRecovery(options, state, attempts, packet);
489
652
  const [nativeBytes, attemptBytes] = await Promise.all([
490
653
  readStable(convergeRunStatePath(options.gitCommonDir, options.target)),
491
654
  readStable(convergeAttemptStatePath(options.gitCommonDir, options.target)),
492
655
  ]);
493
- const source = createOrdinaryMigrationSource({ version: 1, target: options.target,
656
+ const commonSource = { target: options.target,
494
657
  headSha: current.headSha, inputSha256: current.inputSha256, pendingAttempt: current.attempt,
495
658
  round: current.round, originalPid: current.pid, startedAt: current.startedAt,
496
659
  blockingOutcome: 'unknown', reason: 'coordinator_exited_without_durable_blocking_receipts',
497
660
  nativeStateSha256: nativeBytes.sha256, attemptStateSha256: attemptBytes.sha256,
498
661
  retainedAsyncSha256: options.retainedAsyncSha256,
499
662
  migrationPackageSha256: createHash('sha256').update(stableStringify(packet)).digest('hex'),
500
- });
663
+ };
664
+ const source = packet.version === 2
665
+ ? createCycleMigrationSource({ version: 2, ...commonSource, cycle: state.cycle,
666
+ cycleId: state.cycle.id, operationId: state.cycle.operationId,
667
+ attemptCap: attempts.cap, roundCap: state.roundCap, attemptsUsed: attempts.attemptsUsed,
668
+ asyncAttribution: 'cycle-history-unattributed' })
669
+ : createOrdinaryMigrationSource({ version: 1, ...commonSource });
501
670
  validateAsyncArtifacts(source, await options.loadRetainedAsync(), packet);
671
+ const currentPrBinding = options.currentPrHeadSha === undefined ? {} : {
672
+ currentPrHeadSha: options.currentPrHeadSha,
673
+ previewSha256: cyclePendingPreviewSha256({ target: options.target, headSha: options.headSha,
674
+ baseSha: options.baseSha, currentPrHeadSha: options.currentPrHeadSha,
675
+ pendingInputSha256: options.pendingInputSha256,
676
+ nativeStateSha256: nativeBytes.sha256, attemptStateSha256: attemptBytes.sha256,
677
+ retainedAsyncSha256: options.retainedAsyncSha256, maxAttempts: options.maxAttempts,
678
+ sourceDigest: source.digest,
679
+ migrationPackageSha256: createHash('sha256').update(stableStringify(packet)).digest('hex') }),
680
+ };
502
681
  return { source, nativeStateSha256: nativeBytes.sha256, attemptStateSha256: attemptBytes.sha256,
503
- attemptsUsed: attempts.attemptsUsed, cap: attempts.cap, nextAttempt: current.attempt + 1 };
682
+ attemptsUsed: attempts.attemptsUsed, cap: attempts.cap, nextAttempt: current.attempt + 1,
683
+ ...currentPrBinding };
504
684
  });
505
685
  }
506
686
  async function runBound(options, ownership, state, claim, source, launch, resume) {