@fitlab-ai/agent-infra 0.11.6 → 0.11.8

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 (192) hide show
  1. package/dist/bin/internal-cli.js +8 -2
  2. package/dist/lib/agent-clients/adapters/codex.js +1 -1
  3. package/dist/lib/agent-clients/config.js +11 -3
  4. package/dist/lib/defaults.json +6 -3
  5. package/dist/lib/git/primary-worktree.js +58 -0
  6. package/dist/lib/internal/cli-route-inventory.js +3 -0
  7. package/dist/lib/internal/platform-pr-review.js +20 -3
  8. package/dist/lib/internal/task-operation-registry.js +3 -1
  9. package/dist/lib/internal/task-platform-recovery.js +56 -0
  10. package/dist/lib/merge.js +25 -5
  11. package/dist/lib/platform/completion-backfill.js +2 -1
  12. package/dist/lib/platform/in-label-sync.js +12 -1
  13. package/dist/lib/platform/issue-comments.js +58 -4
  14. package/dist/lib/platform/issues.js +105 -5
  15. package/dist/lib/platform/operation-coordinator.js +74 -0
  16. package/dist/lib/platform/pr-review.js +107 -1
  17. package/dist/lib/platform/pr-summary.js +79 -57
  18. package/dist/lib/platform/pull-requests.js +125 -7
  19. package/dist/lib/sandbox/commands/create.js +6 -3
  20. package/dist/lib/sandbox/commands/ls.js +4 -3
  21. package/dist/lib/sandbox/commands/rm.js +1 -1
  22. package/dist/lib/sandbox/config.js +22 -4
  23. package/dist/lib/sandbox/control/server.js +6 -2
  24. package/dist/lib/sandbox/git-metadata.js +50 -0
  25. package/dist/lib/sandbox/index.js +1 -1
  26. package/dist/lib/sandbox/mounts.js +12 -0
  27. package/dist/lib/sandbox/recovery.js +11 -0
  28. package/dist/lib/sandbox/removal.js +58 -20
  29. package/dist/lib/sandbox/tools.js +29 -7
  30. package/dist/lib/server/config.js +7 -27
  31. package/dist/lib/server/daemon.js +4 -3
  32. package/dist/lib/server/process-control.js +4 -2
  33. package/dist/lib/server/runner.js +4 -3
  34. package/dist/lib/task/activity-intent.js +4 -3
  35. package/dist/lib/task/artifact-lifecycle.js +42 -10
  36. package/dist/lib/task/artifact-receipts.js +62 -22
  37. package/dist/lib/task/capabilities.js +22 -4
  38. package/dist/lib/task/completion-facts.js +56 -0
  39. package/dist/lib/task/create-service.js +11 -5
  40. package/dist/lib/task/create.js +60 -79
  41. package/dist/lib/task/events.js +268 -195
  42. package/dist/lib/task/finalization-artifacts.js +4 -1
  43. package/dist/lib/task/finalization-handoff.js +2 -1
  44. package/dist/lib/task/finalization.js +157 -24
  45. package/dist/lib/task/invalidation-command.js +4 -2
  46. package/dist/lib/task/invalidation.js +38 -9
  47. package/dist/lib/task/lifecycle.js +38 -2
  48. package/dist/lib/task/local-artifact-finalization.js +17 -3
  49. package/dist/lib/task/platform-operation-journal.js +152 -0
  50. package/dist/lib/task/platform-operation-recovery.js +459 -0
  51. package/dist/lib/task/qualification-audit.js +23 -105
  52. package/dist/lib/task/review-finalization.js +7 -0
  53. package/dist/lib/task/review-identity.js +69 -0
  54. package/dist/lib/task/sections.js +6 -5
  55. package/dist/lib/task/task-execution-lock.js +6 -3
  56. package/dist/lib/task/verification-engine.js +14 -63
  57. package/dist/lib/task/verification.js +35 -38
  58. package/dist/lib/task/workflow-warning-intents.js +7 -1
  59. package/dist/lifecycle-build-manifest.json +3 -3
  60. package/lib/agent-clients/adapters/codex.ts +1 -1
  61. package/lib/agent-clients/config.ts +9 -3
  62. package/lib/defaults.json +6 -3
  63. package/lib/git/primary-worktree.ts +62 -0
  64. package/lib/internal/cli-route-inventory.ts +2 -0
  65. package/lib/internal/platform-pr-review.ts +14 -3
  66. package/lib/internal/task-operation-registry.ts +3 -1
  67. package/lib/internal/task-platform-recovery.ts +53 -0
  68. package/lib/merge.ts +26 -6
  69. package/lib/platform/completion-backfill.ts +2 -1
  70. package/lib/platform/in-label-sync.ts +12 -0
  71. package/lib/platform/issue-comments.ts +59 -3
  72. package/lib/platform/issues.ts +103 -5
  73. package/lib/platform/operation-coordinator.ts +96 -0
  74. package/lib/platform/pr-review.ts +113 -1
  75. package/lib/platform/pr-summary.ts +22 -1
  76. package/lib/platform/pull-requests.ts +132 -8
  77. package/lib/sandbox/commands/create.ts +7 -8
  78. package/lib/sandbox/commands/ls.ts +4 -3
  79. package/lib/sandbox/commands/rm.ts +1 -1
  80. package/lib/sandbox/config.ts +23 -5
  81. package/lib/sandbox/control/server.ts +5 -2
  82. package/lib/sandbox/git-metadata.ts +66 -0
  83. package/lib/sandbox/index.ts +1 -1
  84. package/lib/sandbox/mounts.ts +14 -0
  85. package/lib/sandbox/recovery.ts +13 -0
  86. package/lib/sandbox/removal.ts +57 -19
  87. package/lib/sandbox/tool-types.ts +1 -1
  88. package/lib/sandbox/tools.ts +36 -7
  89. package/lib/server/config.ts +7 -29
  90. package/lib/server/daemon.ts +5 -3
  91. package/lib/server/process-control.ts +4 -2
  92. package/lib/server/runner.ts +7 -4
  93. package/lib/server/server.schema.json +0 -4
  94. package/lib/task/activity-intent.ts +4 -3
  95. package/lib/task/artifact-lifecycle.ts +35 -11
  96. package/lib/task/artifact-receipts.ts +55 -15
  97. package/lib/task/capabilities.ts +17 -3
  98. package/lib/task/completion-facts.ts +66 -0
  99. package/lib/task/create-service.ts +12 -6
  100. package/lib/task/create.ts +54 -69
  101. package/lib/task/events.ts +248 -182
  102. package/lib/task/finalization-artifacts.ts +4 -1
  103. package/lib/task/finalization-handoff.ts +2 -1
  104. package/lib/task/finalization.ts +154 -21
  105. package/lib/task/invalidation-command.ts +4 -2
  106. package/lib/task/invalidation.ts +35 -10
  107. package/lib/task/lifecycle.ts +35 -3
  108. package/lib/task/local-artifact-finalization.ts +13 -3
  109. package/lib/task/platform-operation-journal.ts +230 -0
  110. package/lib/task/platform-operation-recovery.ts +435 -0
  111. package/lib/task/qualification-audit.ts +21 -114
  112. package/lib/task/review-finalization.ts +7 -0
  113. package/lib/task/review-identity.ts +85 -0
  114. package/lib/task/sections.ts +6 -6
  115. package/lib/task/task-execution-lock.ts +7 -3
  116. package/lib/task/verification-engine.ts +13 -86
  117. package/lib/task/verification.ts +34 -38
  118. package/lib/task/workflow-warning-intents.ts +9 -1
  119. package/package.json +1 -1
  120. package/templates/.agents/README.en.md +19 -17
  121. package/templates/.agents/README.zh-CN.md +18 -16
  122. package/templates/.agents/rules/README.en.md +2 -1
  123. package/templates/.agents/rules/README.zh-CN.md +2 -1
  124. package/templates/.agents/rules/decision-qualification.en.md +4 -3
  125. package/templates/.agents/rules/decision-qualification.zh-CN.md +4 -3
  126. package/templates/.agents/rules/task-management.en.md +12 -0
  127. package/templates/.agents/rules/task-management.zh-CN.md +12 -0
  128. package/templates/.agents/rules/validation-output.en.md +19 -0
  129. package/templates/.agents/rules/validation-output.zh-CN.md +19 -0
  130. package/templates/.agents/skills/analyze-task/SKILL.en.md +3 -1
  131. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +3 -3
  132. package/templates/.agents/skills/block-task/SKILL.en.md +2 -0
  133. package/templates/.agents/skills/block-task/SKILL.zh-CN.md +1 -1
  134. package/templates/.agents/skills/cancel-task/SKILL.en.md +6 -2
  135. package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +5 -3
  136. package/templates/.agents/skills/code-task/SKILL.en.md +5 -3
  137. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +4 -4
  138. package/templates/.agents/skills/code-task/reference/report-template.en.md +4 -8
  139. package/templates/.agents/skills/code-task/reference/report-template.zh-CN.md +4 -8
  140. package/templates/.agents/skills/commit/SKILL.en.md +1 -1
  141. package/templates/.agents/skills/commit/SKILL.zh-CN.md +1 -1
  142. package/templates/.agents/skills/complete-manual-validation/SKILL.en.md +1 -1
  143. package/templates/.agents/skills/complete-manual-validation/SKILL.zh-CN.md +1 -1
  144. package/templates/.agents/skills/complete-task/SKILL.en.md +4 -0
  145. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +4 -0
  146. package/templates/.agents/skills/create-pr/SKILL.en.md +2 -0
  147. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +1 -1
  148. package/templates/.agents/skills/create-task/SKILL.en.md +2 -0
  149. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +1 -1
  150. package/templates/.agents/skills/import-codescan/SKILL.en.md +2 -0
  151. package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +1 -1
  152. package/templates/.agents/skills/import-dependabot/SKILL.en.md +2 -0
  153. package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +1 -1
  154. package/templates/.agents/skills/import-issue/SKILL.en.md +2 -0
  155. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +1 -1
  156. package/templates/.agents/skills/plan-task/SKILL.en.md +3 -1
  157. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +3 -3
  158. package/templates/.agents/skills/plan-task/config/verify.en.json +1 -1
  159. package/templates/.agents/skills/plan-task/config/verify.zh-CN.json +1 -1
  160. package/templates/.agents/skills/review-analysis/SKILL.en.md +3 -1
  161. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +3 -1
  162. package/templates/.agents/skills/review-analysis/reference/report-template.en.md +4 -8
  163. package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +4 -8
  164. package/templates/.agents/skills/review-code/SKILL.en.md +13 -12
  165. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +11 -12
  166. package/templates/.agents/skills/review-code/config/verify.en.json +1 -3
  167. package/templates/.agents/skills/review-code/config/verify.zh-CN.json +1 -3
  168. package/templates/.agents/skills/review-code/reference/output-templates.en.md +23 -46
  169. package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +27 -48
  170. package/templates/.agents/skills/review-code/reference/report-template.en.md +8 -11
  171. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +8 -11
  172. package/templates/.agents/skills/review-code/reference/review-criteria.en.md +2 -2
  173. package/templates/.agents/skills/review-code/reference/review-criteria.zh-CN.md +2 -2
  174. package/templates/.agents/skills/review-plan/SKILL.en.md +3 -1
  175. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +3 -1
  176. package/templates/.agents/skills/review-plan/reference/report-template.en.md +5 -9
  177. package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +5 -9
  178. package/templates/.agents/skills/review-pr/SKILL.en.md +13 -11
  179. package/templates/.agents/skills/review-pr/SKILL.zh-CN.md +13 -11
  180. package/templates/.agents/skills/review-pr/reference/output-guidance.en.md +3 -3
  181. package/templates/.agents/skills/review-pr/reference/output-guidance.zh-CN.md +3 -3
  182. package/templates/.agents/skills/review-pr/reference/report-template.en.md +4 -0
  183. package/templates/.agents/skills/review-pr/reference/report-template.zh-CN.md +4 -0
  184. package/templates/.agents/skills/run-manual-validation/SKILL.en.md +1 -1
  185. package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +1 -1
  186. package/templates/.agents/skills/run-task/SKILL.en.md +1 -1
  187. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +1 -1
  188. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +19 -6
  189. package/templates/.agents/skills/watch-pr/SKILL.en.md +2 -0
  190. package/templates/.agents/skills/watch-pr/SKILL.zh-CN.md +1 -1
  191. package/templates/.agents/templates/task.en.md +2 -2
  192. package/templates/.agents/templates/task.zh-CN.md +2 -2
@@ -9,6 +9,8 @@ import { parseImplementationInputs, selectPendingImplementationInput } from './i
9
9
  import { parseVerdict } from './review-artifacts.ts';
10
10
  import { extractSection, findSectionHeading } from './sections.ts';
11
11
  import { receiptForOutput, sha256File } from './artifact-receipts.ts';
12
+ import { hasArtifactCompletionFact, hasArtifactCompletionLog } from './completion-facts.ts';
13
+ import { inspectReviewIdentity } from './review-identity.ts';
12
14
  import { isArtifactInvalidated, parseInvalidationDocument } from './invalidation.ts';
13
15
  import type { InvalidationDocument } from './invalidation.ts';
14
16
  import type { ArtifactFamily, ArtifactFamilySpec } from './artifact-name.ts';
@@ -228,7 +230,12 @@ function inspectArtifactDirectory(
228
230
  if (!knownRounds.has(round)) diagnostics.push(diagnostic('ROUND_GAP', family, null, `${artifactName(family, round)} is missing`));
229
231
  }
230
232
  const latest = artifacts.at(-1) ?? null;
231
- const reviewedInput = family.startsWith('review-') && latest
233
+ let hasReviewCodeReceipt = false;
234
+ if (family === 'review-code' && latest) {
235
+ try { hasReviewCodeReceipt = Boolean(receiptForOutput(fs.readFileSync(path.join(taskDir, 'task.md'), 'utf8'), latest.name)); }
236
+ catch { hasReviewCodeReceipt = false; }
237
+ }
238
+ const reviewedInput = family.startsWith('review-') && latest && (family !== 'review-code' || hasReviewCodeReceipt)
232
239
  ? resolveReviewedInput(taskDir, latest, family === 'review-analysis' ? 'analysis' : family === 'review-plan' ? 'plan' : 'code', diagnostics)
233
240
  : null;
234
241
  return {
@@ -316,14 +323,16 @@ function assertWritableInventory(inventory: ArtifactInventoryResult): ArtifactEr
316
323
  }
317
324
 
318
325
  const REQUIRED_INPUT: Partial<Record<ArtifactFamily, ArtifactFamily>> = {
319
- 'review-analysis': 'analysis', plan: 'analysis', 'review-plan': 'plan', 'review-code': 'code'
326
+ 'review-analysis': 'analysis', plan: 'analysis', 'review-plan': 'plan'
320
327
  };
321
- const OPTIONAL_CONTEXT: Partial<Record<ArtifactFamily, { family: ArtifactFamily }>> = {
322
- analysis: { family: 'review-analysis' },
323
- plan: { family: 'review-plan' },
324
- 'review-code': { family: 'review-plan' },
325
- 'manual-validation': { family: 'review-code' },
326
- 'validation-run': { family: 'review-code' }
328
+ const OPTIONAL_CONTEXT: Partial<Record<ArtifactFamily, readonly ArtifactFamily[]>> = {
329
+ analysis: ['review-analysis'],
330
+ plan: ['review-analysis', 'review-plan'],
331
+ 'review-plan': ['review-analysis'],
332
+ code: ['review-plan'],
333
+ 'review-code': ['code', 'plan', 'review-plan'],
334
+ 'manual-validation': ['review-code'],
335
+ 'validation-run': ['review-code']
327
336
  };
328
337
  function hasSelectionEvidence(
329
338
  content: string,
@@ -336,6 +345,18 @@ function hasSelectionEvidence(
336
345
  if (family.startsWith('review-')) {
337
346
  const reviewedFamily = family === 'review-analysis' ? 'analysis' : family === 'review-plan' ? 'plan' : 'code';
338
347
  const currentInput = context.inputs.find((input) => input.family === reviewedFamily);
348
+ if (family === 'review-code') {
349
+ if (!context.latest) return false;
350
+ try {
351
+ const reportContent = fs.readFileSync(context.latest.path, 'utf8');
352
+ const identity = inspectReviewIdentity(context.taskDir!, reportContent, options.repoRoot);
353
+ return identity.status !== 'matched'
354
+ || !hasArtifactCompletionFact(content, context.latest.path, 'review-code.completed')
355
+ || !hasArtifactCompletionLog(content, context.latest.name, 'review-code.completed');
356
+ } catch {
357
+ return true;
358
+ }
359
+ }
339
360
  if (currentInput && context.reviewedInput?.name !== currentInput.name) return true;
340
361
  }
341
362
  const target = family === 'analysis' ? 'analysis' : family === 'plan' ? 'plan' : family === 'code' ? 'code' : null;
@@ -450,9 +471,9 @@ function resolveArtifactContext(taskRef: string, family: string, options: Inspec
450
471
  }
451
472
  const optional = OPTIONAL_CONTEXT[inventory.family as ArtifactFamily];
452
473
  if (optional) {
453
- const context = inspectTaskArtifacts(taskRef, optional.family, options);
454
- if (context.status === 'ready' && context.latest) {
455
- inputs.push(context.latest);
474
+ for (const family of optional) {
475
+ const context = inspectTaskArtifacts(taskRef, family, options);
476
+ if (context.status === 'ready' && context.latest) inputs.push(context.latest);
456
477
  }
457
478
  }
458
479
  if (inventory.family === 'manual-validation' || inventory.family === 'validation-run') {
@@ -473,6 +494,9 @@ function resolveCodeContext(inventory: ArtifactInventoryResult, options: Inspect
473
494
  const codeMax = latestCode?.round ?? 0;
474
495
  const reviewMax = reviewCode.latest?.round ?? 0;
475
496
  const inputs = [source.latest];
497
+ const sourceReviewFamily = inputFamily === 'analysis' ? 'review-analysis' : 'review-plan';
498
+ const sourceReview = inspectTaskArtifacts(taskRef, sourceReviewFamily, options);
499
+ if (sourceReview.status === 'ready' && sourceReview.latest && sourceReview.reviewedInput?.name === source.latest.name) inputs.push(sourceReview.latest);
476
500
  if (latestCode) {
477
501
  try {
478
502
  const taskContent = fs.readFileSync(path.join(inventory.taskDir!, 'task.md'), 'utf8');
@@ -7,24 +7,30 @@ import { extractSection, findSectionHeading, parseTable } from './sections.ts';
7
7
  const RECEIPT_SECTION_ALIASES = ['产物生命周期收据', 'Artifact Lifecycle Receipts'] as const;
8
8
  const RECEIPT_COLUMNS = ['event', 'output', 'input', 'input_sha256', 'completed_at'] as const;
9
9
  const RECEIPT_EVENTS = new Set([
10
+ 'analysis.completed',
11
+ 'plan.completed',
10
12
  'review-analysis.completed',
11
13
  'review-plan.completed',
14
+ 'code.completed',
12
15
  'review-code.completed',
13
- 'code.completed'
14
16
  ]);
15
17
  const SHA256_RE = /^[a-f0-9]{64}$/;
16
18
  const RECEIPT_SHAPES = {
19
+ 'analysis.completed': { output: 'analysis', input: 'review-analysis' },
20
+ 'plan.completed': { output: 'plan', input: 'analysis' },
17
21
  'review-analysis.completed': { output: 'review-analysis', input: 'analysis' },
18
22
  'review-plan.completed': { output: 'review-plan', input: 'plan' },
19
- 'review-code.completed': { output: 'review-code', input: 'code' },
20
- 'code.completed': { output: 'code', input: 'plan' }
23
+ 'code.completed': { output: 'code', input: 'plan' },
24
+ 'review-code.completed': { output: 'review-code', input: 'code' }
21
25
  } as const;
22
26
 
23
27
  type ArtifactReceiptEvent =
28
+ | 'analysis.completed'
29
+ | 'plan.completed'
24
30
  | 'review-analysis.completed'
25
31
  | 'review-plan.completed'
26
- | 'review-code.completed'
27
- | 'code.completed';
32
+ | 'code.completed'
33
+ | 'review-code.completed';
28
34
  type ArtifactReceipt = {
29
35
  event: ArtifactReceiptEvent;
30
36
  output: string;
@@ -65,8 +71,16 @@ function validateReceiptShape(event: ArtifactReceiptEvent, output: string, input
65
71
  if (!outputIdentity || !inputIdentity) throw new ArtifactReceiptError(`receipt artifact identity is invalid: ${output} -> ${input}`);
66
72
  const shape = RECEIPT_SHAPES[event];
67
73
  const inputMatches = event === 'code.completed'
68
- ? inputIdentity.family === 'analysis' || inputIdentity.family === 'plan'
69
- : inputIdentity.family === shape.input;
74
+ ? inputIdentity.family === 'analysis' || inputIdentity.family === 'plan' || inputIdentity.family === 'review-analysis' || inputIdentity.family === 'review-plan' || inputIdentity.family === 'review-code'
75
+ : event === 'plan.completed'
76
+ ? inputIdentity.family === 'analysis' || inputIdentity.family === 'review-analysis' || inputIdentity.family === 'review-plan'
77
+ : event === 'analysis.completed'
78
+ ? inputIdentity.family === 'review-analysis'
79
+ : event === 'review-plan.completed'
80
+ ? inputIdentity.family === 'plan' || inputIdentity.family === 'review-analysis'
81
+ : event === 'review-code.completed'
82
+ ? inputIdentity.family === 'code' || inputIdentity.family === 'plan' || inputIdentity.family === 'review-plan'
83
+ : inputIdentity.family === shape.input;
70
84
  if (outputIdentity.family !== shape.output || !inputMatches) {
71
85
  throw new ArtifactReceiptError(`receipt event '${event}' does not match ${output} -> ${input}`);
72
86
  }
@@ -81,7 +95,9 @@ function parseArtifactReceipts(content: string): ArtifactReceiptParseResult {
81
95
  table = parseTable(content, {
82
96
  sectionAliases: [...RECEIPT_SECTION_ALIASES],
83
97
  columns: [...RECEIPT_COLUMNS],
84
- keyColumn: 'output'
98
+ // A completed artifact can have multiple lifecycle inputs. Pair
99
+ // uniqueness is checked below because the table parser supports one key.
100
+ keyColumn: null
85
101
  });
86
102
  } catch (error) {
87
103
  throw new ArtifactReceiptError(error instanceof Error ? error.message : String(error));
@@ -109,15 +125,31 @@ function parseArtifactReceipts(content: string): ArtifactReceiptParseResult {
109
125
  completedAt
110
126
  };
111
127
  });
128
+ const edges = new Set<string>();
129
+ for (const row of rows) {
130
+ const edge = `${row.output}\0${row.input}`;
131
+ if (edges.has(edge)) throw new ArtifactReceiptError(`duplicate receipt edge '${row.output}' <- '${row.input}'`);
132
+ edges.add(edge);
133
+ }
112
134
  return { present: true, rows };
113
135
  }
114
136
 
137
+ function receiptsForOutput(content: string, output: string): readonly ArtifactReceipt[] {
138
+ return parseArtifactReceipts(content).rows.filter((row) => row.output === output);
139
+ }
140
+
115
141
  function receiptForOutput(content: string, output: string): ArtifactReceipt | null {
116
- const parsed = parseArtifactReceipts(content);
117
- return parsed.rows.find((row) => row.output === output) ?? null;
142
+ return receiptsForOutput(content, output)[0] ?? null;
118
143
  }
119
144
 
120
145
  function upsertArtifactReceipt(content: string, receipt: ArtifactReceipt): ReceiptSectionMutation {
146
+ return upsertArtifactReceipts(content, [receipt]);
147
+ }
148
+
149
+ function upsertArtifactReceipts(content: string, receiptsToAdd: readonly ArtifactReceipt[]): ReceiptSectionMutation {
150
+ if (receiptsToAdd.length === 0) throw new ArtifactReceiptError('at least one receipt is required');
151
+ const additions = new Map<string, ArtifactReceipt>();
152
+ for (const receipt of receiptsToAdd) {
121
153
  if (!RECEIPT_EVENTS.has(receipt.event)) throw new ArtifactReceiptError(`unknown receipt event '${receipt.event}'`);
122
154
  if (!receipt.output || !receipt.input) throw new ArtifactReceiptError('receipt output and input are required');
123
155
  validateReceiptShape(receipt.event, receipt.output, receipt.input);
@@ -126,12 +158,18 @@ function upsertArtifactReceipt(content: string, receipt: ArtifactReceipt): Recei
126
158
  throw new ArtifactReceiptError(`receipt completion time for '${receipt.output}' is invalid`);
127
159
  }
128
160
 
161
+ const key = `${receipt.output}\0${receipt.input}`;
162
+ const duplicate = additions.get(key);
163
+ if (duplicate && JSON.stringify(duplicate) !== JSON.stringify(receipt)) throw new ArtifactReceiptError(`receipt for '${receipt.output}' from '${receipt.input}' has conflicting evidence`);
164
+ additions.set(key, receipt);
165
+ }
129
166
  const existing = parseArtifactReceipts(content).rows;
130
- const previous = existing.find((row) => row.output === receipt.output);
131
- if (previous && JSON.stringify(previous) !== JSON.stringify(receipt)) {
132
- throw new ArtifactReceiptError(`receipt for '${receipt.output}' already exists with different evidence`);
167
+ for (const receipt of additions.values()) {
168
+ const previous = existing.find((row) => row.output === receipt.output && row.input === receipt.input);
169
+ if (previous && JSON.stringify(previous) !== JSON.stringify(receipt)) throw new ArtifactReceiptError(`receipt for '${receipt.output}' from '${receipt.input}' already exists with different evidence`);
133
170
  }
134
- const rows = previous ? existing : [...existing, receipt];
171
+ const rows = [...existing];
172
+ for (const receipt of additions.values()) if (!rows.some((row) => row.output === receipt.output && row.input === receipt.input)) rows.push(receipt);
135
173
  const heading = findSectionHeading(content, [...RECEIPT_SECTION_ALIASES]);
136
174
  const body = [
137
175
  `| ${RECEIPT_COLUMNS.join(' | ')} |`,
@@ -146,8 +184,10 @@ export {
146
184
  RECEIPT_SECTION_ALIASES,
147
185
  parseArtifactReceipts,
148
186
  receiptForOutput,
187
+ receiptsForOutput,
149
188
  sha256Bytes,
150
189
  sha256File,
151
- upsertArtifactReceipt
190
+ upsertArtifactReceipt,
191
+ upsertArtifactReceipts
152
192
  };
153
193
  export type { ArtifactReceipt, ArtifactReceiptEvent, ArtifactReceiptParseResult, ReceiptSectionMutation };
@@ -17,6 +17,8 @@ import type { QualificationAudit, TaskQualification } from './qualification-audi
17
17
  import { parseLifecyclePathDecision, pathIncludes } from './lifecycle-path.ts';
18
18
  import type { LifecyclePathState } from './lifecycle-path.ts';
19
19
  import { parseImplementationInputs } from './implementation-inputs.ts';
20
+ import { hasArtifactCompletionFact, hasArtifactCompletionLog } from './completion-facts.ts';
21
+ import { inspectReviewIdentity } from './review-identity.ts';
20
22
 
21
23
  const ARTIFACT_AUDIT_FAMILIES = new Set(['analysis', 'review-analysis', 'plan', 'review-plan', 'code', 'review-code']);
22
24
 
@@ -55,6 +57,7 @@ type LifecycleFacts = {
55
57
  pathState?: LifecyclePathState;
56
58
  reworkClassificationRequired?: readonly ('analysis' | 'plan' | 'code')[];
57
59
  resolvedHumanDecisions?: Partial<Record<'analysis' | 'plan' | 'code', 'review' | 'implementation'>>;
60
+ reviewCodeIdentityValid?: boolean;
58
61
  };
59
62
  type CapabilityResult = {
60
63
  allowed: boolean;
@@ -176,11 +179,12 @@ function canStart(action: LifecycleAction, facts: LifecycleFacts, trigger: Expli
176
179
  return allow('plan-review-approved');
177
180
  }
178
181
  if (action === 'review-code') {
179
- return hasArtifact(facts, 'code') ? allow('code-artifact') : deny('CODE_ARTIFACT_REQUIRED');
182
+ return allow(hasArtifact(facts, 'code') ? 'optional-code-context' : 'review-current-git-diff');
180
183
  }
181
184
  if (action === 'manual-validation' || action === 'validation-run') {
182
185
  if (!hasArtifact(facts, 'review-code')) return deny('CODE_REVIEW_REQUIRED');
183
- if (!reviewMatchesLatest(facts, 'code', 'review-code')) return deny('CODE_REVIEW_NOT_LATEST');
186
+ if (hasArtifact(facts, 'code') && !reviewMatchesLatest(facts, 'code', 'review-code')) return deny('CODE_REVIEW_NOT_LATEST');
187
+ if (!facts.reviewCodeIdentityValid) return deny('CODE_REVIEW_IDENTITY_INVALID');
184
188
  if (facts.reviews['review-code'] !== 'approved') return deny('CODE_REVIEW_NOT_APPROVED');
185
189
  if (facts.unresolvedLedger.code > 0) return deny('CODE_LEDGER_BLOCKED', `unresolved=${facts.unresolvedLedger.code}`);
186
190
  return allow('code-review-approved');
@@ -334,7 +338,9 @@ function buildLifecycleFacts(taskDir: string, content: string, taskState = 'acti
334
338
  try { auditContent = fs.readFileSync(path.join(taskDir, name), 'utf8'); }
335
339
  catch { qualificationStaleArtifacts.push(name); continue; }
336
340
  const audit = parseQualificationAudit(auditContent);
337
- if (!audit.ok || !audit.audit.present || !audit.audit.snapshot) { qualificationStaleArtifacts.push(name); continue; }
341
+ if (!audit.ok) { qualificationStaleArtifacts.push(name); continue; }
342
+ if (!audit.audit.present) continue;
343
+ if (!audit.audit.snapshot) { qualificationStaleArtifacts.push(name); continue; }
338
344
  const constraintsChanged = audit.audit.constraintDependencies.some((dependency) => constraints.get(dependency.constraintId) !== dependency.constraintDigest);
339
345
  const taskInputChanged = audit.audit.snapshot.taskInputDigest !== qualification.qualification.taskInputDigest;
340
346
  if (audit.audit.snapshot.nonConstraintInputDigest !== qualification.qualification.nonConstraintInputDigest
@@ -345,6 +351,7 @@ function buildLifecycleFacts(taskDir: string, content: string, taskState = 'acti
345
351
  }
346
352
  const reviews: LifecycleFacts['reviews'] = {};
347
353
  const reviewedInputs: NonNullable<LifecycleFacts['reviewedInputs']> = {};
354
+ let reviewCodeIdentityValid = false;
348
355
  for (const family of ['review-analysis', 'review-plan', 'review-code'] as const) {
349
356
  const latest = latestArtifact(artifacts[family] ?? []);
350
357
  if (!latest) continue;
@@ -358,6 +365,12 @@ function buildLifecycleFacts(taskDir: string, content: string, taskState = 'acti
358
365
  && (artifacts[expectedFamily] ?? []).includes(input)
359
366
  && artifactHashes[input] === receipt.inputSha256
360
367
  ) reviewedInputs[family] = input;
368
+ if (family === 'review-code') {
369
+ const identity = inspectReviewIdentity(taskDir, reviewContent);
370
+ reviewCodeIdentityValid = identity.status === 'matched'
371
+ && hasArtifactCompletionFact(content, path.join(taskDir, latest), 'review-code.completed')
372
+ && hasArtifactCompletionLog(content, latest, 'review-code.completed');
373
+ }
361
374
  const parsed = parseReviewSummary(reviewContent);
362
375
  if (!parsed.ok) continue;
363
376
  const verdict = resolveCanonicalVerdict(parsed.summary);
@@ -423,6 +436,7 @@ function buildLifecycleFacts(taskDir: string, content: string, taskState = 'acti
423
436
  , pathState
424
437
  , reworkClassificationRequired
425
438
  , resolvedHumanDecisions
439
+ , reviewCodeIdentityValid
426
440
  };
427
441
  facts.recommendedAction = recommendNext(facts).action;
428
442
  return { ok: true, facts };
@@ -0,0 +1,66 @@
1
+ import fs from 'node:fs';
2
+
3
+ import { canonicalSemanticDigest } from './artifact-operations.ts';
4
+ import { inspectActivityLog } from './activity-log.ts';
5
+ import { parseTypedTaskFrontmatter } from './frontmatter.ts';
6
+ import { parseArtifactName } from './artifact-name.ts';
7
+ import { sha256File } from './artifact-receipts.ts';
8
+
9
+ export type CompletionFact = Readonly<{
10
+ event: string;
11
+ output: string;
12
+ outputSha256: string;
13
+ semanticDigest: string;
14
+ requestId: string;
15
+ result: string;
16
+ lifecycleInputs?: readonly Readonly<{ name: string; sha256: string }>[];
17
+ }>;
18
+
19
+ export function parseCompletionFacts(value: unknown): CompletionFact[] {
20
+ if (typeof value !== 'string') return [];
21
+ try {
22
+ const parsed: unknown = JSON.parse(value);
23
+ if (!Array.isArray(parsed)) return [];
24
+ return parsed.filter((fact): fact is CompletionFact => Boolean(
25
+ fact && typeof fact === 'object' && !Array.isArray(fact)
26
+ && typeof (fact as CompletionFact).event === 'string'
27
+ && typeof (fact as CompletionFact).output === 'string'
28
+ && /^[a-f0-9]{64}$/u.test((fact as CompletionFact).outputSha256)
29
+ && /^[a-f0-9]{64}$/u.test((fact as CompletionFact).semanticDigest)
30
+ && typeof (fact as CompletionFact).requestId === 'string'
31
+ && typeof (fact as CompletionFact).result === 'string'
32
+ ));
33
+ } catch {
34
+ return [];
35
+ }
36
+ }
37
+
38
+ export function hasArtifactCompletionFact(taskContent: string, artifactPath: string, event: string): boolean {
39
+ try {
40
+ const frontmatter = parseTypedTaskFrontmatter(taskContent);
41
+ const output = artifactPath.split(/[\\/]/u).at(-1) ?? '';
42
+ const content = fs.readFileSync(artifactPath, 'utf8');
43
+ const outputSha256 = sha256File(artifactPath);
44
+ const semanticDigest = canonicalSemanticDigest(content);
45
+ return parseCompletionFacts(frontmatter.completion_facts).some((fact) =>
46
+ fact.event === event && fact.output === output
47
+ && fact.outputSha256 === outputSha256 && fact.semanticDigest === semanticDigest
48
+ && fact.requestId.length > 0
49
+ );
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ export function hasArtifactCompletionLog(taskContent: string, artifact: string, event: string): boolean {
56
+ const identity = parseArtifactName(artifact);
57
+ if (!identity) return false;
58
+ const action = event === 'review-code.completed' ? 'Review Code'
59
+ : event === 'review-plan.completed' ? 'Review Plan'
60
+ : event === 'review-analysis.completed' ? 'Review Analysis' : null;
61
+ if (!action) return false;
62
+ const { section, invalidEntries } = inspectActivityLog(taskContent);
63
+ if (!section || invalidEntries.length > 0) return false;
64
+ const expected = `${action} (Round ${identity.round})`;
65
+ return section.entries.some((entry) => entry.step === expected && entry.note.includes(`→ ${artifact}`));
66
+ }
@@ -4,6 +4,7 @@ import type { PlatformResult } from '../platform/types.ts';
4
4
  import { VERSION } from '../version.ts';
5
5
  import { applyWorkflowWarningIntent } from './workflow-warning-intents.ts';
6
6
  import { createLocalTask, type TaskCreateCandidateV1 } from './create.ts';
7
+ import { TaskExecutionLockError } from './task-execution-lock.ts';
7
8
  import { verifyTaskEvent } from './verification.ts';
8
9
 
9
10
  type TaskCreateStatus = 'applied' | 'no-op' | 'degraded' | 'failed' | 'blocked';
@@ -19,7 +20,7 @@ type TaskCreateError = { code: string; message: string; retryable: boolean };
19
20
  type TaskCreateResult = Readonly<{
20
21
  status: TaskCreateStatus;
21
22
  changed: boolean;
22
- task: { id: string | null; shortId: string | null };
23
+ task: { id: string | null; shortId: string | null; state: 'active' | 'blocked' | 'completed' | 'archive' | null };
23
24
  issue: { number: number; url: string } | null;
24
25
  operations: readonly TaskCreateOperation[];
25
26
  warnings: readonly TaskCreateWarning[];
@@ -87,9 +88,10 @@ export function parseTaskCreateResult(value: unknown): TaskCreateResult {
87
88
  if (!['applied', 'no-op', 'degraded', 'failed', 'blocked'].includes(result.status as string)
88
89
  || typeof result.changed !== 'boolean'
89
90
  || !result.task || typeof result.task !== 'object' || Array.isArray(result.task)
90
- || !exactKeys(result.task as Record<string, unknown>, ['id', 'shortId'])
91
+ || !exactKeys(result.task as Record<string, unknown>, ['id', 'shortId', 'state'])
91
92
  || !validTaskReference((result.task as { id?: unknown }).id)
92
93
  || !validTaskReference((result.task as { shortId?: unknown }).shortId)
94
+ || ![null, 'active', 'blocked', 'completed', 'archive'].includes((result.task as { state?: unknown }).state as string | null)
93
95
  || !Array.isArray(result.operations)
94
96
  || !result.operations.every((operation) => {
95
97
  if (!operation || typeof operation !== 'object' || Array.isArray(operation)) return false;
@@ -136,7 +138,7 @@ export function taskCreateFailure(
136
138
  return {
137
139
  status: error.retryable ? 'blocked' : 'failed',
138
140
  changed: false,
139
- task: { id: null, shortId: null },
141
+ task: { id: null, shortId: null, state: null },
140
142
  issue: null,
141
143
  operations: [],
142
144
  warnings: [],
@@ -199,13 +201,17 @@ async function createTask(value: unknown, options: CreateTaskOptions): Promise<T
199
201
  });
200
202
  } catch (error) {
201
203
  const message = error instanceof Error ? error.message : String(error);
202
- const code = /^([A-Z][A-Z0-9_]+)/.exec(message)?.[1] ?? 'TASK_CREATE_FAILED';
203
- return taskCreateFailure({ code, message, retryable: code === 'TASK_CREATE_LOCK_TIMEOUT' });
204
+ const lockBusy = error instanceof TaskExecutionLockError && error.code === 'ORCHESTRATION_LOCK_BUSY';
205
+ const code = lockBusy ? error.code : /^([A-Z][A-Z0-9_]+)/.exec(message)?.[1] ?? 'TASK_CREATE_FAILED';
206
+ return taskCreateFailure({ code, message, retryable: lockBusy || code === 'TASK_CREATE_LOCK_TIMEOUT' });
204
207
  }
205
208
 
206
209
  const operations: TaskCreateOperation[] = [{
207
210
  name: 'task:local', status: local.status, reasonCode: null
208
211
  }];
212
+ if (local.status === 'no-op' && local.task.state !== 'active') {
213
+ return { status: 'no-op', changed: false, task: local.task, issue: null, operations, warnings: [], error: null };
214
+ }
209
215
  let issue: TaskCreateResult['issue'] = null;
210
216
  const created = await dependencies.createIssue(local.task.id, { cwd: options.repoRoot, agent: candidate.agent });
211
217
  operations.push(...platformOperations('platform-create', created.operations));
@@ -246,7 +252,7 @@ async function createTask(value: unknown, options: CreateTaskOptions): Promise<T
246
252
  task: local.task, issue, operations, warnings: [], error: verified.error
247
253
  };
248
254
  return {
249
- status: platformFailure.status === 'blocked' ? 'blocked' : 'degraded',
255
+ status: 'degraded',
250
256
  changed: local.changed,
251
257
  task: local.task,
252
258
  issue,
@@ -1,13 +1,16 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import fs from 'node:fs';
3
+ import os from 'node:os';
3
4
  import path from 'node:path';
4
5
 
5
6
  import { isValidAgentInfraVersion, VERSION } from '../version.ts';
6
7
  import { loadShortIdByTaskId, mutateShortIdRegistry } from './short-id.ts';
7
- import { TaskExecutionLockError, withTaskExecutionLock } from './task-execution-lock.ts';
8
+ import { TaskExecutionLockError, withRepositoryMutationLock, withTaskExecutionLock } from './task-execution-lock.ts';
8
9
  import { readDeliveryDefaults, validateBaseRef, validateRemote } from './delivery-target.ts';
9
10
  import { buildUnboundFact, encodePrDeliveryFact } from './pr-delivery-fact.ts';
10
11
  import { CANDIDATE_COLUMNS, CONSTRAINT_COLUMNS, parseTaskQualification } from './qualification-audit.ts';
12
+ import { enumerateAllTaskDirs } from './resolve-ref.ts';
13
+ import { parseTaskFrontmatter } from './frontmatter.ts';
11
14
 
12
15
  const AGENTS = ['claude', 'codex', 'antigravity', 'opencode', 'cursor'] as const;
13
16
  const TYPES = ['feature', 'bugfix', 'refactor', 'docs', 'chore'] as const;
@@ -41,7 +44,7 @@ type TaskCreateCandidateV1 = Readonly<{
41
44
  type LocalTaskCreateResult = Readonly<{
42
45
  status: 'applied' | 'no-op';
43
46
  changed: boolean;
44
- task: { id: string; shortId: string };
47
+ task: { id: string; shortId: string | null; state: 'active' | 'blocked' | 'completed' | 'archive' };
45
48
  }>;
46
49
 
47
50
  type LocalTaskCreateOptions = Readonly<{
@@ -345,16 +348,32 @@ function ensureRealDirectory(directory: string, code: string): void {
345
348
  fs.mkdirSync(directory, { mode: 0o700 });
346
349
  }
347
350
 
348
- function withCreateLock<T>(repoRoot: string, workspaceRoot: string, operation: () => T): T {
349
- const lockRoot = path.join(workspaceRoot, '.task-create.lock');
350
- fs.mkdirSync(lockRoot, { recursive: true, mode: 0o700 });
351
- assertRealDirectory(lockRoot, 'TASK_CREATE_LOCK_FAILED');
351
+ function createLockRoot(project: string): string {
352
+ const root = path.join(os.homedir(), '.agent-infra');
353
+ const directories = [root, path.join(root, 'run'), path.join(root, 'run', project), path.join(root, 'run', project, 'task-create-locks')];
354
+ for (const directory of directories) {
355
+ if (fs.existsSync(directory)) {
356
+ assertRealDirectory(directory, 'TASK_CREATE_LOCK_FAILED');
357
+ continue;
358
+ }
359
+ try {
360
+ fs.mkdirSync(directory, { mode: 0o700 });
361
+ } catch (error) {
362
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
363
+ assertRealDirectory(directory, 'TASK_CREATE_LOCK_FAILED');
364
+ }
365
+ }
366
+ return directories.at(-1)!;
367
+ }
368
+
369
+ function withCreateLock<T>(repoRoot: string, project: string, operation: () => T): T {
370
+ const lockRoot = createLockRoot(project);
352
371
  let callbackError: unknown;
353
372
  try {
354
373
  return withTaskExecutionLock(repoRoot, 'task-create', 'task-create', () => {
355
374
  try { return operation(); }
356
375
  catch (error) { callbackError = error; throw error; }
357
- }, { lockRoot });
376
+ }, { lockRoot, identityScope: `task-create\0${project}` });
358
377
  } catch (error) {
359
378
  if (callbackError !== undefined) throw callbackError;
360
379
  if (error instanceof TaskExecutionLockError && error.code === 'ORCHESTRATION_LOCK_BUSY') {
@@ -365,35 +384,21 @@ function withCreateLock<T>(repoRoot: string, workspaceRoot: string, operation: (
365
384
  }
366
385
  }
367
386
 
368
- function writeReceipt(receiptPath: string, value: unknown): void {
369
- const temporary = `${receiptPath}.tmp.${process.pid}.${randomUUID()}`;
370
- fs.writeFileSync(temporary, `${JSON.stringify(value)}\n`, { flag: 'wx', mode: 0o600 });
371
- try { fs.renameSync(temporary, receiptPath); }
372
- catch (error) {
373
- try { fs.unlinkSync(temporary); } catch { /* best effort */ }
374
- throw error;
375
- }
376
- }
377
-
378
- function recoverPublishedTask(activeRoot: string, repoRoot: string, keyDigest: string, candidateDigest: string): { taskId: string; shortId: string } | null {
379
- for (const name of fs.readdirSync(activeRoot)) {
380
- if (!/^TASK-\d{8}-\d{6}$/.test(name)) continue;
381
- const taskDir = path.join(activeRoot, name);
382
- const stat = fs.lstatSync(taskDir);
383
- if (!stat.isDirectory() || stat.isSymbolicLink()) continue;
384
- const taskMd = path.join(taskDir, 'task.md');
385
- if (!fs.existsSync(taskMd)) continue;
387
+ function recoverPublishedTask(repoRoot: string, keyDigest: string, candidateDigest: string): { taskId: string; shortId: string | null; state: 'active' | 'blocked' | 'completed' | 'archive' } | null {
388
+ for (const task of enumerateAllTaskDirs(repoRoot)) {
389
+ const taskMd = path.join(task.taskDir, 'task.md');
386
390
  const taskStat = fs.lstatSync(taskMd);
387
- if (!taskStat.isFile() || taskStat.isSymbolicLink()) continue;
388
- const content = fs.readFileSync(taskMd, 'utf8');
389
- if (!content.includes(`task_create_key_digest: ${keyDigest}`)) continue;
390
- if (!content.includes(`task_create_candidate_digest: ${candidateDigest}`)) {
391
+ if (!taskStat.isFile() || taskStat.isSymbolicLink()) throw new Error('TASK_CREATE_TASK_INVALID');
392
+ const frontmatter = parseTaskFrontmatter(fs.readFileSync(taskMd, 'utf8'));
393
+ if (frontmatter.task_create_key_digest !== keyDigest) continue;
394
+ if (frontmatter.task_create_candidate_digest !== candidateDigest) {
391
395
  throw new Error('TASK_CREATE_IDEMPOTENCY_CONFLICT');
392
396
  }
393
- const shortId = loadShortIdByTaskId(repoRoot).get(name)
394
- ?? mutateShortIdRegistry(repoRoot, name, 'alloc').shortId;
395
- if (!shortId) throw new Error('TASK_CREATE_RECEIPT_INVALID');
396
- return { taskId: name, shortId };
397
+ const shortId = task.state === 'active'
398
+ ? loadShortIdByTaskId(repoRoot).get(task.taskId) ?? mutateShortIdRegistry(repoRoot, task.taskId, 'alloc').shortId
399
+ : null;
400
+ if (task.state === 'active' && !shortId) throw new Error('TASK_CREATE_SHORT_ID_FAILED');
401
+ return { taskId: task.taskId, shortId, state: task.state };
397
402
  }
398
403
  return null;
399
404
  }
@@ -414,29 +419,10 @@ function createLocalTask(value: unknown, options: LocalTaskCreateOptions): Local
414
419
  const canonical = canonicalTaskCreateCandidate(candidate);
415
420
  const candidateDigest = sha256(canonical);
416
421
  const keyDigest = sha256(candidate.idempotencyKey);
417
- const receipts = path.join(workspaceRoot, '.task-create');
418
-
419
- return withCreateLock(repoRoot, workspaceRoot, () => {
420
- if (fs.existsSync(receipts)) assertRealDirectory(receipts, 'TASK_CREATE_RECEIPT_INVALID');
421
- const receiptPath = path.join(receipts, `${keyDigest}.json`);
422
- if (fs.existsSync(receiptPath)) {
423
- const stat = fs.lstatSync(receiptPath);
424
- if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('TASK_CREATE_RECEIPT_INVALID');
425
- const receipt = JSON.parse(fs.readFileSync(receiptPath, 'utf8')) as { candidateDigest?: string; taskId?: string; shortId?: string };
426
- if (receipt.candidateDigest !== candidateDigest) throw new Error('TASK_CREATE_IDEMPOTENCY_CONFLICT');
427
- if (!receipt.taskId || !receipt.shortId || !fs.existsSync(path.join(activeRoot, receipt.taskId, 'task.md'))) {
428
- throw new Error('TASK_CREATE_RECEIPT_INVALID');
429
- }
430
- return { status: 'no-op', changed: false, task: { id: receipt.taskId, shortId: receipt.shortId } };
431
- }
432
-
433
- const recovered = recoverPublishedTask(activeRoot, repoRoot, keyDigest, candidateDigest);
422
+ function createUnderRepositoryLock(): LocalTaskCreateResult {
423
+ const recovered = recoverPublishedTask(repoRoot, keyDigest, candidateDigest);
434
424
  if (recovered) {
435
- ensureRealDirectory(receipts, 'TASK_CREATE_RECEIPT_INVALID');
436
- writeReceipt(receiptPath, {
437
- version: 1, candidateDigest, taskId: recovered.taskId, shortId: recovered.shortId, status: 'recovered'
438
- });
439
- return { status: 'no-op', changed: false, task: { id: recovered.taskId, shortId: recovered.shortId } };
425
+ return { status: 'no-op', changed: false, task: { id: recovered.taskId, shortId: recovered.shortId, state: recovered.state } };
440
426
  }
441
427
 
442
428
  const base = options.now?.() ?? new Date();
@@ -455,30 +441,29 @@ function createLocalTask(value: unknown, options: LocalTaskCreateOptions): Local
455
441
  agentInfraVersion, keyDigest, candidateDigest
456
442
  });
457
443
  validateRenderedQualification(rendered, candidate);
458
- ensureRealDirectory(receipts, 'TASK_CREATE_RECEIPT_INVALID');
459
444
  const temporary = path.join(workspaceRoot, `.task-create.tmp.${process.pid}.${randomUUID()}`);
460
445
  fs.mkdirSync(temporary, { mode: 0o700 });
461
446
  fs.writeFileSync(path.join(temporary, 'task.md'), rendered, { flag: 'wx', mode: 0o600 });
462
447
  const destination = path.join(activeRoot, taskId);
463
448
  fs.renameSync(temporary, destination);
464
- let shortId: string;
465
- try {
466
- const allocated = mutateShortIdRegistry(repoRoot, taskId, 'alloc');
467
- if (!allocated.shortId) throw new Error('TASK_CREATE_SHORT_ID_FAILED');
468
- shortId = allocated.shortId;
469
- } catch (error) {
470
- fs.rmSync(destination, { recursive: true, force: true });
471
- throw error;
472
- }
449
+ const allocated = mutateShortIdRegistry(repoRoot, taskId, 'alloc');
450
+ if (!allocated.shortId) throw new Error('TASK_CREATE_SHORT_ID_FAILED');
451
+ return { status: 'applied', changed: true, task: { id: taskId, shortId: allocated.shortId, state: 'active' } };
452
+ }
453
+
454
+ return withCreateLock(repoRoot, projectConfig.project, () => {
455
+ let callbackError: unknown;
473
456
  try {
474
- writeReceipt(receiptPath, { version: 1, candidateDigest, taskId, shortId, status: 'created' });
457
+ return withRepositoryMutationLock(repoRoot, () => {
458
+ try { return createUnderRepositoryLock(); }
459
+ catch (error) { callbackError = error; throw error; }
460
+ });
475
461
  } catch (error) {
476
- mutateShortIdRegistry(repoRoot, taskId, 'release');
477
- fs.rmSync(destination, { recursive: true, force: true });
462
+ if (callbackError !== undefined) throw callbackError;
478
463
  throw error;
479
464
  }
480
- return { status: 'applied', changed: true, task: { id: taskId, shortId } };
481
465
  });
466
+
482
467
  }
483
468
 
484
469
  export {