feat(client): back off digital employee sync retries

This commit is contained in:
baiyanyun
2026-06-05 18:19:32 +08:00
parent 35d2cb5ea5
commit b7aecad092
3 changed files with 31 additions and 14 deletions

View File

@@ -40,7 +40,7 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
});
ipcMain.handle("digitalEmployee:flushSync", async () => {
return flushDigitalEmployeeSyncOutbox();
return flushDigitalEmployeeSyncOutbox({ force: true });
});
}

View File

@@ -1,11 +1,14 @@
import log from "electron-log";
import { getDeviceId } from "../system/deviceId";
import { getDomainTokenKey } from "@shared/utils/domain";
import { getDb, readSetting } from "../../db";
const DEFAULT_SYNC_PATH = "/api/digital-employee/sync/outbox";
const DEFAULT_INTERVAL_MS = 30_000;
const DEFAULT_BATCH_SIZE = 50;
const REQUEST_TIMEOUT_MS = 15_000;
const MAX_RETRY_DELAY_MS = 30 * 60_000;
const FAILED_RETRY_CANDIDATE_MULTIPLIER = 4;
let syncTimer: ReturnType<typeof setInterval> | null = null;
let syncing = false;
@@ -27,6 +30,7 @@ interface OutboxRow {
operation: string;
payload: string;
attempts: number;
last_attempt_at?: string | null;
}
interface SyncAck {
@@ -93,7 +97,7 @@ export function getDigitalEmployeeSyncStatus(): DigitalEmployeeSyncStatus {
};
}
export async function flushDigitalEmployeeSyncOutbox(): Promise<DigitalEmployeeSyncStatus> {
export async function flushDigitalEmployeeSyncOutbox(options: { force?: boolean } = {}): Promise<DigitalEmployeeSyncStatus> {
if (syncing) return getDigitalEmployeeSyncStatus();
const config = resolveSyncConfig();
@@ -102,7 +106,7 @@ export async function flushDigitalEmployeeSyncOutbox(): Promise<DigitalEmployeeS
return getDigitalEmployeeSyncStatus();
}
const rows = readPendingOutbox(config.batchSize);
const rows = readPendingOutbox(config.batchSize, options.force === true);
if (rows.length === 0) {
lastSyncError = null;
return getDigitalEmployeeSyncStatus();
@@ -198,8 +202,7 @@ function readAuthToken(serverHost: string | null): string | null {
return oneShotToken;
}
if (serverHost) {
const domain = serverHost.replace(/^https?:\/\//, "").replace(/\/+$/, "");
const domainToken = readSetting(`auth.tokens.${domain}`);
const domainToken = readSetting(getDomainTokenKey(serverHost));
if (typeof domainToken === "string" && domainToken.trim()) {
return domainToken;
}
@@ -207,20 +210,35 @@ function readAuthToken(serverHost: string | null): string | null {
return null;
}
function readPendingOutbox(limit: number): OutboxRow[] {
function readPendingOutbox(limit: number, force: boolean): OutboxRow[] {
const db = getDb();
if (!db) return [];
return db
const candidateLimit = force ? limit : limit * FAILED_RETRY_CANDIDATE_MULTIPLIER;
const rows = db
.prepare(
`
SELECT id, entity_type, entity_id, operation, payload, attempts
SELECT id, entity_type, entity_id, operation, payload, attempts, last_attempt_at
FROM digital_sync_outbox
WHERE status IN ('pending', 'failed')
ORDER BY created_at ASC
LIMIT ?
`,
)
.all(limit) as OutboxRow[];
.all(candidateLimit) as OutboxRow[];
if (force) return rows.slice(0, limit);
return rows.filter(isOutboxRowDueForRetry).slice(0, limit);
}
function isOutboxRowDueForRetry(row: OutboxRow): boolean {
if (!row.last_attempt_at) return true;
const lastAttemptAt = Date.parse(row.last_attempt_at);
if (!Number.isFinite(lastAttemptAt)) return true;
return Date.now() >= lastAttemptAt + retryDelayMs(row.attempts);
}
function retryDelayMs(attempts: number): number {
const exponent = Math.max(0, Math.min(attempts - 1, 10));
return Math.min(DEFAULT_INTERVAL_MS * 2 ** exponent, MAX_RETRY_DELAY_MS);
}
async function postOutbox(

View File

@@ -439,10 +439,10 @@ crates/agent-electron-client/src/preload/webviewPerfBridge.ts
```
- 可通过 settings `digital_employee_sync_config.endpoint` 覆盖同步地址。
- 使用 `auth.saved_key` 或域名级 saved key 作为客户端凭证
- 请求同时携带 `Authorization: Bearer <clientKey>` `X-Qiming-Client-Key`,兼容后端后续选择
- 使用 `auth.saved_key` 或域名级 saved key 作为客户端标识
- 使用管理端登录态 token 作为 `Authorization: Bearer <token>`,同时携带 `X-Qiming-Client-Key`
- 同步成功后回填 outbox 状态,并根据后端 ack 回填实体 `remote_id` / `last_synced_at`
- 同步失败时标记 failed 和错误信息,下轮继续补偿,不影响 `/computer/chat` 和 ACP 执行链路。
- 同步失败时标记 failed 和错误信息;定时 worker 按指数退避补偿,手动 `flushSync` 可强制立即重试,不影响 `/computer/chat` 和 ACP 执行链路。
新增 IPC / Bridge
@@ -472,8 +472,7 @@ qiming-backend/app-platform-modules/app-platform-agent/app-platform-agent-core-a
下一步:
- 在有 Maven 的环境验证 qiming-backend 编译与接口启动。
- 数字员工页面读取 `getSyncStatus()`,展示联动状态
- 增加 outbox 重试退避策略,避免接口长期不可用时频繁请求。
- 数字员工页面继续细化同步状态交互,例如手动同步按钮和失败原因展开
## 管理端联动原则