@fitlab-ai/agent-infra 0.11.3 → 0.11.5

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 (172) hide show
  1. package/README.md +17 -8
  2. package/README.zh-CN.md +17 -8
  3. package/dist/bin/internal-cli.js +1 -6
  4. package/dist/lib/decide.js +4 -41
  5. package/dist/lib/git/branch-worktree.js +45 -0
  6. package/dist/lib/internal/cli-route-inventory.js +1 -4
  7. package/dist/lib/internal/task-activity.js +7 -40
  8. package/dist/lib/internal/task-artifact.js +1 -1
  9. package/dist/lib/internal/task-event.js +4 -37
  10. package/dist/lib/internal/task-finalization.js +48 -1
  11. package/dist/lib/internal/task-ledger.js +14 -44
  12. package/dist/lib/internal/task-lifecycle.js +1 -1
  13. package/dist/lib/internal/task-operation-registry.js +2 -4
  14. package/dist/lib/internal/task-review.js +5 -37
  15. package/dist/lib/internal/task-verify.js +3 -47
  16. package/dist/lib/internal/task-warning.js +4 -40
  17. package/dist/lib/platform/completion-backfill.js +63 -17
  18. package/dist/lib/platform/pr-summary.js +4 -23
  19. package/dist/lib/platform/verification-sync.js +12 -49
  20. package/dist/lib/sandbox/control/client.js +4 -1
  21. package/dist/lib/sandbox/control/executor.js +12 -4
  22. package/dist/lib/sandbox/control/protocol.js +2 -1
  23. package/dist/lib/sandbox/control/server.js +26 -143
  24. package/dist/lib/sandbox/removal.js +15 -8
  25. package/dist/lib/sandbox/task-cleanup.js +13 -20
  26. package/dist/lib/task/activity-intent.js +1 -2
  27. package/dist/lib/task/artifact-command.js +11 -4
  28. package/dist/lib/task/artifact-lifecycle.js +217 -85
  29. package/dist/lib/task/artifact-operations.js +37 -28
  30. package/dist/lib/task/artifact-receipts.js +4 -1
  31. package/dist/lib/task/artifact-schema.js +8 -7
  32. package/dist/lib/task/artifact-selection.js +25 -0
  33. package/dist/lib/task/capabilities.js +158 -39
  34. package/dist/lib/task/commands/status.js +9 -2
  35. package/dist/lib/task/control-authority.js +12 -168
  36. package/dist/lib/task/decision-intents.js +14 -4
  37. package/dist/lib/task/delivery.js +1 -43
  38. package/dist/lib/task/events.js +68 -73
  39. package/dist/lib/task/finalization-handoff.js +92 -0
  40. package/dist/lib/task/finalization.js +423 -154
  41. package/dist/lib/task/intermediate-cleanup.js +9 -148
  42. package/dist/lib/task/ledger-intents.js +159 -20
  43. package/dist/lib/task/lifecycle-path.js +75 -0
  44. package/dist/lib/task/lifecycle.js +60 -11
  45. package/dist/lib/task/local-artifact-finalization.js +13 -47
  46. package/dist/lib/task/orchestration.js +17 -7
  47. package/dist/lib/task/review-command.js +1 -2
  48. package/dist/lib/task/review-finalization.js +4 -18
  49. package/dist/lib/task/rework-intent.js +70 -4
  50. package/dist/lib/task/snapshot.js +6 -6
  51. package/dist/lib/task/verification-engine.js +25 -30
  52. package/dist/lib/task/verification.js +2 -13
  53. package/dist/lib/task/workflow-warning-intents.js +23 -10
  54. package/dist/lib/task/workspace-snapshot.js +7 -2
  55. package/dist/lib/task/write.js +1 -2
  56. package/dist/lifecycle-build-manifest.json +3 -3
  57. package/lib/decide.ts +5 -35
  58. package/lib/git/branch-worktree.ts +41 -0
  59. package/lib/internal/cli-route-inventory.ts +1 -3
  60. package/lib/internal/task-activity.ts +6 -34
  61. package/lib/internal/task-artifact.ts +1 -1
  62. package/lib/internal/task-event.ts +4 -37
  63. package/lib/internal/task-finalization.ts +47 -1
  64. package/lib/internal/task-ledger.ts +15 -41
  65. package/lib/internal/task-lifecycle.ts +2 -2
  66. package/lib/internal/task-operation-registry.ts +2 -4
  67. package/lib/internal/task-review.ts +5 -31
  68. package/lib/internal/task-verify.ts +3 -39
  69. package/lib/internal/task-warning.ts +3 -32
  70. package/lib/platform/completion-backfill.ts +78 -16
  71. package/lib/platform/pr-summary.ts +4 -25
  72. package/lib/platform/verification-sync.ts +13 -53
  73. package/lib/sandbox/control/client.ts +5 -1
  74. package/lib/sandbox/control/executor.ts +13 -4
  75. package/lib/sandbox/control/protocol.ts +3 -2
  76. package/lib/sandbox/control/server.ts +21 -146
  77. package/lib/sandbox/removal.ts +23 -7
  78. package/lib/sandbox/task-cleanup.ts +12 -20
  79. package/lib/task/activity-intent.ts +1 -2
  80. package/lib/task/artifact-command.ts +14 -4
  81. package/lib/task/artifact-lifecycle.ts +236 -85
  82. package/lib/task/artifact-operations.ts +59 -28
  83. package/lib/task/artifact-receipts.ts +4 -1
  84. package/lib/task/artifact-schema.ts +10 -7
  85. package/lib/task/artifact-selection.ts +53 -0
  86. package/lib/task/capabilities.ts +154 -40
  87. package/lib/task/commands/status.ts +11 -2
  88. package/lib/task/control-authority.ts +17 -189
  89. package/lib/task/decision-intents.ts +11 -4
  90. package/lib/task/delivery.ts +1 -40
  91. package/lib/task/events.ts +73 -74
  92. package/lib/task/finalization-handoff.ts +104 -0
  93. package/lib/task/finalization.ts +388 -122
  94. package/lib/task/intermediate-cleanup.ts +9 -125
  95. package/lib/task/ledger-intents.ts +157 -20
  96. package/lib/task/lifecycle-path.ts +98 -0
  97. package/lib/task/lifecycle.ts +58 -13
  98. package/lib/task/local-artifact-finalization.ts +14 -56
  99. package/lib/task/orchestration.ts +26 -8
  100. package/lib/task/review-command.ts +2 -5
  101. package/lib/task/review-finalization.ts +4 -20
  102. package/lib/task/rework-intent.ts +75 -5
  103. package/lib/task/snapshot.ts +6 -6
  104. package/lib/task/verification-engine.ts +29 -30
  105. package/lib/task/verification.ts +3 -16
  106. package/lib/task/workflow-warning-intents.ts +20 -10
  107. package/lib/task/workspace-snapshot.ts +3 -2
  108. package/lib/task/write.ts +1 -4
  109. package/package.json +1 -1
  110. package/templates/.agents/rules/review-handshake.en.md +4 -4
  111. package/templates/.agents/rules/review-handshake.zh-CN.md +4 -4
  112. package/templates/.agents/skills/analyze-task/SKILL.en.md +21 -5
  113. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +21 -5
  114. package/templates/.agents/skills/code-task/SKILL.en.md +14 -8
  115. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +18 -13
  116. package/templates/.agents/skills/code-task/reference/code-rules.en.md +2 -2
  117. package/templates/.agents/skills/code-task/reference/code-rules.zh-CN.md +2 -2
  118. package/templates/.agents/skills/code-task/reference/dual-mode.en.md +4 -4
  119. package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +4 -4
  120. package/templates/.agents/skills/code-task/reference/report-template.en.md +7 -7
  121. package/templates/.agents/skills/code-task/reference/report-template.zh-CN.md +7 -7
  122. package/templates/.agents/skills/complete-manual-validation/config/verify.en.json +2 -2
  123. package/templates/.agents/skills/complete-manual-validation/config/verify.zh-CN.json +2 -2
  124. package/templates/.agents/skills/complete-manual-validation/reference/report-template.en.md +1 -3
  125. package/templates/.agents/skills/complete-manual-validation/reference/report-template.zh-CN.md +1 -3
  126. package/templates/.agents/skills/complete-task/SKILL.en.md +2 -2
  127. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +2 -2
  128. package/templates/.agents/skills/complete-task/config/verify.en.json +2 -2
  129. package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +2 -2
  130. package/templates/.agents/skills/plan-task/SKILL.en.md +13 -10
  131. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +13 -10
  132. package/templates/.agents/skills/review-analysis/SKILL.en.md +5 -1
  133. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +5 -1
  134. package/templates/.agents/skills/review-analysis/reference/report-template.en.md +3 -3
  135. package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +3 -3
  136. package/templates/.agents/skills/review-code/SKILL.en.md +5 -1
  137. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +5 -1
  138. package/templates/.agents/skills/review-code/reference/report-template.en.md +3 -3
  139. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +3 -3
  140. package/templates/.agents/skills/review-plan/SKILL.en.md +5 -1
  141. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +5 -1
  142. package/templates/.agents/skills/review-plan/reference/report-template.en.md +3 -3
  143. package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +3 -3
  144. package/templates/.agents/skills/review-pr/config/verify.en.json +3 -1
  145. package/templates/.agents/skills/review-pr/config/verify.zh-CN.json +3 -1
  146. package/templates/.agents/skills/review-pr/reference/report-template.en.md +3 -3
  147. package/templates/.agents/skills/review-pr/reference/report-template.zh-CN.md +3 -3
  148. package/templates/.agents/skills/run-manual-validation/config/verify.en.json +2 -2
  149. package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +2 -2
  150. package/templates/.agents/skills/run-manual-validation/reference/report-template.en.md +2 -2
  151. package/templates/.agents/skills/run-manual-validation/reference/report-template.zh-CN.md +2 -2
  152. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +18 -2
  153. package/templates/.agents/templates/task.en.md +2 -2
  154. package/templates/.agents/templates/task.zh-CN.md +2 -2
  155. package/templates/.agents/workflows/bug-fix.en.yaml +11 -6
  156. package/templates/.agents/workflows/bug-fix.zh-CN.yaml +11 -6
  157. package/templates/.agents/workflows/feature-development.en.yaml +11 -6
  158. package/templates/.agents/workflows/feature-development.zh-CN.yaml +11 -6
  159. package/templates/.agents/workflows/refactoring.en.yaml +11 -6
  160. package/templates/.agents/workflows/refactoring.zh-CN.yaml +11 -6
  161. package/dist/lib/internal/task-override.js +0 -138
  162. package/dist/lib/sandbox/control/finalization-response.js +0 -33
  163. package/dist/lib/task/artifact-recovery.js +0 -476
  164. package/dist/lib/task/artifact-repair-intent.js +0 -181
  165. package/dist/lib/task/guard-override.js +0 -31
  166. package/dist/lib/task/human-override.js +0 -1259
  167. package/lib/internal/task-override.ts +0 -144
  168. package/lib/sandbox/control/finalization-response.ts +0 -35
  169. package/lib/task/artifact-recovery.ts +0 -572
  170. package/lib/task/artifact-repair-intent.ts +0 -227
  171. package/lib/task/guard-override.ts +0 -50
  172. package/lib/task/human-override.ts +0 -1522
@@ -4,12 +4,13 @@ import { createHash, randomUUID } from 'node:crypto';
4
4
 
5
5
  import { syncPlatformComment } from '../platform/issue-comments.ts';
6
6
  import { canonicalizeSummaryBody } from '../platform/comment-safety.ts';
7
- import { backfillCompletionComments } from '../platform/completion-backfill.ts';
7
+ import { backfillCompletionComments, inspectCompletionBackfillEligibility } from '../platform/completion-backfill.ts';
8
8
  import type { PlatformResult } from '../platform/types.ts';
9
9
  import { taskIssueIdentity } from '../platform/task-identities.ts';
10
10
  import { parseTaskFrontmatter } from './frontmatter.ts';
11
11
  import {
12
12
  applyTaskLifecycle,
13
+ inspectTaskLifecycleProgress,
13
14
  type TaskLifecycleOptions,
14
15
  type TaskLifecycleRequest,
15
16
  type TaskLifecycleResult
@@ -25,6 +26,9 @@ import {
25
26
  type OperationWarning,
26
27
  type OperationWarningSeverity
27
28
  } from './operation-outcome.ts';
29
+ import { readTaskFinalizationHandoff } from './finalization-handoff.ts';
30
+ import { readSandboxControlManifest } from '../sandbox/control/lifecycle.ts';
31
+ import { validateSandboxControlIdentity } from '../sandbox/control/identity-sentinel.ts';
28
32
 
29
33
  const RECEIPT_VERSION = 4 as const;
30
34
  const FINALIZATION_STEPS = ['lifecycle', 'task-comment', 'verification', 'summary'] as const;
@@ -55,11 +59,14 @@ type TaskFinalizationRequest = Readonly<{
55
59
  taskRef: string;
56
60
  intent: 'complete';
57
61
  agent: string;
62
+ handoffSha256?: string;
58
63
  }>;
59
64
 
60
65
  type TaskFinalizationOptions = Readonly<{
61
66
  repoRoot: string;
62
67
  controlBinding?: Readonly<{ generation: string; requestId: string }>;
68
+ handoffDirectory?: string;
69
+ manifestPath?: string;
63
70
  metadataProvider?: TaskLifecycleOptions['metadataProvider'];
64
71
  lifecycle?: typeof applyTaskLifecycle;
65
72
  backfill?: typeof backfillCompletionComments;
@@ -101,7 +108,7 @@ type TaskCommentSyncOutcome = Readonly<{
101
108
  }>;
102
109
 
103
110
  type TaskFinalizationResult = Readonly<{
104
- status: 'completed' | 'failed' | 'blocked';
111
+ status: 'prepared' | 'completed' | 'failed' | 'blocked';
105
112
  changed: boolean;
106
113
  taskId: string | null;
107
114
  backfill: TaskFinalizationStep | null;
@@ -111,7 +118,7 @@ type TaskFinalizationResult = Readonly<{
111
118
  summary: TaskFinalizationStep | null;
112
119
  completedSteps: readonly FinalizationStep[];
113
120
  pendingSteps: readonly FinalizationStep[];
114
- result: 'completed' | 'completed_with_warnings' | 'failed' | 'blocked';
121
+ result: 'prepared' | 'completed' | 'completed_with_warnings' | 'failed' | 'blocked';
115
122
  warnings: readonly OperationWarning[];
116
123
  error: FinalizationError | null;
117
124
  }>;
@@ -198,20 +205,32 @@ function validateReceipt(value: unknown, taskId: string): TaskFinalizationReceip
198
205
  const receipt = value as Record<string, unknown>;
199
206
  const controlBinding = receipt.controlBinding as Record<string, unknown> | null | undefined;
200
207
  const states = ['pending', 'done', 'skipped'];
208
+ const requiredKeys = [
209
+ 'version', 'taskId', 'intent', 'receiptId', 'revision', 'lifecycle', 'taskComment',
210
+ 'verification', 'summary', 'warningProjection', 'warnings', 'updatedAt', 'lastError'
211
+ ];
212
+ if (receipt.controlBinding !== undefined) requiredKeys.push('controlBinding');
201
213
  if (
214
+ Object.keys(receipt).sort().join('\0') !== requiredKeys.sort().join('\0')
215
+ ||
202
216
  receipt.version !== RECEIPT_VERSION || receipt.taskId !== taskId || receipt.intent !== 'complete'
203
217
  || typeof receipt.receiptId !== 'string' || receipt.receiptId.length === 0
204
218
  || !Number.isSafeInteger(receipt.revision) || Number(receipt.revision) < 0
205
- || !states.includes(String(receipt.lifecycle))
206
- || !states.includes(String(receipt.taskComment))
207
- || !states.includes(String(receipt.verification))
208
- || !states.includes(String(receipt.summary))
209
- || !['pending', 'done'].includes(String(receipt.warningProjection))
219
+ || !states.includes(receipt.lifecycle as string)
220
+ || !states.includes(receipt.taskComment as string)
221
+ || !states.includes(receipt.verification as string)
222
+ || !states.includes(receipt.summary as string)
223
+ || !['pending', 'done'].includes(receipt.warningProjection as string)
210
224
  || !Array.isArray(receipt.warnings) || receipt.warnings.some((warning) => !validWarning(warning))
211
- || typeof receipt.updatedAt !== 'string'
212
- || (receipt.lastError !== null && (typeof receipt.lastError !== 'object' || Array.isArray(receipt.lastError)))
225
+ || typeof receipt.updatedAt !== 'string' || receipt.updatedAt.length === 0
226
+ || (receipt.lastError !== null && (typeof receipt.lastError !== 'object' || Array.isArray(receipt.lastError)
227
+ || Object.keys(receipt.lastError).sort().join('\0') !== ['code', 'message', 'retryable'].join('\0')
228
+ || typeof (receipt.lastError as Record<string, unknown>).code !== 'string'
229
+ || typeof (receipt.lastError as Record<string, unknown>).message !== 'string'
230
+ || typeof (receipt.lastError as Record<string, unknown>).retryable !== 'boolean'))
213
231
  || (receipt.controlBinding !== undefined && (!controlBinding || typeof controlBinding !== 'object'
214
232
  || Array.isArray(controlBinding)
233
+ || Object.keys(controlBinding).sort().join('\0') !== ['generation', 'requestId'].join('\0')
215
234
  || typeof controlBinding.generation !== 'string' || controlBinding.generation.length === 0
216
235
  || typeof controlBinding.requestId !== 'string' || !/^[a-f0-9-]{16,64}$/u.test(controlBinding.requestId)))
217
236
  ) throw new Error('receipt schema is invalid');
@@ -243,6 +262,14 @@ function writeReceipt(repoRoot: string, receipt: TaskFinalizationReceipt): void
243
262
  }
244
263
  }
245
264
 
265
+ export function bindTaskFinalizationReceipt(
266
+ repoRoot: string, taskId: string, controlBinding: Readonly<{ generation: string; requestId: string }>
267
+ ): TaskFinalizationReceipt {
268
+ const receipt = readReceipt(path.resolve(repoRoot), taskId);
269
+ if (!receipt) throw new Error('TASK_FINALIZATION_PREPARATION_REQUIRED');
270
+ return updateReceipt(path.resolve(repoRoot), receipt, { controlBinding, lastError: null });
271
+ }
272
+
246
273
  function updateReceipt(
247
274
  repoRoot: string,
248
275
  receipt: TaskFinalizationReceipt,
@@ -314,7 +341,7 @@ function verificationFailure(result: TaskVerificationResult): FinalizationError
314
341
  }
315
342
 
316
343
  function warningFromError(
317
- step: FinalizationStep,
344
+ step: string,
318
345
  error: FinalizationError,
319
346
  severity: OperationWarningSeverity = 'ACTION_REQUIRED'
320
347
  ): OperationWarning {
@@ -357,6 +384,76 @@ function resolveStepWarnings(receipt: TaskFinalizationReceipt, step: Finalizatio
357
384
  : warning);
358
385
  }
359
386
 
387
+ function verificationWarnings(result: TaskVerificationResult): readonly OperationWarning[] {
388
+ const warnings: OperationWarning[] = [];
389
+ for (const invocation of result.invocations) {
390
+ const checks = Array.isArray(invocation.payload.checks) ? invocation.payload.checks : [invocation.payload];
391
+ for (const value of checks) {
392
+ if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
393
+ const check = value as Record<string, unknown>;
394
+ const status = check.effectiveStatus ?? check.status;
395
+ const target = typeof check.checkId === 'string' && check.checkId ? check.checkId : typeof check.type === 'string' ? check.type : null;
396
+ if (!target || status === 'pass') continue;
397
+ const blocked = status === 'blocked';
398
+ const message = [check.reason, check.message, check.action]
399
+ .filter((item): item is string => typeof item === 'string' && item.length > 0)
400
+ .join(' - ');
401
+ warnings.push({
402
+ step: 'verification', target, code: blocked ? 'CHECK_BLOCKED' : 'CHECK_FAILED',
403
+ retryable: blocked, severity: 'ACTION_REQUIRED',
404
+ message: message || `verification ${String(status)}`
405
+ });
406
+ }
407
+ }
408
+ return mergeOperationWarnings(warnings);
409
+ }
410
+
411
+ function applyVerificationWarnings(
412
+ receipt: TaskFinalizationReceipt,
413
+ result: TaskVerificationResult
414
+ ): readonly FinalizationWarning[] {
415
+ if (result.status === 'pass') return resolveStepWarnings(receipt, 'verification');
416
+ const observed = new Map<string, 'pass' | OperationWarning>();
417
+ for (const invocation of result.invocations) {
418
+ const checks = Array.isArray(invocation.payload.checks) ? invocation.payload.checks : [invocation.payload];
419
+ for (const value of checks) {
420
+ if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
421
+ const check = value as Record<string, unknown>;
422
+ const target = typeof check.checkId === 'string' && check.checkId ? check.checkId : typeof check.type === 'string' ? check.type : null;
423
+ if (!target) continue;
424
+ const status = check.effectiveStatus ?? check.status;
425
+ if (status === 'pass') observed.set(target, 'pass');
426
+ }
427
+ }
428
+ for (const warning of verificationWarnings(result)) observed.set(warning.target, warning);
429
+ if (observed.size === 0) {
430
+ return replaceWarning(receipt, warningFromError('verification', verificationFailure(result)), 'open');
431
+ }
432
+ let warnings = [...receipt.warnings];
433
+ for (const [target, current] of observed) {
434
+ if (current === 'pass') {
435
+ warnings = warnings.map((warning) => warning.step === 'verification' && warning.target === target && warning.status === 'open'
436
+ ? { ...warning, status: 'resolved', resolvedAt: now() }
437
+ : warning);
438
+ continue;
439
+ }
440
+ warnings = warnings.map((warning) => warning.step === 'verification' && warning.target === target
441
+ && warning.status === 'open' && warning.code !== current.code
442
+ ? { ...warning, status: 'resolved', resolvedAt: now() }
443
+ : warning);
444
+ const key = warningKey(current);
445
+ warnings = [...warnings.filter((warning) => warningKey(warning) !== key), warningRecord(current, 'open')];
446
+ }
447
+ return warnings;
448
+ }
449
+
450
+ function shouldRunBackfill(receipt: TaskFinalizationReceipt): boolean {
451
+ return receipt.lifecycle !== 'done'
452
+ || receipt.taskComment === 'pending'
453
+ || receipt.verification === 'pending'
454
+ || receipt.warnings.some((warning) => warning.step === 'backfill' && warning.status === 'open');
455
+ }
456
+
360
457
  function issueCapability(receipt: TaskFinalizationReceipt, scope: Exclude<FinalizationStep, 'lifecycle'> | 'warning-projection'): FinalizationCapability {
361
458
  return {
362
459
  receiptId: receipt.receiptId,
@@ -374,6 +471,15 @@ function capabilityError(code: 'FINALIZATION_CAPABILITY_STALE' | 'FINALIZATION_S
374
471
  return error;
375
472
  }
376
473
 
474
+ function hasPreparedTaskDocument(repoRoot: string, taskId: string): boolean {
475
+ const resolved = resolveTaskRef(taskId, { repoRoot });
476
+ if (!resolved.ok) return false;
477
+ if (resolved.state === 'completed') return true;
478
+ if (resolved.state !== 'active') return false;
479
+ try { return parseTaskFrontmatter(fs.readFileSync(resolved.taskMdPath, 'utf8')).status === 'completed'; }
480
+ catch { return false; }
481
+ }
482
+
377
483
  function mutationKeys(value: FinalizationMutation): string[] {
378
484
  return Object.keys(value).sort();
379
485
  }
@@ -451,9 +557,8 @@ function applyFinalizationReceiptMutationUnderLock(
451
557
  mutation: FinalizationMutation,
452
558
  consumed: Set<string>
453
559
  ): TaskFinalizationReceipt {
454
- const resolved = resolveTaskRef(receipt.taskId, { repoRoot });
455
- if (!resolved.ok || resolved.state !== 'completed') {
456
- throw capabilityError('FINALIZATION_SCOPE_INVALID', 'finalization capability requires a completed task');
560
+ if (!hasPreparedTaskDocument(repoRoot, receipt.taskId)) {
561
+ throw capabilityError('FINALIZATION_SCOPE_INVALID', 'finalization capability requires an active prepared task or a completed task');
457
562
  }
458
563
  const current = readReceipt(repoRoot, receipt.taskId);
459
564
  if (!current || current.receiptId !== receipt.receiptId || current.revision !== receipt.revision) {
@@ -471,11 +576,10 @@ function applyFinalizationReceiptMutation(
471
576
  capability: FinalizationCapability,
472
577
  mutation: FinalizationMutation
473
578
  ): TaskFinalizationReceipt {
474
- const resolved = resolveTaskRef(receipt.taskId, { repoRoot });
475
- if (!resolved.ok || resolved.state !== 'completed') {
476
- throw capabilityError('FINALIZATION_SCOPE_INVALID', 'finalization capability requires a completed task');
579
+ if (!hasPreparedTaskDocument(repoRoot, receipt.taskId)) {
580
+ throw capabilityError('FINALIZATION_SCOPE_INVALID', 'finalization capability requires an active prepared task or a completed task');
477
581
  }
478
- return withTaskExecutionLock(repoRoot, resolved.taskId, 'task-finalization.receipt-mutation', () =>
582
+ return withTaskExecutionLock(repoRoot, receipt.taskId, 'task-finalization.receipt-mutation', () =>
479
583
  applyFinalizationReceiptMutationUnderLock(repoRoot, receipt, capability, mutation, new Set<string>())
480
584
  );
481
585
  }
@@ -489,7 +593,10 @@ function reconcileWarningProjection(repoRoot: string, taskId: string, receipt: T
489
593
  const warnings = [...new Map(receipt.warnings.map((warning) => [warningKey(warning), warning])).values()];
490
594
  for (const warning of warnings) {
491
595
  try {
492
- const projected = projectFinalizationWarning(taskId, warning, { repoRoot });
596
+ const superseded = warning.status === 'resolved' && receipt.warnings.some((item) =>
597
+ item.status === 'open' && item.step === warning.step && item.target === warning.target && item.code !== warning.code
598
+ );
599
+ const projected = projectFinalizationWarning(taskId, warning, { repoRoot, resolution: superseded ? 'superseded' : 'passed' });
493
600
  if (projected.status === 'failed') {
494
601
  const detail: FinalizationError = {
495
602
  code: projected.error?.code || 'FINALIZATION_WARNING_PROJECTION_FAILED',
@@ -595,7 +702,11 @@ async function syncPendingSummary(input: {
595
702
  let { receipt } = input;
596
703
  try {
597
704
  const resolved = resolveTaskRef(input.taskId, { repoRoot: input.repoRoot });
598
- if (!resolved.ok || resolved.state !== 'completed') throw Object.assign(new Error('completed task is unavailable for summary sync'), { code: 'SUMMARY_STAGING_INVALID' });
705
+ if (!resolved.ok || !hasPreparedTaskDocument(input.repoRoot, input.taskId)) throw Object.assign(new Error('prepared task is unavailable for summary sync'), { code: 'SUMMARY_STAGING_INVALID' });
706
+ if (!fs.existsSync(path.join(resolved.taskDir, '.delivery-summary.json'))) {
707
+ receipt = updateReceipt(input.repoRoot, receipt, { summary: 'skipped', lastError: null });
708
+ return { receipt, step: { status: 'skipped', changed: false, error: null }, changed: false, error: null };
709
+ }
599
710
  if (!taskIssueIdentity(parseTaskFrontmatter(fs.readFileSync(resolved.taskMdPath, 'utf8')))) {
600
711
  receipt = updateReceipt(input.repoRoot, receipt, {
601
712
  summary: 'skipped', lastError: null
@@ -676,7 +787,25 @@ function terminalResult(
676
787
  };
677
788
  }
678
789
 
679
- async function applyUnderLock(
790
+ function preparedResult(
791
+ taskId: string,
792
+ receipt: TaskFinalizationReceipt,
793
+ steps: Partial<Pick<TaskFinalizationResult, 'backfill' | 'lifecycle' | 'taskComment' | 'verification' | 'summary'>>,
794
+ changed: boolean
795
+ ): TaskFinalizationResult {
796
+ return {
797
+ status: 'prepared', changed, taskId,
798
+ backfill: steps.backfill ?? null,
799
+ lifecycle: steps.lifecycle ?? null,
800
+ taskComment: steps.taskComment ?? null,
801
+ verification: steps.verification ?? null,
802
+ summary: steps.summary ?? null,
803
+ completedSteps: completedSteps(receipt), pendingSteps: pendingSteps(receipt),
804
+ result: 'prepared', warnings: openWarnings(receipt), error: null
805
+ };
806
+ }
807
+
808
+ async function prepareUnderLock(
680
809
  request: TaskFinalizationRequest,
681
810
  taskId: string,
682
811
  options: TaskFinalizationOptions
@@ -696,10 +825,16 @@ async function applyUnderLock(
696
825
  } catch (error) {
697
826
  return failed(taskId, errorOf(error, 'TASK_FINALIZATION_RECEIPT_INVALID'));
698
827
  }
699
- if (options.controlBinding && (
700
- receipt.controlBinding?.generation !== options.controlBinding.generation
701
- || receipt.controlBinding.requestId !== options.controlBinding.requestId
702
- )) receipt = updateReceipt(repoRoot, receipt, { controlBinding: options.controlBinding });
828
+ if (options.controlBinding && (receipt.controlBinding?.generation !== options.controlBinding.generation
829
+ || receipt.controlBinding.requestId !== options.controlBinding.requestId)) {
830
+ const state = resolveTaskRef(taskId, { repoRoot });
831
+ if (!(receipt.lifecycle === 'done' && state.ok && state.state === 'completed')) {
832
+ return failed(taskId, {
833
+ code: 'TASK_FINALIZATION_RECOVERY_PROOF_UNAVAILABLE',
834
+ message: 'canonical receipt is not bound to the current sandbox request', retryable: false
835
+ });
836
+ }
837
+ }
703
838
 
704
839
  const preflightState = resolveTaskRef(taskId, { repoRoot });
705
840
  if (options.preflight && preflightState.ok && preflightState.state === 'active') {
@@ -725,7 +860,18 @@ async function applyUnderLock(
725
860
 
726
861
  let changed = false;
727
862
  let backfillResult: TaskFinalizationStep | null = null;
728
- try {
863
+ let runBackfill = shouldRunBackfill(receipt);
864
+ if (!runBackfill && receipt.lifecycle === 'done') {
865
+ const eligibility = inspectCompletionBackfillEligibility(taskId, { cwd: repoRoot });
866
+ if (eligibility.status === 'failed') return failed(taskId, eligibility.error!, {
867
+ completedSteps: completedSteps(receipt), pendingSteps: pendingSteps(receipt), warnings: openWarnings(receipt)
868
+ });
869
+ runBackfill = eligibility.eligible;
870
+ }
871
+ if (receipt.lifecycle === 'done' && runBackfill && receipt.summary === 'done') {
872
+ receipt = updateReceipt(repoRoot, receipt, { summary: 'pending' });
873
+ }
874
+ if (runBackfill) try {
729
875
  const result = await backfill(taskId, { agent: request.agent, cwd: repoRoot });
730
876
  backfillResult = commentStep(result);
731
877
  const issueNotLinked = result.status === 'no-op' && result.error?.code === 'ISSUE_NOT_LINKED';
@@ -733,66 +879,57 @@ async function applyUnderLock(
733
879
  const detail = backfillResult.error ?? {
734
880
  code: 'COMPLETION_BACKFILL_FAILED', message: 'completion artifact backfill did not reach a successful terminal result', retryable: true
735
881
  };
736
- return failed(taskId, detail, {
882
+ if (receipt.lifecycle !== 'done') return failed(taskId, detail, {
737
883
  backfill: backfillResult,
738
884
  completedSteps: completedSteps(receipt),
739
885
  pendingSteps: pendingSteps(receipt)
740
886
  });
887
+ const warnings = replaceWarning(receipt, warningFromError('backfill', detail), 'open');
888
+ receipt = updateReceipt(repoRoot, receipt, { warningProjection: 'pending', warnings, lastError: detail });
889
+ receipt = reconcileWarningProjection(repoRoot, taskId, receipt, consumedCapabilities);
890
+ if (!detail.retryable) return failed(taskId, detail, {
891
+ changed, backfill: backfillResult, completedSteps: completedSteps(receipt),
892
+ pendingSteps: pendingSteps(receipt), warnings: openWarnings(receipt)
893
+ });
894
+ return terminalResult(taskId, receipt, { backfill: backfillResult }, changed, detail);
741
895
  }
742
896
  changed = result.changed;
897
+ if (receipt.warnings.some((warning) => warning.step === 'backfill' && warning.status === 'open')) {
898
+ const warnings = receipt.warnings.map((warning) => warning.step === 'backfill' && warning.status === 'open'
899
+ ? { ...warning, status: 'resolved' as const, resolvedAt: now() }
900
+ : warning);
901
+ receipt = updateReceipt(repoRoot, receipt, { warningProjection: 'pending', warnings, lastError: null });
902
+ }
743
903
  if (result.changed && receipt.taskComment !== 'pending') {
744
904
  receipt = updateReceipt(repoRoot, receipt, { taskComment: 'pending' });
745
905
  }
746
906
  } catch (error) {
747
907
  const detail = errorOf(error, 'COMPLETION_BACKFILL_FAILED', true);
908
+ if (receipt.lifecycle === 'done') {
909
+ const warnings = replaceWarning(receipt, warningFromError('backfill', detail), 'open');
910
+ receipt = updateReceipt(repoRoot, receipt, { warningProjection: 'pending', warnings, lastError: detail });
911
+ receipt = reconcileWarningProjection(repoRoot, taskId, receipt, consumedCapabilities);
912
+ return terminalResult(taskId, receipt, {
913
+ backfill: { status: 'blocked', changed: false, error: detail }
914
+ }, changed, detail);
915
+ }
748
916
  return failed(taskId, detail, {
749
917
  backfill: { status: detail.retryable ? 'blocked' : 'failed', changed: false, error: detail },
750
918
  completedSteps: completedSteps(receipt),
751
919
  pendingSteps: pendingSteps(receipt)
752
920
  });
753
- }
921
+ } else backfillResult = { status: 'no-op', changed: false, error: null };
754
922
 
755
923
  let lifecycleResult: TaskFinalizationStep | null = null;
756
924
  try {
757
925
  const resolved = resolveTaskRef(taskId, { repoRoot });
758
- const registry = resolved.ok && resolved.state === 'completed' ? inspectShortIdRegistry(repoRoot) : null;
759
- if (registry && registry.status !== 'valid') {
760
- const detail: FinalizationError = {
761
- code: 'TASK_FINALIZATION_SHORT_ID_REGISTRY_UNAVAILABLE',
762
- message: `cannot verify canonical short-id registry: ${registry.error.code}: ${registry.error.message}`,
763
- retryable: false
764
- };
765
- receipt = updateReceipt(repoRoot, receipt, { lifecycle: 'pending', lastError: detail });
766
- return terminalResult(taskId, receipt, {
767
- backfill: backfillResult,
768
- lifecycle: { status: 'failed', changed: false, error: detail },
769
- taskComment: null,
770
- verification: null
771
- }, changed, detail);
772
- }
773
- const shortIds = registry?.status === 'valid' ? registry.shortIds : null;
774
- if (shortIds?.has(taskId)) {
775
- const detail: FinalizationError = {
776
- code: 'TASK_FINALIZATION_CANONICAL_STATE_INVALID',
777
- message: 'completed task still has an active short-id registry entry',
778
- retryable: false
779
- };
780
- receipt = updateReceipt(repoRoot, receipt, { lifecycle: 'pending', lastError: detail });
781
- return terminalResult(taskId, receipt, {
782
- backfill: backfillResult,
783
- lifecycle: { status: 'failed', changed: false, error: detail },
784
- taskComment: null,
785
- verification: null
786
- }, changed, detail);
787
- }
788
- const lifecycleDone = receipt.lifecycle === 'done' && resolved.ok && resolved.state === 'completed' && shortIds !== null;
789
- if (lifecycleDone) {
926
+ if (receipt.lifecycle === 'done' && resolved.ok && resolved.state === 'completed') {
790
927
  lifecycleResult = { status: 'no-op', changed: false, error: null };
791
928
  receipt = updateReceipt(repoRoot, receipt, { lifecycle: 'done', lastError: null });
792
929
  } else {
793
930
  const result = lifecycle(
794
931
  { taskRef: taskId, intent: 'complete', agent: request.agent },
795
- { repoRoot, ...(options.metadataProvider ? { metadataProvider: options.metadataProvider } : {}) }
932
+ { repoRoot, prepareOnly: true, ...(options.metadataProvider ? { metadataProvider: options.metadataProvider } : {}) }
796
933
  );
797
934
  lifecycleResult = lifecycleStep(result);
798
935
  if (result.status !== 'applied' && result.status !== 'no-op') {
@@ -800,7 +937,6 @@ async function applyUnderLock(
800
937
  return terminalResult(taskId, receipt, { backfill: backfillResult, lifecycle: lifecycleResult, taskComment: null, verification: null }, result.changed, lifecycleResult.error);
801
938
  }
802
939
  changed = changed || result.changed;
803
- receipt = updateReceipt(repoRoot, receipt, { lifecycle: 'done', lastError: null });
804
940
  }
805
941
  } catch (error) {
806
942
  const detail = errorOf(error, 'TASK_FINALIZATION_LIFECYCLE_FAILED');
@@ -810,79 +946,64 @@ async function applyUnderLock(
810
946
 
811
947
  let taskComment: TaskFinalizationStep | null = null;
812
948
  receipt = reconcileWarningProjection(repoRoot, taskId, receipt, consumedCapabilities);
813
- const initialComment = await syncPendingTaskComment({
814
- repoRoot, taskId, agent: request.agent, receipt, commentSync, consumedCapabilities
815
- });
816
- receipt = initialComment.receipt;
817
- taskComment = initialComment.step;
818
- changed = changed || initialComment.changed;
819
- if (initialComment.error) {
820
- return terminalResult(taskId, receipt, { backfill: backfillResult, lifecycle: lifecycleResult, taskComment, verification: null }, changed, initialComment.error);
821
- }
822
949
 
823
950
  let verification: TaskFinalizationStep | null = null;
824
- try {
951
+ if (receipt.verification !== 'pending') {
952
+ verification = { status: receipt.verification === 'skipped' ? 'skipped' : 'no-op', changed: false, error: null };
953
+ } else {
954
+ try {
825
955
  receipt = reconcileWarningProjection(repoRoot, taskId, receipt, consumedCapabilities);
826
- const verificationPending = receipt.verification === 'pending';
956
+ const verificationState = resolveTaskRef(taskId, { repoRoot });
957
+ const verificationEvent = verificationState.ok && verificationState.state === 'completed'
958
+ ? 'complete-task.completed'
959
+ : 'complete-task.prepared';
827
960
  const result = await verify(
828
- { taskRef: taskId, event: 'complete-task.completed' },
961
+ { taskRef: taskId, event: verificationEvent },
829
962
  { repoRoot }
830
963
  );
831
964
  verification = verificationStep(result);
832
965
  if (result.status === 'pass') {
833
- if (verificationPending) {
834
- const capability = issueCapability(receipt, 'verification');
835
- receipt = applyFinalizationReceiptMutationUnderLock(repoRoot, receipt, capability, {
836
- scope: 'verification', operation: 'succeeded'
837
- }, consumedCapabilities);
838
- receipt = reconcileWarningProjection(repoRoot, taskId, receipt, consumedCapabilities);
839
- const finalComment = await syncPendingTaskComment({
840
- repoRoot, taskId, agent: request.agent, receipt, commentSync, consumedCapabilities
841
- });
842
- receipt = finalComment.receipt;
843
- if (finalComment.step.status !== 'no-op') taskComment = finalComment.step;
844
- changed = changed || finalComment.changed;
845
- if (finalComment.error) {
846
- return terminalResult(taskId, receipt, { backfill: backfillResult, lifecycle: lifecycleResult, taskComment, verification }, changed, finalComment.error);
847
- }
848
- } else {
849
- receipt = updateReceipt(repoRoot, receipt, { lastError: null });
966
+ const resolvedTaskWarning = receipt.warnings.some((warning) => warning.step === 'verification' && warning.status === 'open');
967
+ const warnings = applyVerificationWarnings(receipt, result);
968
+ receipt = updateReceipt(repoRoot, receipt, {
969
+ verification: 'done', taskComment: resolvedTaskWarning ? 'pending' : receipt.taskComment,
970
+ warningProjection: warnings.length > 0 ? 'pending' : 'done', warnings, lastError: null
971
+ });
972
+ receipt = reconcileWarningProjection(repoRoot, taskId, receipt, consumedCapabilities);
973
+ const finalComment = await syncPendingTaskComment({
974
+ repoRoot, taskId, agent: request.agent, receipt, commentSync, consumedCapabilities
975
+ });
976
+ receipt = finalComment.receipt;
977
+ if (finalComment.step.status !== 'no-op') taskComment = finalComment.step;
978
+ changed = changed || finalComment.changed;
979
+ if (finalComment.error) {
980
+ return terminalResult(taskId, receipt, { backfill: backfillResult, lifecycle: lifecycleResult, taskComment, verification }, changed, finalComment.error);
850
981
  }
851
982
  } else {
852
983
  const detail = verification.error ?? { code: 'VERIFY_FAILED', message: 'verification failed', retryable: true };
853
- if (verificationPending) {
854
- const capability = issueCapability(receipt, 'verification');
855
- receipt = applyFinalizationReceiptMutationUnderLock(repoRoot, receipt, capability, {
856
- scope: 'verification', operation: 'failed', error: detail
857
- }, consumedCapabilities);
858
- receipt = reconcileWarningProjection(repoRoot, taskId, receipt, consumedCapabilities);
859
- } else {
860
- const warnings = replaceWarning(receipt, warningFromError('verification', detail), 'open');
861
- receipt = updateReceipt(repoRoot, receipt, {
862
- verification: 'pending', taskComment: 'pending', warningProjection: 'pending', warnings, lastError: detail
863
- });
864
- receipt = reconcileWarningProjection(repoRoot, taskId, receipt, consumedCapabilities);
865
- const warningComment = await syncPendingTaskComment({ repoRoot, taskId, agent: request.agent, receipt, commentSync, consumedCapabilities });
866
- receipt = warningComment.receipt;
867
- if (warningComment.step.status !== 'no-op') taskComment = warningComment.step;
868
- changed = changed || warningComment.changed;
984
+ const warnings = applyVerificationWarnings(receipt, result);
985
+ receipt = updateReceipt(repoRoot, receipt, {
986
+ verification: 'pending', taskComment: 'pending', warningProjection: 'pending', warnings, lastError: detail
987
+ });
988
+ receipt = reconcileWarningProjection(repoRoot, taskId, receipt, consumedCapabilities);
989
+ const warningComment = await syncPendingTaskComment({
990
+ repoRoot, taskId, agent: request.agent, receipt, commentSync, consumedCapabilities
991
+ });
992
+ receipt = warningComment.receipt;
993
+ taskComment = warningComment.step;
994
+ changed = changed || warningComment.changed;
995
+ if (warningComment.error) {
996
+ return terminalResult(taskId, receipt, { backfill: backfillResult, lifecycle: lifecycleResult, taskComment, verification }, changed, warningComment.error);
869
997
  }
870
998
  return terminalResult(taskId, receipt, { backfill: backfillResult, lifecycle: lifecycleResult, taskComment, verification }, changed, detail);
871
999
  }
872
1000
  } catch (error) {
873
1001
  const detail = errorOf(error, 'VERIFY_FAILED', true);
874
1002
  try {
875
- if (receipt.verification === 'pending') {
876
- const capability = issueCapability(receipt, 'verification');
877
- receipt = applyFinalizationReceiptMutationUnderLock(repoRoot, receipt, capability, {
878
- scope: 'verification', operation: 'failed', error: detail
879
- }, consumedCapabilities);
880
- } else {
881
- const warnings = replaceWarning(receipt, warningFromError('verification', detail), 'open');
882
- receipt = updateReceipt(repoRoot, receipt, {
883
- verification: 'pending', taskComment: 'pending', warningProjection: 'pending', warnings, lastError: detail
884
- });
885
- }
1003
+ const warnings = replaceWarning(receipt, warningFromError('verification', detail), 'open');
1004
+ receipt = updateReceipt(repoRoot, receipt, {
1005
+ verification: 'pending', taskComment: 'pending', warningProjection: 'pending', warnings, lastError: detail
1006
+ });
886
1007
  receipt = reconcileWarningProjection(repoRoot, taskId, receipt, consumedCapabilities);
887
1008
  } catch (writeError) {
888
1009
  const persistence = errorOf(writeError, 'FINALIZATION_RECEIPT_WRITE_FAILED', true);
@@ -902,9 +1023,12 @@ async function applyUnderLock(
902
1023
  changed = changed || warningComment.changed;
903
1024
  } catch { /* preserve the primary error */ }
904
1025
  return terminalResult(taskId, receipt, { backfill: backfillResult, lifecycle: lifecycleResult, taskComment, verification: { status: 'blocked', changed: false, error: detail } }, changed, detail);
1026
+ }
905
1027
  }
906
1028
 
907
- let summary = await syncPendingSummary({ repoRoot, taskId, agent: request.agent, receipt, commentSync });
1029
+ let summary = receipt.summary === 'pending'
1030
+ ? await syncPendingSummary({ repoRoot, taskId, agent: request.agent, receipt, commentSync })
1031
+ : { receipt, step: { status: 'no-op', changed: false, error: null }, changed: false, error: null };
908
1032
  receipt = summary.receipt;
909
1033
  changed = changed || summary.changed;
910
1034
  if (summary.error) {
@@ -912,10 +1036,21 @@ async function applyUnderLock(
912
1036
  return terminalResult(taskId, receipt, { backfill: backfillResult, lifecycle: lifecycleResult, taskComment, verification, summary: summary.step }, changed, summary.error);
913
1037
  }
914
1038
  receipt = reconcileWarningProjection(repoRoot, taskId, receipt, consumedCapabilities);
915
- return terminalResult(taskId, receipt, { backfill: backfillResult, lifecycle: lifecycleResult, taskComment, verification, summary: summary.step }, changed);
1039
+ if (receipt.taskComment === 'pending') {
1040
+ const finalComment = await syncPendingTaskComment({
1041
+ repoRoot, taskId, agent: request.agent, receipt, commentSync, consumedCapabilities
1042
+ });
1043
+ receipt = finalComment.receipt;
1044
+ taskComment = finalComment.step;
1045
+ changed = changed || finalComment.changed;
1046
+ if (finalComment.error) {
1047
+ return terminalResult(taskId, receipt, { backfill: backfillResult, lifecycle: lifecycleResult, taskComment, verification, summary: summary.step }, changed, finalComment.error);
1048
+ }
1049
+ }
1050
+ return preparedResult(taskId, receipt, { backfill: backfillResult, lifecycle: lifecycleResult, taskComment, verification, summary: summary.step }, changed);
916
1051
  }
917
1052
 
918
- async function applyTaskFinalization(request: TaskFinalizationRequest, options: TaskFinalizationOptions): Promise<TaskFinalizationResult> {
1053
+ async function prepareTaskFinalization(request: TaskFinalizationRequest, options: TaskFinalizationOptions): Promise<TaskFinalizationResult> {
919
1054
  if (request.intent !== 'complete' || !request.taskRef || !request.agent) {
920
1055
  return failed(null, { code: 'TASK_FINALIZATION_PAYLOAD_INVALID', message: 'complete finalization requires taskRef and agent', retryable: false });
921
1056
  }
@@ -923,7 +1058,7 @@ async function applyTaskFinalization(request: TaskFinalizationRequest, options:
923
1058
  const resolved = resolveTaskRef(request.taskRef, { repoRoot });
924
1059
  if (!resolved.ok) return failed(resolved.taskId, { code: resolved.code, message: resolved.message, retryable: false });
925
1060
  try {
926
- return await withTaskExecutionLock(repoRoot, resolved.taskId, 'task-finalization.complete', () => applyUnderLock(request, resolved.taskId, options));
1061
+ return await withTaskExecutionLock(repoRoot, resolved.taskId, 'task-finalization.prepare', () => prepareUnderLock(request, resolved.taskId, options));
927
1062
  } catch (error) {
928
1063
  const detail = error instanceof TaskExecutionLockError
929
1064
  ? { code: error.code, message: error.message, retryable: error.code === 'ORCHESTRATION_LOCK_BUSY' }
@@ -932,9 +1067,140 @@ async function applyTaskFinalization(request: TaskFinalizationRequest, options:
932
1067
  }
933
1068
  }
934
1069
 
1070
+ async function commitPreparedTaskFinalization(request: TaskFinalizationRequest, options: TaskFinalizationOptions): Promise<TaskFinalizationResult> {
1071
+ if (request.intent !== 'complete' || !request.taskRef || !request.agent) {
1072
+ return failed(null, { code: 'TASK_FINALIZATION_PAYLOAD_INVALID', message: 'complete finalization requires taskRef and agent', retryable: false });
1073
+ }
1074
+ const repoRoot = path.resolve(options.repoRoot);
1075
+ const resolved = resolveTaskRef(request.taskRef, { repoRoot });
1076
+ if (!resolved.ok) return failed(resolved.taskId, { code: resolved.code, message: resolved.message, retryable: false });
1077
+ try {
1078
+ return await withTaskExecutionLock(repoRoot, resolved.taskId, 'task-finalization.commit', () => {
1079
+ if (options.controlBinding && options.manifestPath) {
1080
+ try {
1081
+ const manifest = readSandboxControlManifest(options.manifestPath);
1082
+ const identity = validateSandboxControlIdentity({
1083
+ publicStatusDir: manifest.publicStatusDir,
1084
+ root: path.dirname(path.resolve(options.manifestPath)),
1085
+ mode: manifest.mode,
1086
+ taskId: manifest.taskId,
1087
+ generation: manifest.generation,
1088
+ controlRootId: manifest.controlRootId
1089
+ });
1090
+ if (identity.state !== 'valid' || manifest.taskId !== resolved.taskId
1091
+ || manifest.generation !== options.controlBinding.generation
1092
+ || fs.realpathSync.native(manifest.repoRoot) !== fs.realpathSync.native(repoRoot)) {
1093
+ throw new Error('current sandbox manifest does not match finalization request');
1094
+ }
1095
+ } catch (error) {
1096
+ return failed(resolved.taskId, {
1097
+ code: 'TASK_FINALIZATION_RECOVERY_PROOF_UNAVAILABLE',
1098
+ message: error instanceof Error ? error.message : String(error), retryable: false
1099
+ });
1100
+ }
1101
+ }
1102
+ let receipt = readReceipt(repoRoot, resolved.taskId);
1103
+ if (!receipt && options.controlBinding && options.handoffDirectory && request.handoffSha256) {
1104
+ try {
1105
+ const handoff = readTaskFinalizationHandoff(
1106
+ options.handoffDirectory, resolved.taskId, options.controlBinding, request.handoffSha256
1107
+ );
1108
+ receipt = validateReceipt(handoff.receipt, resolved.taskId);
1109
+ if (receipt.lifecycle !== 'pending') throw new Error('handoff lifecycle must be pending');
1110
+ if (inspectTaskLifecycleProgress(repoRoot, resolved.taskId, request.agent) === 'unknown') {
1111
+ throw new Error('TASK_FINALIZATION_RECOVERY_PROOF_UNAVAILABLE');
1112
+ }
1113
+ writeReceipt(repoRoot, receipt);
1114
+ try { handoff.cleanup(); } catch { /* keep the persisted canonical receipt usable */ }
1115
+ } catch (error) {
1116
+ return failed(resolved.taskId, {
1117
+ code: 'TASK_FINALIZATION_HANDOFF_INVALID',
1118
+ message: error instanceof Error ? error.message : String(error), retryable: false
1119
+ });
1120
+ }
1121
+ }
1122
+ if (!receipt) return failed(resolved.taskId, {
1123
+ code: 'TASK_FINALIZATION_PREPARATION_REQUIRED', message: 'sandbox preparation receipt is unavailable', retryable: true
1124
+ });
1125
+ if (receipt.lifecycle === 'done' && resolved.state === 'completed') {
1126
+ return terminalResult(resolved.taskId, receipt, { lifecycle: { status: 'no-op', changed: false, error: null } }, false);
1127
+ }
1128
+ if (options.controlBinding) {
1129
+ const proof = inspectTaskLifecycleProgress(repoRoot, resolved.taskId, request.agent);
1130
+ const oldBinding = receipt.controlBinding;
1131
+ if (!oldBinding || oldBinding.generation !== options.controlBinding.generation || proof === 'unknown') {
1132
+ return failed(resolved.taskId, {
1133
+ code: 'TASK_FINALIZATION_RECOVERY_PROOF_UNAVAILABLE',
1134
+ message: 'canonical receipt has no valid host recovery proof', retryable: false
1135
+ });
1136
+ }
1137
+ if (oldBinding.requestId !== options.controlBinding.requestId && proof === 'not-started') {
1138
+ if (receipt.lifecycle !== 'pending' || !options.handoffDirectory || !request.handoffSha256) {
1139
+ return failed(resolved.taskId, {
1140
+ code: 'TASK_FINALIZATION_RECOVERY_PROOF_UNAVAILABLE',
1141
+ message: 'pending receipt cannot be rebound without a current handoff', retryable: false
1142
+ });
1143
+ }
1144
+ try {
1145
+ const handoff = readTaskFinalizationHandoff(
1146
+ options.handoffDirectory, resolved.taskId, options.controlBinding, request.handoffSha256
1147
+ );
1148
+ const nextReceipt = validateReceipt(handoff.receipt, resolved.taskId);
1149
+ if (nextReceipt.receiptId !== receipt.receiptId || nextReceipt.intent !== receipt.intent
1150
+ || nextReceipt.lifecycle !== 'pending') throw new Error('handoff is not continuous with canonical receipt');
1151
+ receipt = updateReceipt(repoRoot, receipt, { controlBinding: options.controlBinding });
1152
+ try { handoff.cleanup(); } catch { /* keep the persisted canonical receipt usable */ }
1153
+ } catch (error) {
1154
+ return failed(resolved.taskId, {
1155
+ code: 'TASK_FINALIZATION_HANDOFF_INVALID',
1156
+ message: error instanceof Error ? error.message : String(error), retryable: false
1157
+ });
1158
+ }
1159
+ }
1160
+ }
1161
+ if (receipt.taskComment === 'pending' || receipt.verification === 'pending' || receipt.summary === 'pending' || receipt.warningProjection === 'pending') {
1162
+ return failed(resolved.taskId, {
1163
+ code: 'TASK_FINALIZATION_PREPARATION_REQUIRED', message: 'sandbox preparation has pending external steps', retryable: true
1164
+ }, { completedSteps: completedSteps(receipt), pendingSteps: pendingSteps(receipt), warnings: openWarnings(receipt) });
1165
+ }
1166
+ const registry = inspectShortIdRegistry(repoRoot);
1167
+ if (registry.status !== 'valid') return failed(resolved.taskId, {
1168
+ code: 'TASK_FINALIZATION_SHORT_ID_REGISTRY_UNAVAILABLE',
1169
+ message: `cannot verify canonical short-id registry: ${registry.error.code}: ${registry.error.message}`,
1170
+ retryable: false
1171
+ });
1172
+ const lifecycle = options.lifecycle ?? applyTaskLifecycle;
1173
+ const result = lifecycle(
1174
+ { taskRef: resolved.taskId, intent: 'complete', agent: request.agent },
1175
+ { repoRoot, ...(options.metadataProvider ? { metadataProvider: options.metadataProvider } : {}) }
1176
+ );
1177
+ const step = lifecycleStep(result);
1178
+ if (result.status !== 'applied' && result.status !== 'no-op') {
1179
+ const updated = updateReceipt(repoRoot, receipt, { lifecycle: 'pending', lastError: step.error });
1180
+ return terminalResult(resolved.taskId, updated, { lifecycle: step }, result.changed, step.error);
1181
+ }
1182
+ const updated = updateReceipt(repoRoot, receipt, { lifecycle: 'done', lastError: null });
1183
+ return terminalResult(resolved.taskId, updated, { lifecycle: step }, result.changed);
1184
+ });
1185
+ } catch (error) {
1186
+ const detail = error instanceof TaskExecutionLockError
1187
+ ? { code: error.code, message: error.message, retryable: error.code === 'ORCHESTRATION_LOCK_BUSY' }
1188
+ : errorOf(error, 'TASK_FINALIZATION_FAILED');
1189
+ return failed(resolved.taskId, detail);
1190
+ }
1191
+ }
1192
+
1193
+ async function applyTaskFinalization(request: TaskFinalizationRequest, options: TaskFinalizationOptions): Promise<TaskFinalizationResult> {
1194
+ const prepared = await prepareTaskFinalization(request, options);
1195
+ if (prepared.status !== 'prepared') return prepared;
1196
+ return commitPreparedTaskFinalization(request, options);
1197
+ }
1198
+
935
1199
  export {
936
1200
  applyFinalizationReceiptMutation,
1201
+ commitPreparedTaskFinalization,
937
1202
  applyTaskFinalization,
1203
+ prepareTaskFinalization,
938
1204
  issueCapability as createFinalizationCapability,
939
1205
  readTaskFinalizationReceipt,
940
1206
  terminalResult