吸收数字员工PlanStep依赖图派发策略

This commit is contained in:
baiyanyun
2026-06-10 18:41:33 +08:00
parent 3ac350eb27
commit d679639175
14 changed files with 795 additions and 212 deletions

View File

@@ -14,6 +14,12 @@ const mockState = vi.hoisted(() => ({
max_catch_up_runs: 5,
max_concurrent_schedule_runs: 1,
},
dispatchPolicy: {
enabled: true,
max_per_sweep: 3,
auto_dispatch_ready_steps: true,
updated_at: null as string | null,
},
}));
vi.mock("../startupPorts", () => ({
@@ -26,6 +32,7 @@ vi.mock("../constants", () => ({
vi.mock("./stateService", () => ({
readDigitalEmployeeCronSettings: () => mockState.settings,
readDigitalEmployeePlanStepDispatchPolicy: () => mockState.dispatchPolicy,
listDigitalEmployeeReadyPlanStepDispatchCandidates: () => mockState.readyCandidates,
listDigitalEmployeeSchedules: () => mockState.schedules,
listDueDigitalEmployeeSchedules: () => mockState.schedules,
@@ -62,6 +69,12 @@ describe("digital employee scheduler service", () => {
mockState.finishedRuns = [];
mockState.dispatchEvents = [];
mockState.governanceCommands = [];
mockState.dispatchPolicy = {
enabled: true,
max_per_sweep: 3,
auto_dispatch_ready_steps: true,
updated_at: null,
};
mockState.schedules = [{
id: "schedule-1",
planId: "plan-1",
@@ -165,6 +178,45 @@ describe("digital employee scheduler service", () => {
expect(mockState.dispatchEvents.map((event) => event.status)).toEqual(["started", "failed"]);
expect(mockState.governanceCommands).toHaveLength(0);
});
it("honors disabled ready step dispatch policy", async () => {
mockState.readyCandidates = [readyStepCandidate()];
mockState.dispatchPolicy = { ...mockState.dispatchPolicy, enabled: false };
const { dispatchReadyPlanSteps } = await import("./schedulerService");
const result = await dispatchReadyPlanSteps(new Date("2026-06-07T01:00:10.000Z"));
expect(result).toMatchObject({ candidates: 1, dispatched: 0, skipped: 1, failed: 0 });
expect(fetch).not.toHaveBeenCalled();
expect(mockState.dispatchEvents).toHaveLength(0);
});
it("honors ready step auto dispatch switch", async () => {
mockState.readyCandidates = [readyStepCandidate()];
mockState.dispatchPolicy = { ...mockState.dispatchPolicy, auto_dispatch_ready_steps: false };
const { dispatchReadyPlanSteps } = await import("./schedulerService");
const result = await dispatchReadyPlanSteps(new Date("2026-06-07T01:00:10.000Z"));
expect(result).toMatchObject({ candidates: 1, dispatched: 0, skipped: 1, failed: 0 });
expect(fetch).not.toHaveBeenCalled();
expect(mockState.dispatchEvents).toHaveLength(0);
});
it("limits ready step dispatches by policy max per sweep", async () => {
mockState.readyCandidates = [
readyStepCandidate(),
{ ...readyStepCandidate(), stepId: "step-ready-2", taskId: "task-2" },
];
mockState.dispatchPolicy = { ...mockState.dispatchPolicy, max_per_sweep: 1 };
const { dispatchReadyPlanSteps } = await import("./schedulerService");
const result = await dispatchReadyPlanSteps(new Date("2026-06-07T01:00:10.000Z"));
expect(result).toMatchObject({ candidates: 2, dispatched: 1, skipped: 1, failed: 0 });
expect(fetch).toHaveBeenCalledTimes(1);
expect(mockState.dispatchEvents.map((event) => event.stepId)).toEqual(["step-ready", "step-ready"]);
});
});
function readyStepCandidate(): Record<string, unknown> {

View File

@@ -7,6 +7,7 @@ import {
listDigitalEmployeeSchedules,
listDueDigitalEmployeeSchedules,
readDigitalEmployeeCronSettings,
readDigitalEmployeePlanStepDispatchPolicy,
readDigitalEmployeeScheduleRuns,
recordDigitalEmployeeGovernanceCommand,
recordDigitalEmployeePlanStepDispatch,
@@ -19,7 +20,6 @@ import {
const SWEEP_INTERVAL_MS = 30_000;
const MAX_SCAN_MINUTES = 366 * 24 * 60;
const RETRY_DELAYS_MS = [60_000, 5 * 60_000, 15 * 60_000];
const MAX_READY_STEP_DISPATCH_PER_SWEEP = 3;
let schedulerTimer: ReturnType<typeof setInterval> | null = null;
let schedulerSweeping = false;
@@ -126,13 +126,19 @@ export async function dispatchReadyPlanSteps(
failed: 0,
errors: [],
};
const policy = readDigitalEmployeePlanStepDispatchPolicy();
if (!policy.enabled || !policy.auto_dispatch_ready_steps) {
result.skipped = candidates.length;
return result;
}
const maxPerSweep = Math.max(1, Math.min(Math.floor(policy.max_per_sweep), 10));
let attempts = 0;
for (const candidate of candidates) {
if (!candidate.dispatchable || !candidate.taskId) {
result.skipped += 1;
continue;
}
if (attempts >= MAX_READY_STEP_DISPATCH_PER_SWEEP) {
if (attempts >= maxPerSweep) {
result.skipped += 1;
continue;
}

View File

@@ -938,6 +938,70 @@ describe("digital employee state service", () => {
});
});
it("persists plan step dispatch policy and records runtime events", async () => {
const { readDigitalEmployeeUiState, saveDigitalEmployeeUiState } = await import("./stateService");
saveDigitalEmployeeUiState({
action: "update_plan_step_dispatch_policy",
metadata: { source: "plan-step-settings" },
state: {
selectedDutyIdsByDate: {},
confirmedDates: {},
reportScheduleTime: "18:00",
reportGeneratedAtByDate: {},
decisionResultsByDate: {},
notificationStateById: {},
notificationPreferences: {
enabled: true,
muted_kinds: [],
muted_levels: [],
updated_at: null,
},
approvalSubscriptionPreferences: {
enabled: true,
subscribed_actions: ["comment", "delegate", "mark_timeout", "mark_risk", "clear_risk", "decision"],
subscribed_risk_levels: ["high", "critical"],
notify_on_timeout: true,
updated_at: null,
},
planStepDispatchPolicy: {
enabled: true,
max_per_sweep: 11,
auto_dispatch_ready_steps: false,
updated_at: "2026-06-07T08:07:00.000Z",
},
consoleRole: "sales",
rolePreferences: {},
profile: {
name: "飞天数字员工",
company: "qimingclaw 客户端",
position: "客户端任务执行员",
currentStatus: "working",
},
},
});
expect(readDigitalEmployeeUiState()).toMatchObject({
planStepDispatchPolicy: {
enabled: true,
max_per_sweep: 10,
auto_dispatch_ready_steps: false,
updated_at: "2026-06-07T08:07:00.000Z",
},
});
const policyEvent = Array.from(mockState.db?.events.values() ?? [])
.find((event) => event.kind === "digital_workday_update_plan_step_dispatch_policy");
expect(policyEvent).toMatchObject({
kind: "digital_workday_update_plan_step_dispatch_policy",
message: "数字员工PlanStep派发策略已更新",
});
expect(mockState.db?.outbox.get(`event:insert:${policyEvent?.id}`)).toMatchObject({
entity_type: "event",
operation: "insert",
status: "pending",
});
});
it("records governance commands as formal runtime records", async () => {
const { recordDigitalEmployeeGovernanceCommand } = await import("./stateService");

View File

@@ -323,6 +323,13 @@ export interface DigitalEmployeePlanStepDispatchRecord {
payload: Record<string, unknown>;
}
export interface DigitalEmployeePlanStepDispatchPolicy {
enabled: boolean;
max_per_sweep: number;
auto_dispatch_ready_steps: boolean;
updated_at: string | null;
}
export interface DigitalEmployeeRouteDecisionRecord {
id: string;
route_kind: string;
@@ -480,6 +487,7 @@ export interface DigitalEmployeeUiState {
notify_on_timeout: boolean;
updated_at: string | null;
};
planStepDispatchPolicy?: DigitalEmployeePlanStepDispatchPolicy;
consoleRole?: string;
rolePreferences?: Record<string, unknown>;
profile: {
@@ -657,6 +665,7 @@ function defaultUiState(): DigitalEmployeeUiState {
notificationStateById: {},
notificationPreferences: defaultNotificationPreferences(),
approvalSubscriptionPreferences: defaultApprovalSubscriptionPreferences(),
planStepDispatchPolicy: defaultPlanStepDispatchPolicy(),
consoleRole: "sales",
rolePreferences: {},
profile: {
@@ -729,6 +738,15 @@ function defaultApprovalSubscriptionPreferences(): NonNullable<DigitalEmployeeUi
};
}
function defaultPlanStepDispatchPolicy(): DigitalEmployeePlanStepDispatchPolicy {
return {
enabled: true,
max_per_sweep: 3,
auto_dispatch_ready_steps: true,
updated_at: null,
};
}
function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return [...new Set(value.filter((item): item is string => typeof item === "string" && Boolean(item.trim())))];
@@ -768,6 +786,25 @@ function normalizeApprovalSubscriptionPreferences(
};
}
function boundedInteger(value: unknown, fallback: number, min: number, max: number): number {
const raw = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(raw)) return fallback;
return Math.max(min, Math.min(Math.floor(raw), max));
}
function normalizePlanStepDispatchPolicy(value: unknown): DigitalEmployeePlanStepDispatchPolicy {
const fallback = defaultPlanStepDispatchPolicy();
const record = value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
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,
};
}
function normalizeUiState(
value: Partial<DigitalEmployeeUiState>,
): DigitalEmployeeUiState {
@@ -784,6 +821,7 @@ function normalizeUiState(
notificationStateById: normalizeRecord(value.notificationStateById),
notificationPreferences: normalizeNotificationPreferences(value.notificationPreferences),
approvalSubscriptionPreferences: normalizeApprovalSubscriptionPreferences(value.approvalSubscriptionPreferences),
planStepDispatchPolicy: normalizePlanStepDispatchPolicy(value.planStepDispatchPolicy),
consoleRole: typeof value.consoleRole === "string" && value.consoleRole.trim()
? value.consoleRole
: fallback.consoleRole,
@@ -826,6 +864,8 @@ function digitalEmployeeUiActionMessage(action: string, date?: string): string {
return "数字员工通知订阅已更新";
case "update_approval_subscriptions":
return "数字员工审批订阅已更新";
case "update_plan_step_dispatch_policy":
return "数字员工PlanStep派发策略已更新";
default:
return `数字员工页面状态已更新${suffix}`;
}
@@ -855,6 +895,10 @@ export function readDigitalEmployeeUiState(): DigitalEmployeeUiState {
return normalizeUiState(state as Partial<DigitalEmployeeUiState>);
}
export function readDigitalEmployeePlanStepDispatchPolicy(): DigitalEmployeePlanStepDispatchPolicy {
return readDigitalEmployeeUiState().planStepDispatchPolicy ?? defaultPlanStepDispatchPolicy();
}
export function saveDigitalEmployeeUiState(
update: DigitalEmployeeUiStateUpdate,
): DigitalEmployeeUiState {

View File

@@ -334,6 +334,15 @@ describe("digital employee sync service", () => {
seq: 2,
dependsOn: ["step-0"],
preferredSkillIds: ["qimingclaw-mcp-tool-crm-crm-search"],
payload: {
dependencyGraph: {
status: "blocked",
blockedDependencies: ["step-0"],
waitingDependencies: ["step-2"],
},
lastDispatch: { status: "failed" },
skipReason: "approval_pending",
},
}),
);
mockState.fetch.mockResolvedValue(
@@ -365,7 +374,16 @@ describe("digital employee sync service", () => {
status: "pending",
seq: 2,
depends_on: ["step-0"],
dependency_count: 1,
dependency_status: "blocked",
blocked_dependencies: ["step-0"],
blocked_dependency_count: 1,
waiting_dependencies: ["step-2"],
waiting_dependency_count: 1,
preferred_skill_ids: ["qimingclaw-mcp-tool-crm-crm-search"],
dispatchable: false,
skip_reason: "approval_pending",
last_dispatch_status: "failed",
}),
}),
],
@@ -541,9 +559,24 @@ describe("digital employee sync service", () => {
});
insertEventEntity("event-dispatch", "plan_step_dispatch_failed", {
step_id: "step-1",
plan_id: "plan-1",
task_id: "task-1",
run_id: "run-1",
status: "failed",
reason: "computer chat failed",
});
insertEventEntity("event-plan-step-ready", "plan_step_ready", {
stepId: "step-2",
planId: "plan-1",
status: "ready",
dependsOn: ["step-1"],
dependencyGraph: {
status: "ready",
blockedDependencies: [],
waitingDependencies: [],
satisfiedDependencies: ["step-1"],
},
});
insertEventEntity("event-governance-input", "governance_provide_task_input", {
commandKind: "provide_task_input",
commandId: "command-provide-input",
@@ -648,6 +681,25 @@ describe("digital employee sync service", () => {
},
},
});
insertEventEntity("event-plan-step-policy", "digital_workday_update_plan_step_dispatch_policy", {
metadata: {
action: "update_plan_step_dispatch_policy",
plan_step_dispatch_policy: {
enabled: false,
max_per_sweep: 1,
auto_dispatch_ready_steps: false,
updated_at: "2026-06-07T08:07:00.000Z",
},
},
state: {
planStepDispatchPolicy: {
enabled: false,
max_per_sweep: 1,
auto_dispatch_ready_steps: false,
updated_at: "2026-06-07T08:07:00.000Z",
},
},
});
mockState.fetch.mockResolvedValue(
jsonResponse({
success: true,
@@ -658,12 +710,14 @@ describe("digital employee sync service", () => {
{ entity_type: "event", entity_id: "event-artifact" },
{ entity_type: "event", entity_id: "event-skill" },
{ entity_type: "event", entity_id: "event-dispatch" },
{ entity_type: "event", entity_id: "event-plan-step-ready" },
{ entity_type: "event", entity_id: "event-governance-input" },
{ entity_type: "event", entity_id: "event-notification-read" },
{ entity_type: "event", entity_id: "event-notification-dismiss" },
{ entity_type: "event", entity_id: "event-notification-reply" },
{ 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" },
],
}),
);
@@ -736,9 +790,22 @@ describe("digital employee sync service", () => {
expect(businessView("event-dispatch")).toMatchObject({
category: "dispatch",
step_id: "step-1",
plan_id: "plan-1",
task_id: "task-1",
run_id: "run-1",
status: "failed",
reason: "computer chat failed",
});
expect(businessView("event-plan-step-ready")).toMatchObject({
category: "dispatch",
step_id: "step-2",
plan_id: "plan-1",
status: "ready",
dependency_count: 1,
blocked_dependencies: [],
waiting_dependencies: [],
satisfied_dependencies: ["step-1"],
});
expect(businessView("event-governance-input")).toMatchObject({
category: "governance",
command_kind: "provide_task_input",
@@ -790,6 +857,16 @@ describe("digital employee sync service", () => {
});
expect(businessView("event-approval-subscriptions")).not.toHaveProperty("state");
expect(businessView("event-approval-subscriptions")).not.toHaveProperty("approvalSubscriptionPreferences");
expect(businessView("event-plan-step-policy")).toMatchObject({
category: "dispatch",
action: "update_plan_step_dispatch_policy",
policy_enabled: false,
max_per_sweep: 1,
auto_dispatch_ready_steps: false,
updated_at: "2026-06-07T08:07:00.000Z",
});
expect(businessView("event-plan-step-policy")).not.toHaveProperty("state");
expect(businessView("event-plan-step-policy")).not.toHaveProperty("planStepDispatchPolicy");
expect(readEntity("event", "event-route")).toMatchObject({
sync_status: "synced",
sync_error: null,

View File

@@ -458,18 +458,7 @@ function buildBusinessView(
assigned_skill_id: stringField(record.assignedSkillId ?? record.assigned_skill_id) || null,
};
case "plan_step":
return {
...base,
plan_id: stringField(record.planId ?? record.plan_id) || null,
step_id: row.entity_id,
seq: typeof record.seq === "number" ? record.seq : null,
depends_on: Array.isArray(record.dependsOn ?? record.depends_on)
? record.dependsOn ?? record.depends_on
: [],
preferred_skill_ids: Array.isArray(record.preferredSkillIds ?? record.preferred_skill_ids)
? record.preferredSkillIds ?? record.preferred_skill_ids
: [],
};
return planStepBusinessView(base, row, record);
case "run":
return {
...base,
@@ -511,6 +500,36 @@ function buildBusinessView(
}
}
function planStepBusinessView(
base: Record<string, unknown>,
row: OutboxRow,
record: Record<string, unknown>,
): Record<string, unknown> {
const payload = objectRecord(record.payload) ?? {};
const dependencyGraph = objectRecord(payload.dependencyGraph ?? payload.dependency_graph) ?? {};
const lastDispatch = objectRecord(payload.lastDispatch ?? payload.last_dispatch) ?? {};
const dependsOn = stringArrayField(record.dependsOn ?? record.depends_on);
const blockedDependencies = stringArrayField(dependencyGraph.blockedDependencies ?? dependencyGraph.blocked_dependencies);
const waitingDependencies = stringArrayField(dependencyGraph.waitingDependencies ?? dependencyGraph.waiting_dependencies);
return {
...base,
plan_id: stringField(record.planId ?? record.plan_id) || null,
step_id: row.entity_id,
seq: numberField(record.seq),
depends_on: dependsOn,
dependency_count: dependsOn.length,
dependency_status: stringField(dependencyGraph.status) || null,
blocked_dependencies: blockedDependencies,
blocked_dependency_count: blockedDependencies.length,
waiting_dependencies: waitingDependencies,
waiting_dependency_count: waitingDependencies.length,
preferred_skill_ids: stringArrayField(record.preferredSkillIds ?? record.preferred_skill_ids),
dispatchable: stringField(record.status) === "ready" && blockedDependencies.length === 0 && waitingDependencies.length === 0,
skip_reason: stringField(payload.skipReason ?? payload.skip_reason) || null,
last_dispatch_status: stringField(lastDispatch.status) || null,
};
}
function buildEventBusinessView(
base: Record<string, unknown>,
row: OutboxRow,
@@ -569,7 +588,9 @@ function eventSpecificBusinessView(
return artifactEventBusinessView(payload);
}
if (kind.startsWith("skill_call_")) return skillCallBusinessView(payload);
if (isPlanStepDependencyEvent(kind)) return planStepDependencyBusinessView(payload);
if (kind.startsWith("plan_step_dispatch_")) return planStepDispatchBusinessView(payload);
if (isPlanStepDispatchPolicyEvent(kind)) return planStepDispatchPolicyBusinessView(payload);
if (kind === "governance_provide_task_input") return governanceProvideTaskInputBusinessView(payload);
if (isNotificationEvent(kind)) return notificationBusinessView(payload);
return {};
@@ -667,11 +688,43 @@ function skillCallBusinessView(payload: Record<string, unknown>): Record<string,
function planStepDispatchBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
return {
step_id: stringField(payload.step_id ?? payload.stepId) || null,
plan_id: stringField(payload.plan_id ?? payload.planId) || null,
task_id: stringField(payload.task_id ?? payload.taskId) || null,
run_id: stringField(payload.run_id ?? payload.runId) || null,
status: stringField(payload.status) || null,
reason: stringField(payload.reason) || null,
};
}
function planStepDependencyBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
const dependencyGraph = objectRecord(payload.dependencyGraph ?? payload.dependency_graph) ?? {};
const dependencies = stringArrayField(payload.dependsOn ?? payload.depends_on);
return {
step_id: stringField(payload.stepId ?? payload.step_id) || null,
plan_id: stringField(payload.planId ?? payload.plan_id) || null,
status: stringField(payload.status ?? dependencyGraph.status) || null,
dependency_count: dependencies.length,
blocked_dependencies: stringArrayField(dependencyGraph.blockedDependencies ?? dependencyGraph.blocked_dependencies),
waiting_dependencies: stringArrayField(dependencyGraph.waitingDependencies ?? dependencyGraph.waiting_dependencies),
satisfied_dependencies: stringArrayField(dependencyGraph.satisfiedDependencies ?? dependencyGraph.satisfied_dependencies),
};
}
function planStepDispatchPolicyBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
const metadata = objectRecord(payload.metadata) ?? {};
const state = objectRecord(payload.state) ?? {};
const policy = objectRecord(metadata.plan_step_dispatch_policy)
?? objectRecord(state.planStepDispatchPolicy)
?? {};
return {
action: stringField(metadata.action ?? payload.action) || "update_plan_step_dispatch_policy",
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,
};
}
function governanceProvideTaskInputBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
const commandPayload = objectRecord(payload.payload) ?? {};
const result = objectRecord(payload.result) ?? {};
@@ -714,7 +767,9 @@ function eventBusinessCategory(kind: string): string {
if (isApprovalSubscriptionEvent(kind)) return "approval";
if (kind.startsWith("artifact_")) return "artifact";
if (kind.startsWith("skill_call_")) return "skill";
if (isPlanStepDependencyEvent(kind)) return "dispatch";
if (kind.startsWith("plan_step_dispatch_")) return "dispatch";
if (isPlanStepDispatchPolicyEvent(kind)) return "dispatch";
if (isNotificationEvent(kind)) return "notification";
if (kind.startsWith("governance_")) return "governance";
return "runtime";
@@ -724,6 +779,14 @@ function isApprovalSubscriptionEvent(kind: string): boolean {
return kind === "digital_workday_update_approval_subscriptions";
}
function isPlanStepDependencyEvent(kind: string): boolean {
return kind === "plan_step_ready" || kind === "plan_step_blocked" || kind === "plan_step_waiting";
}
function isPlanStepDispatchPolicyEvent(kind: string): boolean {
return kind === "digital_workday_update_plan_step_dispatch_policy";
}
function isNotificationEvent(kind: string): boolean {
return kind === "digital_workday_update_notification_preferences"
|| kind === "digital_workday_notification_read"