吸收数字员工PlanStep阻塞处理入口

This commit is contained in:
baiyanyun
2026-06-10 20:37:11 +08:00
parent d679639175
commit a6e0246418
11 changed files with 543 additions and 197 deletions

View File

@@ -2248,6 +2248,41 @@
color: #b42318;
border-color: rgba(180,35,24,0.22);
}
.de-blocked-step-control-strip {
border-top: 1px solid rgba(180,35,24,0.12);
padding-top: 6px;
}
.de-blocked-step-control {
border-color: rgba(180,35,24,0.18);
background: rgba(255,248,247,0.92);
}
.de-blocked-step-control .de-task-agent-control-actions {
flex-wrap: wrap;
justify-content: flex-end;
}
.de-blocked-input-modal { max-width: 520px; }
.de-blocked-input-field {
display: grid;
gap: 8px;
margin-top: 12px;
color: #17344f;
font-size: 12px;
font-weight: 700;
}
.de-blocked-input-field textarea {
min-height: 120px;
resize: vertical;
border: 1px solid rgba(79,172,254,0.2);
border-radius: 10px;
padding: 10px 12px;
color: #12344f;
background: rgba(255,255,255,0.82);
}
.de-blocked-input-field textarea:focus {
outline: none;
border-color: rgba(79,172,254,0.55);
box-shadow: 0 0 0 3px rgba(79,172,254,0.14);
}
.de-run-detail-list-scroll {
overscroll-behavior: contain;
scrollbar-gutter: stable;

View File

@@ -13,6 +13,8 @@ import type {
ChannelDetail,
BrowserSession,
ApprovalSubscriptionPreferences,
BusinessViewResponse,
PlanStepBusinessRow,
PlanStepDispatchPolicy,
NotificationPreferences,
UserNotification,
@@ -609,22 +611,22 @@ export function patchPlanStepDispatchPolicy(
});
}
export function getPlanSteps(params: Record<string, string | number | null | undefined> = {}): Promise<any> {
export function getPlanSteps(params: Record<string, string | number | null | undefined> = {}): Promise<BusinessViewResponse<PlanStepBusinessRow>> {
const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && `${value}`.trim() !== '') query.set(key, `${value}`);
});
const suffix = query.toString();
return apiFetch(`/api/digital-employee/plan-steps${suffix ? `?${suffix}` : ''}`);
return apiFetch<BusinessViewResponse<PlanStepBusinessRow>>(`/api/digital-employee/plan-steps${suffix ? `?${suffix}` : ''}`);
}
export function getPlanStepGraph(params: Record<string, string | number | null | undefined> = {}): Promise<any> {
export function getPlanStepGraph(params: Record<string, string | number | null | undefined> = {}): Promise<BusinessViewResponse<PlanStepBusinessRow>> {
const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && `${value}`.trim() !== '') query.set(key, `${value}`);
});
const suffix = query.toString();
return apiFetch(`/api/digital-employee/plan-step-graph${suffix ? `?${suffix}` : ''}`);
return apiFetch<BusinessViewResponse<PlanStepBusinessRow>>(`/api/digital-employee/plan-step-graph${suffix ? `?${suffix}` : ''}`);
}
export function markNotificationRead(id: string): Promise<boolean> {

View File

@@ -2593,11 +2593,13 @@ function planStepRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] {
dependency_status: stringValue(dependencyGraph.status) || stepDependencyStatus(step, blockedDependencies, waitingDependencies),
blocked_dependencies: blockedDependencies,
blocked_dependency_count: blockedDependencies.length,
blocked_dependency_details: planStepDependencyDetails(blockedDependencies, snapshot),
waiting_dependencies: waitingDependencies,
waiting_dependency_count: waitingDependencies.length,
satisfied_dependencies: satisfiedDependencies,
preferred_skill_ids: step.preferredSkillIds,
task_id: readiness.taskId,
latest_run_id: readiness.latestRunId,
dispatchable: readiness.dispatchable,
skip_reason: readiness.skipReason,
last_dispatch_status: stringValue(lastDispatch.status) || null,
@@ -2626,8 +2628,11 @@ function planStepGraphRows(snapshot: QimingclawSnapshot | null): BusinessViewRow
depends_on: arrayValue(row.depends_on),
dependency_status: stringValue(row.dependency_status) || null,
blocked_dependencies: arrayValue(row.blocked_dependencies),
blocked_dependency_details: arrayValue(row.blocked_dependency_details),
waiting_dependencies: arrayValue(row.waiting_dependencies),
satisfied_dependencies: arrayValue(row.satisfied_dependencies),
task_id: stringValue(row.task_id) || null,
latest_run_id: stringValue(row.latest_run_id) || null,
dispatchable: row.dispatchable === true,
skip_reason: stringValue(row.skip_reason) || null,
sync_status: stringValue(row.sync_status) || null,
@@ -2661,12 +2666,13 @@ function planStepDispatchReadiness(
step: QimingclawPlanStepRecord,
snapshot: QimingclawSnapshot | null,
payload: Record<string, unknown>,
): { taskId: string | null; dispatchable: boolean; skipReason: string | null } {
): { taskId: string | null; latestRunId: string | null; dispatchable: boolean; skipReason: string | null } {
const tasks = runtimePlanTasks(snapshot, step.planId);
const task = tasks.find((candidate) => taskStepId(candidate) === step.id)
?? tasks.find((candidate) => !isTerminalRuntimeTaskStatus(candidate.status))
?? null;
const taskId = task?.id ?? null;
const latestRunId = task ? latestRunForTask(runtimeRuns(snapshot), task.id)?.id ?? null : null;
let skipReason: string | null = null;
if (step.status.toLowerCase() !== 'ready') skipReason = 'not_ready';
else if (!task) skipReason = 'missing_task';
@@ -2674,7 +2680,39 @@ function planStepDispatchReadiness(
else if (runtimeRuns(snapshot).some((run) => run.taskId === task.id && isActiveRuntimeRunStatus(run.status))) skipReason = 'task_already_running';
else if (runtimeApprovals(snapshot).some((approval) => isOpenRuntimeApprovalStatus(approval.status) && (approval.planId === step.planId || approval.taskId === task.id || stringValue(asRecord(approval.payload)?.stepId ?? asRecord(approval.payload)?.step_id) === step.id))) skipReason = 'approval_pending';
else if (payload.auto_dispatch === false || payload.autoDispatch === false) skipReason = 'auto_dispatch_disabled';
return { taskId, dispatchable: !skipReason, skipReason };
return { taskId, latestRunId, dispatchable: !skipReason, skipReason };
}
function planStepDependencyDetails(dependencyIds: string[], snapshot: QimingclawSnapshot | null): BusinessViewRow[] {
const stepsById = new Map(runtimePlanSteps(snapshot).map((step) => [step.id, step]));
return dependencyIds.map((dependencyId) => {
const dependency = stepsById.get(dependencyId);
if (!dependency) {
return {
step_id: dependencyId,
task_id: null,
latest_run_id: null,
title: dependencyId,
status: 'missing',
dependency_status: 'missing',
dispatchable: false,
skip_reason: 'missing_step',
};
}
const payload = asRecord(dependency.payload) ?? {};
const graph = asRecord(payload.dependencyGraph ?? payload.dependency_graph) ?? {};
const readiness = planStepDispatchReadiness(dependency, snapshot, payload);
return {
step_id: dependency.id,
task_id: readiness.taskId,
latest_run_id: readiness.latestRunId,
title: dependency.title,
status: dependency.status,
dependency_status: stringValue(graph.status) || dependency.status,
dispatchable: readiness.dispatchable,
skip_reason: readiness.skipReason,
};
});
}
function isTerminalRuntimeTaskStatus(status: string): boolean {

View File

@@ -6,6 +6,7 @@ import { basePath } from '@/lib/basePath';
import {
dispatchPlanNow,
getDigitalEmployeeWorkday,
getPlanStepGraph,
getSchedulerJobs,
governanceCommand,
postDigitalEmployeeWorkdayAction,
@@ -26,6 +27,8 @@ import type {
DigitalEmployeeWorkEvent,
DigitalEmployeeWorkdayProjection,
GovernanceCommandKind,
PlanStepBusinessRow,
PlanStepDependencyDetail,
} from '@/types/api';
import type { DigitalNavigationTarget } from './navigation';
@@ -38,6 +41,7 @@ type EmployeeVisualState = 'skill' | 'browser' | 'call' | 'redial' | 'evidence'
type EventStatusFilter = 'all' | 'running' | 'success' | 'danger';
type TaskListFilter = 'all' | 'selected' | 'pending';
type TaskAgentControlCommand = Extract<GovernanceCommandKind, 'retry_task' | 'skip_task' | 'cancel_task'>;
type BlockedPlanStepControlCommand = Extract<GovernanceCommandKind, 'retry_task' | 'skip_task' | 'cancel_task' | 'provide_task_input'>;
type ApprovalActionKind = 'comment' | 'delegate' | 'mark_timeout' | 'mark_risk' | 'clear_risk';
interface DigitalPlanItem {
@@ -60,6 +64,11 @@ interface DigitalPlanItem {
source: 'plan_template';
}
interface BlockedPlanStepInputTarget {
step: PlanStepBusinessRow;
dependency: PlanStepDependencyDetail | null;
}
interface ManagementSyncStatus {
running: boolean;
syncing: boolean;
@@ -796,6 +805,26 @@ function taskAgentControlLabel(command: TaskAgentControlCommand): string {
}
}
function blockedPlanStepControlLabel(command: BlockedPlanStepControlCommand): string {
switch (command) {
case 'retry_task': return '重试前置';
case 'skip_task': return '跳过前置';
case 'cancel_task': return '取消此步骤';
case 'provide_task_input': return '补充输入';
default: return command;
}
}
function firstActionableBlockedDependency(step: PlanStepBusinessRow): PlanStepDependencyDetail | null {
return (step.blocked_dependency_details ?? []).find((dependency) => Boolean(dependency.task_id)) ?? null;
}
function blockedPlanStepSummary(step: PlanStepBusinessRow): string {
const dependency = firstActionableBlockedDependency(step) ?? step.blocked_dependency_details?.[0] ?? null;
const dependencyLabel = dependency ? `${dependency.title} ${dependency.status}` : `阻塞依赖 ${step.blocked_dependencies.length}`;
return `${step.title} ${step.dependency_status || step.status}${dependencyLabel}`;
}
function isDecisionOpen(status?: string): boolean {
return ['pending', 'requested'].includes(String(status || '').toLowerCase());
}
@@ -916,6 +945,10 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
const [profileSettingsOpen, setProfileSettingsOpen] = useState(false);
const [reportPreviewOpen, setReportPreviewOpen] = useState(false);
const [selectedSyncFailure, setSelectedSyncFailure] = useState<ManagementSyncFailure | null>(null);
const [blockedPlanSteps, setBlockedPlanSteps] = useState<PlanStepBusinessRow[]>([]);
const [blockedPlanStepError, setBlockedPlanStepError] = useState<string | null>(null);
const [blockedInputTarget, setBlockedInputTarget] = useState<BlockedPlanStepInputTarget | null>(null);
const [blockedInputDraft, setBlockedInputDraft] = useState('');
const [profileForm, setProfileForm] = useState({
name: '',
company: '',
@@ -986,7 +1019,19 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
setManagementSyncStatus(status);
});
await Promise.allSettled([projectionPromise, jobsPromise, syncStatusPromise]);
const planStepGraphPromise = getPlanStepGraph({ status: 'blocked', page_size: 6 })
.then((response) => {
if (!mountedRef.current) return;
setBlockedPlanSteps(response.items.filter((step) => step.status === 'blocked'));
setBlockedPlanStepError(null);
})
.catch((error: unknown) => {
if (!mountedRef.current) return;
setBlockedPlanSteps([]);
setBlockedPlanStepError(error instanceof Error ? error.message : '阻塞步骤加载失败');
});
await Promise.allSettled([projectionPromise, jobsPromise, syncStatusPromise, planStepGraphPromise]);
if (mountedRef.current) {
setProjectionLoading(false);
}
@@ -1523,6 +1568,71 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
}
}, [fetchProjection]);
const controlBlockedPlanStep = useCallback(async (
step: PlanStepBusinessRow,
command: BlockedPlanStepControlCommand,
options: { input?: string } = {},
) => {
const dependency = firstActionableBlockedDependency(step);
const target = command === 'retry_task' || command === 'skip_task' ? dependency : null;
const targetStepId = target?.step_id ?? step.step_id;
const targetTaskId = target?.task_id ?? step.task_id ?? null;
const targetRunId = target?.latest_run_id ?? step.latest_run_id ?? null;
if (!targetTaskId) {
setActionError('当前阻塞步骤缺少可治理任务,无法发送处理命令');
return;
}
const actionKey = `blocked-plan-step:${step.step_id}:${command}`;
setActionBusy(actionKey);
setActionError(null);
setActionNotice(null);
try {
const response = await governanceCommand({
command_kind: command,
context_refs: {
plan_id: step.plan_id,
task_id: targetTaskId,
run_id: targetRunId,
step_id: targetStepId,
blocked_step_id: step.step_id,
},
payload: {
source: 'digital-home-blocked-plan-step',
plan_id: step.plan_id,
task_id: targetTaskId,
run_id: targetRunId,
step_id: targetStepId,
blocked_step_id: step.step_id,
blocked_step_title: step.title,
blocked_dependency_step_id: target?.step_id ?? null,
requested_action: command,
input: options.input,
},
correlation_id: `digital-home-blocked-step-${step.step_id}-${command}-${Date.now()}`,
});
if (!response.accepted) {
throw new Error(response.message || `阻塞步骤处理未被接受:${response.command_id || command}`);
}
setActionNotice(`阻塞步骤已${blockedPlanStepControlLabel(command)}${step.title}`);
setBlockedInputTarget(null);
setBlockedInputDraft('');
await fetchProjection();
} catch (error) {
if (mountedRef.current) {
setActionError(error instanceof Error ? error.message : '阻塞步骤处理失败');
}
} finally {
if (mountedRef.current) setActionBusy(null);
}
}, [fetchProjection]);
const submitBlockedPlanStepInput = useCallback(async () => {
const target = blockedInputTarget;
const input = blockedInputDraft.trim();
if (!target || !input) return;
await controlBlockedPlanStep(target.step, 'provide_task_input', { input });
}, [blockedInputDraft, blockedInputTarget, controlBlockedPlanStep]);
const restartService = useCallback(async (service: DigitalEmployeeManagedService) => {
const actionKey = `managed-service:${service.service_id}:restart`;
setActionBusy(actionKey);
@@ -2115,6 +2225,72 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
</div>
)}
{(blockedPlanSteps.length > 0 || blockedPlanStepError) && (
<div className="de-task-agent-control-strip de-blocked-step-control-strip" aria-label="PlanStep 阻塞处理">
{blockedPlanStepError && <p className="de-empty-text">{blockedPlanStepError}</p>}
{blockedPlanSteps.slice(0, 3).map((step) => {
const dependency = firstActionableBlockedDependency(step);
const retryBusy = actionBusy === `blocked-plan-step:${step.step_id}:retry_task`;
const skipBusy = actionBusy === `blocked-plan-step:${step.step_id}:skip_task`;
const cancelBusy = actionBusy === `blocked-plan-step:${step.step_id}:cancel_task`;
const inputBusy = actionBusy === `blocked-plan-step:${step.step_id}:provide_task_input`;
const dependencyActionDisabled = Boolean(actionBusy) || !dependency?.task_id;
const stepActionDisabled = Boolean(actionBusy) || !step.task_id;
return (
<div key={step.step_id} className="de-task-agent-control de-blocked-step-control de-live-feed-row--danger">
<div className="de-task-agent-control-main">
<span>Blocked PlanStep</span>
<strong>{step.title}</strong>
<small>{blockedPlanStepSummary(step)}</small>
</div>
<div className="de-task-agent-control-actions">
<button
type="button"
className="de-btn-secondary de-btn-sm"
disabled={dependencyActionDisabled}
title={dependency?.task_id ? '重试首个阻塞前置任务' : '缺少可重试的前置任务'}
onClick={() => { void controlBlockedPlanStep(step, 'retry_task'); }}
>
<RefreshCw size={13} aria-hidden="true" />
<span>{retryBusy ? '重试中' : '重试前置'}</span>
</button>
<button
type="button"
className="de-btn-secondary de-btn-sm"
disabled={dependencyActionDisabled}
title={dependency?.task_id ? '跳过首个阻塞前置任务' : '缺少可跳过的前置任务'}
onClick={() => { void controlBlockedPlanStep(step, 'skip_task'); }}
>
<SkipForward size={13} aria-hidden="true" />
<span>{skipBusy ? '跳过中' : '跳过前置'}</span>
</button>
<button
type="button"
className="de-btn-secondary de-btn-sm"
disabled={stepActionDisabled}
title={step.task_id ? '补充当前阻塞步骤所需输入' : '当前步骤缺少可补充输入的任务'}
onClick={() => { setBlockedInputTarget({ step, dependency: null }); setBlockedInputDraft(''); }}
>
<MessageSquare size={13} aria-hidden="true" />
<span>{inputBusy ? '提交中' : '补充输入'}</span>
</button>
<button
type="button"
className="de-btn-secondary de-btn-sm de-task-agent-cancel-btn"
disabled={stepActionDisabled}
title={step.task_id ? '取消当前阻塞步骤' : '当前步骤缺少可取消的任务'}
onClick={() => { void controlBlockedPlanStep(step, 'cancel_task'); }}
>
<Square size={12} aria-hidden="true" />
<span>{cancelBusy ? '取消中' : '取消此步骤'}</span>
</button>
</div>
</div>
);
})}
</div>
)}
<div className="de-stage-feed-tools">
<span>
{visibleEvents.length} / {allEvents.length}
@@ -2292,6 +2468,46 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
</div>
)}
{blockedInputTarget && (
<div className="de-task-manager-modal-overlay" role="presentation" onClick={() => setBlockedInputTarget(null)}>
<div
className="de-profile-settings-modal de-blocked-input-modal"
role="dialog"
aria-modal="true"
aria-label="补充阻塞步骤输入"
onClick={(event) => event.stopPropagation()}
>
<div className="de-task-manager-modal-head">
<div>
<span className="de-drawer-eyebrow">BLOCKED PLANSTEP</span>
<h3></h3>
<p>{blockedInputTarget.step.title}</p>
</div>
<button type="button" className="de-schedule-close" onClick={() => setBlockedInputTarget(null)}>×</button>
</div>
<label className="de-blocked-input-field">
<span></span>
<textarea
value={blockedInputDraft}
onChange={(event) => setBlockedInputDraft(event.target.value)}
placeholder="输入用于解除当前阻塞的信息"
/>
</label>
<div className="de-event-modal-actions">
<button type="button" className="de-btn-secondary de-btn-sm" onClick={() => setBlockedInputTarget(null)}></button>
<button
type="button"
className="de-btn-primary de-btn-sm"
disabled={!blockedInputDraft.trim() || Boolean(actionBusy)}
onClick={() => { void submitBlockedPlanStepInput(); }}
>
</button>
</div>
</div>
</div>
)}
{profileSettingsOpen && (
<div className="de-task-manager-modal-overlay" role="presentation" onClick={() => setProfileSettingsOpen(false)}>
<div

View File

@@ -202,6 +202,46 @@ export interface PlanStepDispatchPolicy {
updated_at: string | null;
}
export interface PlanStepDependencyDetail {
step_id: string;
task_id: string | null;
latest_run_id?: string | null;
title: string;
status: string;
dependency_status: string | null;
dispatchable: boolean;
skip_reason: string | null;
}
export interface PlanStepBusinessRow {
step_id: string;
node_id?: string;
plan_id: string | null;
title: string;
status: string;
seq?: number | null;
task_id?: string | null;
latest_run_id?: string | null;
depends_on: string[];
dependency_status: string | null;
blocked_dependencies: string[];
waiting_dependencies: string[];
satisfied_dependencies: string[];
blocked_dependency_details?: PlanStepDependencyDetail[];
dispatchable: boolean;
skip_reason: string | null;
sync_status?: string | null;
updated_at?: string | null;
}
export interface BusinessViewResponse<T> {
items: T[];
page_no: number;
page_size: number;
total: number;
source?: string;
}
export type ChatRouteMode = 'auto' | 'chat' | 'direct' | 'plan';
export interface WsMessage {

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,8 +16,8 @@
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
}
</script>
<script type="module" crossorigin src="./assets/index-Dar5kQHh.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DqdYYbRq.css">
<script type="module" crossorigin src="./assets/index-BRZXqRQH.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-4TVkOCsf.css">
</head>
<body>
<div id="root"></div>

View File

@@ -288,12 +288,16 @@ function checkDigitalPlanStepGraphDispatchPolicy() {
const adapterPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "lib", "qimingclawAdapter.ts");
const apiPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "lib", "api.ts");
const typesPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "types", "api.ts");
const commandDeckPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "pages", "digital", "CommandDeck.tsx");
const cssPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "index.css");
const stateServicePath = path.join(packageRoot, "src", "main", "services", "digitalEmployee", "stateService.ts");
const schedulerServicePath = path.join(packageRoot, "src", "main", "services", "digitalEmployee", "schedulerService.ts");
const syncServicePath = path.join(packageRoot, "src", "main", "services", "digitalEmployee", "syncService.ts");
const adapter = fs.readFileSync(adapterPath, "utf8");
const api = fs.readFileSync(apiPath, "utf8");
const types = fs.readFileSync(typesPath, "utf8");
const commandDeck = fs.readFileSync(commandDeckPath, "utf8");
const css = fs.readFileSync(cssPath, "utf8");
const stateService = fs.readFileSync(stateServicePath, "utf8");
const schedulerService = fs.readFileSync(schedulerServicePath, "utf8");
const syncService = fs.readFileSync(syncServicePath, "utf8");
@@ -304,12 +308,23 @@ function checkDigitalPlanStepGraphDispatchPolicy() {
[adapter, "planStepRows", "management plan step rows"],
[adapter, "planStepGraphRows", "management plan step graph rows"],
[adapter, "filterPlanStepRows", "management plan step filters"],
[adapter, "blocked_dependency_details", "plan step blocked dependency details"],
[adapter, "planStepDependencyDetails", "plan step blocked dependency detail mapper"],
[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, "getPlanSteps", "plan steps API getter"],
[api, "getPlanStepGraph", "plan step graph API getter"],
[api, "BusinessViewResponse<PlanStepBusinessRow>", "typed plan step business view API"],
[types, "PlanStepDispatchPolicy", "plan step dispatch policy type"],
[types, "PlanStepDependencyDetail", "plan step blocked dependency detail type"],
[types, "PlanStepBusinessRow", "plan step business row type"],
[types, "BusinessViewResponse", "business view response type"],
[commandDeck, "getPlanStepGraph", "home blocked plan step graph fetch"],
[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"],
[schedulerService, "readDigitalEmployeePlanStepDispatchPolicy", "scheduler plan step dispatch policy reader"],