@fitlab-ai/agent-infra 0.10.0 → 0.11.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.
Files changed (95) 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/platform-comment.js +60 -2
  7. package/dist/lib/internal/task-artifact.js +1 -1
  8. package/dist/lib/internal/task-finalization.js +2 -2
  9. package/dist/lib/internal/task-lifecycle.js +2 -2
  10. package/dist/lib/internal/task-operation-registry.js +22 -12
  11. package/dist/lib/internal/task-orchestration.js +2 -2
  12. package/dist/lib/internal/task-review.js +10 -5
  13. package/dist/lib/platform/activity-recovery.js +159 -0
  14. package/dist/lib/platform/issue-comments.js +84 -32
  15. package/dist/lib/platform/task-comment-projection.js +70 -0
  16. package/dist/lib/platform/task-recovery.js +55 -0
  17. package/dist/lib/platform/verification-sync.js +43 -9
  18. package/dist/lib/sandbox/control/executor.js +4 -2
  19. package/dist/lib/sandbox/control/task-workflow.js +1 -1
  20. package/dist/lib/sandbox/control/workflow-executor.js +83 -21
  21. package/dist/lib/server/process-state.js +17 -5
  22. package/dist/lib/task/artifact-command.js +6 -3
  23. package/dist/lib/task/artifact-operations.js +1 -1
  24. package/dist/lib/task/artifact-recovery.js +114 -68
  25. package/dist/lib/task/artifact-repair-intent.js +25 -8
  26. package/dist/lib/task/control-authority.js +1 -1
  27. package/dist/lib/task/events.js +6 -6
  28. package/dist/lib/task/intermediate-cleanup.js +28 -3
  29. package/dist/lib/task/local-artifact-finalization.js +78 -14
  30. package/dist/lib/task/review-command.js +2 -2
  31. package/dist/lib/task/review-finalization.js +203 -10
  32. package/dist/lib/{host-control → task}/secure-fs.js +8 -19
  33. package/dist/lib/task/workflow-command.js +2 -0
  34. package/dist/lib/task/workflow-dispatch.js +161 -0
  35. package/dist/lifecycle-build-manifest.json +3 -3
  36. package/lib/internal/cli-route-inventory.ts +3 -4
  37. package/lib/internal/platform-comment.ts +53 -2
  38. package/lib/internal/task-artifact.ts +1 -1
  39. package/lib/internal/task-finalization.ts +1 -1
  40. package/lib/internal/task-lifecycle.ts +1 -1
  41. package/lib/internal/task-operation-registry.ts +22 -12
  42. package/lib/internal/task-orchestration.ts +1 -1
  43. package/lib/internal/task-review.ts +9 -5
  44. package/lib/platform/activity-recovery.ts +179 -0
  45. package/lib/platform/issue-comments.ts +98 -31
  46. package/lib/platform/task-comment-projection.ts +79 -0
  47. package/lib/platform/task-recovery.ts +54 -0
  48. package/lib/platform/verification-sync.ts +46 -13
  49. package/lib/sandbox/control/executor.ts +4 -2
  50. package/lib/sandbox/control/protocol.ts +1 -1
  51. package/lib/sandbox/control/task-workflow.ts +1 -1
  52. package/lib/sandbox/control/workflow-executor.ts +102 -21
  53. package/lib/server/process-state.ts +26 -5
  54. package/lib/task/artifact-command.ts +6 -4
  55. package/lib/task/artifact-operations.ts +1 -1
  56. package/lib/task/artifact-recovery.ts +120 -67
  57. package/lib/task/artifact-repair-intent.ts +38 -11
  58. package/lib/task/control-authority.ts +1 -1
  59. package/lib/task/events.ts +6 -6
  60. package/lib/task/intermediate-cleanup.ts +18 -2
  61. package/lib/task/local-artifact-finalization.ts +85 -11
  62. package/lib/task/review-command.ts +1 -1
  63. package/lib/task/review-finalization.ts +203 -8
  64. package/lib/{host-control → task}/secure-fs.ts +10 -32
  65. package/lib/task/workflow-command.ts +2 -0
  66. package/lib/task/workflow-dispatch.ts +171 -0
  67. package/package.json +1 -1
  68. package/templates/.agents/rules/issue-sync.en.md +9 -1
  69. package/templates/.agents/rules/issue-sync.zh-CN.md +10 -3
  70. package/templates/.agents/rules/local-artifact-repair.en.md +7 -6
  71. package/templates/.agents/rules/local-artifact-repair.zh-CN.md +10 -8
  72. package/templates/.agents/skills/code-task/SKILL.en.md +1 -1
  73. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +2 -2
  74. package/templates/.agents/skills/restore-task/SKILL.en.md +9 -4
  75. package/templates/.agents/skills/restore-task/SKILL.zh-CN.md +9 -4
  76. package/templates/.agents/skills/review-analysis/SKILL.en.md +1 -1
  77. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +1 -1
  78. package/templates/.agents/skills/review-code/SKILL.en.md +3 -2
  79. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +3 -2
  80. package/templates/.agents/skills/review-plan/SKILL.en.md +1 -1
  81. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +1 -1
  82. package/dist/lib/host-control/audit.js +0 -19
  83. package/dist/lib/host-control/client.js +0 -142
  84. package/dist/lib/host-control/command.js +0 -49
  85. package/dist/lib/host-control/path.js +0 -160
  86. package/dist/lib/host-control/server.js +0 -159
  87. package/dist/lib/host-control/service.js +0 -112
  88. package/dist/lib/internal/host-control.js +0 -28
  89. package/lib/host-control/audit.ts +0 -22
  90. package/lib/host-control/client.ts +0 -172
  91. package/lib/host-control/command.ts +0 -60
  92. package/lib/host-control/path.ts +0 -167
  93. package/lib/host-control/server.ts +0 -180
  94. package/lib/host-control/service.ts +0 -133
  95. package/lib/internal/host-control.ts +0 -28
@@ -125,7 +125,9 @@ export async function prepareSandboxControlExecution(params) {
125
125
  owner: gateOwner,
126
126
  requestId: params.request.id,
127
127
  operationId: authority?.operationId ?? params.request.id,
128
- phase: authority?.phase ?? (params.request.family === 'task-workflow' ? 'artifact.finalize-local' : 'orchestration.prepare'),
128
+ phase: authority?.phase ?? (params.request.family === 'task-workflow'
129
+ && params.request.workflow?.operation === 'artifact-preflight' ? 'artifact.preflight'
130
+ : params.request.family === 'task-workflow' ? 'artifact.finalize-local' : 'orchestration.prepare'),
129
131
  authority
130
132
  });
131
133
  },
@@ -163,7 +165,7 @@ function waitForGate(nonce, timeoutMs = 2_000) {
163
165
  return;
164
166
  }
165
167
  if (typeof value.requestId !== 'string' || typeof value.operationId !== 'string'
166
- || !['orchestration.prepare', 'artifact.finalize-local', 'task-event.completed'].includes(value.phase)
168
+ || !['orchestration.prepare', 'artifact.preflight', 'artifact.finalize-local', 'task-event.completed'].includes(value.phase)
167
169
  || (value.authority !== null && value.authority !== undefined && (() => {
168
170
  try {
169
171
  validateLifecycleRecoveryAttestation(value.authority);
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
- import { readStableFile, SecureFileError, writeAtomicFile } from '../../host-control/secure-fs.js';
4
+ import { readStableFile, SecureFileError, writeAtomicFile } from '../../task/secure-fs.js';
5
5
  import { TASK_WORKFLOW_COMMANDS, TASK_WORKFLOW_OPERATIONS } from '../../task/workflow-command.js';
6
6
  export function validateTaskWorkflowRequest(value) {
7
7
  if (!value || typeof value !== 'object' || Array.isArray(value))
@@ -1,10 +1,11 @@
1
1
  import { TASK_WORKFLOW_COMMANDS } from '../../task/workflow-command.js';
2
2
  import { parseArtifactCommand, executeArtifactCommand } from '../../task/artifact-command.js';
3
3
  import { parseReviewCommand } from '../../task/review-command.js';
4
- import { prepareLocalArtifact, commitLocalArtifactProvenance } from '../../task/local-artifact-finalization.js';
4
+ import { prepareLocalArtifact, preflightLocalArtifact, commitLocalArtifactProvenance } from '../../task/local-artifact-finalization.js';
5
5
  import { prepareReviewSummaryCandidate, commitReviewSummaryProvenance } from '../../task/review-finalization.js';
6
+ import { readArtifactRecoveryIntent } from '../../task/artifact-repair-intent.js';
6
7
  import { withTaskExecutionLock } from '../../task/task-execution-lock.js';
7
- import { dispatchHostControlCommand } from '../../host-control/command.js';
8
+ import { dispatchWorkflowCommand } from '../../task/workflow-dispatch.js';
8
9
  import { assertSandboxTaskSource } from '../workspace-view.js';
9
10
  import { appendDiagnosticAudit } from './audit.js';
10
11
  import { readTaskArtifact } from './task-workflow.js';
@@ -14,9 +15,32 @@ function executionResult(result) {
14
15
  return { exitCode: result.status === 'failed' || result.status === 'refused' ? 1 : 0, stdout: `${JSON.stringify(result)}\n`, stderr: '' };
15
16
  }
16
17
  /** The broker already authorized this host executor; domain operations own validation. */
17
- export async function executeTaskWorkflow(manifest, request, lifecycleRecoveryAttestation = null) {
18
- let publicationStarted = false;
18
+ export async function executeTaskWorkflow(manifest, request, lifecycleRecoveryAttestation = null, options = {}) {
19
+ let publicationCommitted = false;
20
+ let recoveryObservation = null;
21
+ const observeRecoveryPublication = () => {
22
+ if (!recoveryObservation || recoveryObservation.wasPassed)
23
+ return;
24
+ const intent = readArtifactRecoveryIntent(manifest.repoRoot, request.taskId, recoveryObservation.family, recoveryObservation.artifact);
25
+ publicationCommitted ||= intent?.state === 'preflight-ready'
26
+ || intent?.state === 'preflight-commit-started'
27
+ || intent?.state === 'preflight-passed'
28
+ || intent?.state === 'passed'
29
+ || intent?.state === 'commit-started';
30
+ };
31
+ let faultTriggered = false;
32
+ const fault = (window) => {
33
+ if (faultTriggered || options.faultWindow !== window)
34
+ return;
35
+ faultTriggered = true;
36
+ throw new Error(`TASK_WORKFLOW_FAULT_INJECTED:${window}`);
37
+ };
38
+ const response = (result) => {
39
+ fault('before-result-return');
40
+ return executionResult(result);
41
+ };
19
42
  try {
43
+ fault('before-call');
20
44
  const taskDir = assertSandboxTaskSource(manifest.repoRoot, request.taskId);
21
45
  const [command] = TASK_WORKFLOW_COMMANDS[request.operation];
22
46
  if (request.operation === 'event') {
@@ -24,66 +48,104 @@ export async function executeTaskWorkflow(manifest, request, lifecycleRecoveryAt
24
48
  const boundEventRequest = lifecycleRecoveryAttestation && eventRequest.requestId === undefined
25
49
  ? { ...eventRequest, requestId: lifecycleRecoveryAttestation.lifecycleRequestId }
26
50
  : eventRequest;
51
+ fault('before-domain-write');
27
52
  const result = applyTaskEvent(boundEventRequest, {
28
53
  repoRoot: manifest.repoRoot,
29
54
  lifecycleRecoveryAttestation,
30
55
  deferLifecycleRecoveryConsumption: true
31
56
  });
32
- return executionResult(result);
57
+ publicationCommitted = result.changed;
58
+ fault('after-atomic-rename');
59
+ return response(result);
33
60
  }
34
61
  if (command !== 'task-artifact' && command !== 'task-review') {
35
- publicationStarted = true;
36
- // Inherit this executor's process group so its existing recovery owns the worker too.
37
- const result = await dispatchHostControlCommand({
38
- operation: command, payload: { workingDirectory: manifest.repoRoot, args: request.args }
39
- });
40
- return result;
62
+ // The broker owns this short-lived executor; no global service or worker
63
+ // credential participates in the command's authority or recovery path.
64
+ fault('before-domain-write');
65
+ const result = dispatchWorkflowCommand(manifest.repoRoot, request.operation, request.args);
66
+ publicationCommitted = result.changed === true;
67
+ fault('after-atomic-rename');
68
+ return response(result);
41
69
  }
42
70
  const input = command === 'task-artifact' ? parseArtifactCommand(request.args) : parseReviewCommand(request.args);
43
71
  if (command === 'task-artifact' && 'operation' in input && input.operation === 'inspect') {
44
- return executionResult(executeArtifactCommand(input, { repoRoot: manifest.repoRoot }));
72
+ fault('before-domain-write');
73
+ const result = executeArtifactCommand(input, { repoRoot: manifest.repoRoot });
74
+ publicationCommitted = result.changed === true;
75
+ fault('after-atomic-rename');
76
+ return response(result);
45
77
  }
46
78
  if ('operation' in input && input.operation === 'init') {
47
- return executionResult(executeArtifactCommand(input, { repoRoot: manifest.repoRoot, artifactDir: taskDir }));
79
+ fault('before-domain-write');
80
+ const result = executeArtifactCommand(input, { repoRoot: manifest.repoRoot, artifactDir: taskDir });
81
+ publicationCommitted = result.changed === true;
82
+ fault('after-atomic-rename');
83
+ return response(result);
48
84
  }
49
85
  return await withTaskExecutionLock(manifest.repoRoot, request.taskId, `sandbox-control.${request.operation}`, async () => {
50
86
  const artifact = await readTaskArtifact(taskDir, { artifact: input.artifact });
51
87
  const content = artifact.bytes.toString('utf8');
88
+ fault('before-domain-write');
52
89
  let result;
53
90
  if ('operation' in input) {
54
91
  const { family } = input;
55
92
  if (family !== 'analysis' && family !== 'plan' && family !== 'code')
56
93
  throw new Error('ARTIFACT_IDENTITY_INVALID');
57
94
  const local = { taskRef: request.taskId, family, artifact: input.artifact, repoRoot: manifest.repoRoot, recoveryId: input.recoveryId, lockAlreadyHeld: true };
95
+ if (input.operation === 'preflight') {
96
+ // Preflight only seals a verified generation. It must not publish the
97
+ // formal artifact, set publicationStarted, or append finalizer audit.
98
+ const result = preflightLocalArtifact(local);
99
+ recoveryObservation = { family, artifact: input.artifact, wasPassed: false };
100
+ observeRecoveryPublication();
101
+ return response(result);
102
+ }
103
+ recoveryObservation = {
104
+ family,
105
+ artifact: input.artifact,
106
+ wasPassed: readArtifactRecoveryIntent(manifest.repoRoot, request.taskId, family, input.artifact)?.state === 'passed'
107
+ };
58
108
  const prepared = prepareLocalArtifact(local, content, lifecycleRecoveryAttestation ?? undefined);
109
+ observeRecoveryPublication();
110
+ const commitOptions = { afterPublish: () => fault('after-atomic-rename') };
59
111
  if (prepared.result.status === 'failed')
60
- return executionResult(commitLocalArtifactProvenance(prepared));
61
- publicationStarted = true;
62
- result = commitLocalArtifactProvenance(prepared);
112
+ return executionResult(commitLocalArtifactProvenance(prepared, commitOptions));
113
+ result = commitLocalArtifactProvenance(prepared, commitOptions);
63
114
  }
64
115
  else {
65
116
  if (input.overrideTicket)
66
117
  throw new Error('TASK_WORKFLOW_OVERRIDE_UNSUPPORTED');
118
+ const family = `review-${input.stage}`;
119
+ recoveryObservation = {
120
+ family,
121
+ artifact: input.artifact,
122
+ wasPassed: readArtifactRecoveryIntent(manifest.repoRoot, request.taskId, family, input.artifact)?.state === 'passed'
123
+ };
67
124
  const prepared = prepareReviewSummaryCandidate(input, content, { repoRoot: manifest.repoRoot, lockAlreadyHeld: true, startRecovery: true });
125
+ observeRecoveryPublication();
68
126
  if (input.dryRun || prepared.result.status === 'planned')
69
127
  return executionResult(prepared.result);
128
+ const commitOptions = { afterPublish: () => fault('after-atomic-rename') };
70
129
  if (prepared.result.status === 'failed')
71
- return executionResult(commitReviewSummaryProvenance(prepared, manifest.repoRoot));
72
- publicationStarted = true;
73
- result = commitReviewSummaryProvenance(prepared, manifest.repoRoot);
130
+ return executionResult(commitReviewSummaryProvenance(prepared, manifest.repoRoot, commitOptions));
131
+ result = commitReviewSummaryProvenance(prepared, manifest.repoRoot, commitOptions);
74
132
  }
133
+ observeRecoveryPublication();
134
+ publicationCommitted ||= result.changed === true;
75
135
  if (result.status !== 'failed')
76
136
  appendDiagnosticAudit(manifest, 'task-workflow-artifact-finalized', {
77
137
  requestId: request.id, sandboxTaskId: request.taskId, workflowOperation: request.operation,
78
138
  artifact: input.artifact, sha256: result.artifactSha256, semanticDigest: result.semanticDigest
79
139
  });
80
- return executionResult({ ...result, changed: true });
140
+ fault('after-atomic-rename');
141
+ return response(result);
81
142
  });
82
143
  }
83
144
  catch (error) {
145
+ observeRecoveryPublication();
84
146
  const message = error instanceof Error ? error.message : String(error);
85
147
  const code = error?.code ?? /^([A-Z][A-Z0-9_]+)/u.exec(message)?.[1] ?? 'TASK_WORKFLOW_REQUEST_INVALID';
86
- return executionResult({ status: 'failed', changed: publicationStarted ? null : false, error: { code, message } });
148
+ return executionResult({ status: 'failed', changed: publicationCommitted ? null : false, error: { code, message } });
87
149
  }
88
150
  }
89
151
  //# sourceMappingURL=workflow-executor.js.map
@@ -97,7 +97,7 @@ export function getProcessStartTime(pid, platform = process.platform) {
97
97
  return null;
98
98
  }
99
99
  }
100
- export function getProcessIdentityState(identity, platform = process.platform) {
100
+ export function getProcessIdentityState(identity, platform = process.platform, runtime = {}) {
101
101
  if (!Number.isInteger(identity.pid) || identity.pid <= 0 || !Number.isSafeInteger(identity.startTime)) {
102
102
  return 'dead';
103
103
  }
@@ -114,8 +114,9 @@ export function getProcessIdentityState(identity, platform = process.platform) {
114
114
  return error.code === 'ENOENT' ? 'dead' : 'unknown';
115
115
  }
116
116
  }
117
+ const probePid = runtime.probePid ?? ((pid) => process.kill(pid, 0));
117
118
  try {
118
- process.kill(identity.pid, 0);
119
+ probePid(identity.pid);
119
120
  }
120
121
  catch (error) {
121
122
  const code = error.code;
@@ -128,23 +129,34 @@ export function getProcessIdentityState(identity, platform = process.platform) {
128
129
  if (query === null)
129
130
  return 'unknown';
130
131
  try {
131
- const output = execFileSync(query.command, query.args, {
132
+ const output = (runtime.execFileSync ?? execFileSync)(query.command, query.args, {
132
133
  encoding: 'utf8',
133
134
  env: platform === 'darwin' ? { ...process.env, LC_ALL: 'C', LANG: 'C', TZ: 'UTC' } : process.env
134
135
  }).trim();
135
136
  if (output.length === 0)
136
- return 'unknown';
137
+ return recheckDarwinProcessExit(identity.pid, platform, probePid);
137
138
  const startTime = platform === 'win32'
138
139
  ? new Date(output).getTime()
139
140
  : platform === 'darwin'
140
141
  ? parseDarwinStartTime(output)
141
142
  : null;
142
143
  if (startTime === null || Number.isNaN(startTime))
143
- return 'unknown';
144
+ return recheckDarwinProcessExit(identity.pid, platform, probePid);
144
145
  return startTime === identity.startTime ? 'alive' : 'dead';
145
146
  }
146
147
  catch {
148
+ return recheckDarwinProcessExit(identity.pid, platform, probePid);
149
+ }
150
+ }
151
+ function recheckDarwinProcessExit(pid, platform, probePid) {
152
+ if (platform !== 'darwin')
147
153
  return 'unknown';
154
+ try {
155
+ probePid(pid);
156
+ return 'unknown';
157
+ }
158
+ catch (error) {
159
+ return error.code === 'ESRCH' ? 'dead' : 'unknown';
148
160
  }
149
161
  }
150
162
  function parsePidRecord(raw) {
@@ -3,18 +3,19 @@ import { resolveArtifactContext, hasOpenArtifactRound } from './artifact-lifecyc
3
3
  import { parseArtifactName } from './artifact-name.js';
4
4
  import { resolveTaskRef } from './resolve-ref.js';
5
5
  import { getArtifactSchema } from './artifact-schema.js';
6
- import { finalizeLocalArtifact } from './local-artifact-finalization.js';
6
+ import { finalizeLocalArtifact, preflightLocalArtifact } from './local-artifact-finalization.js';
7
7
  import { initializeArtifactSkeleton } from './artifact-operations.js';
8
8
  /** One option contract for the CLI and trusted projection executor. */
9
9
  export function parseArtifactCommand(args) {
10
10
  const [taskRef, operation] = args;
11
- if (!taskRef || !['inspect', 'init', 'finalize-local'].includes(operation ?? '')) {
11
+ if (!taskRef || !['inspect', 'init', 'preflight', 'finalize-local'].includes(operation ?? '')) {
12
12
  throw new Error('task ref and a supported artifact operation are required');
13
13
  }
14
14
  const fields = {};
15
15
  const allowed = {
16
16
  inspect: ['--family'],
17
17
  init: ['--family', '--artifact', '--locale'],
18
+ 'preflight': ['--family', '--artifact', '--recovery-id'],
18
19
  'finalize-local': ['--family', '--artifact', '--recovery-id']
19
20
  }[operation];
20
21
  for (let index = 2; index < args.length; index += 1) {
@@ -72,8 +73,10 @@ export function executeArtifactCommand(command, options = {}) {
72
73
  return { ...initializeArtifactSkeleton({ ...input, ...(locale ? { locale } : {}) }), ...identity };
73
74
  }
74
75
  if (family !== 'analysis' && family !== 'plan' && family !== 'code') {
75
- return fail('ARTIFACT_PAYLOAD_INVALID', "finalize-local only supports 'analysis', 'plan', and 'code'");
76
+ return fail('ARTIFACT_PAYLOAD_INVALID', "preflight and finalize-local only support 'analysis', 'plan', and 'code'");
76
77
  }
78
+ if (operation === 'preflight')
79
+ return { ...preflightLocalArtifact({ taskRef, family, artifact, repoRoot: resolved.repoRoot, recoveryId }) };
77
80
  return { ...finalizeLocalArtifact({ taskRef, family, artifact, repoRoot: resolved.repoRoot, recoveryId }) };
78
81
  }
79
82
  //# sourceMappingURL=artifact-command.js.map
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { createHash } from 'node:crypto';
4
- import { readStableFileSync } from '../host-control/secure-fs.js';
4
+ import { readStableFileSync } from './secure-fs.js';
5
5
  import { parseArtifactName } from './artifact-name.js';
6
6
  import { scanVisibleMarkdown } from './markdown.js';
7
7
  import { getArtifactSchema, renderArtifactSkeleton } from './artifact-schema.js';
@@ -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) {