@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
@@ -2,10 +2,11 @@ import { TASK_WORKFLOW_COMMANDS } from '../../task/workflow-command.ts';
2
2
 
3
3
  import { parseArtifactCommand, executeArtifactCommand } from '../../task/artifact-command.ts';
4
4
  import { parseReviewCommand } from '../../task/review-command.ts';
5
- import { prepareLocalArtifact, commitLocalArtifactProvenance } from '../../task/local-artifact-finalization.ts';
5
+ import { prepareLocalArtifact, preflightLocalArtifact, commitLocalArtifactProvenance } from '../../task/local-artifact-finalization.ts';
6
6
  import { prepareReviewSummaryCandidate, commitReviewSummaryProvenance } from '../../task/review-finalization.ts';
7
+ import { readArtifactRecoveryIntent } from '../../task/artifact-repair-intent.ts';
7
8
  import { withTaskExecutionLock } from '../../task/task-execution-lock.ts';
8
- import { dispatchHostControlCommand } from '../../host-control/command.ts';
9
+ import { dispatchWorkflowCommand } from '../../task/workflow-dispatch.ts';
9
10
  import { assertSandboxTaskSource } from '../workspace-view.ts';
10
11
  import { appendDiagnosticAudit } from './audit.ts';
11
12
  import {
@@ -17,6 +18,17 @@ import type { LifecycleRecoveryAttestationV1 } from '../../task/control-authorit
17
18
  import { applyTaskEvent } from '../../task/events.ts';
18
19
  import { parseTaskEventRequest } from '../../internal/task-event.ts';
19
20
 
21
+ export type TaskWorkflowFaultWindow =
22
+ | 'before-call'
23
+ | 'before-domain-write'
24
+ | 'after-atomic-rename'
25
+ | 'before-result-return';
26
+
27
+ export type TaskWorkflowExecutionOptions = Readonly<{
28
+ /** Deterministic test seam; production callers leave this unset. */
29
+ faultWindow?: TaskWorkflowFaultWindow;
30
+ }>;
31
+
20
32
  function executionResult(result: Record<string, unknown>): SandboxControlExecutionResult {
21
33
  return { exitCode: result.status === 'failed' || result.status === 'refused' ? 1 : 0, stdout: `${JSON.stringify(result)}\n`, stderr: '' };
22
34
  }
@@ -25,10 +37,41 @@ function executionResult(result: Record<string, unknown>): SandboxControlExecuti
25
37
  export async function executeTaskWorkflow(
26
38
  manifest: SandboxControlManifest,
27
39
  request: TaskWorkflowRequest,
28
- lifecycleRecoveryAttestation: LifecycleRecoveryAttestationV1 | null = null
40
+ lifecycleRecoveryAttestation: LifecycleRecoveryAttestationV1 | null = null,
41
+ options: TaskWorkflowExecutionOptions = {}
29
42
  ): Promise<SandboxControlExecutionResult> {
30
- let publicationStarted = false;
43
+ let publicationCommitted = false;
44
+ let recoveryObservation: Readonly<{
45
+ family: 'analysis' | 'plan' | 'code' | 'review-analysis' | 'review-plan' | 'review-code';
46
+ artifact: string;
47
+ wasPassed: boolean;
48
+ }> | null = null;
49
+ const observeRecoveryPublication = (): void => {
50
+ if (!recoveryObservation || recoveryObservation.wasPassed) return;
51
+ const intent = readArtifactRecoveryIntent(
52
+ manifest.repoRoot,
53
+ request.taskId,
54
+ recoveryObservation.family,
55
+ recoveryObservation.artifact
56
+ );
57
+ publicationCommitted ||= intent?.state === 'preflight-ready'
58
+ || intent?.state === 'preflight-commit-started'
59
+ || intent?.state === 'preflight-passed'
60
+ || intent?.state === 'passed'
61
+ || intent?.state === 'commit-started';
62
+ };
63
+ let faultTriggered = false;
64
+ const fault = (window: TaskWorkflowFaultWindow): void => {
65
+ if (faultTriggered || options.faultWindow !== window) return;
66
+ faultTriggered = true;
67
+ throw new Error(`TASK_WORKFLOW_FAULT_INJECTED:${window}`);
68
+ };
69
+ const response = (result: Record<string, unknown>): SandboxControlExecutionResult => {
70
+ fault('before-result-return');
71
+ return executionResult(result);
72
+ };
31
73
  try {
74
+ fault('before-call');
32
75
  const taskDir = assertSandboxTaskSource(manifest.repoRoot, request.taskId);
33
76
  const [command] = TASK_WORKFLOW_COMMANDS[request.operation];
34
77
  if (request.operation === 'event') {
@@ -36,57 +79,95 @@ export async function executeTaskWorkflow(
36
79
  const boundEventRequest = lifecycleRecoveryAttestation && eventRequest.requestId === undefined
37
80
  ? { ...eventRequest, requestId: lifecycleRecoveryAttestation.lifecycleRequestId }
38
81
  : eventRequest;
82
+ fault('before-domain-write');
39
83
  const result = applyTaskEvent(boundEventRequest, {
40
84
  repoRoot: manifest.repoRoot,
41
85
  lifecycleRecoveryAttestation,
42
86
  deferLifecycleRecoveryConsumption: true
43
87
  });
44
- return executionResult(result);
88
+ publicationCommitted = result.changed;
89
+ fault('after-atomic-rename');
90
+ return response(result);
45
91
  }
46
92
  if (command !== 'task-artifact' && command !== 'task-review') {
47
- publicationStarted = true;
48
- // Inherit this executor's process group so its existing recovery owns the worker too.
49
- const result = await dispatchHostControlCommand({
50
- operation: command, payload: { workingDirectory: manifest.repoRoot, args: request.args }
51
- });
52
- return result;
93
+ // The broker owns this short-lived executor; no global service or worker
94
+ // credential participates in the command's authority or recovery path.
95
+ fault('before-domain-write');
96
+ const result = dispatchWorkflowCommand(manifest.repoRoot, request.operation, request.args);
97
+ publicationCommitted = result.changed === true;
98
+ fault('after-atomic-rename');
99
+ return response(result);
53
100
  }
54
101
  const input = command === 'task-artifact' ? parseArtifactCommand(request.args) : parseReviewCommand(request.args);
55
102
  if (command === 'task-artifact' && 'operation' in input && input.operation === 'inspect') {
56
- return executionResult(executeArtifactCommand(input, { repoRoot: manifest.repoRoot }));
103
+ fault('before-domain-write');
104
+ const result = executeArtifactCommand(input, { repoRoot: manifest.repoRoot });
105
+ publicationCommitted = result.changed === true;
106
+ fault('after-atomic-rename');
107
+ return response(result);
57
108
  }
58
109
  if ('operation' in input && input.operation === 'init') {
59
- return executionResult(executeArtifactCommand(input, { repoRoot: manifest.repoRoot, artifactDir: taskDir }));
110
+ fault('before-domain-write');
111
+ const result = executeArtifactCommand(input, { repoRoot: manifest.repoRoot, artifactDir: taskDir });
112
+ publicationCommitted = result.changed === true;
113
+ fault('after-atomic-rename');
114
+ return response(result);
60
115
  }
61
116
  return await withTaskExecutionLock(manifest.repoRoot, request.taskId, `sandbox-control.${request.operation}`, async () => {
62
117
  const artifact = await readTaskArtifact(taskDir, { artifact: input.artifact });
63
118
  const content = artifact.bytes.toString('utf8');
119
+ fault('before-domain-write');
64
120
  let result: Record<string, unknown>;
65
121
  if ('operation' in input) {
66
122
  const { family } = input;
67
123
  if (family !== 'analysis' && family !== 'plan' && family !== 'code') throw new Error('ARTIFACT_IDENTITY_INVALID');
68
124
  const local = { taskRef: request.taskId, family, artifact: input.artifact, repoRoot: manifest.repoRoot, recoveryId: input.recoveryId, lockAlreadyHeld: true } as const;
125
+ if (input.operation === 'preflight') {
126
+ // Preflight only seals a verified generation. It must not publish the
127
+ // formal artifact, set publicationStarted, or append finalizer audit.
128
+ const result = preflightLocalArtifact(local);
129
+ recoveryObservation = { family, artifact: input.artifact, wasPassed: false };
130
+ observeRecoveryPublication();
131
+ return response(result);
132
+ }
133
+ recoveryObservation = {
134
+ family,
135
+ artifact: input.artifact,
136
+ wasPassed: readArtifactRecoveryIntent(manifest.repoRoot, request.taskId, family, input.artifact)?.state === 'passed'
137
+ };
69
138
  const prepared = prepareLocalArtifact(local, content, lifecycleRecoveryAttestation ?? undefined);
70
- if (prepared.result.status === 'failed') return executionResult(commitLocalArtifactProvenance(prepared));
71
- publicationStarted = true;
72
- result = commitLocalArtifactProvenance(prepared);
139
+ observeRecoveryPublication();
140
+ const commitOptions = { afterPublish: () => fault('after-atomic-rename') };
141
+ if (prepared.result.status === 'failed') return executionResult(commitLocalArtifactProvenance(prepared, commitOptions));
142
+ result = commitLocalArtifactProvenance(prepared, commitOptions);
73
143
  } else {
74
144
  if (input.overrideTicket) throw new Error('TASK_WORKFLOW_OVERRIDE_UNSUPPORTED');
145
+ const family = `review-${input.stage}` as 'review-analysis' | 'review-plan' | 'review-code';
146
+ recoveryObservation = {
147
+ family,
148
+ artifact: input.artifact,
149
+ wasPassed: readArtifactRecoveryIntent(manifest.repoRoot, request.taskId, family, input.artifact)?.state === 'passed'
150
+ };
75
151
  const prepared = prepareReviewSummaryCandidate(input, content, { repoRoot: manifest.repoRoot, lockAlreadyHeld: true, startRecovery: true });
152
+ observeRecoveryPublication();
76
153
  if (input.dryRun || prepared.result.status === 'planned') return executionResult(prepared.result);
77
- if (prepared.result.status === 'failed') return executionResult(commitReviewSummaryProvenance(prepared, manifest.repoRoot));
78
- publicationStarted = true;
79
- result = commitReviewSummaryProvenance(prepared, manifest.repoRoot);
154
+ const commitOptions = { afterPublish: () => fault('after-atomic-rename') };
155
+ if (prepared.result.status === 'failed') return executionResult(commitReviewSummaryProvenance(prepared, manifest.repoRoot, commitOptions));
156
+ result = commitReviewSummaryProvenance(prepared, manifest.repoRoot, commitOptions);
80
157
  }
158
+ observeRecoveryPublication();
159
+ publicationCommitted ||= result.changed === true;
81
160
  if (result.status !== 'failed') appendDiagnosticAudit(manifest, 'task-workflow-artifact-finalized', {
82
161
  requestId: request.id, sandboxTaskId: request.taskId, workflowOperation: request.operation,
83
162
  artifact: input.artifact, sha256: result.artifactSha256 as string, semanticDigest: result.semanticDigest as string
84
163
  });
85
- return executionResult({ ...result, changed: true });
164
+ fault('after-atomic-rename');
165
+ return response(result);
86
166
  });
87
167
  } catch (error) {
168
+ observeRecoveryPublication();
88
169
  const message = error instanceof Error ? error.message : String(error);
89
170
  const code = (error as { code?: string })?.code ?? /^([A-Z][A-Z0-9_]+)/u.exec(message)?.[1] ?? 'TASK_WORKFLOW_REQUEST_INVALID';
90
- return executionResult({ status: 'failed', changed: publicationStarted ? null : false, error: { code, message } });
171
+ return executionResult({ status: 'failed', changed: publicationCommitted ? null : false, error: { code, message } });
91
172
  }
92
173
  }
@@ -12,6 +12,11 @@ export type ProcessIdentity = Readonly<{
12
12
  export type ProcessIdentityState = 'alive' | 'dead' | 'unknown';
13
13
  export type ProcessIdentityProbe = (identity: ProcessIdentity) => ProcessIdentityState;
14
14
 
15
+ type ProcessIdentityRuntime = Readonly<{
16
+ probePid?: (pid: number) => void;
17
+ execFileSync?: typeof execFileSync;
18
+ }>;
19
+
15
20
  export type PidRecord = ProcessIdentity & {
16
21
  version: 2;
17
22
  };
@@ -132,7 +137,8 @@ export function getProcessStartTime(
132
137
 
133
138
  export function getProcessIdentityState(
134
139
  identity: ProcessIdentity,
135
- platform: NodeJS.Platform = process.platform
140
+ platform: NodeJS.Platform = process.platform,
141
+ runtime: ProcessIdentityRuntime = {}
136
142
  ): ProcessIdentityState {
137
143
  if (!Number.isInteger(identity.pid) || identity.pid <= 0 || !Number.isSafeInteger(identity.startTime)) {
138
144
  return 'dead';
@@ -147,8 +153,9 @@ export function getProcessIdentityState(
147
153
  return (error as NodeJS.ErrnoException).code === 'ENOENT' ? 'dead' : 'unknown';
148
154
  }
149
155
  }
156
+ const probePid = runtime.probePid ?? ((pid: number) => process.kill(pid, 0));
150
157
  try {
151
- process.kill(identity.pid, 0);
158
+ probePid(identity.pid);
152
159
  } catch (error) {
153
160
  const code = (error as NodeJS.ErrnoException).code;
154
161
  if (code === 'ESRCH') return 'dead';
@@ -157,20 +164,34 @@ export function getProcessIdentityState(
157
164
  const query = buildProcessStartTimeQuery(identity.pid, platform);
158
165
  if (query === null) return 'unknown';
159
166
  try {
160
- const output = execFileSync(query.command, query.args, {
167
+ const output = (runtime.execFileSync ?? execFileSync)(query.command, query.args, {
161
168
  encoding: 'utf8',
162
169
  env: platform === 'darwin' ? { ...process.env, LC_ALL: 'C', LANG: 'C', TZ: 'UTC' } : process.env
163
170
  }).trim();
164
- if (output.length === 0) return 'unknown';
171
+ if (output.length === 0) return recheckDarwinProcessExit(identity.pid, platform, probePid);
165
172
  const startTime = platform === 'win32'
166
173
  ? new Date(output).getTime()
167
174
  : platform === 'darwin'
168
175
  ? parseDarwinStartTime(output)
169
176
  : null;
170
- if (startTime === null || Number.isNaN(startTime)) return 'unknown';
177
+ if (startTime === null || Number.isNaN(startTime)) return recheckDarwinProcessExit(identity.pid, platform, probePid);
171
178
  return startTime === identity.startTime ? 'alive' : 'dead';
172
179
  } catch {
180
+ return recheckDarwinProcessExit(identity.pid, platform, probePid);
181
+ }
182
+ }
183
+
184
+ function recheckDarwinProcessExit(
185
+ pid: number,
186
+ platform: NodeJS.Platform,
187
+ probePid: (pid: number) => void
188
+ ): ProcessIdentityState {
189
+ if (platform !== 'darwin') return 'unknown';
190
+ try {
191
+ probePid(pid);
173
192
  return 'unknown';
193
+ } catch (error) {
194
+ return (error as NodeJS.ErrnoException).code === 'ESRCH' ? 'dead' : 'unknown';
174
195
  }
175
196
  }
176
197
 
@@ -4,12 +4,12 @@ import { resolveArtifactContext, hasOpenArtifactRound } from './artifact-lifecyc
4
4
  import { parseArtifactName } from './artifact-name.ts';
5
5
  import { resolveTaskRef } from './resolve-ref.ts';
6
6
  import { getArtifactSchema } from './artifact-schema.ts';
7
- import { finalizeLocalArtifact } from './local-artifact-finalization.ts';
7
+ import { finalizeLocalArtifact, preflightLocalArtifact } from './local-artifact-finalization.ts';
8
8
  import { initializeArtifactSkeleton } from './artifact-operations.ts';
9
9
 
10
10
  export type ArtifactCommand = Readonly<{
11
11
  taskRef: string;
12
- operation: 'inspect' | 'init' | 'finalize-local';
12
+ operation: 'inspect' | 'init' | 'preflight' | 'finalize-local';
13
13
  family: string;
14
14
  artifact: string;
15
15
  locale?: 'zh-CN' | 'en';
@@ -19,13 +19,14 @@ export type ArtifactCommand = Readonly<{
19
19
  /** One option contract for the CLI and trusted projection executor. */
20
20
  export function parseArtifactCommand(args: readonly string[]): ArtifactCommand {
21
21
  const [taskRef, operation] = args;
22
- if (!taskRef || !['inspect', 'init', 'finalize-local'].includes(operation ?? '')) {
22
+ if (!taskRef || !['inspect', 'init', 'preflight', 'finalize-local'].includes(operation ?? '')) {
23
23
  throw new Error('task ref and a supported artifact operation are required');
24
24
  }
25
25
  const fields: Record<string, string> = {};
26
26
  const allowed = {
27
27
  inspect: ['--family'],
28
28
  init: ['--family', '--artifact', '--locale'],
29
+ 'preflight': ['--family', '--artifact', '--recovery-id'],
29
30
  'finalize-local': ['--family', '--artifact', '--recovery-id']
30
31
  }[operation as ArtifactCommand['operation']];
31
32
  for (let index = 2; index < args.length; index += 1) {
@@ -79,7 +80,8 @@ export function executeArtifactCommand(
79
80
  return { ...initializeArtifactSkeleton({ ...input, ...(locale ? { locale } : {}) }), ...identity };
80
81
  }
81
82
  if (family !== 'analysis' && family !== 'plan' && family !== 'code') {
82
- return fail('ARTIFACT_PAYLOAD_INVALID', "finalize-local only supports 'analysis', 'plan', and 'code'");
83
+ return fail('ARTIFACT_PAYLOAD_INVALID', "preflight and finalize-local only support 'analysis', 'plan', and 'code'");
83
84
  }
85
+ if (operation === 'preflight') return { ...preflightLocalArtifact({ taskRef, family, artifact, repoRoot: resolved.repoRoot, recoveryId }) };
84
86
  return { ...finalizeLocalArtifact({ taskRef, family, artifact, repoRoot: resolved.repoRoot, recoveryId }) };
85
87
  }
@@ -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.ts';
4
+ import { readStableFileSync } from './secure-fs.ts';
5
5
  import { parseArtifactName } from './artifact-name.ts';
6
6
 
7
7
  import { scanVisibleMarkdown, type VisibleMarkdown, type VisibleHeading } from './markdown.ts';
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
 
5
5
  import { writeDurableFile } from '../fs/durable-write.ts';
6
- import { readStableFileSync } from '../host-control/secure-fs.ts';
6
+ import { readStableFileSync } from './secure-fs.ts';
7
7
  import { parseArtifactName } from './artifact-name.ts';
8
8
  import { canonicalSemanticDigest, sha256Content } from './artifact-operations.ts';
9
9
  import {
@@ -39,7 +39,7 @@ export type ArtifactRecoveryContext = Readonly<ArtifactRecoveryTuple & {
39
39
  formalPath: string;
40
40
  stagingPath: string;
41
41
  baselinePath: string;
42
- finalPath: string;
42
+ generationsPath: string;
43
43
  baselineSha256: string;
44
44
  baselineSemanticDigest: string;
45
45
  }>;
@@ -191,7 +191,7 @@ function contextPaths(
191
191
  formalPath: path.join(taskDir, tuple.artifact),
192
192
  stagingPath: path.join(root, 'candidate.md'),
193
193
  baselinePath: path.join(root, 'baseline.md'),
194
- finalPath: path.join(root, 'final.md'),
194
+ generationsPath: path.join(root, 'generations'),
195
195
  baselineSha256: '',
196
196
  baselineSemanticDigest: ''
197
197
  };
@@ -221,16 +221,22 @@ function contextWithIntent(context: ArtifactRecoveryContext, intent: ArtifactRec
221
221
 
222
222
  function createIntent(context: ArtifactRecoveryContext, now: number): ArtifactRecoveryIntent {
223
223
  return {
224
- version: 3,
224
+ version: 4,
225
225
  taskId: context.taskId,
226
226
  family: context.family,
227
227
  artifact: context.artifact,
228
228
  round: context.round,
229
- state: 'awaiting-recovery',
229
+ state: 'awaiting-preflight-recovery',
230
230
  baselineSha256: context.baselineSha256,
231
231
  baselineSemanticDigest: context.baselineSemanticDigest,
232
232
  stagingId: context.stagingId,
233
233
  candidateSha256: context.baselineSha256,
234
+ preflightArtifactSha256: null,
235
+ preflightSemanticDigest: null,
236
+ activeGenerationSha256: null,
237
+ activeGenerationSemanticDigest: null,
238
+ pendingGenerationSha256: null,
239
+ pendingGenerationSemanticDigest: null,
234
240
  finalArtifactSha256: null,
235
241
  finalSemanticDigest: null,
236
242
  recoveryOperationId: context.recoveryId,
@@ -267,7 +273,7 @@ export function beginArtifactRecovery(
267
273
  fail('ARTIFACT_RECOVERY_BASELINE_MISMATCH', 'formal artifact does not match the recovery baseline');
268
274
  }
269
275
  const existing = readArtifactRecoveryIntent(prepared.repoRoot, prepared.taskId, prepared.family, prepared.artifact);
270
- if (existing && existing.state !== 'aborted' && existing.state !== 'consumed') {
276
+ if (existing && !['aborted', 'consumed', 'passed'].includes(existing.state)) {
271
277
  fail('ARTIFACT_RECOVERY_CONFLICT', 'an active recovery journal already exists for this artifact');
272
278
  }
273
279
  writeBytes(prepared.baselinePath, baselineBytes);
@@ -287,6 +293,7 @@ export function recordArtifactRecoveryPassed(
287
293
  assertRecoveryId(context.recoveryId);
288
294
  assertRegular(context.formalPath, 'formal artifact');
289
295
  return runLocked(context, 'task-artifact.recovery.record-passed', () => {
296
+ assertRecoveryRoot(context);
290
297
  const current = readStableFileSync(context.formalPath, { maxBytes: MAX_ARTIFACT_BYTES });
291
298
  const prepared = {
292
299
  ...context,
@@ -306,18 +313,18 @@ export function recordArtifactRecoveryPassed(
306
313
  && existing.finalSemanticDigest === prepared.baselineSemanticDigest) {
307
314
  return recoveryContextFromIntent(prepared.repoRoot, prepared.taskDir, existing);
308
315
  }
309
- if (existing && existing.state !== 'aborted') {
316
+ if (existing && existing.state !== 'aborted' && existing.state !== 'passed') {
310
317
  fail('ARTIFACT_RECOVERY_CONFLICT', 'an active recovery journal already exists for this artifact');
311
318
  }
312
- const now = Date.now();
313
- const next: ArtifactRecoveryIntent = {
314
- ...createIntent(prepared, now),
315
- state: 'passed',
316
- candidateSha256: prepared.baselineSha256,
317
- finalArtifactSha256: prepared.baselineSha256,
318
- finalSemanticDigest: prepared.baselineSemanticDigest
319
- };
320
- writeArtifactRecoveryIntent(prepared.repoRoot, next, { expected: existing });
319
+ const base = createIntent(prepared, Date.now());
320
+ writeBytes(prepared.baselinePath, current.bytes);
321
+ writeBytes(prepared.stagingPath, current.bytes);
322
+ writeArtifactRecoveryIntent(prepared.repoRoot, base, { expected: existing });
323
+ const staged = stageArtifactCandidate(prepared, current.bytes, { lockAlreadyHeld: true });
324
+ prepareArtifactRecoveryCommit(prepared, staged.candidateSha256, staged.semanticDigest, { lockAlreadyHeld: true });
325
+ commitArtifactRecovery(prepared, { lockAlreadyHeld: true });
326
+ prepareArtifactRecoveryFinal(prepared, current.bytes, { lockAlreadyHeld: true });
327
+ commitArtifactRecovery(prepared, { lockAlreadyHeld: true });
321
328
  return prepared;
322
329
  }, options.lockAlreadyHeld);
323
330
  }
@@ -330,7 +337,7 @@ export function stageArtifactCandidate(
330
337
  return runLocked(context, 'task-artifact.recovery.stage', () => {
331
338
  assertRecoveryRoot(context);
332
339
  const intent = readIntentForContext(context);
333
- if (intent.state !== 'awaiting-recovery') fail('ARTIFACT_RECOVERY_STATE_INVALID', `cannot stage a candidate from '${intent.state}'`);
340
+ if (intent.state !== 'awaiting-preflight-recovery') fail('ARTIFACT_RECOVERY_STATE_INVALID', `cannot stage a candidate from '${intent.state}'`);
334
341
  const candidateSha256 = sha256Content(candidateBytes.toString('utf8'));
335
342
  const semanticDigest = canonicalSemanticDigest(candidateBytes.toString('utf8'));
336
343
  writeBytes(context.stagingPath, candidateBytes);
@@ -340,6 +347,49 @@ export function stageArtifactCandidate(
340
347
  }, options.lockAlreadyHeld);
341
348
  }
342
349
 
350
+ function generationPath(context: ArtifactRecoveryContext, sha256: string): string {
351
+ assertDigest(sha256, 'generation digest');
352
+ return path.join(context.generationsPath, `${sha256}.md`);
353
+ }
354
+
355
+ /** Persist and verify an immutable digest-addressed snapshot before it is journaled. */
356
+ function writeGeneration(context: ArtifactRecoveryContext, bytes: Buffer, sha256: string, semanticDigest: string): void {
357
+ ensureOwnedDirectoryTree(context.repoRoot, context.generationsPath);
358
+ const target = generationPath(context, sha256);
359
+ if (fs.existsSync(target)) {
360
+ const existing = readStableFileSync(target, { maxBytes: MAX_ARTIFACT_BYTES, expectedSha256: sha256 });
361
+ if (canonicalSemanticDigest(existing.bytes.toString('utf8')) !== semanticDigest) {
362
+ fail('ARTIFACT_RECOVERY_CONFLICT', 'existing generation does not match its semantic digest');
363
+ }
364
+ return;
365
+ }
366
+ writeBytes(target, bytes);
367
+ fs.chmodSync(target, 0o400);
368
+ const sealed = readStableFileSync(target, { maxBytes: MAX_ARTIFACT_BYTES, expectedSha256: sha256 });
369
+ if (canonicalSemanticDigest(sealed.bytes.toString('utf8')) !== semanticDigest) {
370
+ fail('ARTIFACT_RECOVERY_CANDIDATE_MISMATCH', 'generation does not match its validated semantic digest');
371
+ }
372
+ }
373
+
374
+ function publishGeneration(context: ArtifactRecoveryContext, sha256: string, semanticDigest: string, baselineSha256: string): void {
375
+ const target = readStableFileSync(context.formalPath, { maxBytes: MAX_ARTIFACT_BYTES });
376
+ const generation = readStableFileSync(generationPath(context, sha256), { maxBytes: MAX_ARTIFACT_BYTES, expectedSha256: sha256 });
377
+ if (canonicalSemanticDigest(generation.bytes.toString('utf8')) !== semanticDigest) {
378
+ fail('ARTIFACT_RECOVERY_CONFLICT', 'generation semantic digest does not match the journal');
379
+ }
380
+ if (target.sha256 === sha256) return;
381
+ if (target.sha256 !== baselineSha256) fail('ARTIFACT_RECOVERY_CONFLICT', 'formal artifact changed outside the expected generation');
382
+ const publishPath = path.join(path.dirname(context.stagingPath), 'publish.md');
383
+ unlinkIfPresent(publishPath);
384
+ writeBytes(publishPath, generation.bytes);
385
+ fs.chmodSync(publishPath, 0o400);
386
+ fs.renameSync(publishPath, context.formalPath);
387
+ const published = readStableFileSync(context.formalPath, { maxBytes: MAX_ARTIFACT_BYTES, expectedSha256: sha256 });
388
+ if (canonicalSemanticDigest(published.bytes.toString('utf8')) !== semanticDigest) {
389
+ fail('ARTIFACT_RECOVERY_CONFLICT', 'published artifact does not match its generation');
390
+ }
391
+ }
392
+
343
393
  export function prepareArtifactRecoveryCommit(
344
394
  context: ArtifactRecoveryContext,
345
395
  finalSha256: string,
@@ -350,24 +400,21 @@ export function prepareArtifactRecoveryCommit(
350
400
  assertDigest(finalSemanticDigest, 'final semantic digest');
351
401
  return runLocked(context, 'task-artifact.recovery.prepare', () => {
352
402
  const intent = readIntentForContext(context);
353
- if (intent.state !== 'awaiting-recovery') fail('ARTIFACT_RECOVERY_STATE_INVALID', `cannot prepare a commit from '${intent.state}'`);
403
+ if (intent.state !== 'awaiting-preflight-recovery') fail('ARTIFACT_RECOVERY_STATE_INVALID', `cannot prepare a preflight from '${intent.state}'`);
354
404
  assertRecoveryRoot(context);
355
405
  const staged = readStableFileSync(context.stagingPath, { maxBytes: MAX_ARTIFACT_BYTES });
356
406
  if (staged.sha256 !== finalSha256 || canonicalSemanticDigest(staged.bytes.toString('utf8')) !== finalSemanticDigest) {
357
407
  fail('ARTIFACT_RECOVERY_CANDIDATE_MISMATCH', 'staged candidate does not match the finalizer digest');
358
408
  }
359
- writeBytes(context.finalPath, staged.bytes);
360
- fs.chmodSync(context.finalPath, 0o400);
361
- const sealed = readStableFileSync(context.finalPath, { maxBytes: MAX_ARTIFACT_BYTES });
362
- if (sealed.sha256 !== finalSha256 || canonicalSemanticDigest(sealed.bytes.toString('utf8')) !== finalSemanticDigest) {
363
- fail('ARTIFACT_RECOVERY_CANDIDATE_MISMATCH', 'sealed candidate does not match the finalizer digest');
364
- }
409
+ writeGeneration(context, staged.bytes, finalSha256, finalSemanticDigest);
365
410
  const next: ArtifactRecoveryIntent = {
366
411
  ...intent,
367
- state: 'finalize-ready',
412
+ state: 'preflight-ready',
368
413
  candidateSha256: staged.sha256,
369
- finalArtifactSha256: finalSha256,
370
- finalSemanticDigest,
414
+ preflightArtifactSha256: finalSha256,
415
+ preflightSemanticDigest: finalSemanticDigest,
416
+ activeGenerationSha256: finalSha256,
417
+ activeGenerationSemanticDigest: finalSemanticDigest,
371
418
  errorCode: null,
372
419
  errorMessage: null,
373
420
  updatedAt: Date.now()
@@ -378,59 +425,61 @@ export function prepareArtifactRecoveryCommit(
378
425
  }
379
426
 
380
427
  function markCommitStarted(context: ArtifactRecoveryContext, intent: ArtifactRecoveryIntent): ArtifactRecoveryIntent {
381
- if (intent.state === 'commit-started') return intent;
382
- if (intent.state !== 'finalize-ready') fail('ARTIFACT_RECOVERY_STATE_INVALID', `cannot commit from '${intent.state}'`);
383
- const next: ArtifactRecoveryIntent = { ...intent, state: 'commit-started', updatedAt: Date.now() };
428
+ if (intent.state === 'preflight-commit-started' || intent.state === 'commit-started') return intent;
429
+ if (intent.state !== 'preflight-ready' && intent.state !== 'full-finalizer-ready') fail('ARTIFACT_RECOVERY_STATE_INVALID', `cannot commit from '${intent.state}'`);
430
+ const next: ArtifactRecoveryIntent = { ...intent, state: intent.state === 'preflight-ready' ? 'preflight-commit-started' : 'commit-started', updatedAt: Date.now() };
384
431
  writeArtifactRecoveryIntent(context.repoRoot, next, { expected: intent });
385
432
  return next;
386
433
  }
387
434
 
388
435
  export function commitArtifactRecovery(
389
436
  context: ArtifactRecoveryContext,
390
- options: Readonly<{ lockAlreadyHeld?: boolean }> = {}
437
+ options: Readonly<{ lockAlreadyHeld?: boolean; afterPublish?: () => void }> = {}
391
438
  ): ArtifactRecoveryIntent {
392
439
  return runLocked(context, 'task-artifact.recovery.commit', () => {
393
440
  let intent = readIntentForContext(context);
394
441
  if (intent.state === 'passed' || intent.state === 'consumed') return intent;
395
442
  assertRecoveryRoot(context);
396
443
  intent = markCommitStarted(context, intent);
397
- const target = readStableFileSync(context.formalPath, { maxBytes: MAX_ARTIFACT_BYTES });
398
- if (target.sha256 === intent.finalArtifactSha256) {
399
- const passed: ArtifactRecoveryIntent = { ...intent, state: 'passed', updatedAt: Date.now() };
444
+ if (intent.state === 'preflight-commit-started') {
445
+ publishGeneration(context, intent.activeGenerationSha256!, intent.activeGenerationSemanticDigest!, intent.baselineSha256);
446
+ const passed = { ...intent, state: 'preflight-passed' as const, updatedAt: Date.now() };
400
447
  writeArtifactRecoveryIntent(context.repoRoot, passed, { expected: intent });
401
448
  return passed;
402
449
  }
403
- if (target.sha256 !== intent.baselineSha256) fail('ARTIFACT_RECOVERY_CONFLICT', 'formal artifact changed outside the recovery baseline');
404
- const sealed = readStableFileSync(context.finalPath, {
405
- maxBytes: MAX_ARTIFACT_BYTES,
406
- expectedSha256: intent.finalArtifactSha256!
407
- });
408
- if (canonicalSemanticDigest(sealed.bytes.toString('utf8')) !== intent.finalSemanticDigest) {
409
- fail('ARTIFACT_RECOVERY_CONFLICT', 'sealed artifact does not match the staged final semantic digest');
410
- }
411
- // Publish the bytes that were validated above. The sealed path is only a
412
- // recovery input; renaming it directly would reopen a same-uid path swap
413
- // between validation and publication.
414
- const publishPath = path.join(path.dirname(context.finalPath), 'publish.md');
415
- unlinkIfPresent(publishPath);
416
- writeBytes(publishPath, sealed.bytes);
417
- fs.chmodSync(publishPath, 0o400);
418
- const publish = readStableFileSync(publishPath, {
419
- maxBytes: MAX_ARTIFACT_BYTES,
420
- expectedSha256: intent.finalArtifactSha256!
421
- });
422
- if (canonicalSemanticDigest(publish.bytes.toString('utf8')) !== intent.finalSemanticDigest) {
423
- fail('ARTIFACT_RECOVERY_CONFLICT', 'publish artifact does not match the staged final semantic digest');
424
- }
425
- fs.renameSync(publishPath, context.formalPath);
426
- const published = readStableFileSync(context.formalPath, { maxBytes: MAX_ARTIFACT_BYTES });
427
- if (published.sha256 !== intent.finalArtifactSha256) fail('ARTIFACT_RECOVERY_CONFLICT', 'formal artifact does not match the staged final digest');
428
- const passed: ArtifactRecoveryIntent = { ...intent, state: 'passed', updatedAt: Date.now() };
450
+ publishGeneration(context, intent.pendingGenerationSha256!, intent.pendingGenerationSemanticDigest!, intent.activeGenerationSha256!);
451
+ options.afterPublish?.();
452
+ const passed: ArtifactRecoveryIntent = {
453
+ ...intent, state: 'passed', activeGenerationSha256: intent.pendingGenerationSha256,
454
+ activeGenerationSemanticDigest: intent.pendingGenerationSemanticDigest,
455
+ pendingGenerationSha256: null, pendingGenerationSemanticDigest: null, updatedAt: Date.now()
456
+ };
429
457
  writeArtifactRecoveryIntent(context.repoRoot, passed, { expected: intent });
430
458
  return passed;
431
459
  }, options.lockAlreadyHeld);
432
460
  }
433
461
 
462
+ export function prepareArtifactRecoveryFinal(
463
+ context: ArtifactRecoveryContext,
464
+ finalBytes: Buffer,
465
+ options: Readonly<{ lockAlreadyHeld?: boolean }> = {}
466
+ ): ArtifactRecoveryIntent {
467
+ return runLocked(context, 'task-artifact.recovery.finalize', () => {
468
+ const intent = readIntentForContext(context);
469
+ if (intent.state !== 'preflight-passed') fail('ARTIFACT_RECOVERY_STATE_INVALID', `cannot finalize from '${intent.state}'`);
470
+ const finalSha256 = sha256Content(finalBytes.toString('utf8'));
471
+ const finalSemanticDigest = canonicalSemanticDigest(finalBytes.toString('utf8'));
472
+ if (finalSha256 !== intent.activeGenerationSha256) writeGeneration(context, finalBytes, finalSha256, finalSemanticDigest);
473
+ const next: ArtifactRecoveryIntent = {
474
+ ...intent, state: 'full-finalizer-ready', pendingGenerationSha256: finalSha256,
475
+ pendingGenerationSemanticDigest: finalSemanticDigest, finalArtifactSha256: finalSha256,
476
+ finalSemanticDigest, updatedAt: Date.now()
477
+ };
478
+ writeArtifactRecoveryIntent(context.repoRoot, next, { expected: intent });
479
+ return next;
480
+ }, options.lockAlreadyHeld);
481
+ }
482
+
434
483
  export function abortArtifactRecovery(
435
484
  context: ArtifactRecoveryContext,
436
485
  code: string,
@@ -462,19 +511,23 @@ export function reconcileArtifactRecovery(
462
511
  lockAlreadyHeld?: boolean;
463
512
  validateFinal?: () => boolean;
464
513
  restoreBaseline?: boolean;
514
+ afterPublish?: () => void;
465
515
  }> = {}
466
516
  ): ArtifactRecoveryReconcileResult {
467
517
  return runLocked(context, 'task-artifact.recovery.reconcile', () => {
468
518
  const intent = readIntentForContext(context);
469
- if (intent.state !== 'commit-started') return { status: intent.state, intent };
519
+ if (intent.state !== 'preflight-commit-started' && intent.state !== 'commit-started') return { status: intent.state, intent };
470
520
  const target = readStableFileSync(context.formalPath, { maxBytes: MAX_ARTIFACT_BYTES });
471
- if (target.sha256 === intent.finalArtifactSha256 && (options.validateFinal?.() ?? true)) {
472
- const passed: ArtifactRecoveryIntent = { ...intent, state: 'passed', updatedAt: Date.now() };
473
- writeArtifactRecoveryIntent(context.repoRoot, passed, { expected: intent });
474
- return { status: 'passed', intent: passed };
521
+ const expected = intent.state === 'preflight-commit-started' ? intent.activeGenerationSha256! : intent.pendingGenerationSha256!;
522
+ if (target.sha256 === expected && (options.validateFinal?.() ?? true)) {
523
+ const next = intent.state === 'preflight-commit-started'
524
+ ? { ...intent, state: 'preflight-passed' as const, updatedAt: Date.now() }
525
+ : { ...intent, state: 'passed' as const, activeGenerationSha256: intent.pendingGenerationSha256, activeGenerationSemanticDigest: intent.pendingGenerationSemanticDigest, pendingGenerationSha256: null, pendingGenerationSemanticDigest: null, updatedAt: Date.now() };
526
+ writeArtifactRecoveryIntent(context.repoRoot, next, { expected: intent });
527
+ return { status: next.state, intent: next };
475
528
  }
476
- if (target.sha256 === intent.baselineSha256) {
477
- const retried = commitArtifactRecovery(context, { lockAlreadyHeld: true });
529
+ if (target.sha256 === (intent.state === 'preflight-commit-started' ? intent.baselineSha256 : intent.activeGenerationSha256)) {
530
+ const retried = commitArtifactRecovery(context, { lockAlreadyHeld: true, afterPublish: options.afterPublish });
478
531
  return { status: retried.state, intent: retried };
479
532
  }
480
533
  if (options.restoreBaseline) {