Context
Surfaced as architecture observation #1 in PR #412 review.
Problem
PR #412 added near-identical TrySendWithFallbackAsync + SendOutcome record + SendOutboundAsync in two places:
FeishuCardHumanInteractionPort's implementation has a comment that explicitly says "Mirrors SkillRunnerGAgent.TrySendWithFallbackAsync". Already drifting:
- Different log strings:
Skill runner ... primary delivery target rejected as ... vs Feishu human interaction port primary delivery target rejected as ...
SendOutcome record duplicated as a private nested type in each class
This violates CLAUDE.md "删除优先:空转发、重复抽象、无业务价值代码直接删除". Future retry-policy tweaks (additional error codes, backoff, telemetry) require touching both copies and they will keep drifting.
Proposal
Extract a shared retry helper that takes the send delegate plus the primary/fallback target pair:
internal static class LarkOutboundRetryPolicy
{
public static async Task<SendOutcome> SendWithFallbackAsync(
Func<LarkReceiveTarget, CancellationToken, Task<string>> sendAsync,
LarkReceiveTarget primary,
LarkReceiveTarget? fallback,
ILogger logger,
string callerContext,
CancellationToken ct)
{
// shared retry-on-230002 policy here
}
}
Each callsite passes its own sendAsync delegate (different proxy paths, different headers) and a callerContext string for logs. The retry policy itself lives in one place.
Acceptance:
Out of scope
- Moving the retry policy into a different DI-resolved abstraction (
ILarkOutboundDispatcher) is tracked separately as the third architecture follow-up — that's a deeper boundary change. This issue is the minimum-viable DRY-up.
Context
Surfaced as architecture observation #1 in PR #412 review.
Problem
PR #412 added near-identical
TrySendWithFallbackAsync+SendOutcomerecord +SendOutboundAsyncin two places:SkillRunnerGAgent.csFeishuCardHumanInteractionPort.csFeishuCardHumanInteractionPort's implementation has a comment that explicitly says "MirrorsSkillRunnerGAgent.TrySendWithFallbackAsync". Already drifting:Skill runner ... primary delivery target rejected as ...vsFeishu human interaction port primary delivery target rejected as ...SendOutcomerecord duplicated as a private nested type in each classThis violates CLAUDE.md "删除优先:空转发、重复抽象、无业务价值代码直接删除". Future retry-policy tweaks (additional error codes, backoff, telemetry) require touching both copies and they will keep drifting.
Proposal
Extract a shared retry helper that takes the send delegate plus the primary/fallback target pair:
Each callsite passes its own
sendAsyncdelegate (different proxy paths, different headers) and acallerContextstring for logs. The retry policy itself lives in one place.Acceptance:
SendOutcometype, one log line shape, one error-code allowlist).SkillRunnerGAgent.SendOutputAsyncandFeishuCardHumanInteractionPort.SendMessageAsyncboth call the shared helper.bot_kickedvariant) requires editing exactly one file.Out of scope
ILarkOutboundDispatcher) is tracked separately as the third architecture follow-up — that's a deeper boundary change. This issue is the minimum-viable DRY-up.