吸收数字员工步骤依赖调度

This commit is contained in:
baiyanyun
2026-06-09 14:18:07 +08:00
parent ddd39396f7
commit fd130f1f60
6 changed files with 416 additions and 12 deletions

View File

@@ -1165,6 +1165,8 @@ function taskStatusTone(status: string): 'running' | 'success' | 'danger' | 'wai
function taskStatusLabel(status: string): string {
const normalized = status.toLowerCase();
if (normalized === 'ready') return '待启动';
if (normalized === 'blocked') return '依赖阻塞';
if (normalized === 'skipped') return '已跳过';
if (normalized === 'cancelled') return '已取消';
if (normalized === 'paused') return '已暂停';

View File

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

View File

@@ -481,6 +481,158 @@ describe("digital employee state service", () => {
expect(mockState.db?.outbox.get("run:upsert:run-governance")).toMatchObject({ entity_type: "run" });
});
it("marks dependent plan steps ready when skipped dependencies are satisfied", async () => {
const { recordDigitalEmployeeGovernanceCommand } = await import("./stateService");
createGovernableTask();
addPlanStep({ id: "step-dependent", title: "后续执行步骤", seq: 2, dependsOn: ["step-governance"] });
recordDigitalEmployeeGovernanceCommand({
commandKind: "skip_task",
commandId: "command-unlock-dependent",
planId: "plan-governance",
taskId: "task-governance",
runId: "run-governance",
stepId: "step-governance",
accepted: true,
payload: { reason: "跳过前置步骤" },
});
expect(mockState.db?.planSteps.get("step-dependent")).toMatchObject({
status: "ready",
sync_status: "pending",
});
expect(JSON.parse(mockState.db?.planSteps.get("step-dependent")?.payload ?? "{}")).toMatchObject({
dependencyGraph: {
status: "ready",
triggeredByStepId: "step-governance",
satisfiedDependencies: ["step-governance"],
},
});
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "plan_step_ready", plan_id: "plan-governance" }),
]));
expect(mockState.db?.outbox.get("plan_step:upsert:step-dependent")).toMatchObject({
entity_type: "plan_step",
entity_id: "step-dependent",
status: "pending",
});
});
it("blocks dependent plan steps on cancellation and returns them to pending on retry", async () => {
const { recordDigitalEmployeeGovernanceCommand } = await import("./stateService");
createGovernableTask();
addPlanStep({ id: "step-dependent", title: "后续执行步骤", seq: 2, dependsOn: ["step-governance"] });
recordDigitalEmployeeGovernanceCommand({
commandKind: "cancel_task",
commandId: "command-block-dependent",
planId: "plan-governance",
taskId: "task-governance",
runId: "run-governance",
stepId: "step-governance",
accepted: true,
payload: { reason: "取消前置步骤" },
});
expect(mockState.db?.planSteps.get("step-dependent")).toMatchObject({ status: "blocked" });
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "plan_step_blocked" }),
]));
recordDigitalEmployeeGovernanceCommand({
commandKind: "retry_task",
commandId: "command-wait-dependent",
planId: "plan-governance",
taskId: "task-governance",
runId: "run-governance",
stepId: "step-governance",
accepted: true,
payload: { reason: "重试前置步骤" },
});
expect(mockState.db?.planSteps.get("step-governance")).toMatchObject({ status: "running" });
expect(mockState.db?.planSteps.get("step-dependent")).toMatchObject({ status: "pending" });
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "plan_step_waiting" }),
]));
expect(JSON.parse(mockState.db?.planSteps.get("step-dependent")?.payload ?? "{}")).toMatchObject({
dependencyGraph: {
status: "pending",
waitingDependencies: ["step-governance"],
},
});
});
it("waits for all dependencies before marking a plan step ready", async () => {
const { recordDigitalEmployeeGovernanceCommand } = await import("./stateService");
createGovernableTask();
addPlanStep({ id: "step-other", title: "第二个前置步骤", seq: 2 });
addGovernableTaskForStep({ taskId: "task-other", runId: "run-other", stepId: "step-other" });
addPlanStep({ id: "step-dependent", title: "汇总步骤", seq: 3, dependsOn: ["step-governance", "step-other"] });
recordDigitalEmployeeGovernanceCommand({
commandKind: "skip_task",
commandId: "command-first-dependency",
planId: "plan-governance",
taskId: "task-governance",
runId: "run-governance",
stepId: "step-governance",
accepted: true,
});
expect(mockState.db?.planSteps.get("step-dependent")).toMatchObject({ status: "pending" });
recordDigitalEmployeeGovernanceCommand({
commandKind: "skip_task",
commandId: "command-second-dependency",
planId: "plan-governance",
taskId: "task-other",
runId: "run-other",
stepId: "step-other",
accepted: true,
});
expect(mockState.db?.planSteps.get("step-dependent")).toMatchObject({ status: "ready" });
expect(JSON.parse(mockState.db?.planSteps.get("step-dependent")?.payload ?? "{}")).toMatchObject({
dependencyGraph: {
status: "ready",
satisfiedDependencies: ["step-governance", "step-other"],
},
});
});
it("does not overwrite already running or completed dependent plan steps", async () => {
const { recordDigitalEmployeeGovernanceCommand } = await import("./stateService");
createGovernableTask();
addPlanStep({ id: "step-running-dependent", status: "running", seq: 2, dependsOn: ["step-governance"] });
addPlanStep({ id: "step-completed-dependent", status: "completed", seq: 3, dependsOn: ["step-governance"] });
recordDigitalEmployeeGovernanceCommand({
commandKind: "skip_task",
commandId: "command-preserve-dependent",
planId: "plan-governance",
taskId: "task-governance",
runId: "run-governance",
stepId: "step-governance",
accepted: true,
});
expect(mockState.db?.planSteps.get("step-running-dependent")).toMatchObject({
status: "running",
sync_status: "synced",
});
expect(mockState.db?.planSteps.get("step-completed-dependent")).toMatchObject({
status: "completed",
sync_status: "synced",
});
expect(mockState.db?.outbox.has("plan_step:upsert:step-running-dependent")).toBe(false);
expect(mockState.db?.outbox.has("plan_step:upsert:step-completed-dependent")).toBe(false);
});
it("creates a retry run and preserves the failed source run", async () => {
const { recordDigitalEmployeeGovernanceCommand } = await import("./stateService");
@@ -754,6 +906,59 @@ function createGovernableTask(options: {
});
}
function addPlanStep(options: {
id: string;
title?: string;
status?: string;
seq?: number;
dependsOn?: string[];
}): void {
mockState.db?.planSteps.set(options.id, {
id: options.id,
plan_id: "plan-governance",
title: options.title ?? options.id,
status: options.status ?? "pending",
seq: options.seq ?? 2,
depends_on: JSON.stringify(options.dependsOn ?? []),
preferred_skill_ids: JSON.stringify([]),
payload: JSON.stringify({ source: "test" }),
sync_status: "synced",
created_at: "2026-06-07T07:00:00.000Z",
updated_at: "2026-06-07T07:00:00.000Z",
});
}
function addGovernableTaskForStep(options: {
taskId: string;
runId: string;
stepId: string;
status?: string;
}): void {
mockState.db?.tasks.set(options.taskId, {
id: options.taskId,
plan_id: "plan-governance",
title: `${options.stepId} 任务`,
status: options.status ?? "running",
assigned_skill_id: "qimingclaw-task-agent",
payload: JSON.stringify({ source: "test", stepId: options.stepId }),
sync_status: "synced",
created_at: "2026-06-07T07:00:00.000Z",
updated_at: "2026-06-07T07:00:00.000Z",
});
mockState.db?.runs.set(options.runId, {
id: options.runId,
plan_id: "plan-governance",
task_id: options.taskId,
status: options.status ?? "running",
started_at: "2026-06-07T07:10:00.000Z",
finished_at: null,
payload: JSON.stringify({ source: "test" }),
sync_status: "synced",
created_at: "2026-06-07T07:10:00.000Z",
updated_at: "2026-06-07T07:10:00.000Z",
});
}
interface TestPlanRow {
id: string;
title: string;
@@ -808,7 +1013,7 @@ interface TestEventRow {
id: string;
plan_id: string;
task_id: string;
run_id: string;
run_id: string | null;
kind: string;
message: string;
payload: string;
@@ -946,9 +1151,17 @@ class TestDb {
};
}
private all(sql: string, _args: unknown[]): unknown[] {
private all(sql: string, args: unknown[]): unknown[] {
if (sql.includes("FROM digital_plans")) return Array.from(this.plans.values());
if (sql.includes("FROM digital_plan_steps")) return Array.from(this.planSteps.values());
if (sql.includes("FROM digital_plan_steps")) {
const rows = Array.from(this.planSteps.values());
if (sql.includes("WHERE plan_id = ?")) {
return rows
.filter((step) => step.plan_id === args[0])
.sort((left, right) => left.seq - right.seq || left.created_at.localeCompare(right.created_at));
}
return rows;
}
if (sql.includes("FROM digital_schedules")) return Array.from(this.schedules.values());
if (sql.includes("FROM digital_schedule_runs")) return Array.from(this.scheduleRuns.values());
if (sql.includes("FROM digital_tasks")) return Array.from(this.tasks.values());
@@ -1304,7 +1517,7 @@ class TestDb {
string,
string,
string,
string,
string | null,
string,
string,
string,

View File

@@ -1921,7 +1921,7 @@ function shouldUpdateTaskForGovernance(commandKind: string): boolean {
}
function shouldUpdateStepForGovernance(commandKind: string): boolean {
return ["retry_task", "skip_task", "pause_task", "resume_task", "cancel_task", "start_run"].includes(commandKind);
return ["retry_task", "skip_task", "pause_task", "resume_task", "cancel_task", "start_run", "cancel_run"].includes(commandKind);
}
function mergeGovernancePayload(
@@ -2003,6 +2003,195 @@ function updatePlanStepForGovernance(input: {
payload,
now: input.now,
}, input.now);
advancePlanStepDependencyGraph({
planId: stringValue(row.plan_id),
changedStepId: input.stepId,
now: input.now,
});
}
function advancePlanStepDependencyGraph(input: {
planId: string;
changedStepId: string;
now: string;
}): void {
const db = getDigitalEmployeeDb();
if (!db || !input.planId || !input.changedStepId) return;
const rows = db.prepare(`
SELECT id, plan_id, title, status, seq, depends_on, preferred_skill_ids, payload, created_at
FROM digital_plan_steps
WHERE plan_id = ?
ORDER BY seq ASC, created_at ASC
`).all(input.planId) as Array<Record<string, unknown>>;
if (rows.length === 0) return;
const stepsById = new Map(rows.map((row) => [stringValue(row.id), row]));
const queue = [input.changedStepId];
const visited = new Set<string>();
while (queue.length > 0) {
const sourceStepId = queue.shift() || "";
if (!sourceStepId || visited.has(sourceStepId)) continue;
visited.add(sourceStepId);
for (const row of rows) {
const stepId = stringValue(row.id);
const dependsOn = parseStringArray(row.depends_on);
if (!stepId || !dependsOn.includes(sourceStepId)) continue;
const currentStatus = normalizePlanStepStatus(row.status);
if (!canAdvanceDependentPlanStep(currentStatus)) continue;
const nextState = evaluatePlanStepDependencyState(row, stepsById, input.now, input.changedStepId);
if (!nextState || nextState.status === currentStatus) continue;
updatePlanStepForDependencyState({
row,
dependsOn,
status: nextState.status,
eventKind: nextState.eventKind,
message: nextState.message,
dependencyGraph: nextState.dependencyGraph,
now: input.now,
});
row.status = nextState.status;
row.payload = stringify({
...(objectRecord(parseStoredPayload(row.payload)) ?? {}),
dependencyGraph: nextState.dependencyGraph,
});
queue.push(stepId);
}
}
}
function evaluatePlanStepDependencyState(
row: Record<string, unknown>,
stepsById: Map<string, Record<string, unknown>>,
now: string,
triggeredByStepId: string,
): {
status: "ready" | "blocked" | "pending";
eventKind: "plan_step_ready" | "plan_step_blocked" | "plan_step_waiting";
message: string;
dependencyGraph: Record<string, unknown>;
} | null {
const stepId = stringValue(row.id);
const dependsOn = parseStringArray(row.depends_on);
if (!stepId || dependsOn.length === 0) return null;
const dependencyStates = dependsOn.map((dependencyId) => {
const dependency = stepsById.get(dependencyId);
return {
id: dependencyId,
status: normalizePlanStepStatus(dependency?.status),
exists: Boolean(dependency),
};
});
const blockedDependencies = dependencyStates
.filter((dependency) => isBlockingPlanStepStatus(dependency.status))
.map((dependency) => dependency.id);
const waitingDependencies = dependencyStates
.filter((dependency) => !dependency.exists || !isSatisfiedPlanStepStatus(dependency.status))
.map((dependency) => dependency.id);
const satisfiedDependencies = dependencyStates
.filter((dependency) => isSatisfiedPlanStepStatus(dependency.status))
.map((dependency) => dependency.id);
const dependencyGraph = {
evaluatedAt: now,
triggeredByStepId,
dependencies: dependencyStates,
satisfiedDependencies,
waitingDependencies,
blockedDependencies,
};
if (blockedDependencies.length > 0) {
return {
status: "blocked",
eventKind: "plan_step_blocked",
message: `计划步骤“${stringValue(row.title) || stepId}”因依赖阻塞,等待人工处理。`,
dependencyGraph: { ...dependencyGraph, status: "blocked" },
};
}
if (waitingDependencies.length === 0) {
return {
status: "ready",
eventKind: "plan_step_ready",
message: `计划步骤“${stringValue(row.title) || stepId}”依赖已满足,等待启动。`,
dependencyGraph: { ...dependencyGraph, status: "ready" },
};
}
return {
status: "pending",
eventKind: "plan_step_waiting",
message: `计划步骤“${stringValue(row.title) || stepId}”重新等待依赖完成。`,
dependencyGraph: { ...dependencyGraph, status: "pending" },
};
}
function updatePlanStepForDependencyState(input: {
row: Record<string, unknown>;
dependsOn: string[];
status: "ready" | "blocked" | "pending";
eventKind: "plan_step_ready" | "plan_step_blocked" | "plan_step_waiting";
message: string;
dependencyGraph: Record<string, unknown>;
now: string;
}): void {
const db = getDigitalEmployeeDb();
if (!db) return;
const stepId = stringValue(input.row.id);
const planId = stringValue(input.row.plan_id);
const payload = {
...(objectRecord(parseStoredPayload(input.row.payload)) ?? {}),
dependencyGraph: input.dependencyGraph,
};
db.prepare(`
UPDATE digital_plan_steps
SET status = ?, payload = ?, sync_status = 'pending', updated_at = ?
WHERE id = ?
`).run(input.status, stringify(payload), input.now, stepId);
markOutbox("plan_step", stepId, "upsert", {
id: stepId,
planId,
title: stringValue(input.row.title),
status: input.status,
seq: typeof input.row.seq === "number" ? input.row.seq : Number(input.row.seq) || 0,
dependsOn: input.dependsOn,
preferredSkillIds: parseStringArray(input.row.preferred_skill_ids),
payload,
now: input.now,
}, input.now);
insertEvent(
{
event_id: `${stepId}:dependency:${input.status}:${safeId(stringValue(input.dependencyGraph.triggeredByStepId))}:${safeId(input.now)}`,
kind: input.eventKind,
occurred_at: input.now,
message: input.message,
plan_id: planId,
task_id: null,
},
null,
{
source: "qimingclaw-plan-step-dependency",
stepId,
planId,
status: input.status,
dependencyGraph: input.dependencyGraph,
},
);
}
function canAdvanceDependentPlanStep(status: string): boolean {
return ["pending", "ready", "blocked"].includes(status);
}
function isSatisfiedPlanStepStatus(status: string): boolean {
return ["completed", "succeeded", "success", "done", "skipped"].includes(status);
}
function isBlockingPlanStepStatus(status: string): boolean {
return ["failed", "error", "cancelled", "canceled", "rejected", "blocked"].includes(status);
}
function normalizePlanStepStatus(status: unknown): string {
return stringValue(status).toLowerCase() || "pending";
}
function persistTaskGovernanceRun(input: {
@@ -3247,7 +3436,7 @@ function upsertApproval(input: {
function insertEvent(
event: DigitalEmployeeStateEvent,
runId: string,
runId: string | null,
payload: unknown,
): void {
const db = getDigitalEmployeeDb();

View File

@@ -538,11 +538,11 @@ GET /api/digital-employee/approvals
以下清单按当前 qimingclaw 已落地状态盘点,不重复列出已完成的前端嵌入、本地 Plan / Task / Run / Event / Artifact / Approval / MemoryEntry、outbox 同步、技能投影和同步诊断。
1. **PlanStep 与依赖图(已开始)**
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。
- 后续缺口:步骤依赖调度和跨任务编排执行仍待吸收。
- 建议:下一轮围绕依赖图调度,把已完成/跳过/取消的步骤继续驱动到后续步骤
- 当前能力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 接入安全的执行触发策略
2. **调度 / 定时 / 周期任务**
- 已吸收:`digital_schedules``digital_schedule_runs` 已进入 qimingclaw 本地 SQLite 与 outboxentity type 为 `schedule` / `schedule_run`;旧 `/api/cron``/api/scheduler/jobs` 和治理命令会优先读取真实本地调度记录。