feat(desktop): add monitor scheduler, Playwright CDP layer, and account health
Move monitor-task scheduling authority onto the client with a durable file-backed queue that survives restart, drops stale cross-day tasks, enforces per-platform serialism, and adapts global concurrency from Electron process metrics. Publish tasks keep their existing FIFO. Add a hidden Playwright CDP manager that attaches to Electron Chromium on account session partitions, lets adapters opt into `executionMode: "playwright"`, and leaves the existing hidden WebContentsView path in place for current adapters. Introduce an account-health subsystem with silent probes, projected health/auth states, and IPC invalidation events so the renderer can show accurate auth/probe status and verification timestamps. Server-side, derive and forward title/business_date/scheduler_group_key/ question_text metadata on desktop task events so the local scheduler can defer same-question fan-out before leasing.
This commit is contained in:
@@ -11,7 +11,7 @@ import type {
|
||||
JsonValue,
|
||||
LeaseDesktopTaskResponse,
|
||||
} from "@geo/shared-types";
|
||||
import { isAIPlatformId } from "@geo/shared-types";
|
||||
import { getAIPlatformCatalogItem, isAIPlatformId } from "@geo/shared-types";
|
||||
|
||||
import {
|
||||
doubaoAdapter,
|
||||
@@ -22,10 +22,17 @@ import {
|
||||
type PublishAdapter,
|
||||
} from "./adapters";
|
||||
import {
|
||||
ensurePublishAccountSessionHandle,
|
||||
inspectPublishAccountLocalSession,
|
||||
type PublishAccountIdentity,
|
||||
type PublishAccountProfile,
|
||||
} from "./account-binder";
|
||||
import {
|
||||
ensureAccountReady,
|
||||
forgetTrackedAccountHealth,
|
||||
getProjectedAccountHealth,
|
||||
probeTrackedAccount,
|
||||
reportAccountFailure,
|
||||
syncTrackedAccounts,
|
||||
} from "./account-health";
|
||||
import {
|
||||
buildAccountIdentityKey,
|
||||
normalizeAccountPlatformUid,
|
||||
@@ -33,6 +40,21 @@ import {
|
||||
} from "../shared/account-identity";
|
||||
import { releaseHotView, retainHotView } from "./view-pool";
|
||||
import { getLeaseManagerSnapshot, noteLeaseExtended, noteLeaseReleased, setActiveLease } from "./lease-manager";
|
||||
import {
|
||||
enqueueMonitorTaskFromEvent,
|
||||
getMonitorSchedulerSnapshot,
|
||||
initMonitorScheduler,
|
||||
noteMonitorTaskCanceled,
|
||||
noteMonitorTaskCompleted,
|
||||
noteMonitorTaskLeaseMiss,
|
||||
noteMonitorTaskLeased,
|
||||
selectNextMonitorTask,
|
||||
} from "./monitor-scheduler";
|
||||
import {
|
||||
retainHiddenPlaywrightPage,
|
||||
startHiddenPlaywrightReaper,
|
||||
} from "./playwright-cdp";
|
||||
import { getProcessMetricsSnapshot } from "./process-metrics";
|
||||
import {
|
||||
initScheduler,
|
||||
noteSchedulerAccountsSync,
|
||||
@@ -47,7 +69,7 @@ import {
|
||||
setSchedulerPhase,
|
||||
setSchedulerQueueDepth,
|
||||
} from "./scheduler";
|
||||
import { clearSessionHandle, createSessionHandle, getSessionHandle } from "./session-registry";
|
||||
import { clearSessionHandle, createSessionHandle } from "./session-registry";
|
||||
import {
|
||||
completeDesktopTask,
|
||||
configureTransport,
|
||||
@@ -136,19 +158,26 @@ interface PendingTaskRequest {
|
||||
routing: RuntimeTaskRouting;
|
||||
}
|
||||
|
||||
interface ActiveRuntimeExecution {
|
||||
taskId: string;
|
||||
kind: "publish" | "monitor";
|
||||
platform: string;
|
||||
accountId: string;
|
||||
abortController: AbortController;
|
||||
}
|
||||
|
||||
interface RuntimeState {
|
||||
session: DesktopRuntimeSessionSyncRequest | null;
|
||||
client: DesktopClientInfo | null;
|
||||
running: boolean;
|
||||
sseConnected: boolean;
|
||||
queue: PendingTaskRequest[];
|
||||
queuedTaskIds: Set<string>;
|
||||
publishQueue: PendingTaskRequest[];
|
||||
queuedPublishTaskIds: Set<string>;
|
||||
tasks: Map<string, RuntimeTaskRecord>;
|
||||
accounts: DesktopAccountInfo[];
|
||||
accountProfiles: Map<string, PublishAccountProfile>;
|
||||
activity: RuntimeControllerActivity[];
|
||||
currentTaskId: string | null;
|
||||
currentExecutionAbort: AbortController | null;
|
||||
activeExecutions: Map<string, ActiveRuntimeExecution>;
|
||||
leaseInFlight: boolean;
|
||||
heartbeatTimer: ReturnType<typeof setInterval> | null;
|
||||
pullTimer: ReturnType<typeof setInterval> | null;
|
||||
@@ -168,14 +197,13 @@ const state: RuntimeState = {
|
||||
client: null,
|
||||
running: false,
|
||||
sseConnected: false,
|
||||
queue: [],
|
||||
queuedTaskIds: new Set<string>(),
|
||||
publishQueue: [],
|
||||
queuedPublishTaskIds: new Set<string>(),
|
||||
tasks: new Map<string, RuntimeTaskRecord>(),
|
||||
accounts: [],
|
||||
accountProfiles: new Map<string, PublishAccountProfile>(),
|
||||
activity: [],
|
||||
currentTaskId: null,
|
||||
currentExecutionAbort: null,
|
||||
activeExecutions: new Map<string, ActiveRuntimeExecution>(),
|
||||
leaseInFlight: false,
|
||||
heartbeatTimer: null,
|
||||
pullTimer: null,
|
||||
@@ -190,6 +218,41 @@ const state: RuntimeState = {
|
||||
activitySeq: 0,
|
||||
};
|
||||
|
||||
function accountIdentityFromDesktopAccount(account: DesktopAccountInfo): PublishAccountIdentity {
|
||||
return {
|
||||
id: account.id,
|
||||
platform: account.platform,
|
||||
platformUid: normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid,
|
||||
displayName: account.display_name,
|
||||
};
|
||||
}
|
||||
|
||||
function accountIdentityFromTask(task: RuntimeTaskRecord): PublishAccountIdentity | null {
|
||||
if (!task.accountId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const matched = state.accounts.find((account) => account.id === task.accountId) ?? null;
|
||||
if (matched) {
|
||||
return accountIdentityFromDesktopAccount(matched);
|
||||
}
|
||||
|
||||
return {
|
||||
id: task.accountId,
|
||||
platform: task.platform,
|
||||
platformUid: "",
|
||||
displayName: task.accountName || "待同步账号",
|
||||
};
|
||||
}
|
||||
|
||||
function isDefinitiveAuthState(authState: string): boolean {
|
||||
return ["active", "expired", "challenge_required", "revoked"].includes(authState);
|
||||
}
|
||||
|
||||
function isoFromTimestamp(value: number | null): string | null {
|
||||
return value ? new Date(value).toISOString() : null;
|
||||
}
|
||||
|
||||
export function syncRuntimeSession(next: DesktopRuntimeSessionSyncRequest | null): void {
|
||||
const normalized = normalizeSession(next);
|
||||
const changed = !sameSession(state.session, normalized);
|
||||
@@ -256,7 +319,7 @@ export function getRuntimeControllerSnapshot(): RuntimeControllerSnapshot {
|
||||
client: state.client ? { ...state.client } : null,
|
||||
running: state.running,
|
||||
sseConnected: state.sseConnected,
|
||||
queueDepth: state.queue.length,
|
||||
queueDepth: localQueueDepth(),
|
||||
accounts: state.accounts.map((item) => ({ ...item })),
|
||||
accountProfiles: Object.fromEntries(
|
||||
[...state.accountProfiles.entries()].map(([accountId, profile]) => [accountId, { ...profile }]),
|
||||
@@ -281,6 +344,8 @@ function startRuntime(): void {
|
||||
}
|
||||
|
||||
initScheduler();
|
||||
initMonitorScheduler();
|
||||
startHiddenPlaywrightReaper();
|
||||
state.running = true;
|
||||
setSchedulerPhase("running");
|
||||
setSchedulerError(null);
|
||||
@@ -292,16 +357,17 @@ function startRuntime(): void {
|
||||
|
||||
void sendHeartbeat("startup");
|
||||
void syncAccounts("startup");
|
||||
processQueue();
|
||||
pumpExecutionLoop();
|
||||
}
|
||||
|
||||
function stopRuntime(): void {
|
||||
state.running = false;
|
||||
state.sseConnected = false;
|
||||
state.leaseInFlight = false;
|
||||
state.currentExecutionAbort?.abort();
|
||||
state.currentExecutionAbort = null;
|
||||
state.currentTaskId = null;
|
||||
for (const execution of state.activeExecutions.values()) {
|
||||
execution.abortController.abort();
|
||||
}
|
||||
state.activeExecutions.clear();
|
||||
|
||||
if (state.heartbeatTimer) {
|
||||
clearInterval(state.heartbeatTimer);
|
||||
@@ -319,7 +385,7 @@ function stopRuntime(): void {
|
||||
setTransportSseState("idle");
|
||||
setSchedulerPhase("idle");
|
||||
setSchedulerCurrentTask(null);
|
||||
setSchedulerQueueDepth(0);
|
||||
setSchedulerQueueDepth(localQueueDepth());
|
||||
setSchedulerNextHeartbeat(null);
|
||||
setSchedulerNextPull(null);
|
||||
|
||||
@@ -329,12 +395,12 @@ function stopRuntime(): void {
|
||||
}
|
||||
|
||||
function clearRuntimeState(): void {
|
||||
state.queue = [];
|
||||
state.queuedTaskIds.clear();
|
||||
state.publishQueue = [];
|
||||
state.queuedPublishTaskIds.clear();
|
||||
state.tasks.clear();
|
||||
state.accounts = [];
|
||||
state.accountProfiles.clear();
|
||||
state.currentTaskId = null;
|
||||
state.activeExecutions.clear();
|
||||
state.lastHeartbeatAt = 0;
|
||||
state.lastHeartbeatStatus = "idle";
|
||||
state.lastPullAt = 0;
|
||||
@@ -342,7 +408,7 @@ function clearRuntimeState(): void {
|
||||
state.lastAccountsSyncAt = 0;
|
||||
state.lastServerTime = null;
|
||||
state.lastError = null;
|
||||
setSchedulerQueueDepth(0);
|
||||
setSchedulerQueueDepth(localQueueDepth());
|
||||
}
|
||||
|
||||
function scheduleHeartbeatLoop(): void {
|
||||
@@ -365,7 +431,7 @@ function schedulePullLoop(): void {
|
||||
setSchedulerNextPull(Date.now() + pullIntervalMs);
|
||||
state.pullTimer = setInterval(() => {
|
||||
setSchedulerNextPull(Date.now() + pullIntervalMs);
|
||||
processQueue();
|
||||
pumpExecutionLoop();
|
||||
}, pullIntervalMs);
|
||||
}
|
||||
|
||||
@@ -478,9 +544,20 @@ function handleTaskEvent(event: DesktopTaskEventMessage): void {
|
||||
|
||||
state.tasks.set(event.task_id, next);
|
||||
|
||||
if (event.kind === "monitor") {
|
||||
if (event.type === "task_available") {
|
||||
enqueueMonitorTaskFromEvent(event, "rabbitmq-primary");
|
||||
}
|
||||
if (event.type === "task_completed" || event.type === "task_reconciled" || event.type === "task_canceled") {
|
||||
noteMonitorTaskCanceled(event.task_id);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "task_available") {
|
||||
enqueueTask(event.task_id, "rabbitmq-primary");
|
||||
processQueue();
|
||||
if (event.kind === "publish") {
|
||||
enqueuePublishTask(event.task_id, "rabbitmq-primary");
|
||||
}
|
||||
pumpExecutionLoop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -498,7 +575,16 @@ async function sendHeartbeat(source: "startup" | "timer"): Promise<void> {
|
||||
cpu_arch: process.arch,
|
||||
client_version: state.client?.client_version ?? "0.1.0-dev",
|
||||
channel: state.client?.channel ?? "dev",
|
||||
account_ids: state.accounts.filter((account) => account.health === "live").map((account) => account.id),
|
||||
account_ids: state.accounts
|
||||
.filter((account) =>
|
||||
getProjectedAccountHealth({
|
||||
accountId: account.id,
|
||||
platform: account.platform,
|
||||
health: account.health,
|
||||
verifiedAt: account.verified_at,
|
||||
}).health === "live",
|
||||
)
|
||||
.map((account) => account.id),
|
||||
});
|
||||
|
||||
state.client = response.client;
|
||||
@@ -547,28 +633,41 @@ async function syncAccounts(source: "startup" | "manual"): Promise<void> {
|
||||
noteSchedulerAccountsSync();
|
||||
setSchedulerError(null);
|
||||
|
||||
const identities = accounts.map((account) => accountIdentityFromDesktopAccount(account));
|
||||
syncTrackedAccounts(identities);
|
||||
if (source === "manual") {
|
||||
await Promise.allSettled(
|
||||
identities.map((account) =>
|
||||
probeTrackedAccount(account, {
|
||||
trigger: "manual",
|
||||
allowSilentRefresh: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
state.accountProfiles.clear();
|
||||
const syncedAccounts: Array<DesktopAccountInfo | null> = await Promise.all(accounts.map(async (account) => {
|
||||
const normalizedPlatformUid = normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid;
|
||||
const isAIPlatform = isAIPlatformId(account.platform);
|
||||
const identity = {
|
||||
id: account.id,
|
||||
const identity = accountIdentityFromDesktopAccount(account);
|
||||
const projected = getProjectedAccountHealth({
|
||||
accountId: account.id,
|
||||
platform: account.platform,
|
||||
platformUid: normalizedPlatformUid,
|
||||
displayName: account.display_name,
|
||||
} as const;
|
||||
const local = await inspectPublishAccountLocalSession(identity);
|
||||
health: account.health,
|
||||
verifiedAt: account.verified_at,
|
||||
});
|
||||
|
||||
if (local.availability === "missing") {
|
||||
return null;
|
||||
if (projected.profile?.subjectUid && projected.profile.displayName) {
|
||||
state.accountProfiles.set(account.id, {
|
||||
platformUid: projected.profile.subjectUid,
|
||||
displayName: projected.profile.displayName,
|
||||
avatarUrl: projected.profile.avatarUrl,
|
||||
});
|
||||
}
|
||||
|
||||
if (local.profile) {
|
||||
state.accountProfiles.set(account.id, local.profile);
|
||||
}
|
||||
|
||||
const normalizedLocalPlatformUid = local.profile
|
||||
? (normalizeAccountPlatformUid(local.profile.platformUid) || local.profile.platformUid)
|
||||
const normalizedLocalPlatformUid = projected.profile?.subjectUid
|
||||
? (normalizeAccountPlatformUid(projected.profile.subjectUid) || projected.profile.subjectUid)
|
||||
: null;
|
||||
const hasPlatformUidMismatch = Boolean(
|
||||
normalizedLocalPlatformUid && !sameAccountPlatformUid(normalizedLocalPlatformUid, normalizedPlatformUid),
|
||||
@@ -585,22 +684,27 @@ async function syncAccounts(source: "startup" | "manual"): Promise<void> {
|
||||
let resolvedAccount: DesktopAccountInfo = {
|
||||
...account,
|
||||
platform_uid: normalizedPlatformUid,
|
||||
display_name: local.profile?.displayName ?? account.display_name,
|
||||
avatar_url: local.profile?.avatarUrl ?? account.avatar_url,
|
||||
health: local.availability === "expired" ? ("expired" as const) : ("live" as const),
|
||||
display_name: projected.profile?.displayName ?? account.display_name,
|
||||
avatar_url: projected.profile?.avatarUrl ?? account.avatar_url,
|
||||
health: projected.health,
|
||||
verified_at: isoFromTimestamp(projected.lastVerifiedAt) ?? account.verified_at,
|
||||
};
|
||||
|
||||
const shouldRelinkClient = Boolean(currentClientId && resolvedAccount.client_id !== currentClientId);
|
||||
if (shouldRelinkClient) {
|
||||
const shouldMirrorHealth =
|
||||
resolvedAccount.health !== account.health
|
||||
&& isDefinitiveAuthState(projected.authState);
|
||||
|
||||
if (shouldRelinkClient || shouldMirrorHealth) {
|
||||
try {
|
||||
const updated = await upsertDesktopAccount({
|
||||
platform: resolvedAccount.platform,
|
||||
platform_uid: normalizedPlatformUid,
|
||||
display_name: local.profile?.displayName ?? resolvedAccount.display_name,
|
||||
avatar_url: local.profile?.avatarUrl ?? resolvedAccount.avatar_url,
|
||||
display_name: projected.profile?.displayName ?? resolvedAccount.display_name,
|
||||
avatar_url: projected.profile?.avatarUrl ?? resolvedAccount.avatar_url,
|
||||
account_fingerprint: resolvedAccount.account_fingerprint ?? normalizedPlatformUid,
|
||||
health: resolvedAccount.health,
|
||||
verified_at: new Date().toISOString(),
|
||||
verified_at: isoFromTimestamp(projected.lastVerifiedAt) ?? resolvedAccount.verified_at,
|
||||
tags: resolvedAccount.tags,
|
||||
});
|
||||
|
||||
@@ -694,6 +798,7 @@ export async function unbindRuntimeAccount(accountId: string, syncVersion: numbe
|
||||
|
||||
await deleteDesktopAccount(accountId, syncVersion);
|
||||
await clearSessionHandle(accountId);
|
||||
forgetTrackedAccountHealth(accountId);
|
||||
|
||||
state.accounts = state.accounts.filter((item) => item.id !== accountId);
|
||||
state.accountProfiles.delete(accountId);
|
||||
@@ -710,41 +815,74 @@ export async function unbindRuntimeAccount(accountId: string, syncVersion: numbe
|
||||
await syncAccounts("manual");
|
||||
}
|
||||
|
||||
function enqueueTask(taskId: string, routing: RuntimeTaskRouting): void {
|
||||
if (!taskId || state.currentTaskId === taskId || state.queuedTaskIds.has(taskId)) {
|
||||
function enqueuePublishTask(taskId: string, routing: RuntimeTaskRouting): void {
|
||||
if (!taskId || state.activeExecutions.has(taskId) || state.queuedPublishTaskIds.has(taskId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.queue.push({ taskId, routing });
|
||||
state.queuedTaskIds.add(taskId);
|
||||
setSchedulerQueueDepth(state.queue.length);
|
||||
state.publishQueue.push({ taskId, routing });
|
||||
state.queuedPublishTaskIds.add(taskId);
|
||||
syncSchedulerSurface();
|
||||
}
|
||||
|
||||
function dequeueTask(): PendingTaskRequest | null {
|
||||
const next = state.queue.shift() ?? null;
|
||||
function dequeuePublishTask(): PendingTaskRequest | null {
|
||||
const next = state.publishQueue.shift() ?? null;
|
||||
if (next) {
|
||||
state.queuedTaskIds.delete(next.taskId);
|
||||
state.queuedPublishTaskIds.delete(next.taskId);
|
||||
}
|
||||
setSchedulerQueueDepth(state.queue.length);
|
||||
syncSchedulerSurface();
|
||||
return next;
|
||||
}
|
||||
|
||||
function processQueue(): void {
|
||||
if (!state.running || state.currentTaskId || state.leaseInFlight) {
|
||||
function pumpExecutionLoop(): void {
|
||||
if (!state.running || state.leaseInFlight) {
|
||||
return;
|
||||
}
|
||||
|
||||
const queued = dequeueTask();
|
||||
if (queued) {
|
||||
void leaseSpecificTask(queued);
|
||||
syncSchedulerSurface();
|
||||
|
||||
if (hasActivePublishExecution()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void pullNextTask();
|
||||
if (state.publishQueue.length > 0) {
|
||||
if (state.activeExecutions.size === 0) {
|
||||
const queuedPublish = dequeuePublishTask();
|
||||
if (queuedPublish) {
|
||||
void leaseSpecificTask(queuedPublish, "publish");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (canStartAnotherMonitorExecution()) {
|
||||
const selected = selectNextMonitorTask({
|
||||
maxConcurrency: resolveAdaptiveMonitorConcurrency(),
|
||||
activePlatforms: activeMonitorPlatforms(),
|
||||
activeQuestionKeys: activeMonitorQuestionKeys(),
|
||||
activeCount: activeMonitorCount(),
|
||||
});
|
||||
if (selected) {
|
||||
void leaseSpecificTask(selected, "monitor");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (state.activeExecutions.size === 0) {
|
||||
void pullNextTask("publish");
|
||||
return;
|
||||
}
|
||||
|
||||
if (canStartAnotherMonitorExecution()) {
|
||||
void pullNextTask("monitor");
|
||||
}
|
||||
}
|
||||
|
||||
async function leaseSpecificTask(request: PendingTaskRequest): Promise<void> {
|
||||
if (!canRunLive(state.session) || state.currentTaskId || state.leaseInFlight) {
|
||||
async function leaseSpecificTask(
|
||||
request: PendingTaskRequest,
|
||||
expectedKind: "publish" | "monitor",
|
||||
): Promise<void> {
|
||||
if (!canRunLive(state.session) || state.leaseInFlight) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -753,11 +891,18 @@ async function leaseSpecificTask(request: PendingTaskRequest): Promise<void> {
|
||||
try {
|
||||
const leased = await leaseDesktopTask({ task_id: request.taskId });
|
||||
if (!leased.task) {
|
||||
if (expectedKind === "monitor") {
|
||||
noteMonitorTaskLeaseMiss(request.taskId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
state.leaseInFlight = false;
|
||||
await executeLeasedTask(leased, request.routing);
|
||||
} catch (error) {
|
||||
if (expectedKind === "monitor") {
|
||||
noteMonitorTaskLeaseMiss(request.taskId);
|
||||
}
|
||||
|
||||
if (isApiClientError(error, 401)) {
|
||||
handleAuthExpired(error);
|
||||
return;
|
||||
@@ -769,22 +914,24 @@ async function leaseSpecificTask(request: PendingTaskRequest): Promise<void> {
|
||||
} finally {
|
||||
if (state.leaseInFlight) {
|
||||
state.leaseInFlight = false;
|
||||
if (state.queue.length > 0) {
|
||||
processQueue();
|
||||
syncSchedulerSurface();
|
||||
if (state.publishQueue.length > 0 || localQueueDepth() > 0) {
|
||||
pumpExecutionLoop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function pullNextTask(): Promise<void> {
|
||||
if (!canRunLive(state.session) || state.currentTaskId || state.leaseInFlight) {
|
||||
async function pullNextTask(kind: "publish" | "monitor"): Promise<void> {
|
||||
if (!canRunLive(state.session) || state.leaseInFlight) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.leaseInFlight = true;
|
||||
let leasedTask = false;
|
||||
|
||||
try {
|
||||
const leased = await leaseDesktopTask({});
|
||||
const leased = await leaseDesktopTask({ kind });
|
||||
state.lastPullAt = Date.now();
|
||||
state.lastPullStatus = "success";
|
||||
noteTransportPull(true);
|
||||
@@ -794,6 +941,7 @@ async function pullNextTask(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
leasedTask = true;
|
||||
state.leaseInFlight = false;
|
||||
await executeLeasedTask(leased, "db-recovery");
|
||||
} catch (error) {
|
||||
@@ -812,6 +960,11 @@ async function pullNextTask(): Promise<void> {
|
||||
recordActivity("warn", "兜底拉取失败", state.lastError);
|
||||
} finally {
|
||||
state.leaseInFlight = false;
|
||||
syncSchedulerSurface();
|
||||
|
||||
if (!leasedTask && kind === "publish" && canStartAnotherMonitorExecution()) {
|
||||
void pullNextTask("monitor");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -825,8 +978,6 @@ async function executeLeasedTask(
|
||||
|
||||
const task = leased.task;
|
||||
|
||||
state.currentTaskId = task.id;
|
||||
setSchedulerCurrentTask(task.id);
|
||||
setSchedulerPhase("running");
|
||||
|
||||
setActiveLease({
|
||||
@@ -849,7 +1000,18 @@ async function executeLeasedTask(
|
||||
);
|
||||
|
||||
const abortController = new AbortController();
|
||||
state.currentExecutionAbort = abortController;
|
||||
state.activeExecutions.set(taskRecord.id, {
|
||||
taskId: taskRecord.id,
|
||||
kind: taskRecord.kind,
|
||||
platform: taskRecord.platform,
|
||||
accountId: taskRecord.accountId,
|
||||
abortController,
|
||||
});
|
||||
syncSchedulerSurface();
|
||||
|
||||
if (task.kind === "monitor") {
|
||||
noteMonitorTaskLeased(task, routing);
|
||||
}
|
||||
|
||||
const extendHandle = setInterval(() => {
|
||||
void extendActiveLease(taskRecord.id, leased.lease_token as string);
|
||||
@@ -864,7 +1026,7 @@ async function executeLeasedTask(
|
||||
error: execution.error,
|
||||
});
|
||||
|
||||
noteLeaseReleased(execution.status === "failed" ? "failed" : "completed");
|
||||
noteLeaseReleased(execution.status === "failed" ? "failed" : "completed", taskRecord.id);
|
||||
upsertTaskFromInfo(completed, {
|
||||
routing,
|
||||
summary: execution.summary,
|
||||
@@ -881,9 +1043,28 @@ async function executeLeasedTask(
|
||||
: "任务执行成功",
|
||||
`${taskRecord.title} ${execution.summary}`,
|
||||
);
|
||||
|
||||
if (task.kind === "monitor") {
|
||||
noteMonitorTaskCompleted(taskRecord.id);
|
||||
}
|
||||
} catch (error) {
|
||||
const failureMessage = errorMessage(error);
|
||||
const structuredError = toStructuredError(error);
|
||||
const accountIdentity = accountIdentityFromTask(taskRecord);
|
||||
|
||||
if (accountIdentity) {
|
||||
await reportAccountFailure(accountIdentity, {
|
||||
summary: failureMessage,
|
||||
error: structuredError,
|
||||
}).catch((reportError) => {
|
||||
console.warn("[desktop-runtime] thrown failure report failed", {
|
||||
taskId: taskRecord.id,
|
||||
accountId: accountIdentity.id,
|
||||
platform: accountIdentity.platform,
|
||||
message: reportError instanceof Error ? reportError.message : String(reportError),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const completed = await completeDesktopTask(taskRecord.id, {
|
||||
@@ -909,15 +1090,18 @@ async function executeLeasedTask(
|
||||
}
|
||||
}
|
||||
|
||||
noteLeaseReleased("failed");
|
||||
noteLeaseReleased("failed", taskRecord.id);
|
||||
recordActivity("danger", "任务执行失败", `${taskRecord.title} 执行失败:${failureMessage || "未知错误"}`);
|
||||
|
||||
if (task.kind === "monitor") {
|
||||
noteMonitorTaskCompleted(taskRecord.id);
|
||||
}
|
||||
} finally {
|
||||
clearInterval(extendHandle);
|
||||
state.currentExecutionAbort = null;
|
||||
state.currentTaskId = null;
|
||||
setSchedulerCurrentTask(null);
|
||||
state.activeExecutions.delete(taskRecord.id);
|
||||
syncSchedulerSurface();
|
||||
noteSchedulerTaskFinished();
|
||||
processQueue();
|
||||
pumpExecutionLoop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -925,7 +1109,7 @@ async function extendActiveLease(taskId: string, leaseToken: string): Promise<vo
|
||||
try {
|
||||
const task = await extendDesktopTask(taskId, { lease_token: leaseToken });
|
||||
|
||||
noteLeaseExtended(task.lease_expires_at ?? null);
|
||||
noteLeaseExtended(task.lease_expires_at ?? null, taskId);
|
||||
|
||||
const existing = state.tasks.get(taskId);
|
||||
if (existing) {
|
||||
@@ -946,6 +1130,14 @@ async function executeTaskAdapter(
|
||||
task: RuntimeTaskRecord,
|
||||
signal: AbortSignal,
|
||||
): Promise<AdapterExecutionResult> {
|
||||
const accountIdentity = accountIdentityFromTask(task);
|
||||
if (accountIdentity) {
|
||||
const readiness = await ensureAccountReady(accountIdentity);
|
||||
if (readiness.authState !== "active") {
|
||||
return buildAccountBlockedResult(task, readiness);
|
||||
}
|
||||
}
|
||||
|
||||
const sessionHandle = createSessionHandle(task.accountId || task.id);
|
||||
const payload = task.payload;
|
||||
|
||||
@@ -959,14 +1151,29 @@ async function executeTaskAdapter(
|
||||
adapter.executionMode === "session" || adapter.executionMode === "playwright"
|
||||
? null
|
||||
: retainHotView(task.accountId || task.id);
|
||||
const playwrightLease =
|
||||
adapter.executionMode === "playwright"
|
||||
? await retainHiddenPlaywrightPage({
|
||||
accountId: task.accountId || task.id,
|
||||
session: sessionHandle.session,
|
||||
targetURL: resolvePlaywrightTargetURL(task.platform, payload),
|
||||
title: `${task.title} · Hidden Playwright`,
|
||||
})
|
||||
: null;
|
||||
|
||||
try {
|
||||
return await adapter.publish(
|
||||
const result = await adapter.publish(
|
||||
{
|
||||
taskId: task.id,
|
||||
accountId: task.accountId || task.id,
|
||||
session: sessionHandle.session,
|
||||
view: viewHandle?.view ?? null,
|
||||
playwright: playwrightLease
|
||||
? {
|
||||
browser: playwrightLease.browser,
|
||||
page: playwrightLease.page,
|
||||
}
|
||||
: null,
|
||||
signal,
|
||||
phase: "initial",
|
||||
article: await loadPublishArticle(task),
|
||||
@@ -976,7 +1183,10 @@ async function executeTaskAdapter(
|
||||
},
|
||||
payload,
|
||||
);
|
||||
await maybeReportTaskAuthFailure(task, result, accountIdentity);
|
||||
return result;
|
||||
} finally {
|
||||
await playwrightLease?.release();
|
||||
if (viewHandle) {
|
||||
releaseHotView(viewHandle.accountId);
|
||||
}
|
||||
@@ -1010,14 +1220,29 @@ async function executeTaskAdapter(
|
||||
adapter.executionMode === "session" || adapter.executionMode === "playwright"
|
||||
? null
|
||||
: retainHotView(task.accountId || task.id);
|
||||
const playwrightLease =
|
||||
adapter.executionMode === "playwright"
|
||||
? await retainHiddenPlaywrightPage({
|
||||
accountId: task.accountId || task.id,
|
||||
session: sessionHandle.session,
|
||||
targetURL: resolvePlaywrightTargetURL(task.platform, payload),
|
||||
title: `${task.title} · Hidden Playwright`,
|
||||
})
|
||||
: null;
|
||||
|
||||
try {
|
||||
return await adapter.query(
|
||||
const result = await adapter.query(
|
||||
{
|
||||
taskId: task.id,
|
||||
accountId: task.accountId || task.id,
|
||||
session: sessionHandle.session,
|
||||
view: viewHandle?.view ?? null,
|
||||
playwright: playwrightLease
|
||||
? {
|
||||
browser: playwrightLease.browser,
|
||||
page: playwrightLease.page,
|
||||
}
|
||||
: null,
|
||||
signal,
|
||||
phase: "initial",
|
||||
reportProgress(stage: string) {
|
||||
@@ -1026,13 +1251,91 @@ async function executeTaskAdapter(
|
||||
},
|
||||
payload,
|
||||
);
|
||||
await maybeReportTaskAuthFailure(task, result, accountIdentity);
|
||||
return result;
|
||||
} finally {
|
||||
await playwrightLease?.release();
|
||||
if (viewHandle) {
|
||||
releaseHotView(viewHandle.accountId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildAccountBlockedResult(
|
||||
task: RuntimeTaskRecord,
|
||||
readiness: Awaited<ReturnType<typeof ensureAccountReady>>,
|
||||
): AdapterExecutionResult {
|
||||
if (readiness.authState === "challenge_required") {
|
||||
return {
|
||||
status: "failed",
|
||||
summary: `${task.accountName} 需要完成人机验证后才能继续执行。`,
|
||||
error: {
|
||||
code: "desktop_account_challenge_required",
|
||||
message: "desktop account challenge required",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (readiness.authState === "expired" || readiness.authState === "revoked") {
|
||||
return {
|
||||
status: "failed",
|
||||
summary: `${task.accountName} 登录态已失效,请重新授权后再试。`,
|
||||
error: {
|
||||
code: "desktop_account_auth_expired",
|
||||
message: "desktop account auth expired",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "unknown",
|
||||
summary: `${task.accountName} 最近校验失败,已暂缓执行任务。`,
|
||||
error: {
|
||||
code: "desktop_account_probe_pending",
|
||||
message: "desktop account readiness pending",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function maybeReportTaskAuthFailure(
|
||||
task: RuntimeTaskRecord,
|
||||
result: AdapterExecutionResult,
|
||||
accountIdentity: PublishAccountIdentity | null,
|
||||
): Promise<void> {
|
||||
if (!accountIdentity) {
|
||||
return;
|
||||
}
|
||||
|
||||
await reportAccountFailure(accountIdentity, {
|
||||
summary: result.summary,
|
||||
error: result.error ?? null,
|
||||
}).catch((error) => {
|
||||
console.warn("[desktop-runtime] account failure report failed", {
|
||||
taskId: task.id,
|
||||
accountId: accountIdentity.id,
|
||||
platform: accountIdentity.platform,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function resolvePlaywrightTargetURL(
|
||||
platform: string,
|
||||
payload: Record<string, JsonValue>,
|
||||
): string {
|
||||
const payloadURL = resolveStringField(payload, ["target_url", "targetUrl", "console_url", "consoleUrl", "url"]);
|
||||
if (payloadURL) {
|
||||
return payloadURL;
|
||||
}
|
||||
|
||||
const aiPlatform = getAIPlatformCatalogItem(platform);
|
||||
if (aiPlatform?.consoleUrl) {
|
||||
return aiPlatform.consoleUrl;
|
||||
}
|
||||
|
||||
return "about:blank";
|
||||
}
|
||||
|
||||
function buildScaffoldResult(
|
||||
task: RuntimeTaskRecord,
|
||||
adapterPayload: Record<string, JsonValue>,
|
||||
@@ -1096,6 +1399,23 @@ function toPositiveInt(value: JsonValue | null): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveStringField(
|
||||
payload: Record<string, JsonValue>,
|
||||
keys: string[],
|
||||
): string | null {
|
||||
for (const key of keys) {
|
||||
const value = payload[key];
|
||||
if (typeof value !== "string") {
|
||||
continue;
|
||||
}
|
||||
const normalized = value.trim();
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveStaleMonitoringBusinessDate(payload: Record<string, JsonValue>): string | null {
|
||||
const businessDate = resolveMonitoringBusinessDate(payload);
|
||||
if (!businessDate) {
|
||||
@@ -1219,6 +1539,90 @@ function recordActivity(
|
||||
}
|
||||
}
|
||||
|
||||
function syncSchedulerSurface(): void {
|
||||
setSchedulerQueueDepth(localQueueDepth());
|
||||
setSchedulerCurrentTask(primaryActiveTaskId());
|
||||
}
|
||||
|
||||
function localQueueDepth(): number {
|
||||
const snapshot = getMonitorSchedulerSnapshot();
|
||||
return state.publishQueue.length + snapshot.queuedCount + snapshot.leasingCount;
|
||||
}
|
||||
|
||||
function primaryActiveTaskId(): string | null {
|
||||
const active = [...state.activeExecutions.values()];
|
||||
const publish = active.find((item) => item.kind === "publish") ?? null;
|
||||
return publish?.taskId ?? active[0]?.taskId ?? null;
|
||||
}
|
||||
|
||||
function hasActivePublishExecution(): boolean {
|
||||
return [...state.activeExecutions.values()].some((item) => item.kind === "publish");
|
||||
}
|
||||
|
||||
function activeMonitorCount(): number {
|
||||
return [...state.activeExecutions.values()].filter((item) => item.kind === "monitor").length;
|
||||
}
|
||||
|
||||
function activeMonitorPlatforms(): Set<string> {
|
||||
return new Set(
|
||||
[...state.activeExecutions.values()]
|
||||
.filter((item) => item.kind === "monitor")
|
||||
.map((item) => item.platform),
|
||||
);
|
||||
}
|
||||
|
||||
function activeMonitorQuestionKeys(): Set<string> {
|
||||
return new Set(
|
||||
getMonitorSchedulerSnapshot().tasks
|
||||
.filter((task) => task.state === "active" && Boolean(task.questionKey))
|
||||
.map((task) => task.questionKey as string),
|
||||
);
|
||||
}
|
||||
|
||||
function canStartAnotherMonitorExecution(): boolean {
|
||||
if (state.publishQueue.length > 0 || hasActivePublishExecution()) {
|
||||
return false;
|
||||
}
|
||||
const limit = resolveAdaptiveMonitorConcurrency();
|
||||
return limit > 0 && activeMonitorCount() < limit;
|
||||
}
|
||||
|
||||
function resolveAdaptiveMonitorConcurrency(): number {
|
||||
const monitorAccountCount = state.accounts.filter((account) =>
|
||||
isAIPlatformId(account.platform)
|
||||
&& getProjectedAccountHealth({
|
||||
accountId: account.id,
|
||||
platform: account.platform,
|
||||
health: account.health,
|
||||
verifiedAt: account.verified_at,
|
||||
}).health === "live",
|
||||
).length;
|
||||
if (monitorAccountCount <= 1) {
|
||||
return monitorAccountCount;
|
||||
}
|
||||
|
||||
const metrics = getProcessMetricsSnapshot().latestSample;
|
||||
if (!metrics) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const totalCPUUsage = metrics.processes.reduce((sum, item) => sum + item.percentCPUUsage, 0);
|
||||
const totalPrivateBytesKB = metrics.totals.privateBytes;
|
||||
const processCount = metrics.totals.processCount;
|
||||
const mainPrivateBytesKB = metrics.mainProcess.memory?.private ?? 0;
|
||||
|
||||
if (
|
||||
totalCPUUsage >= 180
|
||||
|| totalPrivateBytesKB >= 2_500_000
|
||||
|| mainPrivateBytesKB >= 1_000_000
|
||||
|| processCount >= 40
|
||||
) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 2;
|
||||
}
|
||||
|
||||
function handleAuthExpired(error: unknown): void {
|
||||
const message = errorMessage(error);
|
||||
state.lastError = message;
|
||||
|
||||
Reference in New Issue
Block a user