From c87842347efa31db3e2cf66ce4ff264ada0d9afc Mon Sep 17 00:00:00 2001 From: liangxu Date: Thu, 30 Apr 2026 18:15:39 +0800 Subject: [PATCH] feat(account-health): reconcile remote bind state and ignore stale runtime reports Track an authRevision per local record so a fresh bind cancels in-flight probes, sync server-confirmed health back into the desktop cache via reconcileTrackedAccountRemoteState, include verified_at in the upsert signature, and drop runtime health reports older than the stored verified_at on the server side. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../desktop-client/src/main/account-health.ts | 87 ++++++++++++++++++- .../src/main/runtime-controller.ts | 14 +++ .../src/main/transport/api-client.ts | 1 + .../tenant/app/desktop_account_service.go | 10 +++ .../app/desktop_account_service_test.go | 40 +++++++++ 5 files changed, 151 insertions(+), 1 deletion(-) diff --git a/apps/desktop-client/src/main/account-health.ts b/apps/desktop-client/src/main/account-health.ts index 53857fe..35d0151 100644 --- a/apps/desktop-client/src/main/account-health.ts +++ b/apps/desktop-client/src/main/account-health.ts @@ -47,6 +47,7 @@ interface PersistedAccountHealthRecord { interface AccountHealthRecord { accountId: string; platform: string; + authRevision: number; authState: AccountAuthState; probeState: AccountProbeState; authReason: AccountAuthReason; @@ -111,6 +112,7 @@ function normalizePersisted( return { accountId: record.accountId, platform: record.platform, + authRevision: 0, authState: record.lastVerifiedAt ? "active" : "unknown", probeState: "idle", authReason: record.lastVerifiedAt ? "probe_success" : null, @@ -223,6 +225,7 @@ function ensureRecord(account: PublishAccountIdentity): AccountHealthRecord { const created: AccountHealthRecord = { accountId: account.id, platform: account.platform, + authRevision: 0, authState: "unknown", probeState: "idle", authReason: null, @@ -360,6 +363,7 @@ async function performProbeLocked( ): Promise { const record = ensureRecord(account); const adapter = getPlatformAdapter(account.platform); + const probeAuthRevision = record.authRevision; const previousSignature = snapshotSignature(record); record.probeState = "probing"; @@ -369,6 +373,9 @@ async function performProbeLocked( if (options.allowSilentRefresh) { const refreshed = await adapter.silentRefresh(account, partition).catch(() => false); + if (record.authRevision !== probeAuthRevision) { + return toPublicSnapshot(record); + } if (!refreshed) { const currentSignature = snapshotSignature(record); record.authReason = "silent_refresh_failed"; @@ -383,6 +390,10 @@ async function performProbeLocked( sessionFingerprint: null, })); + if (record.authRevision !== probeAuthRevision) { + return toPublicSnapshot(record); + } + const now = Date.now(); switch (result.verdict) { case "active": @@ -455,6 +466,20 @@ function enqueueProbeJob(job: AccountProbeJob): void { drainProbeQueue(); } +function removeQueuedProbeJob(accountId: string): void { + const job = probeJobs.get(accountId); + if (!job || job.status !== "queued") { + return; + } + + probeJobs.delete(accountId); + for (let index = probeQueue.length - 1; index >= 0; index -= 1) { + if (probeQueue[index] === accountId) { + probeQueue.splice(index, 1); + } + } +} + function scheduleProbeRetry(job: AccountProbeJob): void { const retryIndex = Math.min(Math.max(job.attempt - 2, 0), ACCOUNT_PROBE_RETRY_BACKOFF_MS.length - 1); const backoff = ACCOUNT_PROBE_RETRY_BACKOFF_MS[retryIndex] @@ -689,6 +714,7 @@ export function markTrackedAccountBound( }; trackedAccounts.set(account.id, account); + record.authRevision += 1; record.platform = account.platform; record.authState = "active"; record.probeState = "idle"; @@ -702,7 +728,66 @@ export function markTrackedAccountBound( record.suspectedExpiredAt = null; record.confirmFailureCount = 0; - return commitRecord(record, previousSignature); + const snapshot = commitRecord(record, previousSignature); + const queuedJob = probeJobs.get(account.id); + if (queuedJob?.status === "queued") { + removeQueuedProbeJob(account.id); + queuedJob.resolve(snapshot); + } + + return snapshot; +} + +export function reconcileTrackedAccountRemoteState( + account: PublishAccountIdentity, + input: { + health?: DesktopAccountInfo["health"] | null; + verifiedAt?: string | null; + profile?: AccountHealthProfile | null; + }, +): AccountHealthSnapshot | null { + if (input.health !== "live") { + return getAccountHealthSnapshot(account.id); + } + + const remoteVerifiedAt = parseVerifiedAt(input.verifiedAt); + if (!remoteVerifiedAt) { + return getAccountHealthSnapshot(account.id); + } + + const record = ensureRecord(account); + const newestLocalStateAt = Math.max( + record.lastVerifiedAt ?? 0, + record.lastAuthFailureAt ?? 0, + record.lastProbeAt ?? 0, + ); + if (remoteVerifiedAt <= newestLocalStateAt) { + return toPublicSnapshot(record); + } + + const previousSignature = snapshotSignature(record); + record.authRevision += 1; + record.platform = account.platform; + record.authState = "active"; + record.probeState = "idle"; + record.authReason = "bind_success"; + record.lastVerifiedAt = remoteVerifiedAt; + record.lastProbeAt = remoteVerifiedAt; + record.nextProbeAt = nextRegularProbeAt(); + record.lastAuthFailureAt = null; + record.profile = input.profile ?? record.profile; + record.sessionFingerprint = input.profile?.subjectUid ?? record.sessionFingerprint; + record.suspectedExpiredAt = null; + record.confirmFailureCount = 0; + + const snapshot = commitRecord(record, previousSignature); + const queuedJob = probeJobs.get(account.id); + if (queuedJob?.status === "queued") { + removeQueuedProbeJob(account.id); + queuedJob.resolve(snapshot); + } + + return snapshot; } export function getAccountHealthSnapshot(accountId: string): AccountHealthSnapshot | null { diff --git a/apps/desktop-client/src/main/runtime-controller.ts b/apps/desktop-client/src/main/runtime-controller.ts index 4b917cb..e055667 100644 --- a/apps/desktop-client/src/main/runtime-controller.ts +++ b/apps/desktop-client/src/main/runtime-controller.ts @@ -47,6 +47,7 @@ import { getProjectedAccountHealth, markTrackedAccountBound, probeTrackedAccount, + reconcileTrackedAccountRemoteState, reportAccountFailure, syncTrackedAccounts, } from "./account-health"; @@ -948,6 +949,19 @@ async function syncAccounts(source: "startup" | "manual"): Promise { const syncedAccounts: Array = accounts.map((account) => { const normalizedPlatformUid = normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid; const isAIPlatform = isAIPlatformId(account.platform); + const accountIdentity = accountIdentityFromDesktopAccount({ + ...account, + platform_uid: normalizedPlatformUid, + }); + reconcileTrackedAccountRemoteState(accountIdentity, { + health: account.health, + verifiedAt: account.verified_at, + profile: { + subjectUid: normalizedPlatformUid, + displayName: account.display_name, + avatarUrl: account.avatar_url, + }, + }); const projected = getProjectedAccountHealth({ accountId: account.id, platform: account.platform, diff --git a/apps/desktop-client/src/main/transport/api-client.ts b/apps/desktop-client/src/main/transport/api-client.ts index a77363d..409f3f7 100644 --- a/apps/desktop-client/src/main/transport/api-client.ts +++ b/apps/desktop-client/src/main/transport/api-client.ts @@ -175,6 +175,7 @@ function accountUpsertSignature(payload: UpsertDesktopAccountRequest): string { display_name: payload.display_name.trim(), avatar_url: payload.avatar_url ?? null, health: payload.health, + verified_at: payload.verified_at ?? null, tags: payload.tags ?? [], if_sync_version: payload.if_sync_version ?? null, }); diff --git a/server/internal/tenant/app/desktop_account_service.go b/server/internal/tenant/app/desktop_account_service.go index cbe68d3..d777ecb 100644 --- a/server/internal/tenant/app/desktop_account_service.go +++ b/server/internal/tenant/app/desktop_account_service.go @@ -504,6 +504,9 @@ func (s *DesktopAccountService) applyRuntimeHealth(ctx context.Context, workspac if !ok { continue } + if !shouldApplyDesktopAccountRuntimeHealth(items[index], report) { + continue + } health := effectiveDesktopAccountRuntimeHealth(report) authState := report.AuthState @@ -523,6 +526,13 @@ func (s *DesktopAccountService) applyRuntimeHealth(ctx context.Context, workspac } } +func shouldApplyDesktopAccountRuntimeHealth(item DesktopAccountView, report bufferedDesktopAccountHealthReport) bool { + if item.VerifiedAt == nil { + return true + } + return !report.CheckedAt.Before(*item.VerifiedAt) +} + func effectiveDesktopAccountRuntimeHealth(report bufferedDesktopAccountHealthReport) string { authState := strings.TrimSpace(report.AuthState) probeState := strings.TrimSpace(report.ProbeState) diff --git a/server/internal/tenant/app/desktop_account_service_test.go b/server/internal/tenant/app/desktop_account_service_test.go index 9aa55a3..68c1133 100644 --- a/server/internal/tenant/app/desktop_account_service_test.go +++ b/server/internal/tenant/app/desktop_account_service_test.go @@ -117,3 +117,43 @@ func TestBuildDesktopAccountView_FallsBackToStoredClientWhenNoPresence(t *testin } assert.Equal(t, &lastSeen, view.ClientLastSeenAt) } + +func TestShouldApplyDesktopAccountRuntimeHealth_IgnoresReportOlderThanVerifiedAt(t *testing.T) { + verifiedAt := time.Date(2026, 4, 30, 9, 30, 0, 0, time.UTC) + reportCheckedAt := verifiedAt.Add(-time.Minute) + + shouldApply := shouldApplyDesktopAccountRuntimeHealth( + DesktopAccountView{ + ID: uuid.NewString(), + Health: "live", + VerifiedAt: &verifiedAt, + }, + bufferedDesktopAccountHealthReport{ + Health: "expired", + AuthState: "expired", + CheckedAt: reportCheckedAt, + }, + ) + + assert.False(t, shouldApply) +} + +func TestShouldApplyDesktopAccountRuntimeHealth_AppliesReportAfterVerifiedAt(t *testing.T) { + verifiedAt := time.Date(2026, 4, 30, 9, 30, 0, 0, time.UTC) + reportCheckedAt := verifiedAt.Add(time.Minute) + + shouldApply := shouldApplyDesktopAccountRuntimeHealth( + DesktopAccountView{ + ID: uuid.NewString(), + Health: "live", + VerifiedAt: &verifiedAt, + }, + bufferedDesktopAccountHealthReport{ + Health: "expired", + AuthState: "expired", + CheckedAt: reportCheckedAt, + }, + ) + + assert.True(t, shouldApply) +}