吸收数字员工PlanStep跨任务编排执行

This commit is contained in:
baiyanyun
2026-06-10 21:00:59 +08:00
parent a6e0246418
commit 909f11dae5
17 changed files with 646 additions and 217 deletions

View File

@@ -16,6 +16,7 @@ import type {
BusinessViewResponse,
PlanStepBusinessRow,
PlanStepDispatchPolicy,
ReadyStepDispatchResult,
NotificationPreferences,
UserNotification,
DebugStoreResponse,
@@ -713,6 +714,13 @@ export function dispatchPlanNow(planId: string): Promise<PlanDispatchResponse> {
});
}
export function dispatchPlanReadySteps(planId: string): Promise<ReadyStepDispatchResult> {
return apiFetch<ReadyStepDispatchResult>(`/api/digital-employee/plans/${encodeURIComponent(planId)}/dispatch-ready-steps`, {
method: 'POST',
timeoutMs: DIGITAL_EMPLOYEE_WORKDAY_TIMEOUT_MS,
});
}
export function getDebugPlans(): Promise<DebugPlan[]> {
return apiFetch<{ plans: DebugPlan[] }>('/api/debug/plans?limit=80')
.then((data) => data.plans ?? []);

View File

@@ -34,6 +34,7 @@ import type {
MemoryEntry,
NotificationPreferences,
PlanDispatchResponse,
ReadyStepDispatchResult,
PlanStepDispatchPolicy,
PlanStepSummary,
RouteDecisionRecord,
@@ -63,6 +64,7 @@ declare global {
saveCronSettings?: (patch: Partial<QimingclawCronSettings>) => Promise<QimingclawCronSettings>;
runSchedulerCheck?: () => Promise<{ activated: number; due?: number; skipped?: number }>;
runScheduleNow?: (scheduleId: string) => Promise<{ ok?: boolean; scheduleRunId?: string; error?: string }>;
dispatchPlanReadySteps?: (planId: string) => Promise<ReadyStepDispatchResult>;
getScheduleRuns?: (scheduleId: string, limit?: number) => Promise<QimingclawScheduleRunRecord[]>;
listRouteDecisions?: (options?: { limit?: number }) => Promise<RouteDecisionRecord[]>;
recordRouteDecision?: (input: QimingclawRouteDecisionInput) => Promise<RouteDecisionRecord>;
@@ -925,6 +927,10 @@ export async function handleQimingclawDigitalApi<T>(
if (method === 'GET' && pathname.startsWith('/api/debug/task/') && pathname.endsWith('/flow')) {
return buildTaskFlow(decodeURIComponent(pathname.split('/')[4] || ''), await readQimingclawSnapshot()) as T;
}
const planReadyDispatchMatch = pathname.match(/^\/api\/digital-employee\/plans\/([^/]+)\/dispatch-ready-steps$/);
if (method === 'POST' && planReadyDispatchMatch) {
return await dispatchPlanReadySteps(decodeURIComponent(planReadyDispatchMatch[1] || ''), await readQimingclawSnapshot()) as T;
}
if (method === 'POST' && pathname.startsWith('/api/debug/plan/') && pathname.endsWith('/dispatch')) {
return await dispatchPlan(decodeURIComponent(pathname.split('/')[4] || ''), await readQimingclawSnapshot()) as T;
}
@@ -6123,6 +6129,89 @@ async function dispatchPlan(
};
}
async function dispatchPlanReadySteps(
planId: string,
snapshot: QimingclawSnapshot | null = null,
): Promise<ReadyStepDispatchResult> {
const targetPlanId = stringValue(planId);
const bridgeResult = targetPlanId ? await window.QimingClawBridge?.digital?.dispatchPlanReadySteps?.(targetPlanId).catch(() => null) : null;
if (bridgeResult) return bridgeResult;
const now = new Date().toISOString();
const orchestrationId = `manual-ready-steps-${slugifyIdPart(targetPlanId || 'unknown')}-${slugifyIdPart(now)}`;
const rows = planStepRows(snapshot).filter((row) => stringValue(row.plan_id) === targetPlanId && stringValue(row.status).toLowerCase() === 'ready');
const state = notificationAdapterState(snapshot);
const policy = planStepDispatchPolicy(state);
const result: ReadyStepDispatchResult = {
ok: true,
plan_id: targetPlanId || null,
trigger_source: 'manual',
orchestration_id: orchestrationId,
candidates: rows.length,
dispatched: 0,
skipped: 0,
failed: 0,
dispatched_step_ids: [],
skipped_step_ids: [],
failed_step_ids: [],
errors: [],
};
if (!targetPlanId || !policy.enabled) {
result.skipped = rows.length;
result.skipped_step_ids = rows.map((row) => stringValue(row.step_id)).filter(Boolean);
return result;
}
const maxPerSweep = Math.max(1, Math.min(Math.floor(policy.max_per_sweep), 10));
let attempts = 0;
for (const row of rows) {
const stepId = stringValue(row.step_id);
const taskId = stringValue(row.task_id);
if (attempts >= maxPerSweep || row.dispatchable !== true || !taskId) {
result.skipped += 1;
if (stepId) result.skipped_step_ids?.push(stepId);
continue;
}
attempts += 1;
try {
const runId = stringValue(row.latest_run_id) || `${taskId}:run:start_run:${slugifyIdPart(orchestrationId)}`;
const commandId = `manual-ready-step-${slugifyIdPart(stepId)}-${Date.now()}`;
await recordGovernanceCommand(
{
command_kind: 'start_run',
context_refs: { plan_id: targetPlanId, task_id: taskId, run_id: runId, step_id: stepId },
payload: {
source: 'digital-home-plan-orchestration',
plan_id: targetPlanId,
task_id: taskId,
run_id: runId,
step_id: stepId,
trigger_source: 'manual',
orchestration_id: orchestrationId,
candidate_count: rows.length,
},
correlation_id: commandId,
},
governanceAccepted(commandId, commandId, '已手动推进 Ready PlanStep。', {
plan_id: targetPlanId,
task_id: taskId,
run_id: runId,
step_id: stepId,
status: 'running',
trigger_source: 'manual',
orchestration_id: orchestrationId,
source: 'qimingclaw-plan-orchestration',
}),
);
result.dispatched += 1;
if (stepId) result.dispatched_step_ids?.push(stepId);
} catch (error) {
result.failed += 1;
if (stepId) result.failed_step_ids?.push(stepId);
result.errors?.push({ stepId, error: error instanceof Error ? error.message : 'dispatch failed' });
}
}
return result;
}
function buildPlanMessages(planId: string, snapshot: QimingclawSnapshot | null = null): Array<{ role: string; content: string; created_at?: string | null }> {
const runtimePlan = runtimePlans(snapshot).find((candidate) => candidate.id === planId);
if (runtimePlan) {

View File

@@ -5,6 +5,7 @@ import Avatar3D from '@/components/digital/Avatar3D';
import { basePath } from '@/lib/basePath';
import {
dispatchPlanNow,
dispatchPlanReadySteps,
getDigitalEmployeeWorkday,
getPlanStepGraph,
getSchedulerJobs,
@@ -12,7 +13,6 @@ import {
postDigitalEmployeeWorkdayAction,
recordApprovalAction,
restartManagedService,
triggerCronCheck,
} from '@/lib/api';
import { isTauri } from '@/lib/tauri';
import type {
@@ -60,10 +60,19 @@ interface DigitalPlanItem {
conversationId?: string | null;
stepCount: number;
taskCount: number;
readyStepCount: number;
runningStepCount: number;
blockedStepCount: number;
schedulerJob?: CronJob | null;
source: 'plan_template';
}
interface PlanStepOrchestrationSummary {
ready: number;
running: number;
blocked: number;
}
interface BlockedPlanStepInputTarget {
step: PlanStepBusinessRow;
dependency: PlanStepDependencyDetail | null;
@@ -405,6 +414,9 @@ function buildPlanTemplateItem(job: CronJob, duties: DigitalEmployeeDuty[], sele
conversationId: linkedDuty?.conversation_id ?? templateId,
stepCount,
taskCount: skillCount,
readyStepCount: 0,
runningStepCount: 0,
blockedStepCount: 0,
schedulerJob: job,
source: 'plan_template',
};
@@ -419,6 +431,26 @@ function planExecutionDisabledReason(planItem: DigitalPlanItem): string {
return '';
}
function planReadyStepActionLabel(planItem: DigitalPlanItem): string {
if (planItem.readyStepCount > 0) return `推进 ${planItem.readyStepCount}`;
return '推进';
}
function planStepOrchestrationSummary(rows: PlanStepBusinessRow[]): Map<string, PlanStepOrchestrationSummary> {
const summary = new Map<string, PlanStepOrchestrationSummary>();
rows.forEach((step) => {
const planId = step.plan_id || '';
if (!planId) return;
const current = summary.get(planId) ?? { ready: 0, running: 0, blocked: 0 };
const status = step.status.toLowerCase();
if (status === 'ready') current.ready += 1;
else if (['running', 'active', 'leased', 'queued'].includes(status)) current.running += 1;
else if (status === 'blocked') current.blocked += 1;
summary.set(planId, current);
});
return summary;
}
function eventMatchesPlanItem(event: DigitalEmployeeWorkEvent, planItem: DigitalPlanItem): boolean {
const haystack = `${event.conversation_id ?? ''} ${event.plan_title} ${event.task_title} ${event.step_title ?? ''} ${event.detail}`;
return haystack.includes(planItem.id) || haystack.includes(planItem.title);
@@ -946,6 +978,7 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
const [reportPreviewOpen, setReportPreviewOpen] = useState(false);
const [selectedSyncFailure, setSelectedSyncFailure] = useState<ManagementSyncFailure | null>(null);
const [blockedPlanSteps, setBlockedPlanSteps] = useState<PlanStepBusinessRow[]>([]);
const [planStepGraphRows, setPlanStepGraphRows] = useState<PlanStepBusinessRow[]>([]);
const [blockedPlanStepError, setBlockedPlanStepError] = useState<string | null>(null);
const [blockedInputTarget, setBlockedInputTarget] = useState<BlockedPlanStepInputTarget | null>(null);
const [blockedInputDraft, setBlockedInputDraft] = useState('');
@@ -1019,14 +1052,16 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
setManagementSyncStatus(status);
});
const planStepGraphPromise = getPlanStepGraph({ status: 'blocked', page_size: 6 })
const planStepGraphPromise = getPlanStepGraph({ page_size: 80 })
.then((response) => {
if (!mountedRef.current) return;
setBlockedPlanSteps(response.items.filter((step) => step.status === 'blocked'));
setPlanStepGraphRows(response.items);
setBlockedPlanSteps(response.items.filter((step) => step.status === 'blocked').slice(0, 6));
setBlockedPlanStepError(null);
})
.catch((error: unknown) => {
if (!mountedRef.current) return;
setPlanStepGraphRows([]);
setBlockedPlanSteps([]);
setBlockedPlanStepError(error instanceof Error ? error.message : '阻塞步骤加载失败');
});
@@ -1066,6 +1101,7 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
[isLiveProjection, workdayProjection?.duties],
);
const planItems = useMemo(() => {
const orchestrationByPlan = planStepOrchestrationSummary(planStepGraphRows);
return schedulerJobs
.slice()
.sort((left, right) => {
@@ -1084,9 +1120,20 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
})
.map((job) => {
const templateId = templateIdForSchedulerJob(job);
return buildPlanTemplateItem(job, liveProjectionDuties, selectedDutyIds.has(templateId));
const item = buildPlanTemplateItem(job, liveProjectionDuties, selectedDutyIds.has(templateId));
const orchestration = orchestrationByPlan.get(item.planId) ?? orchestrationByPlan.get(templateId);
if (!orchestration) return item;
return {
...item,
readyStepCount: orchestration.ready,
runningStepCount: orchestration.running,
blockedStepCount: orchestration.blocked,
resultText: orchestration.ready > 0 || orchestration.running > 0 || orchestration.blocked > 0
? `Ready ${orchestration.ready} · 运行 ${orchestration.running} · 阻塞 ${orchestration.blocked}`
: item.resultText,
};
});
}, [liveProjectionDuties, schedulerJobs, selectedDutyIds]);
}, [liveProjectionDuties, planStepGraphRows, schedulerJobs, selectedDutyIds]);
const selectedDuties = useMemo(
() => planItems.filter((planItem) => selectedDutyIds.has(planItem.id)),
[planItems, selectedDutyIds],
@@ -1717,13 +1764,14 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
throw new Error(response.error || '执行接口未接受请求');
}
const dispatchedCount = response.dispatched_tasks?.length ?? 0;
const schedulerResult = await triggerCronCheck();
const readyDispatched = schedulerResult.readyStepDispatch?.dispatched ?? 0;
const readyFailed = schedulerResult.readyStepDispatch?.failed ?? 0;
const readyResult = await dispatchPlanReadySteps(planItem.planId);
const readyDispatched = readyResult.dispatched ?? 0;
const readySkipped = readyResult.skipped ?? 0;
const readyFailed = readyResult.failed ?? 0;
setExecutePlanItem(null);
setActionNotice(
readyDispatched > 0 || readyFailed > 0
? `已触发执行:${planItem.title},新建 ${dispatchedCount} 个步骤,Ready 步骤自动派发 ${readyDispatched}${readyFailed > 0 ? `,失败 ${readyFailed}` : ''}`
readyDispatched > 0 || readySkipped > 0 || readyFailed > 0
? `已触发执行:${planItem.title},新建 ${dispatchedCount} 个步骤,推进 ${readyDispatched}${readySkipped > 0 ? `,跳过 ${readySkipped}` : ''}${readyFailed > 0 ? `,失败 ${readyFailed}` : ''}`
: `已触发执行:${planItem.title},已派发 ${dispatchedCount} 个步骤。`,
);
await fetchProjection();
@@ -1736,6 +1784,32 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
}
}, [fetchProjection]);
const controlPlanReadySteps = useCallback(async (planItem: DigitalPlanItem) => {
if (!isPlanItemExecutable(planItem)) {
setActionError(planExecutionDisabledReason(planItem));
setActionNotice(null);
return;
}
const actionKey = `orchestrate:${planItem.id}`;
setActionBusy(actionKey);
setActionError(null);
setActionNotice(null);
try {
const result = await dispatchPlanReadySteps(planItem.planId);
if (result.ok === false) {
throw new Error(result.errors?.[0]?.error || '计划推进未被接受');
}
setActionNotice(`已推进:${planItem.title},派发 ${result.dispatched ?? 0} 个,跳过 ${result.skipped ?? 0}${(result.failed ?? 0) > 0 ? `,失败 ${result.failed}` : ''}`);
await fetchProjection();
} catch (error) {
if (mountedRef.current) {
setActionError(error instanceof Error ? error.message : '计划推进失败');
}
} finally {
if (mountedRef.current) setActionBusy(null);
}
}, [fetchProjection]);
const dismissWorkdayGuide = useCallback(() => {
setGuideOpen(false);
setGuideDismissedDate(selectedDate);
@@ -1782,6 +1856,16 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
</button>
<div className="de-duty-card-actions">
<button
type="button"
className="de-btn-secondary de-btn-sm"
disabled={!planItem.implemented || planItem.readyStepCount === 0 || Boolean(actionBusy)}
title={planItem.readyStepCount > 0 ? '推进此计划的 Ready 步骤' : '暂无可推进 Ready 步骤'}
onClick={() => { void controlPlanReadySteps(planItem); }}
>
<TimerReset size={13} aria-hidden="true" />
<span>{actionBusy === `orchestrate:${planItem.id}` ? '推进中' : planReadyStepActionLabel(planItem)}</span>
</button>
<button
type="button"
className="de-btn-secondary de-btn-sm"
@@ -1807,7 +1891,7 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
</div>
</div>
);
}, [actionBusy, openDuty, selectedDutyIds, toggleDuty]);
}, [actionBusy, controlPlanReadySteps, openDuty, selectedDutyIds, toggleDuty]);
const openEventDetail = useCallback((event: DigitalEmployeeWorkEvent) => {
setSelectedEvent(event);
@@ -2043,6 +2127,16 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
<span>{planItem.resultText}</span>
</button>
<div className="de-template-item-actions">
<button
type="button"
className="de-btn-secondary de-btn-sm"
disabled={!planItem.implemented || planItem.readyStepCount === 0 || Boolean(actionBusy)}
title={planItem.readyStepCount > 0 ? '推进此计划的 Ready 步骤' : '暂无可推进 Ready 步骤'}
onClick={() => { void controlPlanReadySteps(planItem); }}
>
<TimerReset size={13} aria-hidden="true" />
<span>{actionBusy === `orchestrate:${planItem.id}` ? '推进中' : '推进'}</span>
</button>
<button
type="button"
className="de-btn-secondary de-btn-sm"

View File

@@ -445,10 +445,17 @@ export interface GovernanceCommandResponse {
}
export interface ReadyStepDispatchResult {
ok?: boolean;
plan_id?: string | null;
trigger_source?: 'auto' | 'manual' | string;
orchestration_id?: string;
candidates: number;
dispatched: number;
skipped: number;
failed: number;
dispatched_step_ids?: string[];
skipped_step_ids?: string[];
failed_step_ids?: string[];
errors?: Array<{ stepId: string; error: string }>;
}