@fitlab-ai/agent-infra 0.9.15 → 0.10.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 (184) hide show
  1. package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.js +6 -2
  2. package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +43 -3
  3. package/dist/lib/fs/durable-write.js +2 -0
  4. package/dist/lib/internal/cli-route-inventory.js +1 -1
  5. package/dist/lib/internal/manual-validation.js +17 -8
  6. package/dist/lib/internal/task-artifact.js +1 -1
  7. package/dist/lib/internal/task-lifecycle.js +2 -2
  8. package/dist/lib/internal/task-operation-registry.js +0 -1
  9. package/dist/lib/internal/task-review.js +4 -4
  10. package/dist/lib/platform/issue-comments.js +16 -10
  11. package/dist/lib/platform/issues.js +1 -1
  12. package/dist/lib/platform/pr-change-report.js +5 -4
  13. package/dist/lib/platform/pr-summary.js +64 -32
  14. package/dist/lib/platform/pull-requests.js +3 -3
  15. package/dist/lib/platform/task-identities.js +2 -9
  16. package/dist/lib/platform/verification-sync.js +6 -2
  17. package/dist/lib/pr-review/evidence-grading.js +4 -8
  18. package/dist/lib/sandbox/commands/rm.js +21 -2
  19. package/dist/lib/sandbox/control/server.js +29 -9
  20. package/dist/lib/sandbox/control/state.js +7 -1
  21. package/dist/lib/sandbox/control/workflow-executor.js +4 -14
  22. package/dist/lib/sandbox/index.js +1 -2
  23. package/dist/lib/sandbox/removal.js +116 -1011
  24. package/dist/lib/sandbox/workspace-identity.js +17 -1
  25. package/dist/lib/task/artifact-command.js +6 -28
  26. package/dist/lib/task/artifact-operations.js +16 -230
  27. package/dist/lib/task/artifact-recovery.js +430 -0
  28. package/dist/lib/task/artifact-repair-intent.js +73 -39
  29. package/dist/lib/task/commit-operation.js +31 -4
  30. package/dist/lib/task/control-authority.js +38 -7
  31. package/dist/lib/task/control-recovery.js +14 -2
  32. package/dist/lib/task/create-service.js +1 -8
  33. package/dist/lib/task/delegation-receipts.js +64 -3
  34. package/dist/lib/task/events.js +164 -22
  35. package/dist/lib/task/human-override.js +5 -8
  36. package/dist/lib/task/intermediate-cleanup.js +66 -8
  37. package/dist/lib/task/lifecycle-recovery.js +549 -0
  38. package/dist/lib/task/lifecycle.js +12 -3
  39. package/dist/lib/task/local-artifact-finalization.js +148 -123
  40. package/dist/lib/task/manual-validation-receipt.js +5 -1
  41. package/dist/lib/task/orchestration.js +52 -2
  42. package/dist/lib/task/pr-delivery-fact.js +12 -56
  43. package/dist/lib/task/recovery-warning.js +19 -0
  44. package/dist/lib/task/review-command.js +1 -0
  45. package/dist/lib/task/review-finalization.js +104 -77
  46. package/dist/lib/task/short-id.js +15 -7
  47. package/dist/lib/task/task-execution-lock.js +1 -1
  48. package/dist/lib/task/verification-engine.js +3 -14
  49. package/dist/lib/task/workflow-command.js +0 -1
  50. package/dist/lib/version.js +1 -11
  51. package/dist/lifecycle-build-manifest.json +3 -3
  52. package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +7 -2
  53. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +55 -3
  54. package/lib/fs/durable-write.ts +1 -0
  55. package/lib/internal/cli-route-inventory.ts +1 -1
  56. package/lib/internal/manual-validation.ts +17 -8
  57. package/lib/internal/task-artifact.ts +1 -1
  58. package/lib/internal/task-lifecycle.ts +4 -3
  59. package/lib/internal/task-operation-registry.ts +0 -1
  60. package/lib/internal/task-review.ts +4 -4
  61. package/lib/platform/issue-comments.ts +18 -10
  62. package/lib/platform/issues.ts +1 -1
  63. package/lib/platform/pr-change-report.ts +7 -5
  64. package/lib/platform/pr-summary.ts +64 -32
  65. package/lib/platform/pull-requests.ts +3 -3
  66. package/lib/platform/task-identities.ts +3 -14
  67. package/lib/platform/verification-sync.ts +6 -2
  68. package/lib/pr-review/evidence-grading.ts +4 -9
  69. package/lib/sandbox/commands/rm.ts +34 -2
  70. package/lib/sandbox/control/protocol.ts +5 -0
  71. package/lib/sandbox/control/server.ts +30 -10
  72. package/lib/sandbox/control/state.ts +9 -1
  73. package/lib/sandbox/control/workflow-executor.ts +4 -14
  74. package/lib/sandbox/index.ts +1 -2
  75. package/lib/sandbox/removal.ts +126 -1128
  76. package/lib/sandbox/workspace-identity.ts +19 -3
  77. package/lib/task/artifact-command.ts +8 -27
  78. package/lib/task/artifact-operations.ts +21 -251
  79. package/lib/task/artifact-recovery.ts +519 -0
  80. package/lib/task/artifact-repair-intent.ts +99 -51
  81. package/lib/task/commit-operation.ts +35 -4
  82. package/lib/task/control-authority.ts +47 -14
  83. package/lib/task/control-recovery.ts +13 -1
  84. package/lib/task/create-service.ts +1 -10
  85. package/lib/task/delegation-receipts.ts +73 -2
  86. package/lib/task/events.ts +187 -23
  87. package/lib/task/human-override.ts +5 -8
  88. package/lib/task/intermediate-cleanup.ts +50 -12
  89. package/lib/task/lifecycle-recovery.ts +691 -0
  90. package/lib/task/lifecycle.ts +13 -4
  91. package/lib/task/local-artifact-finalization.ts +176 -132
  92. package/lib/task/manual-validation-receipt.ts +5 -1
  93. package/lib/task/orchestration.ts +65 -0
  94. package/lib/task/pr-delivery-fact.ts +15 -62
  95. package/lib/task/recovery-warning.ts +21 -0
  96. package/lib/task/review-command.ts +1 -0
  97. package/lib/task/review-finalization.ts +106 -87
  98. package/lib/task/short-id.ts +27 -5
  99. package/lib/task/task-execution-lock.ts +1 -1
  100. package/lib/task/verification-engine.ts +3 -17
  101. package/lib/task/verification-types.ts +0 -2
  102. package/lib/task/workflow-command.ts +0 -1
  103. package/lib/version.ts +1 -15
  104. package/package.json +1 -1
  105. package/templates/.agents/rules/create-issue.en.md +1 -1
  106. package/templates/.agents/rules/create-issue.zh-CN.md +1 -1
  107. package/templates/.agents/rules/issue-sync.en.md +4 -2
  108. package/templates/.agents/rules/issue-sync.zh-CN.md +4 -3
  109. package/templates/.agents/rules/local-artifact-repair.en.md +46 -39
  110. package/templates/.agents/rules/local-artifact-repair.zh-CN.md +43 -36
  111. package/templates/.agents/skills/analyze-task/SKILL.en.md +6 -6
  112. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +6 -6
  113. package/templates/.agents/skills/analyze-task/config/verify.en.json +1 -1
  114. package/templates/.agents/skills/analyze-task/config/verify.zh-CN.json +1 -1
  115. package/templates/.agents/skills/block-task/SKILL.en.md +2 -2
  116. package/templates/.agents/skills/block-task/SKILL.zh-CN.md +2 -2
  117. package/templates/.agents/skills/block-task/config/verify.json +1 -1
  118. package/templates/.agents/skills/cancel-task/SKILL.en.md +3 -3
  119. package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +3 -3
  120. package/templates/.agents/skills/cancel-task/config/verify.json +1 -1
  121. package/templates/.agents/skills/code-task/SKILL.en.md +7 -7
  122. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +7 -7
  123. package/templates/.agents/skills/code-task/config/verify.en.json +1 -1
  124. package/templates/.agents/skills/code-task/config/verify.zh-CN.json +1 -1
  125. package/templates/.agents/skills/commit/reference/issue-metadata-sync.en.md +1 -1
  126. package/templates/.agents/skills/commit/reference/issue-metadata-sync.zh-CN.md +1 -1
  127. package/templates/.agents/skills/complete-manual-validation/SKILL.en.md +1 -1
  128. package/templates/.agents/skills/complete-manual-validation/SKILL.zh-CN.md +1 -1
  129. package/templates/.agents/skills/complete-task/SKILL.en.md +2 -2
  130. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +2 -2
  131. package/templates/.agents/skills/complete-task/config/verify.en.json +2 -2
  132. package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +2 -2
  133. package/templates/.agents/skills/complete-task/reference/external-delivery.en.md +1 -1
  134. package/templates/.agents/skills/complete-task/reference/external-delivery.zh-CN.md +1 -1
  135. package/templates/.agents/skills/create-pr/SKILL.en.md +2 -2
  136. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +2 -2
  137. package/templates/.agents/skills/create-pr/config/verify.json +1 -1
  138. package/templates/.agents/skills/create-pr/reference/pr-body-template.en.md +2 -2
  139. package/templates/.agents/skills/create-pr/reference/pr-body-template.zh-CN.md +2 -2
  140. package/templates/.agents/skills/create-task/SKILL.en.md +5 -5
  141. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +5 -5
  142. package/templates/.agents/skills/create-task/config/verify.json +1 -1
  143. package/templates/.agents/skills/import-issue/SKILL.en.md +8 -8
  144. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +8 -8
  145. package/templates/.agents/skills/import-issue/config/verify.json +2 -2
  146. package/templates/.agents/skills/plan-task/SKILL.en.md +4 -4
  147. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +4 -4
  148. package/templates/.agents/skills/plan-task/config/verify.en.json +1 -1
  149. package/templates/.agents/skills/plan-task/config/verify.zh-CN.json +1 -1
  150. package/templates/.agents/skills/restore-task/SKILL.en.md +2 -2
  151. package/templates/.agents/skills/restore-task/SKILL.zh-CN.md +2 -2
  152. package/templates/.agents/skills/restore-task/config/verify.json +1 -1
  153. package/templates/.agents/skills/review-analysis/SKILL.en.md +2 -2
  154. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +2 -2
  155. package/templates/.agents/skills/review-analysis/config/verify.en.json +1 -1
  156. package/templates/.agents/skills/review-analysis/config/verify.zh-CN.json +1 -1
  157. package/templates/.agents/skills/review-analysis/reference/report-template.en.md +7 -7
  158. package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +6 -6
  159. package/templates/.agents/skills/review-code/SKILL.en.md +2 -2
  160. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +2 -2
  161. package/templates/.agents/skills/review-code/config/verify.en.json +1 -1
  162. package/templates/.agents/skills/review-code/config/verify.zh-CN.json +1 -1
  163. package/templates/.agents/skills/review-code/reference/report-template.en.md +12 -12
  164. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +12 -12
  165. package/templates/.agents/skills/review-plan/SKILL.en.md +2 -2
  166. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +2 -2
  167. package/templates/.agents/skills/review-plan/config/verify.en.json +1 -1
  168. package/templates/.agents/skills/review-plan/config/verify.zh-CN.json +1 -1
  169. package/templates/.agents/skills/review-plan/reference/report-template.en.md +7 -7
  170. package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +7 -7
  171. package/templates/.agents/skills/review-pr/config/verify.en.json +1 -1
  172. package/templates/.agents/skills/review-pr/config/verify.zh-CN.json +1 -1
  173. package/templates/.agents/skills/review-pr/reference/evidence-grading.en.md +3 -3
  174. package/templates/.agents/skills/review-pr/reference/evidence-grading.zh-CN.md +3 -3
  175. package/templates/.agents/skills/run-manual-validation/config/verify.en.json +1 -1
  176. package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +1 -1
  177. package/templates/.agents/skills/run-task/SKILL.en.md +1 -1
  178. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +1 -1
  179. package/templates/.agents/templates/task.en.md +1 -1
  180. package/templates/.agents/templates/task.zh-CN.md +1 -1
  181. package/templates/.claude/commands/import-issue.en.md +1 -1
  182. package/templates/.claude/commands/import-issue.zh-CN.md +1 -1
  183. package/templates/.claude/commands/restore-task.en.md +1 -1
  184. package/templates/.claude/commands/restore-task.zh-CN.md +1 -1
@@ -3,36 +3,53 @@ import path from 'node:path';
3
3
  import { isDeepStrictEqual } from 'node:util';
4
4
 
5
5
  import type { ArtifactSchemaFamily } from './artifact-schema.ts';
6
+ import { parseArtifactName } from './artifact-name.ts';
6
7
 
7
- export type ArtifactRepairPhase =
8
+ export type ArtifactRecoveryPhase =
8
9
  | 'orchestration.prepare'
9
10
  | 'artifact.finalize-local'
10
11
  | 'task-event.completed';
11
- export type ArtifactRepairState = 'awaiting-repair' | 'finalize-ready' | 'commit-started' | 'passed' | 'consumed';
12
+ export type ArtifactRecoveryState =
13
+ | 'awaiting-recovery'
14
+ | 'finalize-ready'
15
+ | 'commit-started'
16
+ | 'passed'
17
+ | 'consumed'
18
+ | 'aborted';
12
19
 
13
- export type ArtifactRepairIntent = Readonly<{
14
- version: 2;
20
+ export type ArtifactRecoveryIntent = Readonly<{
21
+ version: 3;
15
22
  taskId: string;
16
23
  family: ArtifactSchemaFamily;
17
24
  artifact: string;
18
- state: ArtifactRepairState;
19
- baselineSemanticDigest: string | null;
20
- artifactSha256: string;
21
- semanticDigest: string;
22
- recoveryOperationId: string | null;
23
- phase: ArtifactRepairPhase | null;
25
+ round: number;
26
+ state: ArtifactRecoveryState;
27
+ baselineSha256: string;
28
+ baselineSemanticDigest: string;
29
+ stagingId: string;
30
+ candidateSha256: string | null;
31
+ finalArtifactSha256: string | null;
32
+ finalSemanticDigest: string | null;
33
+ recoveryOperationId: string;
34
+ phase: ArtifactRecoveryPhase | null;
24
35
  authorityDigest: string | null;
25
36
  requestId: string;
37
+ errorCode: string | null;
38
+ errorMessage: string | null;
26
39
  createdAt: number;
27
40
  updatedAt: number;
28
41
  }>;
29
42
 
30
- function intentRoot(repoRoot: string): string {
43
+ export function artifactRecoveryIntentRoot(repoRoot: string): string {
31
44
  return path.join(repoRoot, '.agents', 'workspace', '.local-artifact-finalization-intents');
32
45
  }
33
46
 
47
+ export function artifactRecoveryRoot(taskDir: string, stagingId: string): string {
48
+ return path.join(taskDir, '.local-artifact-recovery', stagingId);
49
+ }
50
+
34
51
  function intentPath(repoRoot: string, taskId: string, family: ArtifactSchemaFamily, artifact: string): string {
35
- return path.join(intentRoot(repoRoot), `${taskId}-${family}-${artifact}.json`);
52
+ return path.join(artifactRecoveryIntentRoot(repoRoot), `${taskId}-${family}-${artifact}.json`);
36
53
  }
37
54
 
38
55
  function validDigest(value: unknown): value is string {
@@ -43,82 +60,115 @@ function validTaskId(value: unknown): value is string {
43
60
  return typeof value === 'string' && /^TASK-\d{8}-\d{6}$/u.test(value);
44
61
  }
45
62
 
63
+ function validToken(value: unknown): value is string {
64
+ return typeof value === 'string' && value.length > 0 && !/[\r\n]/u.test(value);
65
+ }
66
+
67
+ function validRecoveryId(value: unknown): value is string {
68
+ return typeof value === 'string' && /^[a-f0-9-]{16,64}$/u.test(value);
69
+ }
70
+
71
+ function validArtifact(value: unknown, family: unknown, round: unknown): value is string {
72
+ if (typeof value !== 'string' || path.basename(value) !== value || !value.endsWith('.md')) return false;
73
+ const parsed = parseArtifactName(value);
74
+ return Boolean(parsed && parsed.family === family && parsed.round === round);
75
+ }
76
+
46
77
  function exactKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
47
78
  return Object.keys(value).sort().join(',') === [...keys].sort().join(',');
48
79
  }
49
80
 
50
- export function isArtifactRepairIntent(value: unknown): value is ArtifactRepairIntent {
81
+ export function isArtifactRecoveryIntent(value: unknown): value is ArtifactRecoveryIntent {
51
82
  if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
52
83
  const intent = value as Record<string, unknown>;
53
84
  return exactKeys(intent, [
54
- 'artifact', 'artifactSha256', 'authorityDigest', 'baselineSemanticDigest', 'createdAt',
55
- 'family', 'phase', 'recoveryOperationId', 'requestId', 'semanticDigest', 'state',
56
- 'taskId', 'updatedAt', 'version'
85
+ 'artifact', 'authorityDigest', 'baselineSemanticDigest', 'baselineSha256', 'candidateSha256',
86
+ 'createdAt', 'errorCode', 'errorMessage', 'family', 'finalArtifactSha256', 'finalSemanticDigest',
87
+ 'phase', 'recoveryOperationId', 'requestId', 'round', 'stagingId', 'state', 'taskId', 'updatedAt',
88
+ 'version'
57
89
  ])
58
- && intent.version === 2
90
+ && intent.version === 3
59
91
  && validTaskId(intent.taskId)
60
92
  && typeof intent.family === 'string'
61
93
  && ['analysis', 'review-analysis', 'plan', 'review-plan', 'code', 'review-code'].includes(intent.family)
62
- && typeof intent.artifact === 'string' && intent.artifact.length > 0
63
- && ['awaiting-repair', 'finalize-ready', 'commit-started', 'passed', 'consumed'].includes(String(intent.state))
64
- && (intent.baselineSemanticDigest === null || validDigest(intent.baselineSemanticDigest))
65
- && validDigest(intent.artifactSha256)
66
- && validDigest(intent.semanticDigest)
67
- && (intent.recoveryOperationId === null || (typeof intent.recoveryOperationId === 'string' && /^[a-f0-9-]{16,64}$/u.test(intent.recoveryOperationId)))
94
+ && Number.isSafeInteger(intent.round) && (intent.round as number) > 0
95
+ && validArtifact(intent.artifact, intent.family, intent.round)
96
+ && ['awaiting-recovery', 'finalize-ready', 'commit-started', 'passed', 'consumed', 'aborted'].includes(String(intent.state))
97
+ && validDigest(intent.baselineSha256)
98
+ && validDigest(intent.baselineSemanticDigest)
99
+ && validRecoveryId(intent.stagingId)
100
+ && (intent.candidateSha256 === null || validDigest(intent.candidateSha256))
101
+ && (intent.finalArtifactSha256 === null || validDigest(intent.finalArtifactSha256))
102
+ && (intent.finalSemanticDigest === null || validDigest(intent.finalSemanticDigest))
103
+ && validRecoveryId(intent.recoveryOperationId)
104
+ && intent.stagingId === intent.recoveryOperationId
68
105
  && (intent.phase === null || ['orchestration.prepare', 'artifact.finalize-local', 'task-event.completed'].includes(String(intent.phase)))
69
106
  && (intent.authorityDigest === null || validDigest(intent.authorityDigest))
70
- && typeof intent.requestId === 'string' && intent.requestId.length > 0 && !/[\r\n]/u.test(intent.requestId)
71
- && Number.isSafeInteger(intent.createdAt) && Number.isSafeInteger(intent.updatedAt)
72
- && (intent.state === 'awaiting-repair' ? intent.baselineSemanticDigest === intent.semanticDigest : true);
107
+ && validToken(intent.requestId)
108
+ && (intent.errorCode === null || validToken(intent.errorCode))
109
+ && (intent.errorMessage === null || validToken(intent.errorMessage))
110
+ && Number.isSafeInteger(intent.createdAt)
111
+ && Number.isSafeInteger(intent.updatedAt)
112
+ && (intent.state === 'awaiting-recovery'
113
+ ? intent.finalArtifactSha256 === null && intent.finalSemanticDigest === null
114
+ : true)
115
+ && (intent.state === 'finalize-ready' || intent.state === 'commit-started' || intent.state === 'passed' || intent.state === 'consumed'
116
+ ? validDigest(intent.candidateSha256) && validDigest(intent.finalArtifactSha256) && validDigest(intent.finalSemanticDigest)
117
+ : true)
118
+ && (intent.state === 'aborted' ? intent.errorCode !== null && intent.errorMessage !== null : true);
73
119
  }
74
120
 
75
- function readArtifactRepairIntent(
121
+ export function readArtifactRecoveryIntent(
76
122
  repoRoot: string,
77
123
  taskId: string,
78
124
  family: ArtifactSchemaFamily,
79
125
  artifact: string
80
- ): ArtifactRepairIntent | null {
126
+ ): ArtifactRecoveryIntent | null {
127
+ if (!validTaskId(taskId) || !validArtifact(artifact, family, parseArtifactName(artifact)?.round)) {
128
+ throw new Error('ARTIFACT_RECOVERY_INTENT_INVALID: recovery journal identity is invalid');
129
+ }
81
130
  const target = intentPath(repoRoot, taskId, family, artifact);
82
131
  if (!fs.existsSync(target)) return null;
83
132
  let value: unknown;
84
133
  try { value = JSON.parse(fs.readFileSync(target, 'utf8')) as unknown; }
85
- catch (error) { throw new Error(`ARTIFACT_REPAIR_INTENT_INVALID: ${String(error)}`); }
86
- if (!isArtifactRepairIntent(value) || value.taskId !== taskId || value.family !== family || value.artifact !== artifact) {
87
- throw new Error('ARTIFACT_REPAIR_INTENT_INVALID: repair provenance schema is invalid');
134
+ catch (error) { throw new Error(`ARTIFACT_RECOVERY_INTENT_INVALID: ${String(error)}`); }
135
+ if (!isArtifactRecoveryIntent(value) || value.taskId !== taskId || value.family !== family || value.artifact !== artifact) {
136
+ throw new Error('ARTIFACT_RECOVERY_INTENT_INVALID: recovery journal schema is invalid');
88
137
  }
89
138
  return value;
90
139
  }
91
140
 
92
- export function writeArtifactRepairIntent(
141
+ export function writeArtifactRecoveryIntent(
93
142
  repoRoot: string,
94
- value: ArtifactRepairIntent,
95
- options: Readonly<{ expected?: ArtifactRepairIntent | null }> = {}
143
+ value: unknown,
144
+ options: Readonly<{ expected?: ArtifactRecoveryIntent | null }> = {}
96
145
  ): void {
97
- if (!isArtifactRepairIntent(value)) throw new Error('ARTIFACT_REPAIR_INTENT_INVALID: repair provenance schema is invalid');
98
- const directory = intentRoot(repoRoot);
146
+ if (!isArtifactRecoveryIntent(value)) throw new Error('ARTIFACT_RECOVERY_INTENT_INVALID: recovery journal schema is invalid');
147
+ const intent = value;
148
+ const directory = artifactRecoveryIntentRoot(repoRoot);
99
149
  fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
100
- const target = intentPath(repoRoot, value.taskId, value.family, value.artifact);
150
+ const target = intentPath(repoRoot, intent.taskId, intent.family, intent.artifact);
101
151
  const lock = `${target}.lock`;
102
152
  let descriptor: number;
103
153
  try { descriptor = fs.openSync(lock, 'wx', 0o600); }
104
154
  catch (error) {
105
155
  if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
106
- throw new Error('ARTIFACT_REPAIR_INTENT_CONFLICT: another provenance writer owns the intent');
156
+ throw new Error('ARTIFACT_RECOVERY_INTENT_CONFLICT: another journal writer owns the intent');
107
157
  }
108
158
  throw error;
109
159
  }
110
160
  try {
111
161
  const current = fs.existsSync(target)
112
- ? readArtifactRepairIntent(repoRoot, value.taskId, value.family, value.artifact)
162
+ ? readArtifactRecoveryIntent(repoRoot, intent.taskId, intent.family, intent.artifact)
113
163
  : null;
114
164
  if (!isDeepStrictEqual(current, options.expected ?? null)) {
115
- throw new Error('ARTIFACT_REPAIR_INTENT_CONFLICT: repair provenance changed during finalization');
165
+ throw new Error('ARTIFACT_RECOVERY_INTENT_CONFLICT: recovery journal changed during finalization');
116
166
  }
117
167
  const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
118
- fs.writeFileSync(temporary, `${JSON.stringify(value)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
168
+ fs.writeFileSync(temporary, `${JSON.stringify(intent)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
119
169
  try { fs.renameSync(temporary, target); }
120
170
  catch (error) {
121
- try { fs.unlinkSync(temporary); } catch { /* preserve primary error */ }
171
+ try { fs.unlinkSync(temporary); } catch { /* preserve the primary error */ }
122
172
  throw error;
123
173
  }
124
174
  } finally {
@@ -127,14 +177,12 @@ export function writeArtifactRepairIntent(
127
177
  }
128
178
  }
129
179
 
130
- export function findArtifactRepairIntentsByOperation(
180
+ export function findArtifactRecoveryIntentsByOperation(
131
181
  repoRoot: string,
132
182
  operationId: string
133
- ): readonly ArtifactRepairIntent[] {
134
- if (!operationId || /[\r\n]/u.test(operationId)) {
135
- throw new Error('ARTIFACT_REPAIR_OPERATION_INVALID: recovery operation id is invalid');
136
- }
137
- const directory = intentRoot(repoRoot);
183
+ ): readonly ArtifactRecoveryIntent[] {
184
+ if (!validToken(operationId)) throw new Error('ARTIFACT_RECOVERY_OPERATION_INVALID: recovery operation id is invalid');
185
+ const directory = artifactRecoveryIntentRoot(repoRoot);
138
186
  if (!fs.existsSync(directory)) return Object.freeze([]);
139
187
  return Object.freeze(fs.readdirSync(directory)
140
188
  .filter((name) => name.endsWith('.json'))
@@ -142,11 +190,11 @@ export function findArtifactRepairIntentsByOperation(
142
190
  const target = path.join(directory, name);
143
191
  let value: unknown;
144
192
  try { value = JSON.parse(fs.readFileSync(target, 'utf8')) as unknown; }
145
- catch (error) { throw new Error(`ARTIFACT_REPAIR_INTENT_INVALID: ${String(error)}`); }
146
- if (!isArtifactRepairIntent(value)) throw new Error('ARTIFACT_REPAIR_INTENT_INVALID: repair provenance schema is invalid');
193
+ catch (error) { throw new Error(`ARTIFACT_RECOVERY_INTENT_INVALID: ${String(error)}`); }
194
+ if (!isArtifactRecoveryIntent(value)) throw new Error('ARTIFACT_RECOVERY_INTENT_INVALID: recovery journal schema is invalid');
147
195
  return value;
148
196
  })
149
197
  .filter((value) => value.recoveryOperationId === operationId));
150
198
  }
151
199
 
152
- export { readArtifactRepairIntent };
200
+ export { intentPath as artifactRecoveryIntentPath };
@@ -32,6 +32,8 @@ type CommitOperationInput = Readonly<{
32
32
  agent?: string;
33
33
  mode?: CommitExecutionMode;
34
34
  round?: number;
35
+ /** Permit one retry to rebase a prepared intent's expected tree while HEAD and intent identity remain unchanged. */
36
+ recoverPreparedIntent?: boolean;
35
37
  delivery?: Readonly<{
36
38
  mode: 'local' | 'push';
37
39
  remote: string;
@@ -280,6 +282,20 @@ function checkpointTimestamp(): string {
280
282
  return new Date().toISOString();
281
283
  }
282
284
 
285
+ function samePreparedCheckpointIdentity(
286
+ pending: NonNullable<ReturnType<typeof readCheckpointIntent>>,
287
+ identity: Parameters<typeof checkpointIntentDigest>[0]
288
+ ): boolean {
289
+ return pending.state === 'prepared'
290
+ && pending.taskId === identity.taskId
291
+ && pending.branch === identity.branch
292
+ && pending.mode === identity.mode
293
+ && pending.expectedHead === identity.expectedHead
294
+ && pending.message === identity.message
295
+ && pending.round === identity.round
296
+ && JSON.stringify(pending.paths) === JSON.stringify(identity.paths);
297
+ }
298
+
283
299
  function executeUnlocked(input: CommitOperationInput, task: BoundTask | null, mode: CommitExecutionMode): CommitOperationResult {
284
300
  const inspected = inspectGitWorkflow(input.cwd);
285
301
  if (!inspected.snapshot) return failure(input, mode, task?.taskId ?? null, 'GIT_INSPECT_FAILED', 'Unable to inspect Git repository');
@@ -313,10 +329,25 @@ function executeUnlocked(input: CommitOperationInput, task: BoundTask | null, mo
313
329
  error: { code: 'COMMIT_INTENT_INVALID', message: error instanceof Error ? error.message : String(error) }
314
330
  };
315
331
  }
316
- if (pendingIntent && !sameCheckpointIntent(pendingIntent, identity)) return {
317
- status: 'failed' as const, changed: false, snapshot: inspected.snapshot, operations: [],
318
- error: { code: 'COMMIT_INTENT_CONFLICT', message: 'A different checkpoint intent is still pending for this task' }
319
- };
332
+ if (pendingIntent && !sameCheckpointIntent(pendingIntent, identity)) {
333
+ if (!input.recoverPreparedIntent || !samePreparedCheckpointIdentity(pendingIntent, identity)) return {
334
+ status: 'failed' as const, changed: false, snapshot: inspected.snapshot, operations: [],
335
+ error: { code: 'COMMIT_INTENT_CONFLICT', message: 'A different checkpoint intent is still pending for this task' }
336
+ };
337
+ pendingIntent = {
338
+ ...pendingIntent,
339
+ expectedTree: identity.expectedTree,
340
+ digest: checkpointIntentDigest(identity),
341
+ updatedAt: checkpointTimestamp()
342
+ };
343
+ try { writeCheckpointIntent(task!.repoRoot, pendingIntent); }
344
+ catch (error) {
345
+ return {
346
+ status: 'failed' as const, changed: false, snapshot: inspected.snapshot, operations: [],
347
+ error: { code: 'COMMIT_INTENT_WRITE_FAILED', message: error instanceof Error ? error.message : String(error) }
348
+ };
349
+ }
350
+ }
320
351
  if (!pendingIntent) {
321
352
  const timestamp = checkpointTimestamp();
322
353
  pendingIntent = {
@@ -39,15 +39,21 @@ import {
39
39
  type TaskLifecycleRequest,
40
40
  type TaskLifecycleResult
41
41
  } from './lifecycle.ts';
42
+ import {
43
+ recoverStartedLifecycleUnderLock,
44
+ recoveryFailure,
45
+ type LifecycleRecoveryResult
46
+ } from './lifecycle-recovery.ts';
47
+ import type { LifecycleRecoveryRequest } from './lifecycle-recovery.ts';
42
48
  import { locateActivityLog } from './activity-log.ts';
43
49
  import { resolveTaskRef, TASK_ID_RE } from './resolve-ref.ts';
44
50
  import { TaskExecutionLockError, withTaskExecutionLock } from './task-execution-lock.ts';
45
51
  import { verifyTaskEvent } from './verification.ts';
46
52
  import { createCodexCapabilityStore } from '../agent-clients/adapters/codex-lifecycle/capability-store.ts';
47
53
  import {
48
- findArtifactRepairIntentsByOperation,
49
- writeArtifactRepairIntent,
50
- type ArtifactRepairIntent
54
+ findArtifactRecoveryIntentsByOperation,
55
+ writeArtifactRecoveryIntent,
56
+ type ArtifactRecoveryIntent
51
57
  } from './artifact-repair-intent.ts';
52
58
  import {
53
59
  computeLifecycleBuildIdentity,
@@ -333,7 +339,7 @@ export function queryLifecycleRecoveryOperation(
333
339
  const store = options.capabilityStore ?? createCodexCapabilityStore();
334
340
  const registry = authorityOperationMap.get(store);
335
341
  const known = [...(registry?.values() ?? [])].filter((value) => value.operationId === operationId);
336
- const intents = options.repoRoot ? findArtifactRepairIntentsByOperation(options.repoRoot, operationId) : [];
342
+ const intents = options.repoRoot ? findArtifactRecoveryIntentsByOperation(options.repoRoot, operationId) : [];
337
343
  const capabilities = store.findByRecoveryOperation(operationId);
338
344
  const phaseMap = new Map<string, LifecycleRecoveryOperationQueryV1['phases'][number]>();
339
345
  for (const capability of capabilities) {
@@ -416,7 +422,7 @@ function lifecycleRecoveryEventCommitted(
416
422
  }
417
423
 
418
424
  function lifecycleRecoveryIntentMatches(
419
- intent: ArtifactRepairIntent,
425
+ intent: ArtifactRecoveryIntent,
420
426
  selector: LifecycleAuthorityRequestV1
421
427
  ): boolean {
422
428
  return intent.taskId === selector.taskId
@@ -473,19 +479,19 @@ export function recoverLifecycleRecoveryOperation(
473
479
  if (!phase) {
474
480
  throw lifecycleRecoveryCompensationError('LIFECYCLE_RECOVERY_PHASE_MISSING', 'durable task completion phase is missing');
475
481
  }
476
- const intents = findArtifactRepairIntentsByOperation(options.repoRoot, selector.operationId)
482
+ const intents = findArtifactRecoveryIntentsByOperation(options.repoRoot, selector.operationId)
477
483
  .filter((intent) => intent.taskId === selector.taskId
478
484
  && intent.family === selector.family
479
485
  && intent.artifact === selector.artifact);
480
486
  if (intents.length !== 1) {
481
- throw lifecycleRecoveryCompensationError('LIFECYCLE_RECOVERY_INTENT_INVALID', 'durable artifact repair intent does not identify this task event');
487
+ throw lifecycleRecoveryCompensationError('LIFECYCLE_RECOVERY_INTENT_INVALID', 'durable artifact recovery intent does not identify this task event');
482
488
  }
483
489
  const intent = intents[0]!;
484
490
  if (!lifecycleRecoveryIntentMatches(intent, selector)
485
491
  || intent.phase !== selector.phase
486
492
  || intent.requestId !== selector.lifecycleRequestId
487
493
  || intent.authorityDigest === null) {
488
- throw lifecycleRecoveryCompensationError('LIFECYCLE_RECOVERY_INTENT_INVALID', 'durable artifact repair intent does not match the recovery request');
494
+ throw lifecycleRecoveryCompensationError('LIFECYCLE_RECOVERY_INTENT_INVALID', 'durable artifact recovery intent does not match the recovery request');
489
495
  }
490
496
  if (!lifecycleRecoveryEventCommitted(selector, options.repoRoot)) {
491
497
  throw lifecycleRecoveryCompensationError('LIFECYCLE_RECOVERY_COMMIT_UNCONFIRMED', 'task completion is not durably present; compensation is not allowed');
@@ -499,7 +505,7 @@ export function recoverLifecycleRecoveryOperation(
499
505
  throw lifecycleRecoveryCompensationError('LIFECYCLE_RECOVERY_STATE_INVALID', 'consumed capability has an unconsumed completion phase');
500
506
  }
501
507
  if (intent.state !== 'consumed') {
502
- writeArtifactRepairIntent(options.repoRoot, {
508
+ writeArtifactRecoveryIntent(options.repoRoot, {
503
509
  ...intent,
504
510
  state: 'consumed',
505
511
  updatedAt: (options.now ?? Date.now)()
@@ -565,7 +571,7 @@ export type TaskControlOperation =
565
571
  options?: Omit<OrchestrationOptions, 'repoRoot'>;
566
572
  }>;
567
573
 
568
- export type TaskControlDispatchResult = TaskLifecycleResult | TaskFinalizationResult | OrchestrationResult;
574
+ export type TaskControlDispatchResult = TaskLifecycleResult | LifecycleRecoveryResult | TaskFinalizationResult | OrchestrationResult;
569
575
 
570
576
  export function taskControlOperationKey(operation: TaskControlOperation): string {
571
577
  if (operation.family === 'task-finalization') return 'complete';
@@ -712,12 +718,15 @@ function lifecycleTaskId(request: TaskLifecycleControlRequest, repoRoot: string)
712
718
  async function applyLifecycleWithAuthority(
713
719
  context: TaskControlExecutionContext,
714
720
  request: TaskLifecycleControlRequest
715
- ): Promise<TaskLifecycleResult & { humanOverride?: unknown }> {
721
+ ): Promise<(TaskLifecycleResult | LifecycleRecoveryResult) & { humanOverride?: unknown }> {
716
722
  const conflict = overrideDryRunConflict(request as unknown as Record<string, unknown>);
717
723
  if (conflict) {
718
724
  return taskLifecycleFailure(request, { code: 'LIFECYCLE_PAYLOAD_INVALID', message: conflict.message });
719
725
  }
720
- const execute = async (): Promise<TaskLifecycleResult & { humanOverride?: unknown }> => {
726
+ const execute = async (): Promise<(TaskLifecycleResult | LifecycleRecoveryResult) & { humanOverride?: unknown }> => {
727
+ if (request.intent === 'recover-started') {
728
+ return recoverStartedLifecycleUnderLock(request as LifecycleRecoveryRequest, { repoRoot: context.repoRoot });
729
+ }
721
730
  const lifecycleResult = applyTaskLifecycle(request, { repoRoot: context.repoRoot });
722
731
  if (lifecycleResult.status !== 'failed' || !request.overrideTicket) return lifecycleResult;
723
732
  if (!request.overrideTarget || !request.overrideScope) {
@@ -751,6 +760,9 @@ async function applyLifecycleWithAuthority(
751
760
  );
752
761
  } catch (error) {
753
762
  if (!(error instanceof TaskExecutionLockError)) throw error;
763
+ if (request.intent === 'recover-started') {
764
+ return recoveryFailure(request as LifecycleRecoveryRequest, 'owner-unknown', error.code, error.message);
765
+ }
754
766
  return taskLifecycleFailure(request, { code: error.code, message: error.message }, taskId);
755
767
  }
756
768
  }
@@ -829,7 +841,7 @@ function orchestrationFailure(error: unknown): OrchestrationResult {
829
841
  export function dispatchTaskControlOperation(
830
842
  context: TaskControlExecutionContext,
831
843
  operation: Extract<TaskControlOperation, { family: 'task-lifecycle' }>
832
- ): Promise<TaskLifecycleResult & { humanOverride?: unknown }>;
844
+ ): Promise<(TaskLifecycleResult | LifecycleRecoveryResult) & { humanOverride?: unknown }>;
833
845
  export function dispatchTaskControlOperation(
834
846
  context: TaskControlExecutionContext,
835
847
  operation: Extract<TaskControlOperation, { family: 'task-finalization' }>
@@ -892,7 +904,7 @@ function operationInvalid(message: string): never {
892
904
 
893
905
  const LIFECYCLE_FLAGS = new Set([
894
906
  '--agent', '--reason', '--unblock-condition', '--note', '--alert-number', '--staging-dir', '--issue-number',
895
- '--override-ticket', '--override-target', '--override-scope', '--dry-run'
907
+ '--stage', '--round', '--artifact', '--override-ticket', '--override-target', '--override-scope', '--dry-run'
896
908
  ]);
897
909
 
898
910
  const FINALIZATION_FLAGS = new Set(['--agent']);
@@ -972,8 +984,29 @@ export function parseTaskControlOperation(
972
984
  ...(value(values, '--override-scope') ? { overrideScope: value(values, '--override-scope') } : {}),
973
985
  ...(value(values, '--alert-number') ? { alertNumber: Number(value(values, '--alert-number')) } : {}),
974
986
  ...(value(values, '--issue-number') ? { issueNumber: Number(value(values, '--issue-number')) } : {}),
987
+ ...(value(values, '--stage') ? { stage: value(values, '--stage') } : {}),
988
+ ...(value(values, '--round') ? { round: Number(value(values, '--round')) } : {}),
989
+ ...(value(values, '--artifact') ? { artifact: value(values, '--artifact') } : {}),
975
990
  ...(values['--dry-run'] === true ? { dryRun: true } : {})
976
991
  };
992
+ if (intent === 'recover-started') {
993
+ const recoveryFlags = new Set(['--agent', '--stage', '--round', '--artifact', '--reason']);
994
+ for (const flag of Object.keys(values)) {
995
+ if (!recoveryFlags.has(flag)) operationInvalid(`option '${flag}' is not supported for recover-started`);
996
+ }
997
+ required(values, ['--stage', '--round', '--artifact', '--reason']);
998
+ const stage = value(values, '--stage');
999
+ if (!['analysis', 'review-analysis', 'plan', 'review-plan', 'code', 'review-code'].includes(stage ?? '')) {
1000
+ operationInvalid('--stage must be a supported lifecycle stage');
1001
+ }
1002
+ const round = Number(value(values, '--round'));
1003
+ if (!Number.isSafeInteger(round) || round < 1) operationInvalid('--round must be a positive integer');
1004
+ if (value(values, '--reason')!.includes('\n') || value(values, '--reason')!.includes('\r')) {
1005
+ operationInvalid('--reason must be a single line');
1006
+ }
1007
+ } else if (values['--stage'] !== undefined || values['--round'] !== undefined || values['--artifact'] !== undefined) {
1008
+ operationInvalid('--stage, --round, and --artifact are only supported for recover-started');
1009
+ }
977
1010
  return { family, request: input as unknown as TaskLifecycleControlRequest };
978
1011
  }
979
1012
 
@@ -1,6 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { isCompletionEvidence } from './orchestration.ts';
3
3
  import { TASK_WORKFLOW_OPERATIONS } from './workflow-command.ts';
4
+ import { sameRecoveryWarning } from './recovery-warning.ts';
4
5
 
5
6
  export type ControlRecoveryOutcome = 'not-executed' | 'in-progress' | 'success' | 'failure' | 'unknown' | 'rejected';
6
7
 
@@ -53,7 +54,7 @@ export type ControlRecoveryDecision = Readonly<{
53
54
  reasonCode: string;
54
55
  }>;
55
56
 
56
- const LIFECYCLE_INTENTS = ['block', 'activate', 'cancel', 'complete', 'close-codescan', 'close-dependabot', 'restore'] as const;
57
+ const LIFECYCLE_INTENTS = ['block', 'activate', 'cancel', 'complete', 'close-codescan', 'close-dependabot', 'restore', 'recover-started'] as const;
57
58
  const ORCHESTRATION_INTENTS = [
58
59
  'begin-or-resume', 'route.read', 'route.clean-completion', 'status', 'prepare', 'dispatch',
59
60
  'await-activation', 'recover-prepared', 'hook-start', 'hook-stop', 'advance', 'pause'
@@ -107,6 +108,15 @@ function domainEvidenceMatches(
107
108
  && snapshot.worktreeTree === completion.worktreeTree
108
109
  && lastReviewedCommit === completion.lastReviewedCommit;
109
110
  }
111
+ if (operation.family === 'task-lifecycle' && operation.intent === 'recover-started') {
112
+ const retryRequired = domain.recoveryState === 'retry-required'
113
+ && sameRecoveryWarning(domain.warning, result.warning);
114
+ return result.targetState === 'active'
115
+ && ['applied', 'no-op'].includes(String(result.status))
116
+ && domain.recovery === true
117
+ && domain.targetState === 'active'
118
+ && (domain.recoveryState === 'released' || retryRequired);
119
+ }
110
120
  if (operation.class === 'read-only') return result.changed === false && domain.snapshotValid === true;
111
121
  return true;
112
122
  }
@@ -159,6 +169,8 @@ export function classifySandboxControlRecovery(input: ControlRecoveryInput): Con
159
169
  responseReconstructable: true,
160
170
  reasonCode: input.operation.class === 'route.clean-completion' && result.changed === false
161
171
  ? 'RECOVERY_ROUTE_COMPLETION_NOOP'
172
+ : input.operation.intent === 'recover-started' && input.domain?.recoveryState === 'retry-required'
173
+ ? 'RECOVERY_RELEASE_RETRY_REQUIRED'
162
174
  : 'RECOVERY_TERMINAL_AND_DOMAIN_MATCH'
163
175
  };
164
176
  }
@@ -1,6 +1,3 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
-
4
1
  import { syncPlatformComment } from '../platform/issue-comments.ts';
5
2
  import { createPlatformIssue, syncPlatformIssue } from '../platform/issues.ts';
6
3
  import type { PlatformResult } from '../platform/types.ts';
@@ -190,12 +187,6 @@ function platformOperations(prefix: string, operations: readonly { name: string;
190
187
  }));
191
188
  }
192
189
 
193
- function issueFromTask(repoRoot: string, taskId: string): { number: number; url: string } | null {
194
- const content = fs.readFileSync(path.join(repoRoot, '.agents', 'workspace', 'active', taskId, 'task.md'), 'utf8');
195
- const match = /^issue_number:\s*(\d+)\s*$/m.exec(content);
196
- return match ? { number: Number(match[1]), url: '' } : null;
197
- }
198
-
199
190
  async function createTask(value: unknown, options: CreateTaskOptions): Promise<TaskCreateResult> {
200
191
  const dependencies = { ...DEFAULT_DEPENDENCIES, ...options.dependencies };
201
192
  let candidate: TaskCreateCandidateV1;
@@ -215,7 +206,7 @@ async function createTask(value: unknown, options: CreateTaskOptions): Promise<T
215
206
  const operations: TaskCreateOperation[] = [{
216
207
  name: 'task:local', status: local.status, reasonCode: null
217
208
  }];
218
- let issue = issueFromTask(options.repoRoot, local.task.id);
209
+ let issue: TaskCreateResult['issue'] = null;
219
210
  const created = await dependencies.createIssue(local.task.id, { cwd: options.repoRoot, agent: candidate.agent });
220
211
  operations.push(...platformOperations('platform-create', created.operations));
221
212
  if (created.issue) issue = { number: created.issue.number, url: created.issue.url };
@@ -186,7 +186,8 @@ function isDelegationHostEvidence(value: unknown): value is DelegationHostEviden
186
186
  function hasCurrentCodexEvidence(receipt: DelegationReceipt): boolean {
187
187
  const provenance = receipt.lifecycleProvenance;
188
188
  if (!provenance) return false;
189
- const activated = ['activated', 'stage-completed', 'sealed', 'consumed'].includes(receipt.status);
189
+ const activated = ['activated', 'stage-completed', 'sealed', 'consumed'].includes(receipt.status)
190
+ || (receipt.status === 'aborted' && receipt.activatedAt !== null);
190
191
  if (!activated) return receipt.hostEvidence === null;
191
192
  const host = receipt.hostEvidence;
192
193
  if (
@@ -219,6 +220,12 @@ function hasCurrentCodexEvidence(receipt: DelegationReceipt): boolean {
219
220
  && host.consumer === receipt.id
220
221
  && exactText(host.consumedAt);
221
222
  }
223
+ if (receipt.status === 'aborted') {
224
+ return Number.isSafeInteger(host.stopRevision)
225
+ && (host.stopRevision as number) > host.startRevision
226
+ && host.consumer === `lifecycle-recovery:${receipt.taskId}:${receipt.id}`
227
+ && exactText(host.consumedAt);
228
+ }
222
229
  return host.stopRevision === null && host.consumer === null && host.consumedAt === null;
223
230
  }
224
231
 
@@ -244,7 +251,8 @@ function hasStatusBoundEvidence(receipt: DelegationReceipt): boolean {
244
251
  const dispatchComplete = dispatchFields.every((field) => field !== null);
245
252
  if (!dispatchEmpty && !dispatchComplete) return false;
246
253
 
247
- const beforeActivation = ['prepared', 'aborted', 'expired'].includes(receipt.status);
254
+ const beforeActivation = ['prepared', 'expired'].includes(receipt.status)
255
+ || (receipt.status === 'aborted' && receipt.activatedAt === null);
248
256
  if (beforeActivation) {
249
257
  return receipt.parentId === null
250
258
  && receipt.childId === null
@@ -282,6 +290,21 @@ function hasStatusBoundEvidence(receipt: DelegationReceipt): boolean {
282
290
  && receipt.consumedAt === null;
283
291
  }
284
292
 
293
+ if (receipt.status === 'aborted') {
294
+ const host = receipt.hostEvidence;
295
+ return receipt.agent === null
296
+ && receipt.afterFingerprint === null
297
+ && receipt.changedPaths.length === 0
298
+ && receipt.sealedAt === null
299
+ && receipt.consumedAt === null
300
+ && host !== null
301
+ && host !== undefined
302
+ && Number.isSafeInteger(host.stopRevision)
303
+ && (host.stopRevision as number) > host.startRevision
304
+ && host.consumer === `lifecycle-recovery:${receipt.taskId}:${receipt.id}`
305
+ && exactText(host.consumedAt);
306
+ }
307
+
285
308
  if (
286
309
  !exactText(receipt.agent)
287
310
  || normalizeAgentToken(receipt.agent) !== normalizeAgentToken(receipt.client)
@@ -584,6 +607,53 @@ function abortPreparedDelegation(receipt: DelegationReceipt): ReceiptResult {
584
607
  };
585
608
  }
586
609
 
610
+ function abortActivatedDelegation(
611
+ receipt: DelegationReceipt,
612
+ event: Readonly<{ childId: string; stopRevision: number; consumer: string; consumedAt: string }>
613
+ ): ReceiptResult {
614
+ if (receipt.status !== 'activated') {
615
+ return fail('DELEGATION_STATE_INVALID', `delegation ${receipt.id} is ${receipt.status}, expected activated`);
616
+ }
617
+ if (
618
+ receipt.client !== 'codex'
619
+ || !receipt.lifecycleProvenance
620
+ || receipt.hostEvidence?.kind !== 'codex-lifecycle-v2'
621
+ || typeof receipt.lifecycleProvenance.controllerInstanceDigest !== 'string'
622
+ || typeof receipt.lifecycleProvenance.controlGeneration !== 'string'
623
+ || typeof receipt.hostEvidence.controllerInstanceDigest !== 'string'
624
+ || typeof receipt.hostEvidence.controlGeneration !== 'string'
625
+ ) {
626
+ return fail('DELEGATION_RECOVERY_EVIDENCE_INVALID', 'activated delegation lacks the current Codex controller binding');
627
+ }
628
+ if (
629
+ event.childId !== receipt.childId
630
+ || !Number.isSafeInteger(event.stopRevision)
631
+ || event.stopRevision <= receipt.hostEvidence.startRevision
632
+ || event.consumer !== `lifecycle-recovery:${receipt.taskId}:${receipt.id}`
633
+ || !exactText(event.consumedAt)
634
+ ) {
635
+ return fail('DELEGATION_RECOVERY_EVIDENCE_INVALID', 'recovery stop evidence does not match the activated delegation');
636
+ }
637
+ return {
638
+ ok: true,
639
+ receipt: Object.freeze({
640
+ ...receipt,
641
+ status: 'aborted' as const,
642
+ agent: null,
643
+ afterFingerprint: null,
644
+ changedPaths: Object.freeze([]),
645
+ sealedAt: null,
646
+ consumedAt: null,
647
+ hostEvidence: Object.freeze({
648
+ ...receipt.hostEvidence,
649
+ stopRevision: event.stopRevision,
650
+ consumer: event.consumer,
651
+ consumedAt: event.consumedAt
652
+ })
653
+ })
654
+ };
655
+ }
656
+
587
657
  function completeDelegationStage(
588
658
  receipt: DelegationReceipt,
589
659
  event: Readonly<{ stage: DelegationStage; round: number; artifact: string; agent: string }>
@@ -666,6 +736,7 @@ function consumeDelegation(receipt: DelegationReceipt, options: { now?: () => st
666
736
 
667
737
  export {
668
738
  activateDelegation,
739
+ abortActivatedDelegation,
669
740
  abortPreparedDelegation,
670
741
  completeDelegationStage,
671
742
  consumeDelegation,