@fitlab-ai/agent-infra 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +2 -6
  2. package/README.zh-CN.md +2 -6
  3. package/bin/internal-cli.sh +0 -13
  4. package/dist/bin/internal-cli.js +11 -49
  5. package/dist/lib/internal/cli-route-inventory.js +3 -4
  6. package/dist/lib/internal/platform-comment.js +60 -2
  7. package/dist/lib/internal/task-artifact.js +1 -1
  8. package/dist/lib/internal/task-finalization.js +2 -2
  9. package/dist/lib/internal/task-lifecycle.js +2 -2
  10. package/dist/lib/internal/task-operation-registry.js +22 -12
  11. package/dist/lib/internal/task-orchestration.js +2 -2
  12. package/dist/lib/internal/task-review.js +10 -5
  13. package/dist/lib/platform/activity-recovery.js +159 -0
  14. package/dist/lib/platform/issue-comments.js +84 -32
  15. package/dist/lib/platform/task-comment-projection.js +70 -0
  16. package/dist/lib/platform/task-recovery.js +55 -0
  17. package/dist/lib/platform/verification-sync.js +43 -9
  18. package/dist/lib/sandbox/control/executor.js +4 -2
  19. package/dist/lib/sandbox/control/task-workflow.js +1 -1
  20. package/dist/lib/sandbox/control/workflow-executor.js +83 -21
  21. package/dist/lib/server/process-state.js +17 -5
  22. package/dist/lib/task/artifact-command.js +6 -3
  23. package/dist/lib/task/artifact-operations.js +1 -1
  24. package/dist/lib/task/artifact-recovery.js +114 -68
  25. package/dist/lib/task/artifact-repair-intent.js +25 -8
  26. package/dist/lib/task/control-authority.js +1 -1
  27. package/dist/lib/task/events.js +6 -6
  28. package/dist/lib/task/intermediate-cleanup.js +28 -3
  29. package/dist/lib/task/local-artifact-finalization.js +78 -14
  30. package/dist/lib/task/review-command.js +2 -2
  31. package/dist/lib/task/review-finalization.js +203 -10
  32. package/dist/lib/{host-control → task}/secure-fs.js +8 -19
  33. package/dist/lib/task/workflow-command.js +2 -0
  34. package/dist/lib/task/workflow-dispatch.js +161 -0
  35. package/dist/lifecycle-build-manifest.json +3 -3
  36. package/lib/internal/cli-route-inventory.ts +3 -4
  37. package/lib/internal/platform-comment.ts +53 -2
  38. package/lib/internal/task-artifact.ts +1 -1
  39. package/lib/internal/task-finalization.ts +1 -1
  40. package/lib/internal/task-lifecycle.ts +1 -1
  41. package/lib/internal/task-operation-registry.ts +22 -12
  42. package/lib/internal/task-orchestration.ts +1 -1
  43. package/lib/internal/task-review.ts +9 -5
  44. package/lib/platform/activity-recovery.ts +179 -0
  45. package/lib/platform/issue-comments.ts +98 -31
  46. package/lib/platform/task-comment-projection.ts +79 -0
  47. package/lib/platform/task-recovery.ts +54 -0
  48. package/lib/platform/verification-sync.ts +46 -13
  49. package/lib/sandbox/control/executor.ts +4 -2
  50. package/lib/sandbox/control/protocol.ts +1 -1
  51. package/lib/sandbox/control/task-workflow.ts +1 -1
  52. package/lib/sandbox/control/workflow-executor.ts +102 -21
  53. package/lib/server/process-state.ts +26 -5
  54. package/lib/task/artifact-command.ts +6 -4
  55. package/lib/task/artifact-operations.ts +1 -1
  56. package/lib/task/artifact-recovery.ts +120 -67
  57. package/lib/task/artifact-repair-intent.ts +38 -11
  58. package/lib/task/control-authority.ts +1 -1
  59. package/lib/task/events.ts +6 -6
  60. package/lib/task/intermediate-cleanup.ts +18 -2
  61. package/lib/task/local-artifact-finalization.ts +85 -11
  62. package/lib/task/review-command.ts +1 -1
  63. package/lib/task/review-finalization.ts +203 -8
  64. package/lib/{host-control → task}/secure-fs.ts +10 -32
  65. package/lib/task/workflow-command.ts +2 -0
  66. package/lib/task/workflow-dispatch.ts +171 -0
  67. package/package.json +1 -1
  68. package/templates/.agents/rules/issue-sync.en.md +9 -1
  69. package/templates/.agents/rules/issue-sync.zh-CN.md +10 -3
  70. package/templates/.agents/rules/local-artifact-repair.en.md +7 -6
  71. package/templates/.agents/rules/local-artifact-repair.zh-CN.md +10 -8
  72. package/templates/.agents/skills/code-task/SKILL.en.md +1 -1
  73. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +2 -2
  74. package/templates/.agents/skills/restore-task/SKILL.en.md +9 -4
  75. package/templates/.agents/skills/restore-task/SKILL.zh-CN.md +9 -4
  76. package/templates/.agents/skills/review-analysis/SKILL.en.md +1 -1
  77. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +1 -1
  78. package/templates/.agents/skills/review-code/SKILL.en.md +3 -2
  79. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +3 -2
  80. package/templates/.agents/skills/review-plan/SKILL.en.md +1 -1
  81. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +1 -1
  82. package/dist/lib/host-control/audit.js +0 -19
  83. package/dist/lib/host-control/client.js +0 -142
  84. package/dist/lib/host-control/command.js +0 -49
  85. package/dist/lib/host-control/path.js +0 -160
  86. package/dist/lib/host-control/server.js +0 -159
  87. package/dist/lib/host-control/service.js +0 -112
  88. package/dist/lib/internal/host-control.js +0 -28
  89. package/lib/host-control/audit.ts +0 -22
  90. package/lib/host-control/client.ts +0 -172
  91. package/lib/host-control/command.ts +0 -60
  92. package/lib/host-control/path.ts +0 -167
  93. package/lib/host-control/server.ts +0 -180
  94. package/lib/host-control/service.ts +0 -133
  95. package/lib/internal/host-control.ts +0 -28
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { createHash } from 'node:crypto';
3
4
 
4
5
  import { parseTaskFrontmatter } from '../task/frontmatter.ts';
5
6
  import { resolveTaskRef } from '../task/resolve-ref.ts';
@@ -16,6 +17,14 @@ import {
16
17
  } from './provider-bridge.ts';
17
18
  import { resourceIdentityNumber } from './resource-identity.ts';
18
19
  import { taskIssueIdentity, taskIssueIdentityError } from './task-identities.ts';
20
+ import {
21
+ recordForArtifact,
22
+ recordForCancel,
23
+ recordForSummary,
24
+ recordForTask,
25
+ renderActivityRecoveryMetadata
26
+ } from './activity-recovery.ts';
27
+ import { projectTaskComment } from './task-comment-projection.ts';
19
28
  import {
20
29
  canonicalizeCommentBody,
21
30
  escapeHtmlText,
@@ -48,7 +57,7 @@ const MARKERS = {
48
57
  artifactChunk: (taskId: string, stem: string, part: number, total: number) =>
49
58
  `<!-- sync-issue:${taskId}:${stem}:${part}/${total} -->`,
50
59
  summary: (taskId: string) => `<!-- sync-issue:${taskId}:summary -->`,
51
- cancel: (taskId: string) => `<!-- sync-issue:${taskId}:cancel -->`
60
+ cancel: (taskId: string) => `<!-- sync-issue:${taskId}:cancel -->`,
52
61
  };
53
62
  const COMMENT_BYTE_LIMIT = 60_000;
54
63
 
@@ -68,10 +77,6 @@ function normalizeCommentContent(content: string): string {
68
77
  return content.replace(/\r\n/g, '\n').replace(/\n+$/, '\n');
69
78
  }
70
79
 
71
- function isTaskCommentTooLarge(content: string): boolean {
72
- return Buffer.byteLength(content, 'utf8') > COMMENT_BYTE_LIMIT;
73
- }
74
-
75
80
  function splitFrontmatter(content: string): { frontmatter: string | null; body: string } {
76
81
  const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
77
82
  if (!match) return { frontmatter: null, body: content };
@@ -88,22 +93,55 @@ function footer(agent: string, taskId: string): string {
88
93
  return `---\n*由 ${agent} 自动生成 · 内部追踪:${taskId}*`;
89
94
  }
90
95
 
91
- function renderTaskComment(content: string, taskId: string, agent: string): string {
96
+ function taskFrontmatterSummary(language = ''): string {
97
+ return language === 'en' || language === 'en-US'
98
+ ? 'Metadata (frontmatter)'
99
+ : '元数据 (frontmatter)';
100
+ }
101
+
102
+ function renderTaskCommentResult(content: string, taskId: string, language = ''): { body: string; byteLength: number; sha256: string } {
92
103
  const split = splitFrontmatter(content);
93
- const safeBody = sanitizeCommentBody(split.body, 'task body');
94
- const taskBody = split.frontmatter === null
104
+ const projectedContent = split.frontmatter === null ? content : projectTaskComment(content).content;
105
+ const projected = splitFrontmatter(projectedContent);
106
+ const safeBody = sanitizeCommentBody(projected.body, 'task body');
107
+ const taskBody = projected.frontmatter === null
95
108
  ? safeBody
96
- : `<details><summary>元数据 (frontmatter)</summary>\n\n${renderSafeCodeFence(`---\n${split.frontmatter}\n---`, 'yaml')}\n\n</details>\n\n${safeBody}`;
97
- return normalizeCommentContent([
109
+ : `<details><summary>${taskFrontmatterSummary(language)}</summary>\n\n${renderSafeCodeFence(`---\n${projected.frontmatter}\n---`, 'yaml')}\n\n</details>\n\n${safeBody}`;
110
+ const recoveryMetadata = renderActivityRecoveryMetadata(recordForTask(taskId, content));
111
+ const body = normalizeCommentContent([
98
112
  MARKERS.task(taskId),
99
113
  '## 任务文件',
100
114
  '',
101
- `> **${agent}** · ${taskId}`,
115
+ `> 任务同步 · ${taskId}`,
102
116
  '',
117
+ ...(recoveryMetadata ? [recoveryMetadata.replace(/\n+$/, ''), ''] : []),
103
118
  taskBody.replace(/\n+$/, ''),
104
119
  '',
105
- footer(agent, taskId)
120
+ '---',
121
+ `*由 agent-infra 自动生成 · 内部追踪:${taskId}*`
106
122
  ].join('\n'));
123
+ return {
124
+ body,
125
+ byteLength: Buffer.byteLength(body, 'utf8'),
126
+ sha256: createHash('sha256').update(body, 'utf8').digest('hex')
127
+ };
128
+ }
129
+
130
+ function renderTaskComment(content: string, taskId: string, _agent: string, language = ''): string {
131
+ return renderTaskCommentResult(content, taskId, language).body;
132
+ }
133
+
134
+ function isTaskCommentTooLarge(content: string, taskId = 'TASK-UNKNOWN', _agent = 'codex'): boolean {
135
+ return renderTaskCommentResult(content, taskId).byteLength > COMMENT_BYTE_LIMIT;
136
+ }
137
+
138
+ function taskCommentLanguage(repoRoot: string): string {
139
+ try {
140
+ const config = JSON.parse(fs.readFileSync(path.join(repoRoot, '.agents', '.airc.json'), 'utf8')) as { language?: unknown };
141
+ return typeof config.language === 'string' ? config.language.trim() : '';
142
+ } catch {
143
+ return '';
144
+ }
107
145
  }
108
146
 
109
147
  function artifactIdentity(artifact: string): { stem: string; title: string } {
@@ -247,7 +285,8 @@ function buildArtifactChunk(
247
285
  total: number,
248
286
  chunked: boolean,
249
287
  backfill: boolean,
250
- renderedContent = content
288
+ renderedContent = content,
289
+ recoveryMetadata = ''
251
290
  ): RenderedChunk {
252
291
  const marker = chunked ? MARKERS.artifactChunk(taskId, stem, part, total) : MARKERS.artifact(taskId, stem);
253
292
  const heading = chunked ? `## ${title}(${part}/${total})` : `## ${title}`;
@@ -259,6 +298,7 @@ function buildArtifactChunk(
259
298
  '',
260
299
  `> **${agent}** · ${taskId}`,
261
300
  '',
301
+ ...(recoveryMetadata ? [recoveryMetadata] : []),
262
302
  renderedContent,
263
303
  '',
264
304
  footer(agent, taskId)
@@ -273,12 +313,13 @@ function chunkArtifactComment(input: {
273
313
  body: string;
274
314
  byteLimit?: number;
275
315
  backfill?: boolean;
316
+ recoveryMetadata?: string;
276
317
  }): RenderedChunk[] {
277
318
  const byteLimit = input.byteLimit || COMMENT_BYTE_LIMIT;
278
319
  const identity = artifactIdentity(input.artifact);
279
320
  const safeBody = sanitizeCommentBody(input.body, 'artifact body');
280
321
  const single = buildArtifactChunk(
281
- input.taskId, identity.stem, identity.title, input.agent, safeBody, 1, 1, false, Boolean(input.backfill)
322
+ input.taskId, identity.stem, identity.title, input.agent, safeBody, 1, 1, false, Boolean(input.backfill), safeBody, input.recoveryMetadata
282
323
  );
283
324
  if (Buffer.byteLength(single.body, 'utf8') <= byteLimit) return [single];
284
325
 
@@ -288,7 +329,7 @@ function chunkArtifactComment(input: {
288
329
  if (!ranges.ok) throw new Error(`${ranges.error.code}: artifact body: ${ranges.error.message} at offset ${ranges.error.offset}`);
289
330
  for (;;) {
290
331
  const probe = buildArtifactChunk(
291
- input.taskId, identity.stem, identity.title, input.agent, '', total, total, true, Boolean(input.backfill)
332
+ input.taskId, identity.stem, identity.title, input.agent, '', 1, total, true, Boolean(input.backfill), '', input.recoveryMetadata
292
333
  );
293
334
  const available = byteLimit - Buffer.byteLength(probe.body, 'utf8');
294
335
  const sourceLimit = Math.min(available, payloadLimit);
@@ -308,7 +349,8 @@ function chunkArtifactComment(input: {
308
349
  total,
309
350
  true,
310
351
  Boolean(input.backfill),
311
- independentChunkContent(piece, ranges.value, sourceLimit)
352
+ independentChunkContent(piece, ranges.value, sourceLimit),
353
+ index === 0 ? input.recoveryMetadata : ''
312
354
  ));
313
355
  const overflow = Math.max(...chunks.map((chunk) => Buffer.byteLength(chunk.body, 'utf8') - byteLimit));
314
356
  if (overflow <= 0) return chunks;
@@ -346,9 +388,11 @@ function hasResolvedPlatformContext(context: PlatformResult): boolean {
346
388
  return context.status === 'degraded' || (context.status === 'no-op' && context.error === null);
347
389
  }
348
390
 
349
- function bodyEnvelope(marker: string, title: string, taskId: string, agent: string, body: string): string {
391
+ function bodyEnvelope(marker: string, title: string, taskId: string, agent: string, body: string, recoveryMetadata = ''): string {
350
392
  return normalizeCommentContent([
351
- marker, `## ${title}`, '', `> **${agent}** · ${taskId}`, '', sanitizeCommentBody(body, `${title} body`).replace(/\n+$/, ''), '', footer(agent, taskId)
393
+ marker, `## ${title}`, '', `> **${agent}** · ${taskId}`, '',
394
+ ...(recoveryMetadata ? [recoveryMetadata.replace(/\n+$/, ''), ''] : []),
395
+ sanitizeCommentBody(body, `${title} body`).replace(/\n+$/, ''), '', footer(agent, taskId)
352
396
  ].join('\n'));
353
397
  }
354
398
 
@@ -368,10 +412,11 @@ function expectedComments(
368
412
  taskId: string,
369
413
  taskContent: string,
370
414
  taskDir: string,
415
+ repoRoot: string,
371
416
  options: SyncOptions
372
417
  ): RenderedChunk[] {
373
418
  if (options.kind === 'task') {
374
- const body = renderTaskComment(taskContent, taskId, options.agent);
419
+ const body = renderTaskComment(taskContent, taskId, options.agent, taskCommentLanguage(repoRoot));
375
420
  return [{ marker: MARKERS.task(taskId), body, content: taskContent, part: 1, total: 1 }];
376
421
  }
377
422
  if (options.kind === 'artifact') {
@@ -381,13 +426,17 @@ function expectedComments(
381
426
  artifact: options.artifact,
382
427
  agent: options.agent,
383
428
  body: resolveArtifactBody(taskDir, options.artifact),
384
- backfill: options.backfill
429
+ backfill: options.backfill,
430
+ recoveryMetadata: renderActivityRecoveryMetadata(recordForArtifact(taskId, taskContent, options.artifact))
385
431
  });
386
432
  }
387
433
  if (options.body === undefined) throw new Error(`${options.kind} sync requires a body`);
388
434
  const marker = options.kind === 'summary' ? MARKERS.summary(taskId) : MARKERS.cancel(taskId);
389
435
  const title = options.kind === 'summary' ? '交付摘要' : '任务取消';
390
- const body = bodyEnvelope(marker, title, taskId, options.agent, options.body);
436
+ const recoveryMetadata = options.kind === 'summary'
437
+ ? renderActivityRecoveryMetadata(recordForSummary(taskId, taskContent))
438
+ : renderActivityRecoveryMetadata(recordForCancel(taskId, taskContent));
439
+ const body = bodyEnvelope(marker, title, taskId, options.agent, options.body, recoveryMetadata);
391
440
  return [{ marker, body, content: options.body, part: 1, total: 1 }];
392
441
  }
393
442
 
@@ -458,22 +507,22 @@ async function syncPlatformComment(taskRef: string, options: SyncOptions): Promi
458
507
  error: { code: 'ISSUE_NOT_LINKED', message: 'Task has no valid platform issue identity', retryable: false }
459
508
  });
460
509
  }
461
- if (options.kind === 'task' && isTaskCommentTooLarge(taskContent)) {
462
- return platformResult('no-op', {
463
- resource: { kind: 'issue', number: resourceIdentityNumber(issueIdentityFromTask), identity: issueIdentityFromTask },
464
- operations: [{ name: `comment:${MARKERS.task(resolved.taskId)}`, status: 'skipped', reasonCode: 'COMMENT_PAYLOAD_TOO_LARGE' }],
465
- error: null
466
- });
467
- }
468
510
  let desired: RenderedChunk[];
469
511
  try {
470
- desired = expectedComments(resolved.taskId, taskContent, resolved.taskDir, options);
512
+ desired = expectedComments(resolved.taskId, taskContent, resolved.taskDir, resolved.repoRoot, options);
471
513
  } catch (error) {
472
514
  return platformResult('failed', {
473
515
  resource: { kind: 'issue', number: resourceIdentityNumber(issueIdentityFromTask) },
474
516
  error: { code: 'COMMENT_PAYLOAD_INVALID', message: error instanceof Error ? error.message : String(error), retryable: false }
475
517
  });
476
518
  }
519
+ if (options.kind === 'task' && Buffer.byteLength(desired[0]!.body, 'utf8') > COMMENT_BYTE_LIMIT) {
520
+ return platformResult('failed', {
521
+ resource: { kind: 'issue', number: resourceIdentityNumber(issueIdentityFromTask), identity: issueIdentityFromTask },
522
+ operations: [{ name: `comment:${MARKERS.task(resolved.taskId)}`, status: 'skipped', reasonCode: 'COMMENT_PAYLOAD_TOO_LARGE' }],
523
+ error: { code: 'COMMENT_PAYLOAD_TOO_LARGE', message: 'Projected task comment exceeds the platform byte limit', retryable: false }
524
+ });
525
+ }
477
526
  const loaded = await resolvePlatformProviderContext({ cwd: resolved.repoRoot, client: options.client });
478
527
  const context = loaded.ok ? loaded.value.context : loaded.context;
479
528
  if (!hasResolvedPlatformContext(context) || !loaded.ok) return context;
@@ -505,6 +554,22 @@ async function syncPlatformComment(taskRef: string, options: SyncOptions): Promi
505
554
  });
506
555
  }
507
556
 
557
+ if (options.kind === 'task' && existing.length === 1) {
558
+ const owner = existing[0]!.user?.login;
559
+ if (!owner || !context.platform.currentUser || owner !== context.platform.currentUser) {
560
+ return platformResult('blocked', {
561
+ ...contextFields(context),
562
+ resource: { kind: 'issue', number: issue, identity: issueIdentityFromTask },
563
+ error: {
564
+ code: 'COMMENT_OWNER_CONFLICT',
565
+ message: owner ? `Task comment is owned by '${owner}'` : 'Task comment owner is unavailable',
566
+ retryable: false
567
+ }
568
+ });
569
+ }
570
+ }
571
+
572
+
508
573
  // Backfill only supplies missing artifact comments; valid existing marker sets stay untouched.
509
574
  if (options.kind === 'artifact' && options.backfill && existing.length > 0) {
510
575
  const operations = existing.map((comment): PlatformOperation => ({
@@ -582,14 +647,15 @@ async function syncPlatformComment(taskRef: string, options: SyncOptions): Promi
582
647
  operations.push({ name: `comment:${comment.id}`, status: 'applied', reasonCode: 'STALE_CHUNK_DELETED' });
583
648
  }
584
649
  const changed = operations.some((operation) => operation.status === 'applied');
585
- return platformResult(changed ? 'applied' : 'no-op', {
650
+ const result = platformResult(changed ? 'applied' : 'no-op', {
586
651
  ...contextFields(context),
587
652
  changed,
588
653
  resource: { kind: 'issue', number: issue },
589
654
  operations,
590
- comment: { kind: options.kind, marker: desired[0]!.marker, ids, parts: desired.length },
655
+ comment: { kind: options.kind, marker: desired[0]?.marker ?? markerPrefix(resolved.taskId, options), ids, parts: desired.length },
591
656
  error: null
592
657
  });
658
+ return result;
593
659
  }
594
660
 
595
661
  async function listPlatformComments(issue: string | number, cwd = process.cwd(), client?: PlatformClient): Promise<PlatformResult & { comments?: RemoteComment[] }> {
@@ -655,6 +721,7 @@ export {
655
721
  listPlatformComments,
656
722
  normalizeCommentContent,
657
723
  renderTaskComment,
724
+ renderTaskCommentResult,
658
725
  isTaskCommentTooLarge,
659
726
  syncPlatformComment,
660
727
  validateRelatedMarkerSet,
@@ -0,0 +1,79 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ const SNAPSHOT_FRONTMATTER = new Set([
4
+ 'id', 'type', 'branch', 'workflow', 'status', 'created_at', 'updated_at',
5
+ 'priority', 'effort', 'start_date', 'target_date', 'current_step', 'assigned_to',
6
+ 'platform_issue_identity', 'delivery_remote', 'delivery_base_ref', 'pr_delivery_fact'
7
+ ]);
8
+
9
+ type TaskCommentProjection = {
10
+ content: string;
11
+ byteLength: number;
12
+ sha256: string;
13
+ };
14
+
15
+ const PROCESS_SECTION_ALIASES = new Set([
16
+ '活动日志', 'activity log',
17
+ '产物生命周期收据', 'artifact lifecycle receipts',
18
+ '审查分歧账本', 'review dispute ledger', 'review disagreement ledger',
19
+ '人工裁决', '人工裁决待办', 'human decisions', 'human decision pending', 'human decisions pending',
20
+ '实现输入', 'implementation input', 'implementation inputs',
21
+ '工作流告警', 'workflow warning', 'workflow warnings', 'warnings',
22
+ '返工意图', 'rework intent', 'rework intents',
23
+ '产物失效记录', 'artifact invalidation', 'artifact invalidation records',
24
+ '实现备注', 'implementation notes',
25
+ '审查反馈', 'review feedback'
26
+ ]);
27
+
28
+ function normalize(content: string): string {
29
+ return content.replace(/\r\n/g, '\n').replace(/\n+$/, '\n');
30
+ }
31
+
32
+ function splitFrontmatter(content: string): { frontmatter: string; body: string } {
33
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
34
+ if (!match) throw new Error('task comment projection requires task frontmatter');
35
+ return { frontmatter: match[1]!, body: content.slice(match[0].length) };
36
+ }
37
+
38
+ function projectFrontmatter(frontmatter: string): string {
39
+ const lines = frontmatter.replace(/\r\n/g, '\n').split('\n');
40
+ const selected = lines.filter((line) => {
41
+ if (!line.trim() || line.trimStart().startsWith('#')) return false;
42
+ const match = /^([^:\s][^:]*):/.exec(line);
43
+ return Boolean(match && SNAPSHOT_FRONTMATTER.has(match[1]!.trim()));
44
+ });
45
+ if (!selected.some((line) => /^id:/.test(line))) throw new Error('task comment projection requires frontmatter id');
46
+ return selected.join('\n');
47
+ }
48
+
49
+ function projectBody(body: string): string {
50
+ const lines = body.replace(/\r\n/g, '\n').split('\n');
51
+ const kept: string[] = [];
52
+ let excludedLevel: number | null = null;
53
+ for (const line of lines) {
54
+ const heading = /^(#{1,6})\s+(.+?)\s*$/.exec(line);
55
+ if (heading) {
56
+ const level = heading[1]!.length;
57
+ if (excludedLevel !== null && level <= excludedLevel) excludedLevel = null;
58
+ if (PROCESS_SECTION_ALIASES.has(heading[2]!.trim().toLowerCase())) {
59
+ excludedLevel = level;
60
+ continue;
61
+ }
62
+ }
63
+ if (excludedLevel === null) kept.push(line);
64
+ }
65
+ return kept.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd();
66
+ }
67
+
68
+ function projectTaskComment(content: string): TaskCommentProjection {
69
+ const split = splitFrontmatter(content);
70
+ const projected = normalize(`---\n${projectFrontmatter(split.frontmatter)}\n---\n\n${projectBody(split.body)}`);
71
+ return {
72
+ content: projected,
73
+ byteLength: Buffer.byteLength(projected, 'utf8'),
74
+ sha256: createHash('sha256').update(projected, 'utf8').digest('hex')
75
+ };
76
+ }
77
+
78
+ export { projectTaskComment };
79
+ export type { TaskCommentProjection };
@@ -0,0 +1,54 @@
1
+ import { parseActivityRecoveryMetadata } from './activity-recovery.ts';
2
+
3
+ type RecoveryRemoteComment = { body: string; user?: { login?: string } };
4
+
5
+ function normalized(body: string): string {
6
+ return body.replace(/\r\n/g, '\n').replace(/\n+$/, '\n');
7
+ }
8
+
9
+ function decodeRenderedText(value: string): string {
10
+ return value
11
+ .replace(/&lt;/g, '<')
12
+ .replace(/&gt;/g, '>')
13
+ .replace(/&quot;/g, '"')
14
+ .replace(/&#39;/g, "'")
15
+ .replace(/&amp;/g, '&');
16
+ }
17
+
18
+ function taskSnapshot(taskId: string, body: string): string {
19
+ const marker = `<!-- sync-issue:${taskId}:task -->`;
20
+ if (normalized(body).split('\n', 1)[0] !== marker) throw new Error('RECOVERY_EVIDENCE_MISSING: task snapshot marker is invalid');
21
+ const frontmatter = body.match(/<details><summary>[^<]*<\/summary>\n\n```yaml\n---\n([\s\S]*?)\n---\n```\n\n<\/details>/);
22
+ if (!frontmatter) throw new Error('RECOVERY_EVIDENCE_MISSING: task snapshot frontmatter is invalid');
23
+ const afterDetails = (frontmatter.index ?? 0) + frontmatter[0].length;
24
+ const footer = body.lastIndexOf('\n\n---\n*由 ');
25
+ if (footer <= afterDetails) throw new Error('RECOVERY_EVIDENCE_MISSING: task snapshot envelope is invalid');
26
+ const projectedBody = body.slice(afterDetails, footer).replace(/^\n+|\n+$/g, '');
27
+ return `---\n${decodeRenderedText(frontmatter[1]!)}\n---\n\n${decodeRenderedText(projectedBody)}\n`;
28
+ }
29
+
30
+ function activityLine(entry: { time: string; step: string; agent: string; note: string }): string {
31
+ return `- ${entry.time} — **${entry.step}** by ${entry.agent} — ${entry.note}`;
32
+ }
33
+
34
+ function recoverTaskFromComments(input: { taskId: string; comments: readonly RecoveryRemoteComment[] }): string {
35
+ const marker = `<!-- sync-issue:${input.taskId}:task -->`;
36
+ const snapshots = input.comments.filter((comment) => normalized(comment.body).split('\n', 1)[0] === marker);
37
+ if (snapshots.length !== 1) throw new Error('RECOVERY_EVIDENCE_MISSING: exactly one task snapshot comment is required');
38
+ const author = snapshots[0]!.user?.login;
39
+ if (!author) throw new Error('RECOVERY_EVIDENCE_MISSING: task snapshot author is unavailable');
40
+ const content = taskSnapshot(input.taskId, normalized(snapshots[0]!.body));
41
+ const entries = new Map<string, { time: string; step: string; agent: string; note: string }>();
42
+ for (const comment of input.comments) {
43
+ if (comment.user?.login !== author) continue;
44
+ const record = parseActivityRecoveryMetadata(comment.body);
45
+ if (!record) continue;
46
+ if (record.taskId !== input.taskId) throw new Error('RECOVERY_EVIDENCE_MISSING: activity metadata task identity is invalid');
47
+ for (const entry of record.entries) entries.set(JSON.stringify(entry), entry);
48
+ }
49
+ const restored = [...entries.values()].sort((left, right) => left.time.localeCompare(right.time));
50
+ return `${content.replace(/\n+$/, '')}\n\n## 活动日志\n\n${restored.map(activityLine).join('\n')}\n`;
51
+ }
52
+
53
+ export { recoverTaskFromComments };
54
+ export type { RecoveryRemoteComment };
@@ -12,7 +12,7 @@ import { planInLabelUpdate, validateInLabelMapping } from "./in-label-sync.ts";
12
12
  import { readPrDeliveryFact } from "../task/pr-delivery-fact.ts";
13
13
  import { providerError, providerOperationContext, resourceIdentityNumber, unsupportedProviderOperation } from "./provider-bridge.ts";
14
14
  import { taskIssueIdentity } from "./task-identities.ts";
15
- import { isTaskCommentTooLarge } from "./issue-comments.ts";
15
+ import { renderTaskCommentResult } from "./issue-comments.ts";
16
16
  import {
17
17
  CONTROL_MARKER_PATTERN,
18
18
  canonicalizeCommentBody,
@@ -542,10 +542,6 @@ function checkTaskCommentContent(context: any, remoteData: any, shared: Verifica
542
542
  if (!context.config.verify_task_comment_content) {
543
543
  return null;
544
544
  }
545
- if (isTaskCommentTooLarge(context.task.content)) {
546
- return null;
547
- }
548
-
549
545
  const taskMarker = `<!-- sync-issue:${context.task.metadata.id}:task -->`;
550
546
  const comment = findCommentByMarker(remoteData.comments, taskMarker);
551
547
  if (!comment) {
@@ -555,14 +551,20 @@ function checkTaskCommentContent(context: any, remoteData: any, shared: Verifica
555
551
  );
556
552
  }
557
553
 
558
- const expectedTaskBody = buildExpectedTaskBody(context.task.content, shared);
559
- if (expectedTaskBody === null) {
560
- return shared.failResult(CHECK_TYPE,
561
- "Task content cannot be rendered safely for comment verification",
562
- "check_failed"
554
+ let renderedTask: { body: string; byteLength: number };
555
+ try {
556
+ renderedTask = renderTaskCommentResult(
557
+ context.task.content,
558
+ context.task.metadata.id,
559
+ loadProjectLanguage(shared)
563
560
  );
561
+ } catch {
562
+ return shared.failResult(CHECK_TYPE, "Task content cannot be rendered safely for comment verification", "check_failed");
563
+ }
564
+ if (renderedTask.byteLength > 60_000) {
565
+ return shared.failResult(CHECK_TYPE, "Task comment exceeds the platform byte limit", "check_failed");
564
566
  }
565
- const expectedBody = shared.normalizeContent(expectedTaskBody);
567
+ const expectedBody = shared.normalizeContent(extractCommentBody(renderedTask.body));
566
568
  const commentBody = shared.normalizeContent(extractCommentBody(comment.body || ""));
567
569
 
568
570
  if (expectedBody === commentBody) {
@@ -833,7 +835,7 @@ function extractCommentBody(commentBody: any): any {
833
835
  start += 1;
834
836
  }
835
837
 
836
- if (start < lines.length && /^> \*\*.+\*\* · .+$/.test(lines[start]!.trim())) {
838
+ if (start < lines.length && (/^> \*\*.+\*\* · .+$/.test(lines[start]!.trim()) || /^> 任务同步 · .+$/.test(lines[start]!.trim()))) {
837
839
  start += 1;
838
840
  }
839
841
 
@@ -841,6 +843,16 @@ function extractCommentBody(commentBody: any): any {
841
843
  start += 1;
842
844
  }
843
845
 
846
+ if (lines[start]?.trim() === "<details><summary>恢复元数据</summary>") {
847
+ const metadataEnd = lines.indexOf("</details>", start + 1);
848
+ if (metadataEnd >= 0) {
849
+ start = metadataEnd + 1;
850
+ while (start < lines.length && lines[start]!.trim() === "") {
851
+ start += 1;
852
+ }
853
+ }
854
+ }
855
+
844
856
  let end = lines.length;
845
857
  for (let index = lines.length - 1; index >= start; index -= 1) {
846
858
  const trimmed = lines[index]!.trim();
@@ -1042,7 +1054,9 @@ function computeExpectedInLabels(taskDir: any, repositoryLabels: string[], mappi
1042
1054
  if (!baseRef) {
1043
1055
  return { ok: false, type: "check_failed", message: "Task has no delivery_base_ref for in-label evidence" };
1044
1056
  }
1045
- const changedFilesResult = gitText(["diff", `${baseRef}...HEAD`, "--name-only"], taskDir);
1057
+ const branch = typeof task.metadata?.branch === "string" ? task.metadata.branch.trim() : "";
1058
+ const gitCwd = branch ? worktreeForBranch(shared.repoRoot, branch) ?? taskDir : taskDir;
1059
+ const changedFilesResult = gitText(["diff", `${baseRef}...HEAD`, "--name-only"], gitCwd);
1046
1060
  if (!changedFilesResult.ok) {
1047
1061
  return { ...changedFilesResult, type: "network_error" };
1048
1062
  }
@@ -1069,6 +1083,25 @@ function computeExpectedInLabels(taskDir: any, repositoryLabels: string[], mappi
1069
1083
  return { ok: true, labels: planned.target, mode: "mapped" };
1070
1084
  }
1071
1085
 
1086
+ function worktreeForBranch(repositoryRoot: string, branch: string): string | null {
1087
+ try {
1088
+ const records = execFileSync("git", ["worktree", "list", "--porcelain"], {
1089
+ cwd: repositoryRoot,
1090
+ encoding: "utf8",
1091
+ stdio: ["pipe", "pipe", "pipe"]
1092
+ }).trim().split(/\r?\n\r?\n/);
1093
+ const expectedRef = `refs/heads/${branch}`;
1094
+ for (const record of records) {
1095
+ const worktree = /^worktree (.+)$/m.exec(record)?.[1];
1096
+ const branchRef = /^branch (.+)$/m.exec(record)?.[1];
1097
+ if (worktree && branchRef === expectedRef) return worktree;
1098
+ }
1099
+ } catch {
1100
+ // Fall back to taskDir so ordinary in-repository task workspaces retain their behavior.
1101
+ }
1102
+ return null;
1103
+ }
1104
+
1072
1105
  function loadInLabelMapping(shared: VerificationShared): any {
1073
1106
  const configPath = path.join(shared.repoRoot, ".agents", ".airc.json");
1074
1107
  if (!fs.existsSync(configPath)) {
@@ -188,7 +188,9 @@ export async function prepareSandboxControlExecution(params: {
188
188
  owner: gateOwner,
189
189
  requestId: params.request.id,
190
190
  operationId: authority?.operationId ?? params.request.id,
191
- phase: authority?.phase ?? (params.request.family === 'task-workflow' ? 'artifact.finalize-local' : 'orchestration.prepare'),
191
+ phase: authority?.phase ?? (params.request.family === 'task-workflow'
192
+ && params.request.workflow?.operation === 'artifact-preflight' ? 'artifact.preflight'
193
+ : params.request.family === 'task-workflow' ? 'artifact.finalize-local' : 'orchestration.prepare'),
192
194
  authority
193
195
  } satisfies SandboxControlExecutorGateV2);
194
196
  },
@@ -235,7 +237,7 @@ function waitForGate(nonce: string, timeoutMs = 2_000): Promise<Readonly<{ owner
235
237
  return;
236
238
  }
237
239
  if (typeof value.requestId !== 'string' || typeof value.operationId !== 'string'
238
- || !['orchestration.prepare', 'artifact.finalize-local', 'task-event.completed'].includes(value.phase as string)
240
+ || !['orchestration.prepare', 'artifact.preflight', 'artifact.finalize-local', 'task-event.completed'].includes(value.phase as string)
239
241
  || (value.authority !== null && value.authority !== undefined && (() => {
240
242
  try { validateLifecycleRecoveryAttestation(value.authority); return false; } catch { return true; }
241
243
  })())) {
@@ -62,7 +62,7 @@ export type SandboxControlExecutorGateV2 = Readonly<{
62
62
  owner: SandboxControlBrokerOwner;
63
63
  requestId: string;
64
64
  operationId: string;
65
- phase: 'orchestration.prepare' | 'artifact.finalize-local' | 'task-event.completed';
65
+ phase: 'orchestration.prepare' | 'artifact.preflight' | 'artifact.finalize-local' | 'task-event.completed';
66
66
  authority: LifecycleRecoveryAttestationV1 | null;
67
67
  }>;
68
68
  type RequestBase = Readonly<{
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
 
5
- import { readStableFile, SecureFileError, writeAtomicFile } from '../../host-control/secure-fs.ts';
5
+ import { readStableFile, SecureFileError, writeAtomicFile } from '../../task/secure-fs.ts';
6
6
 
7
7
  import { TASK_WORKFLOW_COMMANDS, TASK_WORKFLOW_OPERATIONS, type TaskWorkflowOperation, type WorkflowCommand } from '../../task/workflow-command.ts';
8
8