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

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

@@ -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,