@fitlab-ai/agent-infra 0.12.0 → 0.12.1

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 (46) hide show
  1. package/dist/lib/internal/manual-validation.js +11 -17
  2. package/dist/lib/internal/platform-comment.js +56 -7
  3. package/dist/lib/platform/issue-comments.js +373 -267
  4. package/dist/lib/platform/issue-summary-content.js +112 -0
  5. package/dist/lib/platform/pr-change-report.js +175 -26
  6. package/dist/lib/platform/pr-summary-content.js +54 -0
  7. package/dist/lib/platform/pr-summary.js +318 -284
  8. package/dist/lib/platform/summary-content-pipeline.js +18 -0
  9. package/dist/lib/sandbox/removal.js +59 -2
  10. package/dist/lib/task/finalization.js +16 -7
  11. package/dist/lib/task/platform-operation-recovery.js +87 -87
  12. package/dist/lifecycle-build-manifest.json +3 -3
  13. package/lib/internal/manual-validation.ts +12 -18
  14. package/lib/internal/platform-comment.ts +46 -4
  15. package/lib/platform/issue-comments.ts +478 -280
  16. package/lib/platform/issue-summary-content.ts +132 -0
  17. package/lib/platform/pr-change-report.ts +165 -20
  18. package/lib/platform/pr-summary-content.ts +60 -0
  19. package/lib/platform/pr-summary.ts +364 -257
  20. package/lib/platform/summary-content-pipeline.ts +23 -0
  21. package/lib/sandbox/removal.ts +57 -1
  22. package/lib/task/finalization.ts +11 -6
  23. package/lib/task/platform-operation-recovery.ts +70 -81
  24. package/package.json +1 -1
  25. package/templates/.agents/rules/pr-sync.en.md +4 -2
  26. package/templates/.agents/rules/pr-sync.zh-CN.md +6 -9
  27. package/templates/.agents/skills/cancel-task/SKILL.en.md +1 -1
  28. package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +1 -1
  29. package/templates/.agents/skills/commit/reference/pr-summary-sync.en.md +1 -1
  30. package/templates/.agents/skills/commit/reference/pr-summary-sync.zh-CN.md +1 -1
  31. package/templates/.agents/skills/complete-manual-validation/reference/summary-update.en.md +2 -2
  32. package/templates/.agents/skills/complete-manual-validation/reference/summary-update.zh-CN.md +2 -2
  33. package/templates/.agents/skills/complete-task/SKILL.en.md +10 -25
  34. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +11 -26
  35. package/templates/.agents/skills/create-pr/SKILL.en.md +1 -1
  36. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +1 -1
  37. package/templates/.agents/skills/create-pr/reference/change-report.en.md +9 -2
  38. package/templates/.agents/skills/create-pr/reference/change-report.zh-CN.md +9 -2
  39. package/templates/.agents/skills/create-pr/reference/comment-publish.en.md +2 -2
  40. package/templates/.agents/skills/create-pr/reference/comment-publish.zh-CN.md +2 -2
  41. package/templates/.agents/skills/create-task/SKILL.en.md +0 -18
  42. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +0 -18
  43. package/templates/.agents/skills/watch-pr/SKILL.en.md +1 -1
  44. package/templates/.agents/skills/watch-pr/SKILL.zh-CN.md +1 -1
  45. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.en.md +3 -3
  46. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.zh-CN.md +3 -3
@@ -5,11 +5,13 @@ import { createHash } from 'node:crypto';
5
5
  import { parseTaskFrontmatter } from '../task/frontmatter.ts';
6
6
  import { resolveTaskRef } from '../task/resolve-ref.ts';
7
7
  import { coordinatePlatformWrite } from './operation-coordinator.ts';
8
+ import type { PlatformOperation as JournalOperation, RecordOperationInput } from '../task/platform-operation-journal.ts';
8
9
  import { resolvePlatformProviderContext } from './context.ts';
9
- import type { PlatformClient } from './context.ts';
10
+ import type { LoadedContext, PlatformClient } from './context.ts';
10
11
  import { providerErrorRetryable } from './provider-validation.ts';
11
12
  import { platformResult } from './types.ts';
12
13
  import type { PlatformOperation, PlatformResult } from './types.ts';
14
+ import type { ResourceIdentity } from './resource-identity.ts';
13
15
  import {
14
16
  providerError,
15
17
  providerOperationContext,
@@ -58,9 +60,39 @@ type SyncOptions = {
58
60
  runtimeVersion?: string;
59
61
  summaryAuthorization?: { sha256: string };
60
62
  dependency?: 'deferred' | 'required';
61
- skipQueue?: boolean;
63
+ queuedOperation?: JournalOperation;
62
64
  taskProjection?: { content: string; sha256: string };
63
65
  };
66
+ type CommentPlanStrategy = Readonly<{
67
+ taskId: string;
68
+ cwd: string;
69
+ agent: string;
70
+ parentKind: 'issue' | 'pull-request';
71
+ kind: CommentKind;
72
+ client?: PlatformClient;
73
+ resolved?: { loaded: LoadedContext; context: PlatformResult };
74
+ backfill?: boolean;
75
+ markerPrefix: string;
76
+ prepare: () => Promise<{ parent: ResourceIdentity; items: RenderedChunk[] }>;
77
+ validate?: () => Promise<{ code: string; message: string; retryable: boolean } | null>;
78
+ summaryPosition?: boolean;
79
+ authorizeReposition?: (comment: RemoteComment, currentUser: string | null) => { code: string; message: string; retryable: boolean } | null;
80
+ verifyReadback?: boolean;
81
+ dryRun?: boolean;
82
+ }>;
83
+
84
+ async function syncCommentPlan(
85
+ strategy: CommentPlanStrategy,
86
+ operation: Omit<RecordOperationInput, 'state' | 'lastCode'>,
87
+ queuedOperation?: JournalOperation
88
+ ): Promise<PlatformResult> {
89
+ const execute = () => executeCommentPlan(strategy, strategy.client);
90
+ if (strategy.dryRun) return execute();
91
+ if (queuedOperation) return execute();
92
+ return coordinatePlatformWrite({ operation, agent: strategy.agent, client: strategy.client, execute,
93
+ block: (error) => platformResult('blocked', { error }),
94
+ persistenceFailure: (error) => platformResult('failed', { error }) });
95
+ }
64
96
 
65
97
  function providerCommentId(id: string, provider: { identity?: { comment?: string } }): number | string {
66
98
  return provider.identity?.comment === 'number' && /^\d+$/.test(id) ? Number(id) : id;
@@ -472,38 +504,370 @@ function relatedComments(comments: RemoteComment[], prefix: string): RemoteComme
472
504
  });
473
505
  }
474
506
 
475
- function taskManagedComment(comment: RemoteComment, taskId: string): boolean {
476
- const marker = normalizeCommentContent(comment.body).split('\n', 1)[0] || '';
477
- return marker === MARKERS.task(taskId)
478
- || marker === MARKERS.summary(taskId)
479
- || marker.startsWith(`<!-- sync-issue:${taskId}:`);
507
+ async function listedComments(provider: any, loaded: any, parent: ResourceIdentity): Promise<any> {
508
+ return provider.comments?.list
509
+ ? provider.comments.list({ context: providerOperationContext(loaded), parent }).then((response: any) => response.ok
510
+ ? { ok: true as const, value: response.value.map((comment: any) => parentComment(comment, provider)) }
511
+ : { ...response, error: { ...response.error, retryable: isRetryable(response.error) } })
512
+ : unsupportedProviderOperation(provider, 'comments.list');
513
+ }
514
+
515
+ type CommentPlanSelection = Readonly<{
516
+ existing: RemoteComment[];
517
+ byMarker: Map<string, RemoteComment>;
518
+ reposition: boolean;
519
+ }>;
520
+
521
+ function markerSetError(strategy: CommentPlanStrategy, context: PlatformResult, resource: PlatformResult['resource'], existing: RemoteComment[]): PlatformResult | null {
522
+ if (validateRelatedMarkerSet(existing, strategy.markerPrefix).ok) return null;
523
+ return platformResult('failed', { ...contextFields(context), resource, error: {
524
+ code: strategy.parentKind === 'pull-request' ? 'PR_SUMMARY_MARKER_AMBIGUOUS' : 'COMMENT_MARKER_CONFLICT',
525
+ message: strategy.parentKind === 'pull-request' ? 'Multiple PR comments contain the summary marker' : 'Multiple comments use the same registered marker', retryable: false
526
+ } });
527
+ }
528
+
529
+ function taskCommentOwnerError(
530
+ strategy: CommentPlanStrategy,
531
+ context: PlatformResult,
532
+ resource: PlatformResult['resource'],
533
+ existing: RemoteComment[]
534
+ ): PlatformResult | null {
535
+ if (strategy.kind !== 'task' || existing.length !== 1) return null;
536
+ const owner = existing[0]!.user?.login;
537
+ return owner && context.platform.currentUser && owner === context.platform.currentUser ? null : platformResult('blocked', {
538
+ ...contextFields(context), resource, error: {
539
+ code: 'COMMENT_OWNER_CONFLICT', message: owner ? `Task comment is owned by '${owner}'` : 'Task comment owner is unavailable', retryable: false
540
+ }
541
+ });
542
+ }
543
+
544
+ async function strategyValidationError(
545
+ strategy: CommentPlanStrategy,
546
+ context: PlatformResult,
547
+ resource: PlatformResult['resource']
548
+ ): Promise<PlatformResult | null> {
549
+ const error = strategy.validate ? await strategy.validate() : null;
550
+ if (!error) return null;
551
+ return platformResult(error.retryable ? 'blocked' : 'failed', { ...contextFields(context), resource, error });
552
+ }
553
+
554
+ function artifactBackfillResult(
555
+ strategy: CommentPlanStrategy,
556
+ context: PlatformResult,
557
+ resource: PlatformResult['resource'],
558
+ existing: RemoteComment[],
559
+ desired: RenderedChunk[]
560
+ ): PlatformResult | null {
561
+ if (!strategy.backfill || strategy.kind !== 'artifact' || existing.length === 0) return null;
562
+ const operations = existing.map((comment): PlatformOperation => ({
563
+ name: `comment:${normalizeCommentContent(comment.body).split('\n', 1)[0]}`,
564
+ status: 'no-op',
565
+ reasonCode: 'BACKFILL_ALREADY_PRESENT'
566
+ }));
567
+ return platformResult('no-op', {
568
+ ...contextFields(context),
569
+ resource,
570
+ operations,
571
+ comment: {
572
+ kind: strategy.kind,
573
+ marker: desired[0]?.marker ?? strategy.markerPrefix,
574
+ ids: existing.map((item) => item.id),
575
+ parts: existing.length
576
+ },
577
+ error: null
578
+ });
579
+ }
580
+
581
+ function repositionAuthorizationError(
582
+ strategy: CommentPlanStrategy,
583
+ context: PlatformResult,
584
+ resource: PlatformResult['resource'],
585
+ comments: RemoteComment[],
586
+ byMarker: Map<string, RemoteComment>,
587
+ desired: RenderedChunk[]
588
+ ): { error: PlatformResult | null; reposition: boolean } {
589
+ const marker = desired[0]?.marker;
590
+ const current = marker ? byMarker.get(marker) : undefined;
591
+ const position = strategy.summaryPosition && current
592
+ ? issueSummaryPosition(strategy.taskId, comments, marker!)
593
+ : true;
594
+ if (position === null) return {
595
+ error: platformResult('blocked', { ...contextFields(context), resource, error: {
596
+ code: 'SUMMARY_POSITION_UNVERIFIED', message: 'Summary comment ordering cannot be proven from the provider snapshot', retryable: true
597
+ } }),
598
+ reposition: false
599
+ };
600
+ const reposition = Boolean(strategy.summaryPosition && current && !position);
601
+ if (!reposition || !current) return { error: null, reposition: false };
602
+ const error = strategy.authorizeReposition
603
+ ? strategy.authorizeReposition(current, context.platform.currentUser ?? null)
604
+ : { code: 'SUMMARY_REPOSITION_UNAUTHORIZED', message: 'summary reposition requires current authorization and an owned summary comment', retryable: false };
605
+ return {
606
+ error: error ? platformResult(error.retryable ? 'blocked' : 'failed', { ...contextFields(context), resource, error }) : null,
607
+ reposition: true
608
+ };
609
+ }
610
+
611
+ async function inspectCommentPlan(
612
+ strategy: CommentPlanStrategy,
613
+ context: PlatformResult,
614
+ resource: PlatformResult['resource'],
615
+ comments: RemoteComment[],
616
+ desired: RenderedChunk[]
617
+ ): Promise<{ ok: true; value: CommentPlanSelection; result?: PlatformResult } | { ok: false; result: PlatformResult }> {
618
+ const existing = relatedComments(comments, strategy.markerPrefix);
619
+ const markerError = markerSetError(strategy, context, resource, existing);
620
+ if (markerError) return { ok: false, result: markerError };
621
+ const ownerError = taskCommentOwnerError(strategy, context, resource, existing);
622
+ if (ownerError) return { ok: false, result: ownerError };
623
+ const validationError = await strategyValidationError(strategy, context, resource);
624
+ if (validationError) return { ok: false, result: validationError };
625
+ const backfill = artifactBackfillResult(strategy, context, resource, existing, desired);
626
+ if (backfill) return { ok: true, value: { existing, byMarker: new Map(), reposition: false }, result: backfill };
627
+ const byMarker = new Map(existing.map((comment) => [normalizeCommentContent(comment.body).split('\n', 1)[0]!, comment]));
628
+ const reposition = repositionAuthorizationError(strategy, context, resource, comments, byMarker, desired);
629
+ if (reposition.error) return { ok: false, result: reposition.error };
630
+ return { ok: true, value: { existing, byMarker, reposition: reposition.reposition } };
631
+ }
632
+
633
+ async function repositionSummaryComment(
634
+ strategy: CommentPlanStrategy,
635
+ loaded: LoadedContext,
636
+ context: PlatformResult,
637
+ parent: ResourceIdentity,
638
+ resource: PlatformResult['resource'],
639
+ current: RemoteComment | undefined,
640
+ operations: PlatformOperation[]
641
+ ): Promise<PlatformResult | null> {
642
+ if (!current) return null;
643
+ const deleted = loaded.provider.comments?.delete
644
+ ? await loaded.provider.comments.delete({ context: providerOperationContext(loaded), parent,
645
+ comment: providerResourceToken(loaded.provider, 'comment', String(current.id)),
646
+ mutation: { idempotencyKey: `comment-reposition-delete:${strategy.taskId}:${current.id}` } })
647
+ : unsupportedProviderOperation(loaded.provider, 'comments.delete');
648
+ if (!deleted.ok) return platformResult(isRetryable(deleted.error) ? 'blocked' : 'failed', {
649
+ ...contextFields(context), resource, error: deleted.error
650
+ });
651
+ operations.push({
652
+ name: `comment:${current.id}`,
653
+ status: 'applied',
654
+ reasonCode: 'SUMMARY_REPOSITIONED'
655
+ });
656
+ return null;
657
+ }
658
+
659
+ async function observeCommentWrite(
660
+ strategy: CommentPlanStrategy,
661
+ loaded: LoadedContext,
662
+ parent: ResourceIdentity,
663
+ item: RenderedChunk
664
+ ): Promise<{ valid: true } | { valid: false; error: PlatformResult['error'] }> {
665
+ const after = await listedComments(loaded.provider, loaded, parent);
666
+ const comments = after.ok ? after.value as RemoteComment[] : [];
667
+ const found = comments.filter((comment) => normalizeCommentContent(comment.body).split('\n', 1)[0] === item.marker);
668
+ const positionOk = !strategy.summaryPosition || issueSummaryPosition(strategy.taskId, comments, item.marker) === true;
669
+ const validation = strategy.validate ? await strategy.validate() : null;
670
+ if (after.ok
671
+ && found.length === 1
672
+ && normalizeCommentContent(found[0]!.body) === normalizeCommentContent(item.body)
673
+ && positionOk
674
+ && !validation) return { valid: true };
675
+ const error = validation ?? {
676
+ code: strategy.kind === 'summary' || strategy.kind === 'cancel' ? 'SUMMARY_BODY_READBACK_FAILED' : 'COMMENT_READBACK_FAILED',
677
+ message: after.ok ? 'Remote comment did not match the requested body' : after.error.message,
678
+ retryable: after.ok || isRetryable(after.error)
679
+ };
680
+ return { valid: false, error };
480
681
  }
481
682
 
482
- function summaryPosition(comments: RemoteComment[], taskId: string): { ok: boolean; known: boolean; summary: RemoteComment | null } {
483
- const managed = comments.filter((comment) => taskManagedComment(comment, taskId));
484
- const summary = managed.filter((comment) => normalizeCommentContent(comment.body).split('\n', 1)[0] === MARKERS.summary(taskId));
485
- if (summary.length !== 1) return { ok: false, known: false, summary: null };
486
- if (managed.some((comment) => !Number.isSafeInteger(comment.createdSequence) || Number(comment.createdSequence) < 1)) {
487
- return { ok: false, known: false, summary: summary[0]! };
683
+ async function verifyIssueSummaryReadback(
684
+ strategy: CommentPlanStrategy,
685
+ loaded: LoadedContext,
686
+ context: PlatformResult,
687
+ parent: ResourceIdentity,
688
+ resource: PlatformResult['resource'],
689
+ desired: RenderedChunk[],
690
+ operations: PlatformOperation[]
691
+ ): Promise<PlatformResult | null> {
692
+ if (strategy.parentKind !== 'issue' || (strategy.kind !== 'summary' && strategy.kind !== 'cancel')) return null;
693
+ const after = await listedComments(loaded.provider, loaded, parent);
694
+ if (!after.ok) return platformResult(isRetryable(after.error) ? 'blocked' : 'failed', {
695
+ ...contextFields(context), resource, operations, error: after.error
696
+ });
697
+ const item = desired[0];
698
+ const matching = item
699
+ ? after.value.filter((comment: RemoteComment) => normalizeCommentContent(comment.body).split('\n', 1)[0] === item.marker)
700
+ : [];
701
+ if (!item || matching.length !== 1 || normalizeCommentContent(matching[0]!.body) !== normalizeCommentContent(item.body)) {
702
+ return platformResult('blocked', { ...contextFields(context), resource, operations, error: {
703
+ code: 'SUMMARY_BODY_READBACK_FAILED',
704
+ message: strategy.kind === 'cancel' ? 'Remote cancellation body did not match the rendered cancellation summary' : 'Remote summary body did not match the rendered delivery summary',
705
+ retryable: true
706
+ } });
488
707
  }
489
- const sequence = summary[0]!.createdSequence!;
490
- return { ok: managed.every((comment) => sequence >= comment.createdSequence!), known: true, summary: summary[0]! };
708
+ if (strategy.summaryPosition && issueSummaryPosition(strategy.taskId, after.value, item.marker) !== true) {
709
+ return platformResult('blocked', { ...contextFields(context), resource, operations, error: {
710
+ code: 'SUMMARY_POSITION_UNVERIFIED', message: 'Summary comment is not provably last among task-managed comments', retryable: true
711
+ } });
712
+ }
713
+ return null;
491
714
  }
492
715
 
493
- async function listedComments(provider: any, loaded: any, parent: ReturnType<typeof taskIssueIdentity>): Promise<any> {
494
- return provider.comments?.list
495
- ? provider.comments.list({ context: providerOperationContext(loaded), parent }).then((response: any) => response.ok
496
- ? {
497
- ok: true as const,
498
- value: response.value.map((comment: any) => ({
499
- id: providerCommentId(comment.id, provider),
500
- body: comment.body,
501
- user: comment.author?.name ? { login: comment.author.name } : undefined,
502
- createdSequence: comment.createdSequence
503
- }))
504
- }
505
- : response)
506
- : unsupportedProviderOperation(provider, 'comments.list');
716
+ async function compensatePullRequestComment(
717
+ strategy: CommentPlanStrategy,
718
+ loaded: LoadedContext,
719
+ parent: ResourceIdentity,
720
+ current: RemoteComment | undefined,
721
+ id: number | string,
722
+ cause: PlatformResult['error']
723
+ ): Promise<PlatformResult['error']> {
724
+ const compensation = current
725
+ ? await loaded.provider.comments?.write?.({ context: providerOperationContext(loaded), parent, body: current.body,
726
+ existingComment: providerResourceToken(loaded.provider, 'comment', String(current.id)),
727
+ mutation: { idempotencyKey: `pr-summary-restore:${strategy.taskId}` } })
728
+ : await loaded.provider.comments?.delete?.({ context: providerOperationContext(loaded), parent,
729
+ comment: providerResourceToken(loaded.provider, 'comment', String(id)),
730
+ mutation: { idempotencyKey: `pr-summary-delete:${strategy.taskId}:${id}` } });
731
+ if (!compensation?.ok) return {
732
+ code: 'PR_SUMMARY_RACE_COMPENSATION_FAILED',
733
+ message: compensation?.error?.message ?? 'Provider does not support comment compensation',
734
+ retryable: true
735
+ };
736
+ if (cause?.code === 'PR_SUMMARY_HEAD_RACE') return cause;
737
+ return {
738
+ code: 'PR_SUMMARY_POSTWRITE_VERIFY_FAILED',
739
+ message: cause?.message ?? 'Post-write summary verification failed',
740
+ retryable: true
741
+ };
742
+ }
743
+
744
+ async function verifyCommentWrite(
745
+ strategy: CommentPlanStrategy,
746
+ loaded: LoadedContext,
747
+ parent: ResourceIdentity,
748
+ item: RenderedChunk,
749
+ current: RemoteComment | undefined,
750
+ id: number | string
751
+ ): Promise<PlatformResult['error']> {
752
+ if (!strategy.verifyReadback || strategy.parentKind === 'issue') return null;
753
+ const observed = await observeCommentWrite(strategy, loaded, parent, item);
754
+ if (observed.valid) return null;
755
+ if (strategy.parentKind === 'pull-request') {
756
+ return compensatePullRequestComment(strategy, loaded, parent, current, id, observed.error);
757
+ }
758
+ return observed.error;
759
+ }
760
+
761
+ async function writeDesiredComments(
762
+ strategy: CommentPlanStrategy,
763
+ loaded: LoadedContext,
764
+ context: PlatformResult,
765
+ parent: ResourceIdentity,
766
+ resource: PlatformResult['resource'],
767
+ desired: RenderedChunk[],
768
+ selection: CommentPlanSelection,
769
+ operations: PlatformOperation[]
770
+ ): Promise<{ ok: true; ids: Array<number | string> } | { ok: false; result: PlatformResult }> {
771
+ const ids: Array<number | string> = [];
772
+ for (const item of desired) {
773
+ const current = selection.reposition && item === desired[0] ? undefined : selection.byMarker.get(item.marker);
774
+ if (current && normalizeCommentContent(current.body) === normalizeCommentContent(item.body)) {
775
+ ids.push(current.id);
776
+ operations.push({ name: `comment:${item.marker}`, status: 'no-op', reasonCode: null });
777
+ continue;
778
+ }
779
+ const written = loaded.provider.comments?.write
780
+ ? await loaded.provider.comments.write({ context: providerOperationContext(loaded), parent, body: item.body,
781
+ ...(current ? { existingComment: providerResourceToken(loaded.provider, 'comment', String(current.id)) } : {}),
782
+ mutation: { idempotencyKey: `comment:${strategy.taskId}:${item.marker}` } })
783
+ : unsupportedProviderOperation(loaded.provider, 'comments.write');
784
+ if (!written.ok) return { ok: false, result: platformResult(isRetryable(written.error) ? 'blocked' : 'failed', {
785
+ ...contextFields(context), resource, operations, error: written.error
786
+ }) };
787
+ const id = providerCommentId(written.value.remoteId, loaded.provider);
788
+ ids.push(id);
789
+ operations.push({ name: `comment:${item.marker}`, status: 'applied', reasonCode: null });
790
+ const error = await verifyCommentWrite(strategy, loaded, parent, item, current, id);
791
+ if (error) return { ok: false, result: platformResult(error.retryable ? 'blocked' : 'failed', {
792
+ ...contextFields(context), resource, operations, error
793
+ }) };
794
+ }
795
+ return { ok: true, ids };
796
+ }
797
+
798
+ async function deleteStaleComments(
799
+ strategy: CommentPlanStrategy,
800
+ loaded: LoadedContext,
801
+ context: PlatformResult,
802
+ parent: ResourceIdentity,
803
+ resource: PlatformResult['resource'],
804
+ existing: RemoteComment[],
805
+ desired: RenderedChunk[],
806
+ operations: PlatformOperation[]
807
+ ): Promise<PlatformResult | null> {
808
+ const markers = new Set(desired.map((item) => item.marker));
809
+ for (const stale of existing.filter((comment) => !markers.has(normalizeCommentContent(comment.body).split('\n', 1)[0]!))) {
810
+ const deleted = loaded.provider.comments?.delete
811
+ ? await loaded.provider.comments.delete({ context: providerOperationContext(loaded), parent,
812
+ comment: providerResourceToken(loaded.provider, 'comment', String(stale.id)),
813
+ mutation: { idempotencyKey: `comment-delete:${strategy.taskId}:${stale.id}` } })
814
+ : unsupportedProviderOperation(loaded.provider, 'comments.delete');
815
+ if (!deleted.ok) return platformResult(isRetryable(deleted.error) ? 'blocked' : 'failed', {
816
+ ...contextFields(context), resource, operations, error: deleted.error
817
+ });
818
+ operations.push({ name: `comment:${stale.id}`, status: 'applied', reasonCode: 'STALE_CHUNK_DELETED' });
819
+ }
820
+ return null;
821
+ }
822
+
823
+ type PreparedCommentExecution = Readonly<{
824
+ loaded: LoadedContext;
825
+ context: PlatformResult;
826
+ parent: ResourceIdentity;
827
+ desired: RenderedChunk[];
828
+ resource: PlatformResult['resource'];
829
+ selection: CommentPlanSelection;
830
+ result?: PlatformResult;
831
+ }>;
832
+
833
+ async function prepareCommentExecution(
834
+ strategy: CommentPlanStrategy,
835
+ client?: PlatformClient
836
+ ): Promise<{ ok: true; value: PreparedCommentExecution } | { ok: false; result: PlatformResult }> {
837
+ const resolved = strategy.resolved
838
+ ? { ok: true as const, value: strategy.resolved.loaded }
839
+ : await resolvePlatformProviderContext({ cwd: strategy.cwd, client });
840
+ const loaded = resolved;
841
+ const context = strategy.resolved?.context ?? (loaded.ok ? loaded.value.context : loaded.context);
842
+ if (!loaded.ok || !hasResolvedPlatformContext(context)) return { ok: false, result: context };
843
+ let parent: ResourceIdentity;
844
+ let desired: RenderedChunk[];
845
+ try { ({ parent, items: desired } = await strategy.prepare()); }
846
+ catch (error) {
847
+ const value = error as { code?: string; message?: string };
848
+ return { ok: false, result: platformResult('failed', { ...contextFields(context), error: {
849
+ code: value.code || 'COMMENT_PAYLOAD_INVALID', message: value.message || String(error), retryable: false
850
+ } }) };
851
+ }
852
+ const resource = { kind: strategy.parentKind, number: resourceIdentityNumber(parent), identity: parent } as const;
853
+ const listed = await listedComments(loaded.value.provider, loaded.value, parent);
854
+ if (!listed.ok) return { ok: false, result: platformResult(isRetryable(listed.error) ? 'blocked' : 'failed', {
855
+ ...contextFields(context), resource, error: listed.error
856
+ }) };
857
+ const selection = await inspectCommentPlan(strategy, context, resource, listed.value, desired);
858
+ if (!selection.ok) return selection;
859
+ return {
860
+ ok: true,
861
+ value: {
862
+ loaded: loaded.value,
863
+ context,
864
+ parent,
865
+ desired,
866
+ resource,
867
+ selection: selection.value,
868
+ result: selection.result
869
+ }
870
+ };
507
871
  }
508
872
 
509
873
  function validateRelatedMarkerSet(comments: RemoteComment[], prefix: string): { ok: boolean; code: string | null } {
@@ -525,268 +889,102 @@ function validateRelatedMarkerSet(comments: RemoteComment[], prefix: string): {
525
889
  return complete ? { ok: true, code: null } : { ok: false, code: 'COMMENT_MARKER_CONFLICT' };
526
890
  }
527
891
 
528
- function writeComment(
529
- client: PlatformClient,
530
- repo: string,
531
- issue: number,
532
- cwd: string,
533
- body: string,
534
- id?: number
535
- ) {
536
- const endpoint = id ? `repos/${repo}/issues/comments/${id}` : `repos/${repo}/issues/${issue}/comments`;
537
- return client.json<{ id?: number }>(
538
- ['api', endpoint, '-X', id ? 'PATCH' : 'POST', '--input', '-'],
539
- { cwd, method: id ? 'PATCH' : 'POST', input: JSON.stringify({ body }) }
540
- );
892
+ function parentComment(comment: any, provider: any): RemoteComment {
893
+ return {
894
+ id: providerCommentId(comment.id, provider), body: comment.body,
895
+ user: comment.author?.name ? { login: comment.author.name } : undefined,
896
+ createdSequence: comment.createdSequence
897
+ };
541
898
  }
542
899
 
543
- async function syncPlatformCommentImpl(taskRef: string, options: SyncOptions): Promise<PlatformResult> {
544
- const resolved = resolveTaskRef(taskRef, options.cwd ? { repoRoot: options.cwd } : {});
545
- if (!resolved.ok) {
546
- return platformResult('failed', {
547
- error: { code: resolved.code, message: resolved.message, retryable: false }
548
- });
549
- }
550
- const taskContent = fs.readFileSync(resolved.taskMdPath, 'utf8');
551
- let issueIdentityFromTask: ReturnType<typeof taskIssueIdentity>;
552
- try { issueIdentityFromTask = taskIssueIdentity(parseTaskFrontmatter(taskContent)); }
553
- catch (error) { return platformResult('failed', { error: { ...taskIssueIdentityError(error), retryable: false } }); }
554
- if (!issueIdentityFromTask) {
555
- return platformResult('no-op', {
556
- error: { code: 'ISSUE_NOT_LINKED', message: 'Task has no valid platform issue identity', retryable: false }
557
- });
558
- }
559
- let desired: RenderedChunk[];
560
- try {
561
- const projectedContent = options.kind === 'task' && options.taskProjection ? options.taskProjection.content : taskContent;
562
- if (options.kind === 'task' && options.taskProjection
563
- && createHash('sha256').update(projectedContent).digest('hex') !== options.taskProjection.sha256) {
564
- throw Object.assign(new Error('finalization task projection digest does not match content'), { code: 'FINALIZATION_PROJECTION_INVALID' });
565
- }
566
- desired = expectedComments(resolved.taskId, projectedContent, resolved.taskDir, resolved.repoRoot, options);
567
- } catch (error) {
568
- return platformResult('failed', {
569
- resource: { kind: 'issue', number: resourceIdentityNumber(issueIdentityFromTask) },
570
- error: { code: 'COMMENT_PAYLOAD_INVALID', message: error instanceof Error ? error.message : String(error), retryable: false }
571
- });
572
- }
573
- if (options.kind === 'task' && Buffer.byteLength(desired[0]!.body, 'utf8') > COMMENT_BYTE_LIMIT) {
574
- return platformResult('failed', {
575
- resource: { kind: 'issue', number: resourceIdentityNumber(issueIdentityFromTask), identity: issueIdentityFromTask },
576
- operations: [{ name: `comment:${MARKERS.task(resolved.taskId)}`, status: 'skipped', reasonCode: 'COMMENT_PAYLOAD_TOO_LARGE' }],
577
- error: { code: 'COMMENT_PAYLOAD_TOO_LARGE', message: 'Projected task comment exceeds the platform byte limit', retryable: false }
578
- });
579
- }
580
- const loaded = await resolvePlatformProviderContext({ cwd: resolved.repoRoot, client: options.client });
581
- const context = loaded.ok ? loaded.value.context : loaded.context;
582
- if (!hasResolvedPlatformContext(context) || !loaded.ok) return context;
583
- const issue = resourceIdentityNumber(issueIdentityFromTask);
584
- const listed = await listedComments(loaded.value.provider, loaded.value, issueIdentityFromTask);
585
- if (!listed.ok) {
586
- return platformResult(isRetryable(listed.error) ? 'blocked' : 'failed', {
587
- ...contextFields(context), resource: { kind: 'issue', number: issue }, error: listed.error
588
- });
589
- }
590
-
591
- const existing = relatedComments(listed.value, markerPrefix(resolved.taskId, options));
592
- if (!validateRelatedMarkerSet(existing, markerPrefix(resolved.taskId, options)).ok) {
593
- return platformResult('failed', {
594
- ...contextFields(context),
595
- resource: { kind: 'issue', number: issue },
596
- error: { code: 'COMMENT_MARKER_CONFLICT', message: 'Multiple comments use the same registered marker', retryable: false }
597
- });
598
- }
599
-
600
- if (options.kind === 'task' && existing.length === 1) {
601
- const owner = existing[0]!.user?.login;
602
- if (!owner || !context.platform.currentUser || owner !== context.platform.currentUser) {
603
- return platformResult('blocked', {
604
- ...contextFields(context),
605
- resource: { kind: 'issue', number: issue, identity: issueIdentityFromTask },
606
- error: {
607
- code: 'COMMENT_OWNER_CONFLICT',
608
- message: owner ? `Task comment is owned by '${owner}'` : 'Task comment owner is unavailable',
609
- retryable: false
610
- }
611
- });
612
- }
613
- }
614
-
615
- // Backfill only supplies missing artifact comments; valid existing marker sets stay untouched.
616
- if (options.kind === 'artifact' && options.backfill && existing.length > 0) {
617
- const operations = existing.map((comment): PlatformOperation => ({
618
- name: `comment:${normalizeCommentContent(comment.body).split('\n', 1)[0]}`,
619
- status: 'no-op',
620
- reasonCode: 'BACKFILL_ALREADY_PRESENT'
621
- }));
622
- return platformResult('no-op', {
623
- ...contextFields(context),
624
- changed: false,
625
- resource: { kind: 'issue', number: issue },
626
- operations,
627
- comment: {
628
- kind: options.kind,
629
- marker: desired[0]!.marker,
630
- ids: existing.map((comment) => comment.id),
631
- parts: existing.length
632
- },
633
- error: null
634
- });
635
- }
900
+ function issueSummaryPosition(taskId: string, comments: readonly RemoteComment[], marker: string): boolean | null {
901
+ const managed = comments.filter((comment) => comment.body.split('\n', 1)[0]?.startsWith(`<!-- sync-issue:${taskId}:`));
902
+ const summary = managed.filter((comment) => comment.body.split('\n', 1)[0] === marker);
903
+ if (summary.length !== 1 || managed.some((comment) => !Number.isSafeInteger(comment.createdSequence) || comment.createdSequence === null)) return null;
904
+ return managed.every((comment) => summary[0]!.createdSequence! >= comment.createdSequence!);
905
+ }
636
906
 
907
+ async function executeCommentPlan(
908
+ strategy: CommentPlanStrategy,
909
+ client?: PlatformClient
910
+ ): Promise<PlatformResult> {
911
+ const prepared = await prepareCommentExecution(strategy, client);
912
+ if (!prepared.ok) return prepared.result;
913
+ const { loaded, context, parent, desired, resource, selection, result } = prepared.value;
914
+ if (result) return result;
915
+ if (strategy.dryRun) return platformResult('planned', {
916
+ ...contextFields(context), resource,
917
+ operations: desired.map((item) => ({ name: `comment:${item.marker}`, status: 'planned', reasonCode: null })), error: null
918
+ });
637
919
  const operations: PlatformOperation[] = [];
638
- const ids: Array<number | string> = [];
639
- for (const item of desired) {
640
- const current = existing.find((comment) => normalizeCommentContent(comment.body).split('\n', 1)[0] === item.marker);
641
- if (current && normalizeCommentContent(current.body) === normalizeCommentContent(item.body)) {
642
- ids.push(current.id);
643
- operations.push({ name: `comment:${item.marker}`, status: 'no-op', reasonCode: null });
644
- continue;
645
- }
646
- const written = loaded.value.provider.comments?.write
647
- ? await loaded.value.provider.comments.write({
648
- context: providerOperationContext(loaded.value),
649
- parent: issueIdentityFromTask,
650
- body: item.body,
651
- ...(current ? { existingComment: { kind: 'id' as const, value: String(current.id) } } : {}),
652
- mutation: { idempotencyKey: `comment:${resolved.taskId}:${item.marker}` }
653
- })
654
- : unsupportedProviderOperation(loaded.value.provider, 'comments.write');
655
- if (!written.ok) {
656
- if (!current && isRetryable(written.error)) {
657
- // A provider owns reconciliation because only it knows how to address the resource.
658
- }
659
- return platformResult(isRetryable(written.error) ? 'blocked' : 'failed', {
660
- ...contextFields(context),
661
- resource: { kind: 'issue', number: issue },
662
- operations,
663
- error: written.error
664
- });
665
- }
666
- ids.push(providerCommentId((written.value as { remoteId: string }).remoteId, loaded.value.provider));
667
- operations.push({ name: `comment:${item.marker}`, status: 'applied', reasonCode: null });
668
- }
669
-
670
- const desiredMarkers = new Set(desired.map((item) => item.marker));
671
- const stale = existing.filter((comment) => !desiredMarkers.has(normalizeCommentContent(comment.body).split('\n', 1)[0]!));
672
- for (const comment of stale) {
673
- const deleted = loaded.value.provider.comments?.delete
674
- ? await loaded.value.provider.comments.delete({
675
- context: providerOperationContext(loaded.value),
676
- parent: issueIdentityFromTask,
677
- comment: { kind: 'id', value: String(comment.id) },
678
- mutation: { idempotencyKey: `comment-delete:${resolved.taskId}:${comment.id}` }
679
- })
680
- : unsupportedProviderOperation(loaded.value.provider, 'comments.delete');
681
- if (!deleted.ok) {
682
- return platformResult(isRetryable(deleted.error) ? 'blocked' : 'failed', {
683
- ...contextFields(context),
684
- resource: { kind: 'issue', number: issue },
685
- operations,
686
- error: deleted.error
687
- });
688
- }
689
- operations.push({ name: `comment:${comment.id}`, status: 'applied', reasonCode: 'STALE_CHUNK_DELETED' });
690
- }
691
- if (options.kind === 'summary') {
692
- const refreshed = await listedComments(loaded.value.provider, loaded.value, issueIdentityFromTask);
693
- if (!refreshed.ok) {
694
- return platformResult(isRetryable(refreshed.error) ? 'blocked' : 'failed', {
695
- ...contextFields(context), resource: { kind: 'issue', number: issue }, operations, error: refreshed.error
696
- });
697
- }
698
- const position = summaryPosition(refreshed.value, resolved.taskId);
699
- if (!position.ok && !position.known) {
700
- return platformResult('blocked', {
701
- ...contextFields(context), resource: { kind: 'issue', number: issue }, operations,
702
- error: { code: 'SUMMARY_POSITION_UNVERIFIED', message: 'Summary comment ordering cannot be proven from the provider snapshot', retryable: true }
703
- });
704
- }
705
- if (!position.ok && position.summary) {
706
- const owner = position.summary.user?.login;
707
- if (!options.summaryAuthorization || options.summaryAuthorization.sha256 !== createHash('sha256').update(options.body ?? '').digest('hex')
708
- || !owner || owner !== context.platform.currentUser) {
709
- return platformResult('failed', {
710
- ...contextFields(context), resource: { kind: 'issue', number: issue }, operations,
711
- error: { code: 'SUMMARY_REPOSITION_UNAUTHORIZED', message: 'summary reposition requires current finalization authorization and an owned durable summary', retryable: false }
712
- });
713
- }
714
- const deleted = loaded.value.provider.comments?.delete
715
- ? await loaded.value.provider.comments.delete({
716
- context: providerOperationContext(loaded.value), parent: issueIdentityFromTask,
717
- comment: { kind: 'id', value: String(position.summary.id) },
718
- mutation: { idempotencyKey: `summary-reposition-delete:${resolved.taskId}:${position.summary.id}` }
719
- })
720
- : unsupportedProviderOperation(loaded.value.provider, 'comments.delete');
721
- if (!deleted.ok) {
722
- return platformResult(isRetryable(deleted.error) ? 'blocked' : 'failed', {
723
- ...contextFields(context), resource: { kind: 'issue', number: issue }, operations, error: deleted.error
724
- });
725
- }
726
- const written = loaded.value.provider.comments?.write
727
- ? await loaded.value.provider.comments.write({
728
- context: providerOperationContext(loaded.value), parent: issueIdentityFromTask, body: desired[0]!.body,
729
- mutation: { idempotencyKey: `summary-reposition-write:${resolved.taskId}` }
730
- })
731
- : unsupportedProviderOperation(loaded.value.provider, 'comments.write');
732
- if (!written.ok) {
733
- return platformResult(isRetryable(written.error) ? 'blocked' : 'failed', {
734
- ...contextFields(context), resource: { kind: 'issue', number: issue }, operations, error: written.error
735
- });
736
- }
737
- ids.splice(0, ids.length, providerCommentId((written.value as { remoteId: string }).remoteId, loaded.value.provider));
738
- operations.push({ name: `comment:${desired[0]!.marker}`, status: 'applied', reasonCode: 'SUMMARY_REPOSITIONED' });
739
- const reconciled = await listedComments(loaded.value.provider, loaded.value, issueIdentityFromTask);
740
- if (!reconciled.ok || !summaryPosition(reconciled.value, resolved.taskId).ok) {
741
- const error = reconciled.ok
742
- ? { code: 'SUMMARY_POSITION_UNVERIFIED', message: 'Summary comment is not provably last among task-managed comments', retryable: true }
743
- : reconciled.error;
744
- return platformResult(isRetryable(error) ? 'blocked' : 'failed', {
745
- ...contextFields(context), resource: { kind: 'issue', number: issue }, operations, error
746
- });
747
- }
748
- }
920
+ const currentSummary = desired[0] ? selection.byMarker.get(desired[0].marker) : undefined;
921
+ if (selection.reposition) {
922
+ const repositioned = await repositionSummaryComment(strategy, loaded, context, parent, resource, currentSummary, operations);
923
+ if (repositioned) return repositioned;
749
924
  }
925
+ const written = await writeDesiredComments(strategy, loaded, context, parent, resource, desired, selection, operations);
926
+ if (!written.ok) return written.result;
927
+ const deleted = await deleteStaleComments(strategy, loaded, context, parent, resource, selection.existing, desired, operations);
928
+ if (deleted) return deleted;
929
+ const readback = await verifyIssueSummaryReadback(strategy, loaded, context, parent, resource, desired, operations);
930
+ if (readback) return readback;
750
931
  const changed = operations.some((operation) => operation.status === 'applied');
751
- const result = platformResult(changed ? 'applied' : 'no-op', {
752
- ...contextFields(context),
753
- changed,
754
- resource: { kind: 'issue', number: issue },
755
- operations,
756
- comment: { kind: options.kind, marker: desired[0]?.marker ?? markerPrefix(resolved.taskId, options), ids, parts: desired.length },
932
+ return platformResult(changed ? 'applied' : 'no-op', {
933
+ ...contextFields(context), changed, resource, operations,
934
+ comment: {
935
+ kind: strategy.kind,
936
+ marker: desired[0]?.marker ?? strategy.markerPrefix,
937
+ ids: written.ids,
938
+ parts: desired.length
939
+ },
757
940
  error: null
758
941
  });
759
- return result;
760
942
  }
761
943
 
762
- async function syncPlatformComment(taskRef: string, options: SyncOptions): Promise<PlatformResult> {
944
+ function syncPlatformComment(taskRef: string, options: SyncOptions): Promise<PlatformResult> {
763
945
  const resolved = resolveTaskRef(taskRef, options.cwd ? { repoRoot: options.cwd } : {});
764
- if (!resolved.ok) return syncPlatformCommentImpl(taskRef, options);
765
- const taskContent = fs.readFileSync(resolved.taskMdPath, 'utf8');
766
- if (!taskIssueIdentity(parseTaskFrontmatter(taskContent))) return syncPlatformCommentImpl(taskRef, options);
767
-
768
- let operation: { kind: 'task-comment' | 'artifact-comment' | 'summary-comment' | 'cancel-comment'; target: string; expectedDigest: string };
946
+ if (!resolved.ok) return Promise.resolve(platformResult('failed', { error: { code: resolved.code, message: resolved.message, retryable: false } }));
947
+ const readStrategy = (): CommentPlanStrategy => {
948
+ const prefix = markerPrefix(resolved.taskId, options);
949
+ return {
950
+ taskId: resolved.taskId, cwd: resolved.repoRoot, agent: options.agent, client: options.client,
951
+ parentKind: 'issue', kind: options.kind, markerPrefix: prefix,
952
+ backfill: options.backfill,
953
+ prepare: async () => {
954
+ const taskContent = fs.readFileSync(resolved.taskMdPath, 'utf8');
955
+ const parent = taskIssueIdentity(parseTaskFrontmatter(taskContent));
956
+ if (!parent) throw Object.assign(new Error('Task has no valid platform issue identity'), { code: 'ISSUE_NOT_LINKED' });
957
+ const source = options.kind === 'task' && options.taskProjection ? options.taskProjection.content : taskContent;
958
+ if (options.kind === 'task' && options.taskProjection && createHash('sha256').update(source).digest('hex') !== options.taskProjection.sha256) {
959
+ throw Object.assign(new Error('finalization task projection digest does not match content'), { code: 'FINALIZATION_PROJECTION_INVALID' });
960
+ }
961
+ const items = expectedComments(resolved.taskId, source, resolved.taskDir, resolved.repoRoot, options);
962
+ if (options.kind === 'task' && Buffer.byteLength(items[0]!.body, 'utf8') > COMMENT_BYTE_LIMIT) throw Object.assign(new Error('Projected task comment exceeds the platform byte limit'), { code: 'COMMENT_PAYLOAD_TOO_LARGE' });
963
+ return { parent, items };
964
+ },
965
+ summaryPosition: options.kind === 'summary',
966
+ verifyReadback: options.kind === 'summary' || options.kind === 'cancel',
967
+ authorizeReposition: (comment, currentUser) => options.summaryAuthorization
968
+ && options.summaryAuthorization.sha256 === createHash('sha256').update(options.body ?? '').digest('hex')
969
+ && comment.user?.login && currentUser && comment.user.login === currentUser
970
+ ? null : { code: 'SUMMARY_REPOSITION_UNAUTHORIZED', message: 'summary reposition requires current finalization authorization and an owned durable summary', retryable: false }
971
+ };
972
+ };
973
+ let strategy: CommentPlanStrategy;
974
+ let operation: Omit<RecordOperationInput, 'state' | 'lastCode'>;
769
975
  try {
770
- const sourceContent = options.kind === 'task' && options.taskProjection ? options.taskProjection.content : taskContent;
771
- const desired = expectedComments(resolved.taskId, sourceContent, resolved.taskDir, resolved.repoRoot, options);
772
- const content = desired.map((chunk) => chunk.content).join('\0');
773
- const kind = options.kind === 'task' ? 'task-comment' : options.kind === 'summary' ? 'summary-comment'
774
- : options.kind === 'cancel' ? 'cancel-comment' : 'artifact-comment';
976
+ strategy = readStrategy();
977
+ const source = options.kind === 'task' && options.taskProjection ? options.taskProjection.content : fs.readFileSync(resolved.taskMdPath, 'utf8');
978
+ const expected = expectedComments(resolved.taskId, source, resolved.taskDir, resolved.repoRoot, options);
979
+ const digest = createHash('sha256').update(expected.map((item) => item.content).join('\0')).digest('hex');
980
+ const kind: JournalOperation['kind'] = options.kind === 'task' ? 'task-comment' : options.kind === 'artifact' ? 'artifact-comment' : options.kind === 'summary' ? 'summary-comment' : 'cancel-comment';
775
981
  const target = options.kind === 'artifact' ? options.artifact! : options.kind;
776
- operation = { kind, target, expectedDigest: createHash('sha256').update(content).digest('hex') };
777
- } catch {
778
- return syncPlatformCommentImpl(taskRef, options);
982
+ operation = { taskRef: resolved.taskId, cwd: resolved.repoRoot, kind, target, expectedDigest: digest, dependency: options.dependency ?? 'deferred' };
983
+ } catch (error) {
984
+ const value = error as { code?: string; message?: string };
985
+ return Promise.resolve(platformResult('failed', { error: { code: value.code || 'COMMENT_PAYLOAD_INVALID', message: value.message || String(error), retryable: false } }));
779
986
  }
780
-
781
- const execute = () => syncPlatformCommentImpl(taskRef, options);
782
- if (options.skipQueue) return execute();
783
- return coordinatePlatformWrite({
784
- operation: { taskRef: resolved.taskId, cwd: resolved.repoRoot, ...operation, dependency: options.dependency ?? 'deferred' },
785
- agent: options.agent,
786
- execute,
787
- block: (error) => platformResult('blocked', { error }),
788
- persistenceFailure: (error) => platformResult('failed', { error })
789
- });
987
+ return syncCommentPlan(strategy, operation, options.queuedOperation);
790
988
  }
791
989
 
792
990
  function inspectPlatformCommentOperation(taskRef: string, options: SyncOptions): { kind: 'task-comment' | 'artifact-comment' | 'summary-comment' | 'cancel-comment'; target: string; expectedDigest: string; id: string } | null {
@@ -868,9 +1066,9 @@ export {
868
1066
  normalizeCommentContent,
869
1067
  renderTaskComment,
870
1068
  renderTaskCommentResult,
1069
+ syncCommentPlan,
871
1070
  isTaskCommentTooLarge,
872
1071
  syncPlatformComment,
873
- validateRelatedMarkerSet,
874
- writeComment
1072
+ validateRelatedMarkerSet
875
1073
  };
876
- export type { CommentKind, RemoteComment, RenderedChunk, SyncOptions };
1074
+ export type { CommentKind, CommentPlanStrategy, RemoteComment, RenderedChunk, SyncOptions };