吸收数字员工步骤自动调度

This commit is contained in:
baiyanyun
2026-06-10 11:23:22 +08:00
parent 047651e404
commit d12db03216
12 changed files with 772 additions and 191 deletions

View File

@@ -26,6 +26,7 @@ import type {
IngressRouteResponse,
GovernanceCommandRequest,
GovernanceCommandResponse,
SchedulerCheckResponse,
DigitalEmployeeManagedService,
DigitalEmployeeWorkdayProjection,
DigitalEmployeeWorkdayActionRequest,
@@ -709,8 +710,8 @@ export function taskAction(taskId: string, action: string): Promise<any> {
* Current React pages must trigger scheduler checks through runSchedulerNow()
* so backend governance rejection and command errors are surfaced correctly.
*/
export function triggerCronCheck(): Promise<{ ok?: boolean; activated: number }> {
return apiFetch<{ ok?: boolean; activated: number }>('/api/cron/check', { method: 'POST' });
export function triggerCronCheck(): Promise<SchedulerCheckResponse> {
return apiFetch<SchedulerCheckResponse>('/api/cron/check', { method: 'POST' });
}
export function getAuditTrace(kind: string, id: string): Promise<any> {

View File

@@ -3241,6 +3241,7 @@ function buildNotifications(snapshot: QimingclawSnapshot | null, state: AdapterS
...approvalActionNotifications(snapshot),
...syncFailureNotifications(snapshot),
...managedServiceNotifications(snapshot),
...planStepDispatchNotifications(snapshot),
...taskNotifications(snapshot),
]);
const overlays = state.notificationStateById ?? {};
@@ -3353,6 +3354,28 @@ function taskNotifications(snapshot: QimingclawSnapshot | null): UserNotificatio
});
}
function planStepDispatchNotifications(snapshot: QimingclawSnapshot | null): UserNotification[] {
return runtimeEvents(snapshot)
.filter((event) => event.kind === 'plan_step_dispatch_failed')
.map((event) => {
const payload = asRecord(event.payload) ?? {};
const stepId = stringValue(payload.step_id) || event.id;
const reason = stringValue(payload.reason);
return {
notification_id: `plan-step-dispatch:${stepId}:${slugifyIdPart(event.occurredAt)}`,
plan_id: event.planId ?? stringValue(payload.plan_id),
task_id: event.taskId ?? stringValue(payload.task_id),
kind: 'task_failed',
title: 'Ready 步骤派发失败',
body: reason ? `${stepId}${reason}` : (event.message || stepId),
level: 'error',
status: 'unread',
correlation_id: stepId,
created_at: event.occurredAt,
} satisfies UserNotification;
});
}
function applyNotificationOverlay(notification: UserNotification, overlay?: NotificationState): UserNotification {
if (!overlay) return notification;
return {

View File

@@ -11,6 +11,7 @@ import {
postDigitalEmployeeWorkdayAction,
recordApprovalAction,
restartManagedService,
triggerCronCheck,
} from '@/lib/api';
import { isTauri } from '@/lib/tauri';
import type {
@@ -1600,8 +1601,15 @@ 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;
setExecutePlanItem(null);
setActionNotice(`已触发执行:${planItem.title},已派发 ${dispatchedCount} 个步骤。`);
setActionNotice(
readyDispatched > 0 || readyFailed > 0
? `已触发执行:${planItem.title},新建 ${dispatchedCount} 个步骤Ready 步骤自动派发 ${readyDispatched}${readyFailed > 0 ? `,失败 ${readyFailed}` : ''}`
: `已触发执行:${planItem.title},已派发 ${dispatchedCount} 个步骤。`,
);
await fetchProjection();
} catch (error) {
if (mountedRef.current) {

View File

@@ -382,6 +382,24 @@ export interface GovernanceCommandResponse {
message?: string;
}
export interface ReadyStepDispatchResult {
candidates: number;
dispatched: number;
skipped: number;
failed: number;
errors?: Array<{ stepId: string; error: string }>;
}
export interface SchedulerCheckResponse {
ok?: boolean;
checkedAt?: string;
due?: number;
activated: number;
skipped?: number;
errors?: Array<{ scheduleId: string; error: string }>;
readyStepDispatch?: ReadyStepDispatchResult;
}
// ═══════════════════════════════════════════════════════════════════════
// Console types — Debug API responses, Plan/Task/Run, Swimlane
// ═══════════════════════════════════════════════════════════════════════

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-CjpyhZLg.js"></script>
<script type="module" crossorigin src="./assets/index-Cb1LtojZ.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DKoke9Cq.css">
</head>
<body>

View File

@@ -2,8 +2,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mockState = vi.hoisted(() => ({
schedules: [] as Array<Record<string, unknown>>,
readyCandidates: [] as Array<Record<string, unknown>>,
startedRuns: [] as Array<Record<string, unknown>>,
finishedRuns: [] as Array<Record<string, unknown>>,
dispatchEvents: [] as Array<Record<string, unknown>>,
governanceCommands: [] as Array<Record<string, unknown>>,
settings: {
enabled: true,
catch_up_on_startup: true,
@@ -23,6 +26,7 @@ vi.mock("../constants", () => ({
vi.mock("./stateService", () => ({
readDigitalEmployeeCronSettings: () => mockState.settings,
listDigitalEmployeeReadyPlanStepDispatchCandidates: () => mockState.readyCandidates,
listDigitalEmployeeSchedules: () => mockState.schedules,
listDueDigitalEmployeeSchedules: () => mockState.schedules,
readDigitalEmployeeScheduleRuns: () => [],
@@ -36,13 +40,28 @@ vi.mock("./stateService", () => ({
mockState.finishedRuns.push({ id, status, ...options });
return { id, status, ...options };
},
recordDigitalEmployeePlanStepDispatch: (input: Record<string, unknown>) => {
mockState.dispatchEvents.push(input);
return { eventId: `dispatch-${input.status}`, kind: `plan_step_dispatch_${input.status}`, payload: input };
},
recordDigitalEmployeeGovernanceCommand: (input: Record<string, unknown>) => {
mockState.governanceCommands.push(input);
return {
planId: input.planId,
taskId: input.taskId,
runId: input.runId || `${input.taskId}:run:start_run:${input.commandId}`,
};
},
}));
describe("digital employee scheduler service", () => {
beforeEach(() => {
vi.resetModules();
mockState.readyCandidates = [];
mockState.startedRuns = [];
mockState.finishedRuns = [];
mockState.dispatchEvents = [];
mockState.governanceCommands = [];
mockState.schedules = [{
id: "schedule-1",
planId: "plan-1",
@@ -107,4 +126,59 @@ describe("digital employee scheduler service", () => {
expect(mockState.finishedRuns[0]).toMatchObject({ status: "failed", error: "ComputerServer 500: boom" });
expect(mockState.finishedRuns[0].nextRunAt).toBeTruthy();
});
it("dispatches ready plan steps through local ComputerServer", async () => {
mockState.schedules = [];
mockState.readyCandidates = [readyStepCandidate()];
const { runDigitalEmployeeSchedulerCheck } = await import("./schedulerService");
const result = await runDigitalEmployeeSchedulerCheck(new Date("2026-06-07T01:00:10.000Z"));
expect(result.readyStepDispatch).toMatchObject({ candidates: 1, dispatched: 1, skipped: 0, failed: 0 });
expect(fetch).toHaveBeenCalledWith(
"http://127.0.0.1:60006/computer/chat",
expect.objectContaining({ method: "POST" }),
);
expect(mockState.dispatchEvents.map((event) => event.status)).toEqual(["started", "completed"]);
expect(mockState.governanceCommands[0]).toMatchObject({
commandKind: "start_run",
planId: "plan-1",
taskId: "task-1",
stepId: "step-ready",
});
});
it("marks ready step dispatch failures", async () => {
mockState.schedules = [];
mockState.readyCandidates = [readyStepCandidate()];
vi.stubGlobal("fetch", vi.fn(async () => ({
ok: false,
status: 502,
text: async () => "bad gateway",
})));
const { runDigitalEmployeeSchedulerCheck } = await import("./schedulerService");
const result = await runDigitalEmployeeSchedulerCheck(new Date("2026-06-07T01:00:10.000Z"));
expect(result.readyStepDispatch).toMatchObject({ candidates: 1, dispatched: 0, failed: 1 });
expect(result.readyStepDispatch.errors[0]).toMatchObject({ stepId: "step-ready", error: "ComputerServer 502: bad gateway" });
expect(mockState.dispatchEvents.map((event) => event.status)).toEqual(["started", "failed"]);
expect(mockState.governanceCommands).toHaveLength(0);
});
});
function readyStepCandidate(): Record<string, unknown> {
return {
stepId: "step-ready",
planId: "plan-1",
taskId: "task-1",
title: "汇总客户跟进",
taskTitle: "执行客户跟进汇总",
prompt: "执行客户跟进汇总",
preferredSkillIds: ["qimingclaw-acp-session"],
dispatchable: true,
step: { id: "step-ready", payload: {} },
task: { id: "task-1", payload: {} },
plan: { id: "plan-1" },
};
}

View File

@@ -3,18 +3,23 @@ import { LOCALHOST_HOSTNAME } from "../constants";
import { getConfiguredPorts } from "../startupPorts";
import {
finishDigitalEmployeeScheduleRun,
listDigitalEmployeeReadyPlanStepDispatchCandidates,
listDigitalEmployeeSchedules,
listDueDigitalEmployeeSchedules,
readDigitalEmployeeCronSettings,
readDigitalEmployeeScheduleRuns,
recordDigitalEmployeeGovernanceCommand,
recordDigitalEmployeePlanStepDispatch,
recordDigitalEmployeeScheduleRunStarted,
upsertDigitalEmployeeSchedule,
type DigitalEmployeeReadyPlanStepDispatchCandidate,
type DigitalEmployeeScheduleRecord,
} from "./stateService";
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;
@@ -26,6 +31,15 @@ export interface DigitalEmployeeSchedulerCheckResult {
activated: number;
skipped: number;
errors: Array<{ scheduleId: string; error: string }>;
readyStepDispatch: DigitalEmployeeReadyStepDispatchResult;
}
export interface DigitalEmployeeReadyStepDispatchResult {
candidates: number;
dispatched: number;
skipped: number;
failed: number;
errors: Array<{ stepId: string; error: string }>;
}
export function startDigitalEmployeeScheduler(): void {
@@ -52,11 +66,11 @@ export async function runDigitalEmployeeSchedulerCheck(
now = new Date(),
): Promise<DigitalEmployeeSchedulerCheckResult> {
if (schedulerSweeping) {
return { checkedAt: now.toISOString(), due: 0, activated: 0, skipped: 0, errors: [] };
return { checkedAt: now.toISOString(), due: 0, activated: 0, skipped: 0, errors: [], readyStepDispatch: emptyReadyStepDispatchResult() };
}
const settings = readDigitalEmployeeCronSettings();
if (!settings.enabled) {
return { checkedAt: now.toISOString(), due: 0, activated: 0, skipped: 0, errors: [] };
return { checkedAt: now.toISOString(), due: 0, activated: 0, skipped: 0, errors: [], readyStepDispatch: emptyReadyStepDispatchResult() };
}
schedulerSweeping = true;
@@ -67,6 +81,7 @@ export async function runDigitalEmployeeSchedulerCheck(
activated: 0,
skipped: 0,
errors: [],
readyStepDispatch: emptyReadyStepDispatchResult(),
};
try {
@@ -92,6 +107,7 @@ export async function runDigitalEmployeeSchedulerCheck(
}
}
}
result.readyStepDispatch = await dispatchReadyPlanSteps(now);
} finally {
schedulerSweeping = false;
}
@@ -99,6 +115,104 @@ export async function runDigitalEmployeeSchedulerCheck(
return result;
}
export async function dispatchReadyPlanSteps(
now = new Date(),
): Promise<DigitalEmployeeReadyStepDispatchResult> {
const candidates = listDigitalEmployeeReadyPlanStepDispatchCandidates(20);
const result: DigitalEmployeeReadyStepDispatchResult = {
candidates: candidates.length,
dispatched: 0,
skipped: 0,
failed: 0,
errors: [],
};
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) {
result.skipped += 1;
continue;
}
attempts += 1;
try {
await dispatchReadyPlanStep(candidate, now);
result.dispatched += 1;
} catch (error) {
const message = normalizeError(error);
result.failed += 1;
result.errors.push({ stepId: candidate.stepId, error: message });
recordDigitalEmployeePlanStepDispatch({
stepId: candidate.stepId,
planId: candidate.planId,
taskId: candidate.taskId,
status: "failed",
reason: message,
occurredAt: now.toISOString(),
});
}
}
return result;
}
function emptyReadyStepDispatchResult(): DigitalEmployeeReadyStepDispatchResult {
return { candidates: 0, dispatched: 0, skipped: 0, failed: 0, errors: [] };
}
async function dispatchReadyPlanStep(
candidate: DigitalEmployeeReadyPlanStepDispatchCandidate,
now: Date,
): Promise<void> {
const occurredAt = now.toISOString();
const commandId = `ready-step-${safeId(candidate.stepId)}-${safeId(occurredAt)}`;
const runId = `${candidate.taskId}:run:start_run:${safeId(commandId)}`;
recordDigitalEmployeePlanStepDispatch({
stepId: candidate.stepId,
planId: candidate.planId,
taskId: candidate.taskId,
runId,
status: "started",
payload: { command_id: commandId, preferred_skill_ids: candidate.preferredSkillIds },
occurredAt,
});
const chatRequest = buildReadyStepChatRequest(candidate, runId, occurredAt);
const response = await postComputerChat(chatRequest);
const ids = recordDigitalEmployeeGovernanceCommand({
commandKind: "start_run",
commandId,
correlationId: commandId,
planId: candidate.planId,
taskId: candidate.taskId,
runId,
stepId: candidate.stepId,
accepted: true,
message: `自动派发 Ready 步骤:${candidate.title}`,
payload: {
source: "qimingclaw-ready-step-dispatcher",
plan_id: candidate.planId,
task_id: candidate.taskId,
step_id: candidate.stepId,
title: candidate.title,
task_title: candidate.taskTitle,
prompt: candidate.prompt,
preferred_skill_ids: candidate.preferredSkillIds,
chat_request: chatRequest,
},
result: { status: "running", response },
});
recordDigitalEmployeePlanStepDispatch({
stepId: candidate.stepId,
planId: candidate.planId,
taskId: candidate.taskId,
runId: ids.runId,
status: "completed",
payload: { command_id: commandId, response },
occurredAt,
});
}
export async function runDigitalEmployeeScheduleNow(
scheduleId: string,
): Promise<{ ok: boolean; scheduleRunId?: string; error?: string }> {
@@ -212,6 +326,38 @@ function buildScheduleChatRequest(
};
}
function buildReadyStepChatRequest(
candidate: DigitalEmployeeReadyPlanStepDispatchCandidate,
runId: string,
occurredAt: string,
) {
const payload = objectPayload(candidate.step.payload);
const taskPayload = objectPayload(candidate.task?.payload);
const prompt = candidate.prompt || stringFromUnknown(payload.prompt) || stringFromUnknown(taskPayload.prompt) || candidate.title;
return {
user_id: stringFromUnknown(taskPayload.user_id) || "qimingclaw-ready-step-dispatcher",
project_id: candidate.planId,
prompt,
session_id: stringFromUnknown(taskPayload.session_id) || `planstep-${candidate.stepId}`,
request_id: `ready-step-${safeId(candidate.stepId)}-${safeId(occurredAt)}`,
system_prompt: stringFromUnknown(taskPayload.system_prompt) || "你是 qimingclaw 本地数字员工,请执行已就绪的计划步骤并记录必要的结果。",
original_user_prompt: prompt,
open_long_memory: taskPayload.open_long_memory === true,
agent_config: objectPayload(taskPayload.agent_config),
model_provider: objectPayload(taskPayload.model_provider),
ready_step_context: {
plan_id: candidate.planId,
task_id: candidate.taskId,
step_id: candidate.stepId,
run_id: runId,
title: candidate.title,
task_title: candidate.taskTitle,
preferred_skill_ids: candidate.preferredSkillIds,
dispatched_at: occurredAt,
},
};
}
async function postComputerChat(body: Record<string, unknown>): Promise<unknown> {
const { agent: agentPort } = getConfiguredPorts();
const response = await fetch(`http://${LOCALHOST_HOSTNAME}:${agentPort}/computer/chat`, {

View File

@@ -1153,6 +1153,85 @@ describe("digital employee state service", () => {
});
});
it("lists dispatchable ready plan steps and skips approval-blocked steps", async () => {
const { listDigitalEmployeeReadyPlanStepDispatchCandidates } = await import("./stateService");
createGovernableTask({ taskStatus: "pending", stepStatus: "ready", runStatus: "completed", finishedAt: "2026-06-07T07:20:00.000Z" });
expect(listDigitalEmployeeReadyPlanStepDispatchCandidates()).toEqual([
expect.objectContaining({
stepId: "step-governance",
planId: "plan-governance",
taskId: "task-governance",
dispatchable: true,
preferredSkillIds: ["qimingclaw-task-agent"],
}),
]);
mockState.db?.approvals.set("approval-ready-step", {
id: "approval-ready-step",
plan_id: "plan-governance",
task_id: "task-governance",
run_id: null,
title: "确认派发步骤",
status: "pending",
payload: JSON.stringify({ stepId: "step-governance" }),
sync_status: "pending",
created_at: "2026-06-07T07:30:00.000Z",
updated_at: "2026-06-07T07:30:00.000Z",
});
expect(listDigitalEmployeeReadyPlanStepDispatchCandidates()[0]).toMatchObject({
stepId: "step-governance",
dispatchable: false,
skipReason: "approval_pending",
});
});
it("records ready plan step dispatch events and updates step status", async () => {
const { recordDigitalEmployeePlanStepDispatch } = await import("./stateService");
createGovernableTask({ taskStatus: "pending", stepStatus: "ready", runStatus: "completed", finishedAt: "2026-06-07T07:20:00.000Z" });
const started = recordDigitalEmployeePlanStepDispatch({
stepId: "step-governance",
planId: "plan-governance",
taskId: "task-governance",
runId: "run-dispatch",
status: "started",
payload: { command_id: "command-dispatch" },
occurredAt: "2026-06-07T08:10:00.000Z",
});
expect(started?.eventId).toBe("plan-step-dispatch:step-governance:started:2026-06-07T08-10-00-000Z");
expect(mockState.db?.planSteps.get("step-governance")).toMatchObject({ status: "running", sync_status: "pending" });
expect(mockState.db?.events.get(started!.eventId)).toMatchObject({
kind: "plan_step_dispatch_started",
plan_id: "plan-governance",
task_id: "task-governance",
run_id: "run-dispatch",
});
expect(mockState.db?.outbox.get("plan_step:upsert:step-governance")).toMatchObject({ entity_type: "plan_step" });
expect(mockState.db?.outbox.get(`event:insert:${started!.eventId}`)).toMatchObject({ entity_type: "event" });
const failed = recordDigitalEmployeePlanStepDispatch({
stepId: "step-governance",
planId: "plan-governance",
taskId: "task-governance",
runId: "run-dispatch",
status: "failed",
reason: "ComputerServer 500",
occurredAt: "2026-06-07T08:11:00.000Z",
});
expect(failed?.kind).toBe("plan_step_dispatch_failed");
expect(mockState.db?.planSteps.get("step-governance")).toMatchObject({ status: "blocked", sync_status: "pending" });
expect(JSON.parse(mockState.db?.events.get(failed!.eventId)?.payload ?? "{}")).toMatchObject({
step_id: "step-governance",
status: "failed",
reason: "ComputerServer 500",
});
});
it("blocks dependent plan steps on cancellation and returns them to pending on retry", async () => {
const { recordDigitalEmployeeGovernanceCommand } = await import("./stateService");

View File

@@ -289,6 +289,40 @@ export interface DigitalEmployeeApprovalActionRecord {
payload: Record<string, unknown>;
}
export interface DigitalEmployeeReadyPlanStepDispatchCandidate {
stepId: string;
planId: string;
taskId: string | null;
title: string;
taskTitle: string | null;
prompt: string;
preferredSkillIds: string[];
dispatchable: boolean;
skipReason?: string | null;
step: DigitalEmployeePlanStepRecord;
task?: DigitalEmployeeTaskRecord | null;
plan?: DigitalEmployeePlanRecord | null;
}
export interface DigitalEmployeePlanStepDispatchInput {
stepId: string;
planId?: string | null;
taskId?: string | null;
runId?: string | null;
status: "started" | "completed" | "failed" | "skipped";
reason?: string | null;
message?: string | null;
source?: string | null;
payload?: Record<string, unknown> | null;
occurredAt?: string | null;
}
export interface DigitalEmployeePlanStepDispatchRecord {
eventId: string;
kind: "plan_step_dispatch_started" | "plan_step_dispatch_completed" | "plan_step_dispatch_failed" | "plan_step_dispatch_skipped";
payload: Record<string, unknown>;
}
export interface DigitalEmployeeRouteDecisionRecord {
id: string;
route_kind: string;
@@ -1224,6 +1258,204 @@ export function recordDigitalEmployeeApprovalAction(
return { eventId, kind, payload };
}
export function listDigitalEmployeeReadyPlanStepDispatchCandidates(
limit = 20,
): DigitalEmployeeReadyPlanStepDispatchCandidate[] {
const records = readDigitalEmployeeRuntimeRecords();
const tasksByStepId = new Map<string, DigitalEmployeeTaskRecord>();
for (const task of records.tasks) {
const payload = objectRecord(task.payload) ?? {};
const stepId = stringValue(payload.stepId ?? payload.step_id);
if (stepId && !tasksByStepId.has(stepId)) tasksByStepId.set(stepId, task);
}
const tasksByPlanId = new Map<string, DigitalEmployeeTaskRecord[]>();
for (const task of records.tasks) {
const planId = stringValue(task.planId);
if (!planId) continue;
tasksByPlanId.set(planId, [...(tasksByPlanId.get(planId) ?? []), task]);
}
const plansById = new Map(records.plans.map((plan) => [plan.id, plan]));
const activeRunsByTaskId = new Set(records.runs
.filter((run) => isActiveRunStatus(run.status))
.map((run) => stringValue(run.taskId))
.filter(Boolean));
const openApprovals = records.approvals.filter((approval) => isOpenApprovalStatus(approval.status));
return records.planSteps
.filter((step) => normalizePlanStepStatus(step.status) === "ready")
.sort((left, right) => left.seq - right.seq || left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id))
.slice(0, Math.max(1, limit))
.map((step) => {
const task = tasksByStepId.get(step.id) ?? firstRunnableTaskForStep(step, tasksByPlanId.get(step.planId) ?? []) ?? null;
const plan = plansById.get(step.planId) ?? null;
const taskPayload = objectRecord(task?.payload) ?? {};
const stepPayload = objectRecord(step.payload) ?? {};
const preferredSkillIds = normalizeSkillIds([
...step.preferredSkillIds,
...unknownArray(taskPayload.skills),
stringValue(task?.assignedSkillId),
]);
const approvalOpen = openApprovals.some((approval) => {
const payload = objectRecord(approval.payload) ?? {};
return approval.planId === step.planId
|| (task && approval.taskId === task.id)
|| stringValue(payload.stepId ?? payload.step_id) === step.id;
});
const skipReason = readyPlanStepSkipReason({ step, task, activeRunsByTaskId, approvalOpen });
const title = step.title || task?.title || step.id;
const prompt = stringValue(stepPayload.prompt ?? stepPayload.description)
|| stringValue(taskPayload.prompt ?? taskPayload.description)
|| `执行计划步骤:${title}`;
return {
stepId: step.id,
planId: step.planId,
taskId: task?.id ?? null,
title,
taskTitle: task?.title ?? null,
prompt,
preferredSkillIds,
dispatchable: !skipReason,
skipReason,
step,
task,
plan,
};
});
}
export function recordDigitalEmployeePlanStepDispatch(
input: DigitalEmployeePlanStepDispatchInput,
): DigitalEmployeePlanStepDispatchRecord | null {
const db = getDigitalEmployeeDb();
if (!db) return null;
const now = input.occurredAt || new Date().toISOString();
const stepId = stringValue(input.stepId) || "unknown-step";
const planId = stringValue(input.planId) || null;
const taskId = stringValue(input.taskId) || null;
const runId = stringValue(input.runId) || null;
const status = input.status;
const kind = `plan_step_dispatch_${status}` as DigitalEmployeePlanStepDispatchRecord["kind"];
const payload: Record<string, unknown> = {
source: stringValue(input.source) || "qimingclaw-ready-step-dispatcher",
step_id: stepId,
plan_id: planId,
task_id: taskId,
run_id: runId,
status,
reason: stringValue(input.reason) || null,
...(input.payload ?? {}),
};
const eventId = `plan-step-dispatch:${safeId(stepId)}:${status}:${safeId(now)}`;
const tx = db.transaction(() => {
if (status === "started") {
updatePlanStepDispatchStatus(stepId, "running", payload, now);
} else if (status === "failed") {
updatePlanStepDispatchStatus(stepId, "blocked", payload, now);
}
insertEvent(
{
event_id: eventId,
kind,
occurred_at: now,
message: input.message || planStepDispatchMessage(status, stepId, stringValue(input.reason)),
plan_id: planId,
task_id: taskId,
},
runId,
payload,
);
});
tx();
return { eventId, kind, payload };
}
function firstRunnableTaskForStep(
step: DigitalEmployeePlanStepRecord,
tasks: DigitalEmployeeTaskRecord[],
): DigitalEmployeeTaskRecord | null {
const open = tasks.filter((task) => !isTerminalTaskStatus(task.status));
return open.find((task) => {
const payload = objectRecord(task.payload) ?? {};
const dependsOn = normalizeSkillIds(unknownArray(payload.dependsOn ?? payload.depends_on));
return dependsOn.includes(step.id);
}) ?? open[0] ?? null;
}
function readyPlanStepSkipReason(input: {
step: DigitalEmployeePlanStepRecord;
task: DigitalEmployeeTaskRecord | null;
activeRunsByTaskId: Set<string>;
approvalOpen: boolean;
}): string | null {
if (!input.task) return "missing_task";
if (isTerminalTaskStatus(input.task.status)) return "task_terminal";
if (input.activeRunsByTaskId.has(input.task.id)) return "task_already_running";
if (input.approvalOpen) return "approval_pending";
const payload = objectRecord(input.step.payload) ?? {};
if (payload.auto_dispatch === false || payload.autoDispatch === false) return "auto_dispatch_disabled";
return null;
}
function isActiveRunStatus(status: string): boolean {
return ["running", "pending", "queued", "started"].includes(stringValue(status).toLowerCase());
}
function isTerminalTaskStatus(status: string): boolean {
return ["completed", "succeeded", "success", "done", "skipped", "cancelled", "canceled"].includes(stringValue(status).toLowerCase());
}
function isOpenApprovalStatus(status: string): boolean {
return ["pending", "reviewing", "open", "waiting"].includes(stringValue(status).toLowerCase() || "pending");
}
function updatePlanStepDispatchStatus(
stepId: string,
status: string,
dispatchPayload: Record<string, unknown>,
now: string,
): void {
const db = getDigitalEmployeeDb();
if (!db) return;
const row = db.prepare(`
SELECT id, plan_id, title, status, seq, depends_on, preferred_skill_ids, payload, created_at
FROM digital_plan_steps
WHERE id = ?
`).get(stepId) as Record<string, unknown> | undefined;
if (!row) return;
const previousPayload = objectRecord(parseStoredPayload(row.payload)) ?? {};
const payload = {
...previousPayload,
lastDispatch: {
status,
occurredAt: now,
payload: dispatchPayload,
},
};
db.prepare(`
UPDATE digital_plan_steps
SET status = ?, payload = ?, sync_status = 'pending', updated_at = ?
WHERE id = ?
`).run(status, stringify(payload), now, stepId);
markOutbox("plan_step", stepId, "upsert", {
id: stepId,
planId: stringValue(row.plan_id),
title: stringValue(row.title),
status,
seq: typeof row.seq === "number" ? row.seq : Number(row.seq) || 0,
dependsOn: parseStringArray(row.depends_on),
preferredSkillIds: parseStringArray(row.preferred_skill_ids),
payload,
now,
}, now);
}
function planStepDispatchMessage(status: string, stepId: string, reason: string): string {
if (status === "started") return `计划步骤已自动派发:${stepId}`;
if (status === "completed") return `计划步骤派发已触发执行:${stepId}`;
if (status === "failed") return `计划步骤自动派发失败:${stepId}${reason ? `${reason}` : ""}`;
return `计划步骤自动派发已跳过:${stepId}${reason ? `${reason}` : ""}`;
}
function isDigitalEmployeeApprovalAction(action: string): action is DigitalEmployeeApprovalActionKind {
return ["comment", "delegate", "mark_timeout", "mark_risk", "clear_risk"].includes(action);
}

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 并写入依赖图事件。
- 后续缺口:跨任务编排执行、ready 步骤自动调度、依赖阻塞人工处理 UI 和管理端依赖图视图仍待吸收。
- 建议:下一轮围绕入口路由/任务分流,把 ready PlanStep 接入安全的执行触发策略
- 当前能力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` 事件ready PlanStep 安全派发已开始闭环
- 后续缺口:跨任务编排执行、依赖阻塞人工处理 UI、派发并发策略下发和管理端依赖图视图仍待吸收。
- 建议:下一轮围绕管理端依赖图和派发策略下发,把 ready PlanStep 从本地安全派发推进到跨端可治理调度
2. **调度 / 定时 / 周期任务**
- 已吸收:`digital_schedules``digital_schedule_runs` 已进入 qimingclaw 本地 SQLite 与 outboxentity type 为 `schedule` / `schedule_run`;旧 `/api/cron``/api/scheduler/jobs` 和治理命令会优先读取真实本地调度记录。
@@ -589,9 +589,9 @@ GET /api/digital-employee/approvals
10. **入口路由 / 任务分流(路由决策事件闭环已开始)**
- 已吸收:管理端通过 `/computer/chat` 进入 qimingclaw 后会生成本地任务事实embedded `/api/ingress/route``/api/route-decisions` 已由 qimingclaw adapter 接管。
- 当前能力入口请求会按上下文、ready PlanStep、调度、查询和控制动作生成 route decision并写入 `digital_events.kind = route_decision` 与现有 event outbox路由时间线优先从 qimingclaw 本地事件读取;低风险 route decision 已开始映射为明确的本地 governance command并把 `created_command_id` 回填到 route decision。
- 后续缺口:ready PlanStep 自动调度守护、管理端路由时间线视图、风险策略下发和高风险动作审批策略仍待吸收。
- 建议:下一轮围绕 ready PlanStep 安全触发和管理端路由视图,把入口路由从单次 action 映射推进到可观测调度策略。
- 当前能力入口请求会按上下文、ready PlanStep、调度、查询和控制动作生成 route decision并写入 `digital_events.kind = route_decision` 与现有 event outbox路由时间线优先从 qimingclaw 本地事件读取;低风险 route decision 已开始映射为明确的本地 governance command并把 `created_command_id` 回填到 route decisionscheduler check 已接入 ready PlanStep 安全派发,首页立即执行后会触发一次派发 sweep 并展示派发结果
- 后续缺口:管理端路由时间线视图、派发策略下发、风险策略下发和高风险动作审批策略仍待吸收。
- 建议:下一轮围绕管理端路由视图和策略下发,把入口路由从本地派发推进到跨端可观测调度策略。
11. **诊断 / 成本 / 审计视图**
- 已吸收:同步失败诊断较完整,部分 sandbox / memory / service 日志在 qimingclaw 其他模块中存在embedded adapter 已接管 `/api/doctor``/api/cost``/api/audit`,从 snapshot、runtime records、sync status、managed services 和 artifact delivery facts 生成只读诊断、估算成本和审计投影,只读投影已开始闭环。