feat(client): back off digital employee sync retries
This commit is contained in:
@@ -40,7 +40,7 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
|
||||
});
|
||||
|
||||
ipcMain.handle("digitalEmployee:flushSync", async () => {
|
||||
return flushDigitalEmployeeSyncOutbox();
|
||||
return flushDigitalEmployeeSyncOutbox({ force: true });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 重试退避策略,避免接口长期不可用时频繁请求。
|
||||
- 数字员工页面继续细化同步状态交互,例如手动同步按钮和失败原因展开。
|
||||
|
||||
## 管理端联动原则
|
||||
|
||||
|
||||
Reference in New Issue
Block a user