@fitlab-ai/agent-infra 0.10.0 → 0.11.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.
Files changed (154) hide show
  1. package/README.md +2 -6
  2. package/README.zh-CN.md +2 -6
  3. package/bin/internal-cli.sh +0 -13
  4. package/dist/bin/internal-cli.js +11 -49
  5. package/dist/lib/internal/cli-route-inventory.js +3 -4
  6. package/dist/lib/internal/manual-validation.js +6 -2
  7. package/dist/lib/internal/platform-comment.js +60 -2
  8. package/dist/lib/internal/task-artifact.js +1 -1
  9. package/dist/lib/internal/task-finalization.js +2 -2
  10. package/dist/lib/internal/task-lifecycle.js +2 -2
  11. package/dist/lib/internal/task-operation-registry.js +22 -12
  12. package/dist/lib/internal/task-orchestration.js +2 -2
  13. package/dist/lib/internal/task-review.js +10 -5
  14. package/dist/lib/platform/activity-recovery.js +159 -0
  15. package/dist/lib/platform/issue-comments.js +84 -32
  16. package/dist/lib/platform/issues.js +3 -4
  17. package/dist/lib/platform/task-comment-projection.js +70 -0
  18. package/dist/lib/platform/task-recovery.js +55 -0
  19. package/dist/lib/platform/verification-sync.js +184 -162
  20. package/dist/lib/sandbox/control/executor.js +4 -2
  21. package/dist/lib/sandbox/control/task-workflow.js +1 -1
  22. package/dist/lib/sandbox/control/workflow-executor.js +90 -22
  23. package/dist/lib/server/process-state.js +17 -5
  24. package/dist/lib/task/artifact-command.js +6 -3
  25. package/dist/lib/task/artifact-lifecycle.js +15 -52
  26. package/dist/lib/task/artifact-operations.js +28 -2
  27. package/dist/lib/task/artifact-recovery.js +114 -68
  28. package/dist/lib/task/artifact-repair-intent.js +25 -8
  29. package/dist/lib/task/capabilities.js +0 -2
  30. package/dist/lib/task/control-authority.js +1 -1
  31. package/dist/lib/task/events.js +66 -49
  32. package/dist/lib/task/gate-policy.js +94 -0
  33. package/dist/lib/task/intermediate-cleanup.js +28 -3
  34. package/dist/lib/task/local-artifact-finalization.js +85 -18
  35. package/dist/lib/task/orchestration.js +8 -6
  36. package/dist/lib/task/review-artifacts.js +12 -6
  37. package/dist/lib/task/review-command.js +2 -2
  38. package/dist/lib/task/review-finalization.js +198 -11
  39. package/dist/lib/{host-control → task}/secure-fs.js +8 -19
  40. package/dist/lib/task/verification-engine.js +28 -17
  41. package/dist/lib/task/verification.js +25 -9
  42. package/dist/lib/task/workflow-command.js +2 -0
  43. package/dist/lib/task/workflow-dispatch.js +161 -0
  44. package/dist/lifecycle-build-manifest.json +3 -3
  45. package/lib/internal/cli-route-inventory.ts +3 -4
  46. package/lib/internal/manual-validation.ts +6 -2
  47. package/lib/internal/platform-comment.ts +53 -2
  48. package/lib/internal/task-artifact.ts +1 -1
  49. package/lib/internal/task-finalization.ts +1 -1
  50. package/lib/internal/task-lifecycle.ts +1 -1
  51. package/lib/internal/task-operation-registry.ts +22 -12
  52. package/lib/internal/task-orchestration.ts +1 -1
  53. package/lib/internal/task-review.ts +9 -5
  54. package/lib/platform/activity-recovery.ts +179 -0
  55. package/lib/platform/issue-comments.ts +98 -31
  56. package/lib/platform/issues.ts +3 -4
  57. package/lib/platform/task-comment-projection.ts +79 -0
  58. package/lib/platform/task-recovery.ts +54 -0
  59. package/lib/platform/verification-sync.ts +203 -188
  60. package/lib/sandbox/control/executor.ts +4 -2
  61. package/lib/sandbox/control/protocol.ts +1 -1
  62. package/lib/sandbox/control/task-workflow.ts +1 -1
  63. package/lib/sandbox/control/workflow-executor.ts +113 -22
  64. package/lib/server/process-state.ts +26 -5
  65. package/lib/task/artifact-command.ts +6 -4
  66. package/lib/task/artifact-lifecycle.ts +12 -49
  67. package/lib/task/artifact-operations.ts +28 -2
  68. package/lib/task/artifact-recovery.ts +120 -67
  69. package/lib/task/artifact-repair-intent.ts +38 -11
  70. package/lib/task/capabilities.ts +0 -1
  71. package/lib/task/control-authority.ts +1 -1
  72. package/lib/task/events.ts +55 -38
  73. package/lib/task/gate-policy.ts +133 -0
  74. package/lib/task/intermediate-cleanup.ts +18 -2
  75. package/lib/task/local-artifact-finalization.ts +91 -15
  76. package/lib/task/orchestration.ts +12 -7
  77. package/lib/task/review-artifacts.ts +8 -2
  78. package/lib/task/review-command.ts +1 -1
  79. package/lib/task/review-finalization.ts +198 -9
  80. package/lib/{host-control → task}/secure-fs.ts +10 -32
  81. package/lib/task/verification-engine.ts +24 -14
  82. package/lib/task/verification-types.ts +7 -0
  83. package/lib/task/verification.ts +25 -9
  84. package/lib/task/workflow-command.ts +2 -0
  85. package/lib/task/workflow-dispatch.ts +171 -0
  86. package/package.json +1 -1
  87. package/templates/.agents/rules/issue-sync.en.md +11 -1
  88. package/templates/.agents/rules/issue-sync.zh-CN.md +6 -2
  89. package/templates/.agents/rules/local-artifact-repair.en.md +18 -9
  90. package/templates/.agents/rules/local-artifact-repair.zh-CN.md +21 -11
  91. package/templates/.agents/skills/analyze-task/SKILL.en.md +2 -2
  92. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +2 -2
  93. package/templates/.agents/skills/analyze-task/config/verify.en.json +1 -10
  94. package/templates/.agents/skills/analyze-task/config/verify.zh-CN.json +1 -10
  95. package/templates/.agents/skills/block-task/config/verify.json +1 -3
  96. package/templates/.agents/skills/cancel-task/config/verify.json +1 -4
  97. package/templates/.agents/skills/code-task/SKILL.en.md +4 -4
  98. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +5 -5
  99. package/templates/.agents/skills/code-task/config/verify.en.json +1 -12
  100. package/templates/.agents/skills/code-task/config/verify.zh-CN.json +1 -12
  101. package/templates/.agents/skills/code-task/reference/dual-mode.en.md +1 -1
  102. package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +1 -1
  103. package/templates/.agents/skills/code-task/reference/report-template.en.md +11 -0
  104. package/templates/.agents/skills/code-task/reference/report-template.zh-CN.md +11 -0
  105. package/templates/.agents/skills/commit/config/verify.json +1 -5
  106. package/templates/.agents/skills/complete-manual-validation/config/verify.en.json +6 -6
  107. package/templates/.agents/skills/complete-manual-validation/config/verify.zh-CN.json +6 -6
  108. package/templates/.agents/skills/complete-task/config/verify.en.json +3 -14
  109. package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +3 -14
  110. package/templates/.agents/skills/create-pr/config/verify.json +1 -10
  111. package/templates/.agents/skills/create-task/config/verify.json +1 -4
  112. package/templates/.agents/skills/import-issue/config/verify.json +1 -2
  113. package/templates/.agents/skills/plan-task/SKILL.en.md +2 -2
  114. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +2 -2
  115. package/templates/.agents/skills/plan-task/config/verify.en.json +1 -10
  116. package/templates/.agents/skills/plan-task/config/verify.zh-CN.json +1 -10
  117. package/templates/.agents/skills/restore-task/SKILL.en.md +9 -4
  118. package/templates/.agents/skills/restore-task/SKILL.zh-CN.md +10 -5
  119. package/templates/.agents/skills/review-analysis/SKILL.en.md +1 -1
  120. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +1 -1
  121. package/templates/.agents/skills/review-analysis/config/verify.en.json +1 -10
  122. package/templates/.agents/skills/review-analysis/config/verify.zh-CN.json +1 -10
  123. package/templates/.agents/skills/review-analysis/reference/report-template.en.md +2 -0
  124. package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +2 -0
  125. package/templates/.agents/skills/review-code/SKILL.en.md +3 -2
  126. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +3 -2
  127. package/templates/.agents/skills/review-code/config/verify.en.json +1 -10
  128. package/templates/.agents/skills/review-code/config/verify.zh-CN.json +1 -10
  129. package/templates/.agents/skills/review-code/reference/report-template.en.md +2 -0
  130. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +2 -0
  131. package/templates/.agents/skills/review-plan/SKILL.en.md +1 -1
  132. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +1 -1
  133. package/templates/.agents/skills/review-plan/config/verify.en.json +1 -10
  134. package/templates/.agents/skills/review-plan/config/verify.zh-CN.json +1 -10
  135. package/templates/.agents/skills/review-plan/reference/report-template.en.md +2 -0
  136. package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +2 -0
  137. package/templates/.agents/skills/review-pr/config/verify.en.json +1 -8
  138. package/templates/.agents/skills/review-pr/config/verify.zh-CN.json +1 -8
  139. package/templates/.agents/skills/run-manual-validation/config/verify.en.json +38 -13
  140. package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +38 -13
  141. package/dist/lib/host-control/audit.js +0 -19
  142. package/dist/lib/host-control/client.js +0 -142
  143. package/dist/lib/host-control/command.js +0 -49
  144. package/dist/lib/host-control/path.js +0 -160
  145. package/dist/lib/host-control/server.js +0 -159
  146. package/dist/lib/host-control/service.js +0 -112
  147. package/dist/lib/internal/host-control.js +0 -28
  148. package/lib/host-control/audit.ts +0 -22
  149. package/lib/host-control/client.ts +0 -172
  150. package/lib/host-control/command.ts +0 -60
  151. package/lib/host-control/path.ts +0 -167
  152. package/lib/host-control/server.ts +0 -180
  153. package/lib/host-control/service.ts +0 -133
  154. package/lib/internal/host-control.ts +0 -28
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { writeDurableFile } from '../fs/durable-write.js';
5
- import { readStableFileSync } from '../host-control/secure-fs.js';
5
+ import { readStableFileSync } from './secure-fs.js';
6
6
  import { parseArtifactName } from './artifact-name.js';
7
7
  import { canonicalSemanticDigest, sha256Content } from './artifact-operations.js';
8
8
  import { artifactRecoveryRoot, readArtifactRecoveryIntent, writeArtifactRecoveryIntent } from './artifact-repair-intent.js';
@@ -139,7 +139,7 @@ function contextPaths(tuple, options, recoveryId) {
139
139
  formalPath: path.join(taskDir, tuple.artifact),
140
140
  stagingPath: path.join(root, 'candidate.md'),
141
141
  baselinePath: path.join(root, 'baseline.md'),
142
- finalPath: path.join(root, 'final.md'),
142
+ generationsPath: path.join(root, 'generations'),
143
143
  baselineSha256: '',
144
144
  baselineSemanticDigest: ''
145
145
  };
@@ -167,16 +167,22 @@ function contextWithIntent(context, intent) {
167
167
  }
168
168
  function createIntent(context, now) {
169
169
  return {
170
- version: 3,
170
+ version: 4,
171
171
  taskId: context.taskId,
172
172
  family: context.family,
173
173
  artifact: context.artifact,
174
174
  round: context.round,
175
- state: 'awaiting-recovery',
175
+ state: 'awaiting-preflight-recovery',
176
176
  baselineSha256: context.baselineSha256,
177
177
  baselineSemanticDigest: context.baselineSemanticDigest,
178
178
  stagingId: context.stagingId,
179
179
  candidateSha256: context.baselineSha256,
180
+ preflightArtifactSha256: null,
181
+ preflightSemanticDigest: null,
182
+ activeGenerationSha256: null,
183
+ activeGenerationSemanticDigest: null,
184
+ pendingGenerationSha256: null,
185
+ pendingGenerationSemanticDigest: null,
180
186
  finalArtifactSha256: null,
181
187
  finalSemanticDigest: null,
182
188
  recoveryOperationId: context.recoveryId,
@@ -209,7 +215,7 @@ export function beginArtifactRecovery(tuple, baselineBytes, options) {
209
215
  fail('ARTIFACT_RECOVERY_BASELINE_MISMATCH', 'formal artifact does not match the recovery baseline');
210
216
  }
211
217
  const existing = readArtifactRecoveryIntent(prepared.repoRoot, prepared.taskId, prepared.family, prepared.artifact);
212
- if (existing && existing.state !== 'aborted' && existing.state !== 'consumed') {
218
+ if (existing && !['aborted', 'consumed', 'passed'].includes(existing.state)) {
213
219
  fail('ARTIFACT_RECOVERY_CONFLICT', 'an active recovery journal already exists for this artifact');
214
220
  }
215
221
  writeBytes(prepared.baselinePath, baselineBytes);
@@ -225,6 +231,7 @@ export function recordArtifactRecoveryPassed(tuple, options) {
225
231
  assertRecoveryId(context.recoveryId);
226
232
  assertRegular(context.formalPath, 'formal artifact');
227
233
  return runLocked(context, 'task-artifact.recovery.record-passed', () => {
234
+ assertRecoveryRoot(context);
228
235
  const current = readStableFileSync(context.formalPath, { maxBytes: MAX_ARTIFACT_BYTES });
229
236
  const prepared = {
230
237
  ...context,
@@ -244,18 +251,18 @@ export function recordArtifactRecoveryPassed(tuple, options) {
244
251
  && existing.finalSemanticDigest === prepared.baselineSemanticDigest) {
245
252
  return recoveryContextFromIntent(prepared.repoRoot, prepared.taskDir, existing);
246
253
  }
247
- if (existing && existing.state !== 'aborted') {
254
+ if (existing && existing.state !== 'aborted' && existing.state !== 'passed') {
248
255
  fail('ARTIFACT_RECOVERY_CONFLICT', 'an active recovery journal already exists for this artifact');
249
256
  }
250
- const now = Date.now();
251
- const next = {
252
- ...createIntent(prepared, now),
253
- state: 'passed',
254
- candidateSha256: prepared.baselineSha256,
255
- finalArtifactSha256: prepared.baselineSha256,
256
- finalSemanticDigest: prepared.baselineSemanticDigest
257
- };
258
- writeArtifactRecoveryIntent(prepared.repoRoot, next, { expected: existing });
257
+ const base = createIntent(prepared, Date.now());
258
+ writeBytes(prepared.baselinePath, current.bytes);
259
+ writeBytes(prepared.stagingPath, current.bytes);
260
+ writeArtifactRecoveryIntent(prepared.repoRoot, base, { expected: existing });
261
+ const staged = stageArtifactCandidate(prepared, current.bytes, { lockAlreadyHeld: true });
262
+ prepareArtifactRecoveryCommit(prepared, staged.candidateSha256, staged.semanticDigest, { lockAlreadyHeld: true });
263
+ commitArtifactRecovery(prepared, { lockAlreadyHeld: true });
264
+ prepareArtifactRecoveryFinal(prepared, current.bytes, { lockAlreadyHeld: true });
265
+ commitArtifactRecovery(prepared, { lockAlreadyHeld: true });
259
266
  return prepared;
260
267
  }, options.lockAlreadyHeld);
261
268
  }
@@ -263,7 +270,7 @@ export function stageArtifactCandidate(context, candidateBytes, options = {}) {
263
270
  return runLocked(context, 'task-artifact.recovery.stage', () => {
264
271
  assertRecoveryRoot(context);
265
272
  const intent = readIntentForContext(context);
266
- if (intent.state !== 'awaiting-recovery')
273
+ if (intent.state !== 'awaiting-preflight-recovery')
267
274
  fail('ARTIFACT_RECOVERY_STATE_INVALID', `cannot stage a candidate from '${intent.state}'`);
268
275
  const candidateSha256 = sha256Content(candidateBytes.toString('utf8'));
269
276
  const semanticDigest = canonicalSemanticDigest(candidateBytes.toString('utf8'));
@@ -273,30 +280,69 @@ export function stageArtifactCandidate(context, candidateBytes, options = {}) {
273
280
  return { recoveryId: context.recoveryId, candidateSha256, semanticDigest, path: context.stagingPath };
274
281
  }, options.lockAlreadyHeld);
275
282
  }
283
+ function generationPath(context, sha256) {
284
+ assertDigest(sha256, 'generation digest');
285
+ return path.join(context.generationsPath, `${sha256}.md`);
286
+ }
287
+ /** Persist and verify an immutable digest-addressed snapshot before it is journaled. */
288
+ function writeGeneration(context, bytes, sha256, semanticDigest) {
289
+ ensureOwnedDirectoryTree(context.repoRoot, context.generationsPath);
290
+ const target = generationPath(context, sha256);
291
+ if (fs.existsSync(target)) {
292
+ const existing = readStableFileSync(target, { maxBytes: MAX_ARTIFACT_BYTES, expectedSha256: sha256 });
293
+ if (canonicalSemanticDigest(existing.bytes.toString('utf8')) !== semanticDigest) {
294
+ fail('ARTIFACT_RECOVERY_CONFLICT', 'existing generation does not match its semantic digest');
295
+ }
296
+ return;
297
+ }
298
+ writeBytes(target, bytes);
299
+ fs.chmodSync(target, 0o400);
300
+ const sealed = readStableFileSync(target, { maxBytes: MAX_ARTIFACT_BYTES, expectedSha256: sha256 });
301
+ if (canonicalSemanticDigest(sealed.bytes.toString('utf8')) !== semanticDigest) {
302
+ fail('ARTIFACT_RECOVERY_CANDIDATE_MISMATCH', 'generation does not match its validated semantic digest');
303
+ }
304
+ }
305
+ function publishGeneration(context, sha256, semanticDigest, baselineSha256) {
306
+ const target = readStableFileSync(context.formalPath, { maxBytes: MAX_ARTIFACT_BYTES });
307
+ const generation = readStableFileSync(generationPath(context, sha256), { maxBytes: MAX_ARTIFACT_BYTES, expectedSha256: sha256 });
308
+ if (canonicalSemanticDigest(generation.bytes.toString('utf8')) !== semanticDigest) {
309
+ fail('ARTIFACT_RECOVERY_CONFLICT', 'generation semantic digest does not match the journal');
310
+ }
311
+ if (target.sha256 === sha256)
312
+ return;
313
+ if (target.sha256 !== baselineSha256)
314
+ fail('ARTIFACT_RECOVERY_CONFLICT', 'formal artifact changed outside the expected generation');
315
+ const publishPath = path.join(path.dirname(context.stagingPath), 'publish.md');
316
+ unlinkIfPresent(publishPath);
317
+ writeBytes(publishPath, generation.bytes);
318
+ fs.chmodSync(publishPath, 0o400);
319
+ fs.renameSync(publishPath, context.formalPath);
320
+ const published = readStableFileSync(context.formalPath, { maxBytes: MAX_ARTIFACT_BYTES, expectedSha256: sha256 });
321
+ if (canonicalSemanticDigest(published.bytes.toString('utf8')) !== semanticDigest) {
322
+ fail('ARTIFACT_RECOVERY_CONFLICT', 'published artifact does not match its generation');
323
+ }
324
+ }
276
325
  export function prepareArtifactRecoveryCommit(context, finalSha256, finalSemanticDigest, options = {}) {
277
326
  assertDigest(finalSha256, 'final artifact digest');
278
327
  assertDigest(finalSemanticDigest, 'final semantic digest');
279
328
  return runLocked(context, 'task-artifact.recovery.prepare', () => {
280
329
  const intent = readIntentForContext(context);
281
- if (intent.state !== 'awaiting-recovery')
282
- fail('ARTIFACT_RECOVERY_STATE_INVALID', `cannot prepare a commit from '${intent.state}'`);
330
+ if (intent.state !== 'awaiting-preflight-recovery')
331
+ fail('ARTIFACT_RECOVERY_STATE_INVALID', `cannot prepare a preflight from '${intent.state}'`);
283
332
  assertRecoveryRoot(context);
284
333
  const staged = readStableFileSync(context.stagingPath, { maxBytes: MAX_ARTIFACT_BYTES });
285
334
  if (staged.sha256 !== finalSha256 || canonicalSemanticDigest(staged.bytes.toString('utf8')) !== finalSemanticDigest) {
286
335
  fail('ARTIFACT_RECOVERY_CANDIDATE_MISMATCH', 'staged candidate does not match the finalizer digest');
287
336
  }
288
- writeBytes(context.finalPath, staged.bytes);
289
- fs.chmodSync(context.finalPath, 0o400);
290
- const sealed = readStableFileSync(context.finalPath, { maxBytes: MAX_ARTIFACT_BYTES });
291
- if (sealed.sha256 !== finalSha256 || canonicalSemanticDigest(sealed.bytes.toString('utf8')) !== finalSemanticDigest) {
292
- fail('ARTIFACT_RECOVERY_CANDIDATE_MISMATCH', 'sealed candidate does not match the finalizer digest');
293
- }
337
+ writeGeneration(context, staged.bytes, finalSha256, finalSemanticDigest);
294
338
  const next = {
295
339
  ...intent,
296
- state: 'finalize-ready',
340
+ state: 'preflight-ready',
297
341
  candidateSha256: staged.sha256,
298
- finalArtifactSha256: finalSha256,
299
- finalSemanticDigest,
342
+ preflightArtifactSha256: finalSha256,
343
+ preflightSemanticDigest: finalSemanticDigest,
344
+ activeGenerationSha256: finalSha256,
345
+ activeGenerationSemanticDigest: finalSemanticDigest,
300
346
  errorCode: null,
301
347
  errorMessage: null,
302
348
  updatedAt: Date.now()
@@ -306,11 +352,11 @@ export function prepareArtifactRecoveryCommit(context, finalSha256, finalSemanti
306
352
  }, options.lockAlreadyHeld);
307
353
  }
308
354
  function markCommitStarted(context, intent) {
309
- if (intent.state === 'commit-started')
355
+ if (intent.state === 'preflight-commit-started' || intent.state === 'commit-started')
310
356
  return intent;
311
- if (intent.state !== 'finalize-ready')
357
+ if (intent.state !== 'preflight-ready' && intent.state !== 'full-finalizer-ready')
312
358
  fail('ARTIFACT_RECOVERY_STATE_INVALID', `cannot commit from '${intent.state}'`);
313
- const next = { ...intent, state: 'commit-started', updatedAt: Date.now() };
359
+ const next = { ...intent, state: intent.state === 'preflight-ready' ? 'preflight-commit-started' : 'commit-started', updatedAt: Date.now() };
314
360
  writeArtifactRecoveryIntent(context.repoRoot, next, { expected: intent });
315
361
  return next;
316
362
  }
@@ -321,44 +367,41 @@ export function commitArtifactRecovery(context, options = {}) {
321
367
  return intent;
322
368
  assertRecoveryRoot(context);
323
369
  intent = markCommitStarted(context, intent);
324
- const target = readStableFileSync(context.formalPath, { maxBytes: MAX_ARTIFACT_BYTES });
325
- if (target.sha256 === intent.finalArtifactSha256) {
326
- const passed = { ...intent, state: 'passed', updatedAt: Date.now() };
370
+ if (intent.state === 'preflight-commit-started') {
371
+ publishGeneration(context, intent.activeGenerationSha256, intent.activeGenerationSemanticDigest, intent.baselineSha256);
372
+ const passed = { ...intent, state: 'preflight-passed', updatedAt: Date.now() };
327
373
  writeArtifactRecoveryIntent(context.repoRoot, passed, { expected: intent });
328
374
  return passed;
329
375
  }
330
- if (target.sha256 !== intent.baselineSha256)
331
- fail('ARTIFACT_RECOVERY_CONFLICT', 'formal artifact changed outside the recovery baseline');
332
- const sealed = readStableFileSync(context.finalPath, {
333
- maxBytes: MAX_ARTIFACT_BYTES,
334
- expectedSha256: intent.finalArtifactSha256
335
- });
336
- if (canonicalSemanticDigest(sealed.bytes.toString('utf8')) !== intent.finalSemanticDigest) {
337
- fail('ARTIFACT_RECOVERY_CONFLICT', 'sealed artifact does not match the staged final semantic digest');
338
- }
339
- // Publish the bytes that were validated above. The sealed path is only a
340
- // recovery input; renaming it directly would reopen a same-uid path swap
341
- // between validation and publication.
342
- const publishPath = path.join(path.dirname(context.finalPath), 'publish.md');
343
- unlinkIfPresent(publishPath);
344
- writeBytes(publishPath, sealed.bytes);
345
- fs.chmodSync(publishPath, 0o400);
346
- const publish = readStableFileSync(publishPath, {
347
- maxBytes: MAX_ARTIFACT_BYTES,
348
- expectedSha256: intent.finalArtifactSha256
349
- });
350
- if (canonicalSemanticDigest(publish.bytes.toString('utf8')) !== intent.finalSemanticDigest) {
351
- fail('ARTIFACT_RECOVERY_CONFLICT', 'publish artifact does not match the staged final semantic digest');
352
- }
353
- fs.renameSync(publishPath, context.formalPath);
354
- const published = readStableFileSync(context.formalPath, { maxBytes: MAX_ARTIFACT_BYTES });
355
- if (published.sha256 !== intent.finalArtifactSha256)
356
- fail('ARTIFACT_RECOVERY_CONFLICT', 'formal artifact does not match the staged final digest');
357
- const passed = { ...intent, state: 'passed', updatedAt: Date.now() };
376
+ publishGeneration(context, intent.pendingGenerationSha256, intent.pendingGenerationSemanticDigest, intent.activeGenerationSha256);
377
+ options.afterPublish?.();
378
+ const passed = {
379
+ ...intent, state: 'passed', activeGenerationSha256: intent.pendingGenerationSha256,
380
+ activeGenerationSemanticDigest: intent.pendingGenerationSemanticDigest,
381
+ pendingGenerationSha256: null, pendingGenerationSemanticDigest: null, updatedAt: Date.now()
382
+ };
358
383
  writeArtifactRecoveryIntent(context.repoRoot, passed, { expected: intent });
359
384
  return passed;
360
385
  }, options.lockAlreadyHeld);
361
386
  }
387
+ export function prepareArtifactRecoveryFinal(context, finalBytes, options = {}) {
388
+ return runLocked(context, 'task-artifact.recovery.finalize', () => {
389
+ const intent = readIntentForContext(context);
390
+ if (intent.state !== 'preflight-passed')
391
+ fail('ARTIFACT_RECOVERY_STATE_INVALID', `cannot finalize from '${intent.state}'`);
392
+ const finalSha256 = sha256Content(finalBytes.toString('utf8'));
393
+ const finalSemanticDigest = canonicalSemanticDigest(finalBytes.toString('utf8'));
394
+ if (finalSha256 !== intent.activeGenerationSha256)
395
+ writeGeneration(context, finalBytes, finalSha256, finalSemanticDigest);
396
+ const next = {
397
+ ...intent, state: 'full-finalizer-ready', pendingGenerationSha256: finalSha256,
398
+ pendingGenerationSemanticDigest: finalSemanticDigest, finalArtifactSha256: finalSha256,
399
+ finalSemanticDigest, updatedAt: Date.now()
400
+ };
401
+ writeArtifactRecoveryIntent(context.repoRoot, next, { expected: intent });
402
+ return next;
403
+ }, options.lockAlreadyHeld);
404
+ }
362
405
  export function abortArtifactRecovery(context, code, message, options = {}) {
363
406
  return runLocked(context, 'task-artifact.recovery.abort', () => {
364
407
  const intent = readIntentForContext(context);
@@ -383,16 +426,19 @@ export function abortArtifactRecovery(context, code, message, options = {}) {
383
426
  export function reconcileArtifactRecovery(context, options = {}) {
384
427
  return runLocked(context, 'task-artifact.recovery.reconcile', () => {
385
428
  const intent = readIntentForContext(context);
386
- if (intent.state !== 'commit-started')
429
+ if (intent.state !== 'preflight-commit-started' && intent.state !== 'commit-started')
387
430
  return { status: intent.state, intent };
388
431
  const target = readStableFileSync(context.formalPath, { maxBytes: MAX_ARTIFACT_BYTES });
389
- if (target.sha256 === intent.finalArtifactSha256 && (options.validateFinal?.() ?? true)) {
390
- const passed = { ...intent, state: 'passed', updatedAt: Date.now() };
391
- writeArtifactRecoveryIntent(context.repoRoot, passed, { expected: intent });
392
- return { status: 'passed', intent: passed };
432
+ const expected = intent.state === 'preflight-commit-started' ? intent.activeGenerationSha256 : intent.pendingGenerationSha256;
433
+ if (target.sha256 === expected && (options.validateFinal?.() ?? true)) {
434
+ const next = intent.state === 'preflight-commit-started'
435
+ ? { ...intent, state: 'preflight-passed', updatedAt: Date.now() }
436
+ : { ...intent, state: 'passed', activeGenerationSha256: intent.pendingGenerationSha256, activeGenerationSemanticDigest: intent.pendingGenerationSemanticDigest, pendingGenerationSha256: null, pendingGenerationSemanticDigest: null, updatedAt: Date.now() };
437
+ writeArtifactRecoveryIntent(context.repoRoot, next, { expected: intent });
438
+ return { status: next.state, intent: next };
393
439
  }
394
- if (target.sha256 === intent.baselineSha256) {
395
- const retried = commitArtifactRecovery(context, { lockAlreadyHeld: true });
440
+ if (target.sha256 === (intent.state === 'preflight-commit-started' ? intent.baselineSha256 : intent.activeGenerationSha256)) {
441
+ const retried = commitArtifactRecovery(context, { lockAlreadyHeld: true, afterPublish: options.afterPublish });
396
442
  return { status: retried.state, intent: retried };
397
443
  }
398
444
  if (options.restoreBaseline) {
@@ -37,22 +37,28 @@ export function isArtifactRecoveryIntent(value) {
37
37
  return false;
38
38
  const intent = value;
39
39
  return exactKeys(intent, [
40
- 'artifact', 'authorityDigest', 'baselineSemanticDigest', 'baselineSha256', 'candidateSha256',
40
+ 'activeGenerationSemanticDigest', 'activeGenerationSha256', 'artifact', 'authorityDigest', 'baselineSemanticDigest', 'baselineSha256', 'candidateSha256',
41
41
  'createdAt', 'errorCode', 'errorMessage', 'family', 'finalArtifactSha256', 'finalSemanticDigest',
42
- 'phase', 'recoveryOperationId', 'requestId', 'round', 'stagingId', 'state', 'taskId', 'updatedAt',
42
+ 'pendingGenerationSemanticDigest', 'pendingGenerationSha256', 'phase', 'preflightArtifactSha256', 'preflightSemanticDigest', 'recoveryOperationId', 'requestId', 'round', 'stagingId', 'state', 'taskId', 'updatedAt',
43
43
  'version'
44
44
  ])
45
- && intent.version === 3
45
+ && intent.version === 4
46
46
  && validTaskId(intent.taskId)
47
47
  && typeof intent.family === 'string'
48
48
  && ['analysis', 'review-analysis', 'plan', 'review-plan', 'code', 'review-code'].includes(intent.family)
49
49
  && Number.isSafeInteger(intent.round) && intent.round > 0
50
50
  && validArtifact(intent.artifact, intent.family, intent.round)
51
- && ['awaiting-recovery', 'finalize-ready', 'commit-started', 'passed', 'consumed', 'aborted'].includes(String(intent.state))
51
+ && ['awaiting-preflight-recovery', 'preflight-ready', 'preflight-commit-started', 'preflight-passed', 'full-finalizer-ready', 'commit-started', 'passed', 'consumption-started', 'consumed', 'aborted'].includes(String(intent.state))
52
52
  && validDigest(intent.baselineSha256)
53
53
  && validDigest(intent.baselineSemanticDigest)
54
54
  && validRecoveryId(intent.stagingId)
55
55
  && (intent.candidateSha256 === null || validDigest(intent.candidateSha256))
56
+ && (intent.preflightArtifactSha256 === null || validDigest(intent.preflightArtifactSha256))
57
+ && (intent.preflightSemanticDigest === null || validDigest(intent.preflightSemanticDigest))
58
+ && (intent.activeGenerationSha256 === null || validDigest(intent.activeGenerationSha256))
59
+ && (intent.activeGenerationSemanticDigest === null || validDigest(intent.activeGenerationSemanticDigest))
60
+ && (intent.pendingGenerationSha256 === null || validDigest(intent.pendingGenerationSha256))
61
+ && (intent.pendingGenerationSemanticDigest === null || validDigest(intent.pendingGenerationSemanticDigest))
56
62
  && (intent.finalArtifactSha256 === null || validDigest(intent.finalArtifactSha256))
57
63
  && (intent.finalSemanticDigest === null || validDigest(intent.finalSemanticDigest))
58
64
  && validRecoveryId(intent.recoveryOperationId)
@@ -64,11 +70,22 @@ export function isArtifactRecoveryIntent(value) {
64
70
  && (intent.errorMessage === null || validToken(intent.errorMessage))
65
71
  && Number.isSafeInteger(intent.createdAt)
66
72
  && Number.isSafeInteger(intent.updatedAt)
67
- && (intent.state === 'awaiting-recovery'
68
- ? intent.finalArtifactSha256 === null && intent.finalSemanticDigest === null
73
+ && (intent.state === 'awaiting-preflight-recovery'
74
+ ? intent.preflightArtifactSha256 === null && intent.preflightSemanticDigest === null
75
+ && intent.activeGenerationSha256 === null && intent.activeGenerationSemanticDigest === null
76
+ && intent.pendingGenerationSha256 === null && intent.pendingGenerationSemanticDigest === null
77
+ && intent.finalArtifactSha256 === null && intent.finalSemanticDigest === null
69
78
  : true)
70
- && (intent.state === 'finalize-ready' || intent.state === 'commit-started' || intent.state === 'passed' || intent.state === 'consumed'
71
- ? validDigest(intent.candidateSha256) && validDigest(intent.finalArtifactSha256) && validDigest(intent.finalSemanticDigest)
79
+ && (intent.state === 'preflight-ready' || intent.state === 'preflight-commit-started' || intent.state === 'preflight-passed' || intent.state === 'full-finalizer-ready' || intent.state === 'commit-started' || intent.state === 'passed' || intent.state === 'consumed'
80
+ ? validDigest(intent.candidateSha256) && validDigest(intent.preflightArtifactSha256) && validDigest(intent.preflightSemanticDigest)
81
+ && validDigest(intent.activeGenerationSha256) && validDigest(intent.activeGenerationSemanticDigest)
82
+ : true)
83
+ && (intent.state === 'full-finalizer-ready' || intent.state === 'commit-started'
84
+ ? validDigest(intent.pendingGenerationSha256) && validDigest(intent.pendingGenerationSemanticDigest)
85
+ && validDigest(intent.finalArtifactSha256) && validDigest(intent.finalSemanticDigest)
86
+ : true)
87
+ && (intent.state === 'passed' || intent.state === 'consumption-started' || intent.state === 'consumed'
88
+ ? validDigest(intent.finalArtifactSha256) && validDigest(intent.finalSemanticDigest)
72
89
  : true)
73
90
  && (intent.state === 'aborted' ? intent.errorCode !== null && intent.errorMessage !== null : true);
74
91
  }
@@ -146,8 +146,6 @@ function qualificationRecoveryAction(facts) {
146
146
  return QUALIFICATION_RECOVERY_ORDER.find(({ pattern }) => stale.some((name) => pattern.test(name)))?.action ?? null;
147
147
  }
148
148
  function recommendNext(facts) {
149
- if (invalidationBlocks(facts.invalidation))
150
- return { action: null, reasonCode: 'INVALIDATION_INCOMPLETE', evidence: ['reconcile task invalidation before routing'] };
151
149
  if (facts.qualificationStale) {
152
150
  const action = qualificationRecoveryAction(facts);
153
151
  return {
@@ -266,7 +266,7 @@ function lifecycleRecoveryIntentMatches(intent, selector) {
266
266
  return intent.taskId === selector.taskId
267
267
  && intent.family === selector.family
268
268
  && intent.artifact === selector.artifact
269
- && (intent.state === 'commit-started' || intent.state === 'consumed');
269
+ && (intent.state === 'commit-started' || intent.state === 'consumption-started' || intent.state === 'consumed');
270
270
  }
271
271
  /**
272
272
  * Completes only the durable tail of a committed task event after a process