吸收数字员工PlanStep远端策略拉取

This commit is contained in:
baiyanyun
2026-06-10 23:15:51 +08:00
parent 6f2ccdf783
commit c400367d22
17 changed files with 776 additions and 205 deletions

View File

@@ -16,6 +16,7 @@ import type {
BusinessViewResponse,
PlanStepBusinessRow,
PlanStepDispatchPolicy,
PlanStepDispatchPolicyPullResult,
ReadyStepDispatchResult,
NotificationPreferences,
UserNotification,
@@ -612,6 +613,12 @@ export function patchPlanStepDispatchPolicy(
});
}
export function pullPlanStepDispatchPolicy(): Promise<PlanStepDispatchPolicyPullResult> {
return apiFetch<PlanStepDispatchPolicyPullResult>('/api/plan-step/dispatch-policy/pull', {
method: 'POST',
});
}
export function getPlanSteps(params: Record<string, string | number | null | undefined> = {}): Promise<BusinessViewResponse<PlanStepBusinessRow>> {
const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {

View File

@@ -36,6 +36,7 @@ import type {
PlanDispatchResponse,
ReadyStepDispatchResult,
PlanStepDispatchPolicy,
PlanStepDispatchPolicyPullResult,
PlanStepSummary,
RouteDecisionRecord,
RouteDecisionTimelineResponse,
@@ -60,6 +61,7 @@ declare global {
saveUiState?: (update: { state: AdapterState; action?: string; date?: string; metadata?: Record<string, unknown> }) => Promise<AdapterState>;
getSyncStatus?: () => Promise<QimingclawSyncStatus>;
flushSync?: () => Promise<QimingclawSyncStatus>;
pullPlanStepDispatchPolicy?: () => Promise<PlanStepDispatchPolicyPullResult>;
getCronSettings?: () => Promise<QimingclawCronSettings>;
saveCronSettings?: (patch: Partial<QimingclawCronSettings>) => Promise<QimingclawCronSettings>;
runSchedulerCheck?: () => Promise<{ activated: number; due?: number; skipped?: number }>;
@@ -791,6 +793,9 @@ export async function handleQimingclawDigitalApi<T>(
if (method === 'GET' && pathname === '/api/plan-step/dispatch-policy') {
return planStepDispatchPolicy(notificationAdapterState(await readQimingclawSnapshot())) as T;
}
if (method === 'POST' && pathname === '/api/plan-step/dispatch-policy/pull') {
return await handlePlanStepDispatchPolicyPull(await readQimingclawSnapshot()) as T;
}
if (method === 'PATCH' && pathname === '/api/plan-step/dispatch-policy') {
return await handlePlanStepDispatchPolicyPatch(
parseJsonBody<Partial<PlanStepDispatchPolicy>>(options.body),
@@ -3883,6 +3888,10 @@ function defaultPlanStepDispatchPolicy(): PlanStepDispatchPolicy {
max_per_sweep: 3,
auto_dispatch_ready_steps: true,
updated_at: null,
source: 'local',
remote_updated_at: null,
last_pulled_at: null,
last_pull_error: null,
};
}
@@ -3915,6 +3924,10 @@ function planStepDispatchPolicy(state: Partial<AdapterState> | null | undefined)
max_per_sweep: Number.isFinite(maxPerSweep) ? Math.max(1, Math.min(Math.floor(maxPerSweep), 10)) : fallback.max_per_sweep,
auto_dispatch_ready_steps: stored.auto_dispatch_ready_steps !== false,
updated_at: typeof stored.updated_at === 'string' ? stored.updated_at : fallback.updated_at,
source: stored.source === 'remote' ? 'remote' : 'local',
remote_updated_at: typeof stored.remote_updated_at === 'string' ? stored.remote_updated_at : null,
last_pulled_at: typeof stored.last_pulled_at === 'string' ? stored.last_pulled_at : null,
last_pull_error: typeof stored.last_pull_error === 'string' ? stored.last_pull_error : null,
};
}
@@ -4021,6 +4034,27 @@ async function handlePlanStepDispatchPolicyPatch(
return next;
}
async function handlePlanStepDispatchPolicyPull(
snapshot: QimingclawSnapshot | null,
): Promise<PlanStepDispatchPolicyPullResult> {
const fallbackPolicy = planStepDispatchPolicy(snapshot?.uiState ?? readState());
const bridgeResult = await window.QimingClawBridge?.digital?.pullPlanStepDispatchPolicy?.().catch((error) => ({
ok: false,
endpoint: null,
pulled_at: new Date().toISOString(),
policy: fallbackPolicy,
error: error instanceof Error ? error.message : 'plan step dispatch policy pull failed',
}));
if (bridgeResult) return bridgeResult;
return {
ok: false,
endpoint: null,
pulled_at: new Date().toISOString(),
policy: fallbackPolicy,
error: 'qimingclaw_bridge_unavailable',
};
}
function approvalNotifications(snapshot: QimingclawSnapshot | null): UserNotification[] {
const summaries = new Map(businessApprovalRows(snapshot).map((approval) => [stringValue(approval.approval_id), approval]));
return buildApprovals(snapshot)

View File

@@ -11,6 +11,7 @@ import {
getSchedulerJobs,
governanceCommand,
postDigitalEmployeeWorkdayAction,
pullPlanStepDispatchPolicy,
recordApprovalAction,
restartManagedService,
} from '@/lib/api';
@@ -1368,6 +1369,28 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
}
}, [selectedDate]);
const refreshPlanStepDispatchPolicy = useCallback(async () => {
setActionBusy('pull_plan_step_dispatch_policy');
setActionError(null);
setActionNotice(null);
try {
const result = await pullPlanStepDispatchPolicy();
if (!mountedRef.current) return;
await fetchProjection();
if (!mountedRef.current) return;
const policy = result.policy;
setActionNotice(result.ok
? `PlanStep 派发策略已拉取:${policy.enabled ? '启用' : '停用'},每轮最多 ${policy.max_per_sweep} 个。`
: `PlanStep 派发策略拉取失败,已沿用本地策略:${result.error || '未知错误'}`);
} catch (error) {
if (mountedRef.current) {
setActionError(error instanceof Error ? error.message : '拉取PlanStep派发策略失败');
}
} finally {
if (mountedRef.current) setActionBusy(null);
}
}, [fetchProjection]);
const copySyncFailureDiagnostic = useCallback(async (failure: ManagementSyncFailure) => {
const diagnostic = buildSyncFailureDiagnostic(failure, managementSyncStatus);
try {
@@ -2016,6 +2039,16 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
<RefreshCw size={13} aria-hidden="true" />
<span>{actionBusy === 'flush_management_sync' ? '同步中...' : '同步管理端'}</span>
</button>
<button
type="button"
className="de-btn-secondary de-btn-sm"
disabled={projectionLoading || actionBusy === 'pull_plan_step_dispatch_policy'}
onClick={refreshPlanStepDispatchPolicy}
title="从管理端拉取 PlanStep 派发策略"
>
<ShieldCheck size={13} aria-hidden="true" />
<span>{actionBusy === 'pull_plan_step_dispatch_policy' ? '拉取中...' : '拉取策略'}</span>
</button>
</div>
<div className={`de-management-sync-strip de-management-sync-strip--${managementSyncTone}`} title={managementSyncError || managementSyncSummary}>

View File

@@ -200,6 +200,19 @@ export interface PlanStepDispatchPolicy {
max_per_sweep: number;
auto_dispatch_ready_steps: boolean;
updated_at: string | null;
source?: 'local' | 'remote';
remote_updated_at?: string | null;
last_pulled_at?: string | null;
last_pull_error?: string | null;
}
export interface PlanStepDispatchPolicyPullResult {
ok: boolean;
skipped?: boolean;
endpoint: string | null;
pulled_at: string | null;
policy: PlanStepDispatchPolicy;
error?: string | null;
}
export interface PlanStepDependencyDetail {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -16,7 +16,7 @@
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
}
</script>
<script type="module" crossorigin src="./assets/index-6K5_HhCb.js"></script>
<script type="module" crossorigin src="./assets/index-BXlmpGIQ.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-4TVkOCsf.css">
</head>
<body>

View File

@@ -311,6 +311,8 @@ function checkDigitalPlanStepGraphDispatchPolicy() {
[adapter, "dispatch-ready-steps", "adapter plan-scoped ready step dispatch endpoint"],
[adapter, "dispatchPlanReadySteps", "adapter plan-scoped ready step dispatcher"],
[adapter, "/api/plan-step/dispatch-policy", "adapter plan step dispatch policy endpoint"],
[adapter, "/api/plan-step/dispatch-policy/pull", "adapter plan step dispatch policy pull endpoint"],
[adapter, "pullPlanStepDispatchPolicy", "adapter plan step dispatch policy pull bridge"],
[adapter, "planStepRows", "management plan step rows"],
[adapter, "planStepGraphRows", "management plan step graph rows"],
[adapter, "filterPlanStepRows", "management plan step filters"],
@@ -322,6 +324,7 @@ function checkDigitalPlanStepGraphDispatchPolicy() {
[adapter, "update_plan_step_dispatch_policy", "adapter plan step dispatch policy save action"],
[api, "getPlanStepDispatchPolicy", "plan step dispatch policy API getter"],
[api, "patchPlanStepDispatchPolicy", "plan step dispatch policy API patcher"],
[api, "pullPlanStepDispatchPolicy", "plan step dispatch policy API puller"],
[api, "getPlanSteps", "plan steps API getter"],
[api, "getPlanStepGraph", "plan step graph API getter"],
[api, "dispatchPlanReadySteps", "plan-scoped ready step dispatch API"],
@@ -329,6 +332,8 @@ function checkDigitalPlanStepGraphDispatchPolicy() {
[types, "trigger_source", "ready step dispatch trigger source type"],
[types, "orchestration_id", "ready step dispatch orchestration id type"],
[types, "PlanStepDispatchPolicy", "plan step dispatch policy type"],
[types, "PlanStepDispatchPolicyPullResult", "plan step dispatch policy pull result type"],
[types, "last_pull_error", "plan step dispatch policy pull error field"],
[types, "PlanStepLeaseSummary", "plan step lease summary type"],
[types, "PlanStepDependencyDetail", "plan step blocked dependency detail type"],
[types, "lease_active", "plan step lease active type field"],
@@ -338,12 +343,16 @@ function checkDigitalPlanStepGraphDispatchPolicy() {
[commandDeck, "planStepOrchestrationSummary", "home plan step orchestration summary"],
[commandDeck, "leasedStepCount", "home plan step lease count"],
[commandDeck, "controlPlanReadySteps", "home plan-scoped ready step control"],
[commandDeck, "pull_plan_step_dispatch_policy", "home plan step dispatch policy pull action"],
[commandDeck, "controlBlockedPlanStep", "home blocked plan step control handler"],
[commandDeck, "de-blocked-step-control-strip", "home blocked plan step control strip"],
[commandDeck, "provide_task_input", "home blocked plan step input command"],
[css, "de-blocked-input-field", "home blocked plan step input styles"],
[stateService, "planStepDispatchPolicy", "plan step dispatch policy UI state"],
[stateService, "update_plan_step_dispatch_policy", "plan step dispatch policy runtime event"],
[stateService, "pull_plan_step_dispatch_policy", "plan step dispatch policy pull runtime event"],
[stateService, "last_pulled_at", "plan step dispatch policy last pulled field"],
[stateService, "last_pull_error", "plan step dispatch policy last pull error field"],
[stateService, "planIdFilter", "plan-scoped ready step candidate filter"],
[stateService, "dispatchLease", "plan step payload lease state"],
[stateService, "activeDigitalEmployeePlanStepLease", "plan step active lease helper"],
@@ -358,10 +367,16 @@ function checkDigitalPlanStepGraphDispatchPolicy() {
[schedulerService, "releaseDigitalEmployeePlanStepLease", "scheduler release plan step lease"],
[schedulerService, "triggerSource", "ready step dispatch trigger source"],
[schedulerService, "readDigitalEmployeePlanStepDispatchPolicy", "scheduler plan step dispatch policy reader"],
[schedulerService, "pullDigitalEmployeePlanStepDispatchPolicy", "scheduler remote plan step dispatch policy pull"],
[schedulerService, "max_per_sweep", "scheduler max per sweep policy"],
[ipcHandlers, "digitalEmployee:dispatchPlanReadySteps", "IPC plan-scoped ready step dispatch handler"],
[ipcHandlers, "digitalEmployee:pullPlanStepDispatchPolicy", "IPC plan step dispatch policy pull handler"],
[preload, "dispatchPlanReadySteps", "preload plan-scoped ready step bridge"],
[preload, "pullPlanStepDispatchPolicy", "preload plan step dispatch policy pull bridge"],
[syncService, "planStepBusinessView", "plan step sync business view"],
[syncService, "pullDigitalEmployeePlanStepDispatchPolicy", "remote plan step dispatch policy pull service"],
[syncService, "planStepDispatchPolicyEndpoint", "remote plan step dispatch policy endpoint config"],
[syncService, "/api/digital-employee/policies/plan-step-dispatch", "remote plan step dispatch policy default path"],
[syncService, "lease_active", "plan step sync lease active field"],
[syncService, "planStepLeaseBusinessView", "plan step lease sync event view"],
[syncService, "plan_step_lease_acquired", "plan step lease acquired sync event kind"],
@@ -369,6 +384,7 @@ function checkDigitalPlanStepGraphDispatchPolicy() {
[syncService, "orchestration_id", "plan step dispatch sync orchestration id"],
[syncService, "planStepDispatchPolicyBusinessView", "plan step dispatch policy sync view"],
[syncService, "digital_workday_update_plan_step_dispatch_policy", "plan step dispatch policy sync event kind"],
[syncService, "digital_workday_pull_plan_step_dispatch_policy", "plan step dispatch policy pull sync event kind"],
];
const missing = requiredSnippets
.filter(([content, snippet]) => !content.includes(snippet))

View File

@@ -94,6 +94,7 @@ vi.mock("../services/digitalEmployee/schedulerService", () => ({
vi.mock("../services/digitalEmployee/syncService", () => ({
flushDigitalEmployeeSyncOutbox: vi.fn(),
getDigitalEmployeeSyncStatus: vi.fn(),
pullDigitalEmployeePlanStepDispatchPolicy: vi.fn(async () => ({ ok: true, policy: { enabled: true, max_per_sweep: 3, auto_dispatch_ready_steps: true, updated_at: null } })),
}));
vi.mock("../services/digitalEmployee/planTemplateService", () => ({
@@ -232,6 +233,23 @@ describe("digital employee managed service actions", () => {
expect(mockStateService.recordArtifactLifecycle).toHaveBeenCalledWith({ artifactId: "artifact-1", action: "mark_keep" });
});
it("registers plan step dispatch policy pull through the digital employee IPC boundary", async () => {
const electron = await import("electron");
const syncService = await import("../services/digitalEmployee/syncService");
const { registerDigitalEmployeeHandlers } = await import("./digitalEmployeeHandlers");
registerDigitalEmployeeHandlers(createCtx());
const calls = vi.mocked(electron.ipcMain.handle).mock.calls;
const policyPullCall = calls.find(([channel]) => channel === "digitalEmployee:pullPlanStepDispatchPolicy");
expect(policyPullCall).toBeTruthy();
const handler = policyPullCall?.[1] as () => Promise<unknown>;
const result = await handler();
expect(result).toMatchObject({ ok: true });
expect(syncService.pullDigitalEmployeePlanStepDispatchPolicy).toHaveBeenCalledWith({ force: true });
});
it("registers approval action history through the digital employee IPC boundary", async () => {
const electron = await import("electron");
const { registerDigitalEmployeeHandlers } = await import("./digitalEmployeeHandlers");

View File

@@ -57,6 +57,7 @@ import {
import {
flushDigitalEmployeeSyncOutbox,
getDigitalEmployeeSyncStatus,
pullDigitalEmployeePlanStepDispatchPolicy,
} from "../services/digitalEmployee/syncService";
import { listDigitalEmployeePlanTemplates } from "../services/digitalEmployee/planTemplateService";
import {
@@ -188,6 +189,10 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
return flushDigitalEmployeeSyncOutbox({ force: true });
});
ipcMain.handle("digitalEmployee:pullPlanStepDispatchPolicy", async () => {
return pullDigitalEmployeePlanStepDispatchPolicy({ force: true });
});
ipcMain.handle("digitalEmployee:getCronSettings", async () => {
return readDigitalEmployeeCronSettings();
});

View File

@@ -9,6 +9,7 @@ const mockState = vi.hoisted(() => ({
governanceCommands: [] as Array<Record<string, unknown>>,
acquiredLeases: [] as Array<Record<string, unknown>>,
releasedLeases: [] as Array<Record<string, unknown>>,
policyPulls: [] as Array<Record<string, unknown>>,
settings: {
enabled: true,
catch_up_on_startup: true,
@@ -85,6 +86,13 @@ vi.mock("./stateService", () => ({
},
}));
vi.mock("./syncService", () => ({
pullDigitalEmployeePlanStepDispatchPolicy: (input: Record<string, unknown> = {}) => {
mockState.policyPulls.push(input);
return Promise.resolve({ ok: true, endpoint: "https://manage.example.com/api/digital-employee/policies/plan-step-dispatch" });
},
}));
describe("digital employee scheduler service", () => {
beforeEach(() => {
vi.resetModules();
@@ -95,6 +103,7 @@ describe("digital employee scheduler service", () => {
mockState.governanceCommands = [];
mockState.acquiredLeases = [];
mockState.releasedLeases = [];
mockState.policyPulls = [];
mockState.dispatchPolicy = {
enabled: true,
max_per_sweep: 3,
@@ -179,6 +188,7 @@ describe("digital employee scheduler service", () => {
expect.objectContaining({ method: "POST" }),
);
expect(mockState.dispatchEvents.map((event) => event.status)).toEqual(["started", "completed"]);
expect(mockState.policyPulls).toEqual([{}]);
expect(mockState.acquiredLeases).toEqual([expect.objectContaining({ stepId: "step-ready", triggerSource: "auto" })]);
expect(mockState.releasedLeases).toEqual([expect.objectContaining({ stepId: "step-ready", leaseId: "lease-step-ready", reason: "completed" })]);
expect(mockState.governanceCommands[0]).toMatchObject({
@@ -260,6 +270,7 @@ describe("digital employee scheduler service", () => {
expect(result).toMatchObject({ plan_id: "plan-1", trigger_source: "manual", candidates: 1, dispatched: 1, skipped: 0, failed: 0 });
expect(mockState.dispatchEvents[0]).toMatchObject({ status: "started", payload: expect.objectContaining({ trigger_source: "manual" }) });
expect(mockState.policyPulls).toHaveLength(0);
});
it("keeps the enabled switch as a manual plan dispatch kill switch", async () => {

View File

@@ -19,6 +19,7 @@ import {
type DigitalEmployeeReadyPlanStepDispatchCandidate,
type DigitalEmployeeScheduleRecord,
} from "./stateService";
import { pullDigitalEmployeePlanStepDispatchPolicy } from "./syncService";
const SWEEP_INTERVAL_MS = 30_000;
const MAX_SCAN_MINUTES = 366 * 24 * 60;
@@ -157,6 +158,11 @@ export async function dispatchReadyPlanSteps(
failed_step_ids: [],
errors: [],
};
if (triggerSource === "auto") {
await pullDigitalEmployeePlanStepDispatchPolicy().catch((error) => {
log.debug("[DigitalEmployeeScheduler] PlanStep dispatch policy pull skipped/failed:", error);
});
}
const policy = readDigitalEmployeePlanStepDispatchPolicy();
if (!policy.enabled || (!policy.auto_dispatch_ready_steps && !bypassAutoDispatchSwitch)) {
result.skipped = candidates.length;

View File

@@ -361,6 +361,10 @@ export interface DigitalEmployeePlanStepDispatchPolicy {
max_per_sweep: number;
auto_dispatch_ready_steps: boolean;
updated_at: string | null;
source?: "local" | "remote";
remote_updated_at?: string | null;
last_pulled_at?: string | null;
last_pull_error?: string | null;
}
export interface DigitalEmployeeRouteDecisionRecord {
@@ -777,6 +781,10 @@ function defaultPlanStepDispatchPolicy(): DigitalEmployeePlanStepDispatchPolicy
max_per_sweep: 3,
auto_dispatch_ready_steps: true,
updated_at: null,
source: "local",
remote_updated_at: null,
last_pulled_at: null,
last_pull_error: null,
};
}
@@ -825,16 +833,21 @@ function boundedInteger(value: unknown, fallback: number, min: number, max: numb
return Math.max(min, Math.min(Math.floor(raw), max));
}
function normalizePlanStepDispatchPolicy(value: unknown): DigitalEmployeePlanStepDispatchPolicy {
export function normalizePlanStepDispatchPolicy(value: unknown): DigitalEmployeePlanStepDispatchPolicy {
const fallback = defaultPlanStepDispatchPolicy();
const record = value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
const source = record.source === "remote" ? "remote" : "local";
return {
enabled: record.enabled !== false,
max_per_sweep: boundedInteger(record.max_per_sweep, fallback.max_per_sweep, 1, 10),
auto_dispatch_ready_steps: record.auto_dispatch_ready_steps !== false,
updated_at: typeof record.updated_at === "string" ? record.updated_at : null,
source,
remote_updated_at: typeof record.remote_updated_at === "string" ? record.remote_updated_at : null,
last_pulled_at: typeof record.last_pulled_at === "string" ? record.last_pulled_at : null,
last_pull_error: typeof record.last_pull_error === "string" ? record.last_pull_error : null,
};
}
@@ -899,6 +912,8 @@ function digitalEmployeeUiActionMessage(action: string, date?: string): string {
return "数字员工审批订阅已更新";
case "update_plan_step_dispatch_policy":
return "数字员工PlanStep派发策略已更新";
case "pull_plan_step_dispatch_policy":
return "数字员工PlanStep派发策略已拉取";
default:
return `数字员工页面状态已更新${suffix}`;
}

View File

@@ -27,6 +27,7 @@ vi.mock("../../db", () => ({
},
getDb: () => mockState.db,
readSetting: (key: string) => mockState.settings.get(key) ?? null,
writeSetting: (key: string, value: unknown) => mockState.settings.set(key, value),
}));
describe("digital employee sync service", () => {
@@ -70,6 +71,72 @@ describe("digital employee sync service", () => {
expect(mockState.ensureSchemaCalls).toBeGreaterThan(0);
});
it("pulls remote plan step dispatch policy and records policy state", async () => {
mockState.fetch.mockResolvedValue(
jsonResponse({
success: true,
data: {
plan_step_dispatch_policy: {
enabled: true,
max_per_sweep: 5,
auto_dispatch_ready_steps: false,
updated_at: "2026-06-07T08:01:00.000Z",
},
},
}),
);
const { pullDigitalEmployeePlanStepDispatchPolicy } = await import("./syncService");
const { readDigitalEmployeeUiState } = await import("./stateService");
const result = await pullDigitalEmployeePlanStepDispatchPolicy({ force: true });
expect(result).toMatchObject({
ok: true,
endpoint: "https://manage.example.com/api/digital-employee/policies/plan-step-dispatch",
policy: {
source: "remote",
enabled: true,
max_per_sweep: 5,
auto_dispatch_ready_steps: false,
remote_updated_at: "2026-06-07T08:01:00.000Z",
last_pulled_at: "2026-06-07T08:00:00.000Z",
last_pull_error: null,
},
});
expect(mockState.fetch).toHaveBeenCalledWith(
"https://manage.example.com/api/digital-employee/policies/plan-step-dispatch",
expect.objectContaining({ method: "GET" }),
);
expect(readDigitalEmployeeUiState().planStepDispatchPolicy).toMatchObject(result.policy);
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "digital_workday_pull_plan_step_dispatch_policy" }),
]));
});
it("keeps the current plan step dispatch policy when remote policy pull fails", async () => {
mockState.fetch.mockResolvedValue({
ok: false,
status: 503,
text: vi.fn().mockResolvedValue(JSON.stringify({ message: "policy service unavailable" })),
} as unknown as Response);
const { pullDigitalEmployeePlanStepDispatchPolicy } = await import("./syncService");
const result = await pullDigitalEmployeePlanStepDispatchPolicy({ force: true });
expect(result).toMatchObject({
ok: false,
error: "policy service unavailable",
policy: {
source: "local",
enabled: true,
max_per_sweep: 3,
auto_dispatch_ready_steps: true,
last_pulled_at: "2026-06-07T08:00:00.000Z",
last_pull_error: "policy service unavailable",
},
});
});
it("marks entity-only acknowledgements as synced", async () => {
insertPlan("plan-1");
insertOutbox("plan:upsert:plan-1", "plan", "plan-1");
@@ -738,6 +805,25 @@ describe("digital employee sync service", () => {
},
},
});
insertEventEntity("event-plan-step-policy-pull", "digital_workday_pull_plan_step_dispatch_policy", {
metadata: {
action: "pull_plan_step_dispatch_policy",
source: "management-api",
ok: false,
error: "policy service unavailable",
endpoint: "https://manage.example.com/api/digital-employee/policies/plan-step-dispatch",
plan_step_dispatch_policy: {
enabled: true,
max_per_sweep: 2,
auto_dispatch_ready_steps: true,
updated_at: "2026-06-07T08:08:00.000Z",
source: "remote",
remote_updated_at: "2026-06-07T08:08:00.000Z",
last_pulled_at: "2026-06-07T08:09:00.000Z",
last_pull_error: "policy service unavailable",
},
},
});
mockState.fetch.mockResolvedValue(
jsonResponse({
success: true,
@@ -757,6 +843,7 @@ describe("digital employee sync service", () => {
{ entity_type: "event", entity_id: "event-notification-preferences" },
{ entity_type: "event", entity_id: "event-approval-subscriptions" },
{ entity_type: "event", entity_id: "event-plan-step-policy" },
{ entity_type: "event", entity_id: "event-plan-step-policy-pull" },
],
}),
);
@@ -923,6 +1010,18 @@ describe("digital employee sync service", () => {
});
expect(businessView("event-plan-step-policy")).not.toHaveProperty("state");
expect(businessView("event-plan-step-policy")).not.toHaveProperty("planStepDispatchPolicy");
expect(businessView("event-plan-step-policy-pull")).toMatchObject({
category: "dispatch",
action: "pull_plan_step_dispatch_policy",
source: "management-api",
pull_ok: false,
pull_error: "policy service unavailable",
policy_enabled: true,
max_per_sweep: 2,
auto_dispatch_ready_steps: true,
remote_updated_at: "2026-06-07T08:08:00.000Z",
last_pulled_at: "2026-06-07T08:09:00.000Z",
});
expect(readEntity("event", "event-route")).toMatchObject({
sync_status: "synced",
sync_error: null,
@@ -1032,7 +1131,9 @@ function entityMap(
): Map<string, TestSyncEntityRow> | undefined {
if (!mockState.db) return undefined;
if (entityType === "plan") return mockState.db.plans;
if (entityType === "task") return mockState.db.tasks;
if (entityType === "plan_step") return mockState.db.planSteps;
if (entityType === "run") return mockState.db.runs;
if (entityType === "schedule") return mockState.db.schedules;
if (entityType === "schedule_run") return mockState.db.scheduleRuns;
if (entityType === "artifact") return mockState.db.artifacts;
@@ -1108,7 +1209,9 @@ interface TestAttemptRow {
class TestDb {
outbox = new Map<string, TestOutboxRow>();
plans = new Map<string, TestSyncEntityRow>();
tasks = new Map<string, TestSyncEntityRow>();
planSteps = new Map<string, TestSyncEntityRow>();
runs = new Map<string, TestSyncEntityRow>();
schedules = new Map<string, TestSyncEntityRow>();
scheduleRuns = new Map<string, TestSyncEntityRow>();
artifacts = new Map<string, TestSyncEntityRow>();
@@ -1205,6 +1308,23 @@ class TestDb {
return this.plans.get(id);
}
if (sql.startsWith("SELECT title, status, NULL AS objective, payload FROM digital_tasks")) {
const [id] = args as [string];
return this.tasks.get(id);
}
if (sql.startsWith("SELECT id AS title, status, NULL AS objective, payload FROM digital_runs")) {
const [id] = args as [string];
const row = this.runs.get(id);
if (!row) return undefined;
return {
title: row.id,
status: row.status,
objective: null,
payload: row.payload,
};
}
if (sql.startsWith("SELECT name AS title, kind AS status, uri AS summary, payload FROM digital_artifacts")) {
const [id] = args as [string];
return this.artifacts.get(id);
@@ -1267,6 +1387,152 @@ class TestDb {
}
private run(sql: string, args: unknown[]): unknown {
if (sql.startsWith("INSERT INTO digital_plans")) {
const [id, title, objective, status, payload, createdAt, updatedAt] = args as [
string,
string,
string | null,
string,
string,
string,
string,
];
const previous = this.plans.get(id);
this.plans.set(id, {
id,
title,
objective,
status,
payload,
remote_id: previous?.remote_id ?? null,
sync_status: previous?.sync_status === "synced" ? "pending" : previous?.sync_status ?? "pending",
sync_error: previous?.sync_error ?? null,
last_synced_at: previous?.last_synced_at ?? null,
});
return { changes: 1 };
}
if (sql.startsWith("INSERT INTO digital_tasks")) {
const [id, planId, title, status, assignedSkillId, payload] = args as [
string,
string,
string,
string,
string,
string,
string,
string,
];
const previous = this.tasks.get(id);
this.tasks.set(id, {
id,
plan_id: planId,
title,
status,
payload: JSON.stringify({ ...JSON.parse(payload), assigned_skill_id: assignedSkillId }),
remote_id: previous?.remote_id ?? null,
sync_status: previous?.sync_status === "synced" ? "pending" : previous?.sync_status ?? "pending",
sync_error: previous?.sync_error ?? null,
last_synced_at: previous?.last_synced_at ?? null,
});
return { changes: 1 };
}
if (sql.startsWith("INSERT INTO digital_runs")) {
const [id, planId, taskId, status, startedAt, payload] = args as [
string,
string,
string,
string,
string,
string,
string,
string,
];
const previous = this.runs.get(id);
this.runs.set(id, {
id,
plan_id: planId,
task_id: taskId,
status,
payload: JSON.stringify({ ...JSON.parse(payload), started_at: startedAt }),
remote_id: previous?.remote_id ?? null,
sync_status: previous?.sync_status === "synced" ? "pending" : previous?.sync_status ?? "pending",
sync_error: previous?.sync_error ?? null,
last_synced_at: previous?.last_synced_at ?? null,
});
return { changes: 1 };
}
if (sql.startsWith("UPDATE digital_runs SET status = ?, finished_at = ?, updated_at = ?")) {
const [status, finishedAt, _updatedAt, id] = args as [string, string, string, string];
const row = this.runs.get(id);
if (row) {
row.status = status;
row.payload = JSON.stringify({ ...JSON.parse(row.payload ?? "{}"), finished_at: finishedAt });
row.sync_status = "pending";
}
return { changes: row ? 1 : 0 };
}
if (sql.startsWith("INSERT OR IGNORE INTO digital_events")) {
const [id, planId, taskId, runId, kind, message, payload, occurredAt] = args as [
string,
string | null,
string | null,
string | null,
string,
string,
string,
string,
string,
];
if (!this.events.has(id)) {
this.events.set(id, {
id,
plan_id: planId,
task_id: taskId,
run_id: runId,
kind,
message,
payload,
remote_id: null,
sync_status: "pending",
sync_error: null,
last_synced_at: null,
occurred_at: occurredAt,
});
}
return { changes: 1 };
}
if (sql.startsWith("INSERT INTO digital_sync_outbox")) {
const [id, entityType, entityId, operation, payload, createdAt, updatedAt] = args as [
string,
string,
string,
string,
string,
string,
string,
];
const previous = this.outbox.get(id);
this.outbox.set(id, {
id,
entity_type: entityType,
entity_id: entityId,
operation,
payload,
status: "pending",
attempts: previous?.attempts ?? 0,
last_attempt_at: previous?.last_attempt_at ?? null,
error: null,
created_at: previous?.created_at ?? createdAt,
updated_at: updatedAt,
});
return { changes: 1 };
}
if (sql.startsWith("UPDATE digital_sync_outbox SET status = 'syncing'")) {
const [lastAttemptAt, updatedAt, id] = args as [string, string, string];
const row = this.outbox.get(id);
@@ -1349,7 +1615,7 @@ class TestDb {
return { changes: before - this.attempts.length };
}
if (/^UPDATE digital_(plans|plan_steps|schedules|schedule_runs|events|artifacts|approvals|memories) SET sync_status = 'synced'/.test(sql)) {
if (/^UPDATE digital_(plans|tasks|plan_steps|runs|schedules|schedule_runs|events|artifacts|approvals|memories) SET sync_status = 'synced'/.test(sql)) {
const [remoteId, lastSyncedAt, id] = args as [string | null, string, string];
const row = this.entityRowsForUpdate(sql).get(id);
if (row) {
@@ -1361,7 +1627,7 @@ class TestDb {
return { changes: row ? 1 : 0 };
}
if (/^UPDATE digital_(plans|plan_steps|schedules|schedule_runs|events|artifacts|approvals|memories) SET sync_status = 'failed'/.test(sql)) {
if (/^UPDATE digital_(plans|tasks|plan_steps|runs|schedules|schedule_runs|events|artifacts|approvals|memories) SET sync_status = 'failed'/.test(sql)) {
const [syncError, id] = args as [string, string];
const row = this.entityRowsForUpdate(sql).get(id);
if (row) {
@@ -1379,9 +1645,11 @@ class TestDb {
if (sql.startsWith("UPDATE digital_approvals")) return this.approvals;
if (sql.startsWith("UPDATE digital_memories")) return this.memories;
if (sql.startsWith("UPDATE digital_events")) return this.events;
if (sql.startsWith("UPDATE digital_runs")) return this.runs;
if (sql.startsWith("UPDATE digital_schedule_runs")) return this.scheduleRuns;
if (sql.startsWith("UPDATE digital_schedules")) return this.schedules;
if (sql.startsWith("UPDATE digital_plan_steps")) return this.planSteps;
if (sql.startsWith("UPDATE digital_tasks")) return this.tasks;
return this.plans;
}
}

View File

@@ -2,14 +2,22 @@ import log from "electron-log";
import { getDeviceId } from "../system/deviceId";
import { getDomainTokenKey } from "@shared/utils/domain";
import { ensureDigitalEmployeeSchema, getDb, readSetting } from "../../db";
import {
normalizePlanStepDispatchPolicy,
readDigitalEmployeeUiState,
saveDigitalEmployeeUiState,
type DigitalEmployeePlanStepDispatchPolicy,
} from "./stateService";
const DEFAULT_SYNC_PATH = "/api/digital-employee/sync/outbox";
const DEFAULT_PLAN_STEP_DISPATCH_POLICY_PATH = "/api/digital-employee/policies/plan-step-dispatch";
const MANAGEMENT_SYNC_RECORD_PATH =
"/system/content/content-digital-employee-sync";
const DIGITAL_EMPLOYEE_SYNC_CONTRACT_VERSION = "qimingclaw.digital_employee.sync.v1";
const DEFAULT_INTERVAL_MS = 30_000;
const DEFAULT_BATCH_SIZE = 50;
const REQUEST_TIMEOUT_MS = 15_000;
const POLICY_PULL_THROTTLE_MS = 60_000;
const MAX_RETRY_DELAY_MS = 30 * 60_000;
const FAILED_RETRY_CANDIDATE_MULTIPLIER = 4;
const ATTEMPT_HISTORY_RETENTION_MS = 30 * 24 * 60 * 60_000;
@@ -18,6 +26,8 @@ let syncTimer: ReturnType<typeof setInterval> | null = null;
let syncing = false;
let lastSyncAt: string | null = null;
let lastSyncError: string | null = null;
let policyPulling = false;
let lastPolicyPullAttemptMs = 0;
function getDigitalEmployeeDb(): ReturnType<typeof getDb> {
if (!ensureDigitalEmployeeSchema()) return null;
@@ -27,11 +37,21 @@ function getDigitalEmployeeDb(): ReturnType<typeof getDb> {
interface DigitalEmployeeSyncConfig {
enabled: boolean;
endpoint: string | null;
policyEndpoint: string | null;
clientKey: string | null;
authToken: string | null;
batchSize: number;
}
export interface DigitalEmployeePlanStepDispatchPolicyPullResult {
ok: boolean;
skipped?: boolean;
endpoint: string | null;
pulled_at: string | null;
policy: DigitalEmployeePlanStepDispatchPolicy;
error?: string | null;
}
interface OutboxRow {
id: string;
entity_type: string;
@@ -168,6 +188,63 @@ export function getDigitalEmployeeSyncStatus(): DigitalEmployeeSyncStatus {
};
}
export async function pullDigitalEmployeePlanStepDispatchPolicy(
options: { force?: boolean } = {},
): Promise<DigitalEmployeePlanStepDispatchPolicyPullResult> {
const currentState = readDigitalEmployeeUiState();
const currentPolicy = currentState.planStepDispatchPolicy ?? normalizePlanStepDispatchPolicy(null);
if (policyPulling) {
return { ok: true, skipped: true, endpoint: resolveSyncConfig().policyEndpoint, pulled_at: currentPolicy.last_pulled_at ?? null, policy: currentPolicy };
}
const nowMs = Date.now();
if (!options.force && nowMs - lastPolicyPullAttemptMs < POLICY_PULL_THROTTLE_MS) {
return { ok: true, skipped: true, endpoint: resolveSyncConfig().policyEndpoint, pulled_at: currentPolicy.last_pulled_at ?? null, policy: currentPolicy };
}
lastPolicyPullAttemptMs = nowMs;
const config = resolveSyncConfig();
const missingCredentials = missingSyncCredentialLabels(config);
if (!config.policyEndpoint || missingCredentials.length > 0) {
const error = !config.policyEndpoint ? "management policy endpoint unavailable" : `missing credentials: ${missingCredentials.join(", ")}`;
return recordPlanStepDispatchPolicyPullFailure(currentState, currentPolicy, config.policyEndpoint, error);
}
policyPulling = true;
try {
const response = await fetchJsonWithAuth(config, config.policyEndpoint);
const responseError = readSyncResponseError(response as SyncResponse);
if (responseError) throw new Error(responseError);
const remotePolicy = readRemotePlanStepDispatchPolicy(response);
if (!remotePolicy) throw new Error("management policy response missing plan step dispatch policy");
const pulledAt = new Date().toISOString();
const policy = normalizePlanStepDispatchPolicy({
...remotePolicy,
source: "remote",
remote_updated_at: stringField(remotePolicy.updated_at) || stringField(remotePolicy.remote_updated_at) || null,
last_pulled_at: pulledAt,
last_pull_error: null,
});
saveDigitalEmployeeUiState({
action: "pull_plan_step_dispatch_policy",
metadata: {
source: "management-api",
endpoint: config.policyEndpoint,
ok: true,
plan_step_dispatch_policy: policy,
},
state: {
...currentState,
planStepDispatchPolicy: policy,
},
});
return { ok: true, endpoint: config.policyEndpoint, pulled_at: pulledAt, policy, error: null };
} catch (error) {
return recordPlanStepDispatchPolicyPullFailure(currentState, currentPolicy, config.policyEndpoint, normalizeError(error));
} finally {
policyPulling = false;
}
}
export async function flushDigitalEmployeeSyncOutbox(
options: { force?: boolean } = {},
): Promise<DigitalEmployeeSyncStatus> {
@@ -291,6 +368,13 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
? step1.serverHost.trim()
: null;
const endpoint = endpointOverride || buildEndpoint(serverHost);
const policyEndpointOverride =
typeof config.planStepDispatchPolicyEndpoint === "string" && config.planStepDispatchPolicyEndpoint.trim()
? config.planStepDispatchPolicyEndpoint.trim()
: typeof config.policyEndpoint === "string" && config.policyEndpoint.trim()
? config.policyEndpoint.trim()
: null;
const policyEndpoint = policyEndpointOverride || buildEndpoint(serverHost, DEFAULT_PLAN_STEP_DISPATCH_POLICY_PATH);
const disabled = config.enabled === false;
const batchSize =
typeof config.batchSize === "number" && config.batchSize > 0
@@ -300,18 +384,19 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
return {
enabled: !disabled,
endpoint,
policyEndpoint,
clientKey: readClientKey(serverHost),
authToken: readAuthToken(serverHost),
batchSize,
};
}
function buildEndpoint(serverHost: string | null): string | null {
function buildEndpoint(serverHost: string | null, path = DEFAULT_SYNC_PATH): string | null {
if (!serverHost) return null;
const normalized = serverHost.startsWith("http")
? serverHost
: `https://${serverHost}`;
return `${normalized.replace(/\/+$/, "")}${DEFAULT_SYNC_PATH}`;
return `${normalized.replace(/\/+$/, "")}${path}`;
}
function missingSyncCredentialLabels(
@@ -385,35 +470,85 @@ async function postOutbox(
config: DigitalEmployeeSyncConfig,
rows: OutboxRow[],
): Promise<SyncResponse> {
return fetchJsonWithAuth(config, config.endpoint!, {
client_key: config.clientKey,
device_id: getDeviceId(),
contract_version: DIGITAL_EMPLOYEE_SYNC_CONTRACT_VERSION,
items: rows.map(buildSyncItem),
}) as Promise<SyncResponse>;
}
async function fetchJsonWithAuth(
config: DigitalEmployeeSyncConfig,
endpoint: string,
requestBody?: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const response = await fetch(config.endpoint!, {
method: "POST",
const response = await fetch(endpoint, {
method: requestBody ? "POST" : "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.authToken}`,
"X-Qiming-Client-Key": config.clientKey!,
"X-Qiming-Device-Id": getDeviceId(),
},
body: JSON.stringify({
client_key: config.clientKey,
device_id: getDeviceId(),
contract_version: DIGITAL_EMPLOYEE_SYNC_CONTRACT_VERSION,
items: rows.map(buildSyncItem),
}),
body: requestBody ? JSON.stringify(requestBody) : undefined,
signal: controller.signal,
});
const text = await response.text();
const body = text ? (JSON.parse(text) as SyncResponse) : {};
const responseBody = text ? (JSON.parse(text) as Record<string, unknown>) : {};
if (!response.ok) {
throw new Error(body.message || `HTTP ${response.status}`);
throw new Error(stringField(responseBody.message) || `HTTP ${response.status}`);
}
return body;
return responseBody;
} finally {
clearTimeout(timer);
}
}
function readRemotePlanStepDispatchPolicy(response: Record<string, unknown>): Record<string, unknown> | null {
const data = objectRecord(response.data) ?? response;
return objectRecord(data.plan_step_dispatch_policy)
?? objectRecord(data.planStepDispatchPolicy)
?? objectRecord(data.policy)
?? (hasPlanStepDispatchPolicyFields(data) ? data : null);
}
function hasPlanStepDispatchPolicyFields(record: Record<string, unknown>): boolean {
return "enabled" in record || "max_per_sweep" in record || "auto_dispatch_ready_steps" in record;
}
function recordPlanStepDispatchPolicyPullFailure(
state: ReturnType<typeof readDigitalEmployeeUiState>,
currentPolicy: DigitalEmployeePlanStepDispatchPolicy,
endpoint: string | null,
error: string,
): DigitalEmployeePlanStepDispatchPolicyPullResult {
const pulledAt = new Date().toISOString();
const policy = normalizePlanStepDispatchPolicy({
...currentPolicy,
last_pulled_at: pulledAt,
last_pull_error: error,
});
saveDigitalEmployeeUiState({
action: "pull_plan_step_dispatch_policy",
metadata: {
source: "management-api",
endpoint,
ok: false,
error,
plan_step_dispatch_policy: policy,
},
state: {
...state,
planStepDispatchPolicy: policy,
},
});
return { ok: false, endpoint, pulled_at: pulledAt, policy, error };
}
function buildSyncItem(row: OutboxRow): Record<string, unknown> {
const payload = parsePayload(row.payload);
const summary = readEntitySummary(row.entity_type, row.entity_id);
@@ -753,10 +888,16 @@ function planStepDispatchPolicyBusinessView(payload: Record<string, unknown>): R
?? {};
return {
action: stringField(metadata.action ?? payload.action) || "update_plan_step_dispatch_policy",
source: stringField(metadata.source ?? policy.source) || null,
pull_ok: typeof metadata.ok === "boolean" ? metadata.ok : null,
pull_error: stringField(metadata.error ?? policy.last_pull_error) || null,
pull_endpoint: stringField(metadata.endpoint) || null,
policy_enabled: typeof policy.enabled === "boolean" ? policy.enabled : null,
max_per_sweep: numberField(policy.max_per_sweep),
auto_dispatch_ready_steps: typeof policy.auto_dispatch_ready_steps === "boolean" ? policy.auto_dispatch_ready_steps : null,
updated_at: stringField(policy.updated_at) || null,
remote_updated_at: stringField(policy.remote_updated_at) || null,
last_pulled_at: stringField(policy.last_pulled_at) || null,
};
}
@@ -820,7 +961,8 @@ function isPlanStepDependencyEvent(kind: string): boolean {
}
function isPlanStepDispatchPolicyEvent(kind: string): boolean {
return kind === "digital_workday_update_plan_step_dispatch_policy";
return kind === "digital_workday_update_plan_step_dispatch_policy"
|| kind === "digital_workday_pull_plan_step_dispatch_policy";
}
function isPlanStepLeaseEvent(kind: string): boolean {

View File

@@ -128,6 +128,9 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
async flushSync() {
return ipcRenderer.invoke("digitalEmployee:flushSync");
},
async pullPlanStepDispatchPolicy() {
return ipcRenderer.invoke("digitalEmployee:pullPlanStepDispatchPolicy");
},
async getCronSettings() {
return ipcRenderer.invoke("digitalEmployee:getCronSettings");
},

View File

@@ -540,9 +540,9 @@ GET /api/digital-employee/approvals
1. **PlanStep 与依赖图(依赖图调度已开始)**
- 已吸收Plan / Task / Run 记录、计划模板、`create_plan` 中的 steps/tasks 可物化为任务PlanStep 已开始作为正式本地实体落入 `digital_plan_steps`
- 当前能力PlanStep 会进入 `digital_sync_outbox`entity type 为 `plan_step`;任务中心 flow 和 debug store 优先读取真实 PlanStep 生成步骤图Task Agent 的 retry / skip / cancel / pause / resume / input / run 控制已可更新真实 Task、PlanStep、Run、Event 和 outbox前置步骤跳过、取消或重试后会重新评估后继步骤推进为 ready / blocked / pending 并写入依赖图事件scheduler sweep 会识别可派发的 ready PlanStep调用本地 `/computer/chat` 并写入 `plan_step_dispatch_*``governance_start_run` 事件;派发前会写入 5 分钟本地软租约 `payload.dispatchLease`,已有未过期租约的步骤会以 `lease_active` 跳过,派发完成或失败后释放租约并写入 `plan_step_lease_*` 事件embedded 已提供 `/api/digital-employee/plan-steps``/api/digital-employee/plan-step-graph``/api/digital-employee/plans/:planId/dispatch-ready-steps``/api/plan-step/dispatch-policy`ready PlanStep 安全派发、计划级跨任务推进、管理端依赖图视图、本地派发策略、可观察租约状态和首页阻塞依赖处理入口已开始闭环。
- 后续缺口:真正由管理端仲裁的跨设备强租约和远端策略拉取仍待吸收。
- 建议:下一轮围绕管理端 lease 仲裁或远端策略拉取,把本地可治理调度推进到跨端治理闭环。
- 当前能力PlanStep 会进入 `digital_sync_outbox`entity type 为 `plan_step`;任务中心 flow 和 debug store 优先读取真实 PlanStep 生成步骤图Task Agent 的 retry / skip / cancel / pause / resume / input / run 控制已可更新真实 Task、PlanStep、Run、Event 和 outbox前置步骤跳过、取消或重试后会重新评估后继步骤推进为 ready / blocked / pending 并写入依赖图事件scheduler sweep 会识别可派发的 ready PlanStep调用本地 `/computer/chat` 并写入 `plan_step_dispatch_*``governance_start_run` 事件;派发前会写入 5 分钟本地软租约 `payload.dispatchLease`,已有未过期租约的步骤会以 `lease_active` 跳过,派发完成或失败后释放租约并写入 `plan_step_lease_*` 事件;auto sweep 会按节流从管理端 `GET /api/digital-employee/policies/plan-step-dispatch` 拉取远端派发策略,拉取失败时保留本地策略并记录 `last_pull_error`embedded 已提供 `/api/digital-employee/plan-steps``/api/digital-employee/plan-step-graph``/api/digital-employee/plans/:planId/dispatch-ready-steps``/api/plan-step/dispatch-policy``/api/plan-step/dispatch-policy/pull`ready PlanStep 安全派发、计划级跨任务推进、管理端依赖图视图、本地/远端派发策略、可观察租约状态和首页阻塞依赖处理入口已开始闭环。
- 后续缺口:真正由管理端仲裁的跨设备强租约仍待吸收。
- 建议:下一轮围绕管理端 lease 仲裁,把本地可治理调度推进到跨端治理闭环。
2. **调度 / 定时 / 周期任务**
- 已吸收:`digital_schedules``digital_schedule_runs` 已进入 qimingclaw 本地 SQLite 与 outboxentity type 为 `schedule` / `schedule_run`;旧 `/api/cron``/api/scheduler/jobs` 和治理命令会优先读取真实本地调度记录。