吸收数字员工PlanStep本地软租约

This commit is contained in:
baiyanyun
2026-06-10 22:29:52 +08:00
parent 909f11dae5
commit 6f2ccdf783
13 changed files with 604 additions and 49 deletions

View File

@@ -2582,6 +2582,7 @@ function planStepRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] {
const payload = asRecord(step.payload) ?? {}; const payload = asRecord(step.payload) ?? {};
const dependencyGraph = asRecord(payload.dependencyGraph ?? payload.dependency_graph) ?? {}; const dependencyGraph = asRecord(payload.dependencyGraph ?? payload.dependency_graph) ?? {};
const lastDispatch = asRecord(payload.lastDispatch ?? payload.last_dispatch) ?? {}; const lastDispatch = asRecord(payload.lastDispatch ?? payload.last_dispatch) ?? {};
const lease = activePlanStepDispatchLease(payload);
const readiness = planStepDispatchReadiness(step, snapshot, payload); const readiness = planStepDispatchReadiness(step, snapshot, payload);
const blockedDependencies = uniqueStrings(arrayValue(dependencyGraph.blockedDependencies ?? dependencyGraph.blocked_dependencies)); const blockedDependencies = uniqueStrings(arrayValue(dependencyGraph.blockedDependencies ?? dependencyGraph.blocked_dependencies));
const waitingDependencies = uniqueStrings(arrayValue(dependencyGraph.waitingDependencies ?? dependencyGraph.waiting_dependencies)); const waitingDependencies = uniqueStrings(arrayValue(dependencyGraph.waitingDependencies ?? dependencyGraph.waiting_dependencies));
@@ -2610,6 +2611,15 @@ function planStepRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] {
skip_reason: readiness.skipReason, skip_reason: readiness.skipReason,
last_dispatch_status: stringValue(lastDispatch.status) || null, last_dispatch_status: stringValue(lastDispatch.status) || null,
last_dispatch_at: stringValue(lastDispatch.occurredAt ?? lastDispatch.occurred_at) || null, last_dispatch_at: stringValue(lastDispatch.occurredAt ?? lastDispatch.occurred_at) || null,
lease,
lease_id: stringValue(lease?.lease_id ?? lease?.leaseId) || null,
lease_state: stringValue(lease?.state) || null,
lease_owner_device_id: stringValue(lease?.owner_device_id ?? lease?.ownerDeviceId) || null,
lease_acquired_at: stringValue(lease?.acquired_at ?? lease?.acquiredAt) || null,
lease_expires_at: stringValue(lease?.expires_at ?? lease?.expiresAt) || null,
lease_released_at: stringValue(lease?.released_at ?? lease?.releasedAt) || null,
lease_release_reason: stringValue(lease?.release_reason ?? lease?.releaseReason) || null,
lease_active: Boolean(lease),
sync_status: step.syncStatus, sync_status: step.syncStatus,
last_synced_at: step.lastSyncedAt ?? null, last_synced_at: step.lastSyncedAt ?? null,
sync_error: step.syncError ?? null, sync_error: step.syncError ?? null,
@@ -2641,6 +2651,15 @@ function planStepGraphRows(snapshot: QimingclawSnapshot | null): BusinessViewRow
latest_run_id: stringValue(row.latest_run_id) || null, latest_run_id: stringValue(row.latest_run_id) || null,
dispatchable: row.dispatchable === true, dispatchable: row.dispatchable === true,
skip_reason: stringValue(row.skip_reason) || null, skip_reason: stringValue(row.skip_reason) || null,
lease: asRecord(row.lease) ?? null,
lease_id: stringValue(row.lease_id) || null,
lease_state: stringValue(row.lease_state) || null,
lease_owner_device_id: stringValue(row.lease_owner_device_id) || null,
lease_acquired_at: stringValue(row.lease_acquired_at) || null,
lease_expires_at: stringValue(row.lease_expires_at) || null,
lease_released_at: stringValue(row.lease_released_at) || null,
lease_release_reason: stringValue(row.lease_release_reason) || null,
lease_active: row.lease_active === true,
sync_status: stringValue(row.sync_status) || null, sync_status: stringValue(row.sync_status) || null,
updated_at: stringValue(row.updated_at) || null, updated_at: stringValue(row.updated_at) || null,
entity_type: 'plan_step_graph_node', entity_type: 'plan_step_graph_node',
@@ -2679,8 +2698,10 @@ function planStepDispatchReadiness(
?? null; ?? null;
const taskId = task?.id ?? null; const taskId = task?.id ?? null;
const latestRunId = task ? latestRunForTask(runtimeRuns(snapshot), task.id)?.id ?? null : null; const latestRunId = task ? latestRunForTask(runtimeRuns(snapshot), task.id)?.id ?? null : null;
const lease = activePlanStepDispatchLease(payload);
let skipReason: string | null = null; let skipReason: string | null = null;
if (step.status.toLowerCase() !== 'ready') skipReason = 'not_ready'; if (lease) skipReason = 'lease_active';
else if (step.status.toLowerCase() !== 'ready') skipReason = 'not_ready';
else if (!task) skipReason = 'missing_task'; else if (!task) skipReason = 'missing_task';
else if (isTerminalRuntimeTaskStatus(task.status)) skipReason = 'task_terminal'; else if (isTerminalRuntimeTaskStatus(task.status)) skipReason = 'task_terminal';
else if (runtimeRuns(snapshot).some((run) => run.taskId === task.id && isActiveRuntimeRunStatus(run.status))) skipReason = 'task_already_running'; else if (runtimeRuns(snapshot).some((run) => run.taskId === task.id && isActiveRuntimeRunStatus(run.status))) skipReason = 'task_already_running';
@@ -2707,6 +2728,7 @@ function planStepDependencyDetails(dependencyIds: string[], snapshot: Qimingclaw
} }
const payload = asRecord(dependency.payload) ?? {}; const payload = asRecord(dependency.payload) ?? {};
const graph = asRecord(payload.dependencyGraph ?? payload.dependency_graph) ?? {}; const graph = asRecord(payload.dependencyGraph ?? payload.dependency_graph) ?? {};
const lease = activePlanStepDispatchLease(payload);
const readiness = planStepDispatchReadiness(dependency, snapshot, payload); const readiness = planStepDispatchReadiness(dependency, snapshot, payload);
return { return {
step_id: dependency.id, step_id: dependency.id,
@@ -2717,10 +2739,23 @@ function planStepDependencyDetails(dependencyIds: string[], snapshot: Qimingclaw
dependency_status: stringValue(graph.status) || dependency.status, dependency_status: stringValue(graph.status) || dependency.status,
dispatchable: readiness.dispatchable, dispatchable: readiness.dispatchable,
skip_reason: readiness.skipReason, skip_reason: readiness.skipReason,
lease_state: stringValue(lease?.state) || null,
lease_active: Boolean(lease),
}; };
}); });
} }
function activePlanStepDispatchLease(payload: Record<string, unknown>): Record<string, unknown> | null {
const lease = asRecord(payload.dispatchLease ?? payload.dispatch_lease);
if (!lease) return null;
const state = stringValue(lease.state) || 'active';
const expiresAt = stringValue(lease.expires_at ?? lease.expiresAt);
const leaseId = stringValue(lease.lease_id ?? lease.leaseId);
const ownerDeviceId = stringValue(lease.owner_device_id ?? lease.ownerDeviceId);
if (state !== 'active' || !expiresAt || expiresAt <= new Date().toISOString() || !leaseId || !ownerDeviceId) return null;
return lease;
}
function isTerminalRuntimeTaskStatus(status: string): boolean { function isTerminalRuntimeTaskStatus(status: string): boolean {
return ['completed', 'succeeded', 'success', 'done', 'skipped', 'cancelled', 'canceled'].includes(status.toLowerCase()); return ['completed', 'succeeded', 'success', 'done', 'skipped', 'cancelled', 'canceled'].includes(status.toLowerCase());
} }

View File

@@ -63,6 +63,7 @@ interface DigitalPlanItem {
readyStepCount: number; readyStepCount: number;
runningStepCount: number; runningStepCount: number;
blockedStepCount: number; blockedStepCount: number;
leasedStepCount: number;
schedulerJob?: CronJob | null; schedulerJob?: CronJob | null;
source: 'plan_template'; source: 'plan_template';
} }
@@ -71,6 +72,7 @@ interface PlanStepOrchestrationSummary {
ready: number; ready: number;
running: number; running: number;
blocked: number; blocked: number;
leased: number;
} }
interface BlockedPlanStepInputTarget { interface BlockedPlanStepInputTarget {
@@ -417,6 +419,7 @@ function buildPlanTemplateItem(job: CronJob, duties: DigitalEmployeeDuty[], sele
readyStepCount: 0, readyStepCount: 0,
runningStepCount: 0, runningStepCount: 0,
blockedStepCount: 0, blockedStepCount: 0,
leasedStepCount: 0,
schedulerJob: job, schedulerJob: job,
source: 'plan_template', source: 'plan_template',
}; };
@@ -441,9 +444,12 @@ function planStepOrchestrationSummary(rows: PlanStepBusinessRow[]): Map<string,
rows.forEach((step) => { rows.forEach((step) => {
const planId = step.plan_id || ''; const planId = step.plan_id || '';
if (!planId) return; if (!planId) return;
const current = summary.get(planId) ?? { ready: 0, running: 0, blocked: 0 }; const current = summary.get(planId) ?? { ready: 0, running: 0, blocked: 0, leased: 0 };
const status = step.status.toLowerCase(); const status = step.status.toLowerCase();
if (status === 'ready') current.ready += 1; if (step.lease_active || step.skip_reason === 'lease_active') {
current.leased += 1;
current.running += 1;
} else if (status === 'ready' && step.dispatchable) current.ready += 1;
else if (['running', 'active', 'leased', 'queued'].includes(status)) current.running += 1; else if (['running', 'active', 'leased', 'queued'].includes(status)) current.running += 1;
else if (status === 'blocked') current.blocked += 1; else if (status === 'blocked') current.blocked += 1;
summary.set(planId, current); summary.set(planId, current);
@@ -1128,8 +1134,9 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
readyStepCount: orchestration.ready, readyStepCount: orchestration.ready,
runningStepCount: orchestration.running, runningStepCount: orchestration.running,
blockedStepCount: orchestration.blocked, blockedStepCount: orchestration.blocked,
resultText: orchestration.ready > 0 || orchestration.running > 0 || orchestration.blocked > 0 leasedStepCount: orchestration.leased,
? `Ready ${orchestration.ready} · 运行 ${orchestration.running} · 阻塞 ${orchestration.blocked}` resultText: orchestration.ready > 0 || orchestration.running > 0 || orchestration.blocked > 0 || orchestration.leased > 0
? `Ready ${orchestration.ready} · 运行 ${orchestration.running} · 阻塞 ${orchestration.blocked}${orchestration.leased > 0 ? ` · 租约 ${orchestration.leased}` : ''}`
: item.resultText, : item.resultText,
}; };
}); });

View File

@@ -211,6 +211,21 @@ export interface PlanStepDependencyDetail {
dependency_status: string | null; dependency_status: string | null;
dispatchable: boolean; dispatchable: boolean;
skip_reason: string | null; skip_reason: string | null;
lease_state?: string | null;
lease_active?: boolean;
}
export interface PlanStepLeaseSummary {
lease_id?: string | null;
state?: string | null;
owner_device_id?: string | null;
acquired_at?: string | null;
expires_at?: string | null;
released_at?: string | null;
release_reason?: string | null;
trigger_source?: string | null;
orchestration_id?: string | null;
candidate_count?: number | null;
} }
export interface PlanStepBusinessRow { export interface PlanStepBusinessRow {
@@ -230,6 +245,15 @@ export interface PlanStepBusinessRow {
blocked_dependency_details?: PlanStepDependencyDetail[]; blocked_dependency_details?: PlanStepDependencyDetail[];
dispatchable: boolean; dispatchable: boolean;
skip_reason: string | null; skip_reason: string | null;
lease?: PlanStepLeaseSummary | null;
lease_id?: string | null;
lease_state?: string | null;
lease_owner_device_id?: string | null;
lease_acquired_at?: string | null;
lease_expires_at?: string | null;
lease_released_at?: string | null;
lease_release_reason?: string | null;
lease_active?: boolean;
sync_status?: string | null; sync_status?: string | null;
updated_at?: string | null; updated_at?: string | null;
} }

View File

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

View File

@@ -314,6 +314,9 @@ function checkDigitalPlanStepGraphDispatchPolicy() {
[adapter, "planStepRows", "management plan step rows"], [adapter, "planStepRows", "management plan step rows"],
[adapter, "planStepGraphRows", "management plan step graph rows"], [adapter, "planStepGraphRows", "management plan step graph rows"],
[adapter, "filterPlanStepRows", "management plan step filters"], [adapter, "filterPlanStepRows", "management plan step filters"],
[adapter, "activePlanStepDispatchLease", "adapter active plan step lease detector"],
[adapter, "lease_active", "adapter lease-active readiness skip reason"],
[adapter, "lease_state", "adapter plan step lease state projection"],
[adapter, "blocked_dependency_details", "plan step blocked dependency details"], [adapter, "blocked_dependency_details", "plan step blocked dependency details"],
[adapter, "planStepDependencyDetails", "plan step blocked dependency detail mapper"], [adapter, "planStepDependencyDetails", "plan step blocked dependency detail mapper"],
[adapter, "update_plan_step_dispatch_policy", "adapter plan step dispatch policy save action"], [adapter, "update_plan_step_dispatch_policy", "adapter plan step dispatch policy save action"],
@@ -326,11 +329,14 @@ function checkDigitalPlanStepGraphDispatchPolicy() {
[types, "trigger_source", "ready step dispatch trigger source type"], [types, "trigger_source", "ready step dispatch trigger source type"],
[types, "orchestration_id", "ready step dispatch orchestration id type"], [types, "orchestration_id", "ready step dispatch orchestration id type"],
[types, "PlanStepDispatchPolicy", "plan step dispatch policy type"], [types, "PlanStepDispatchPolicy", "plan step dispatch policy type"],
[types, "PlanStepLeaseSummary", "plan step lease summary type"],
[types, "PlanStepDependencyDetail", "plan step blocked dependency detail type"], [types, "PlanStepDependencyDetail", "plan step blocked dependency detail type"],
[types, "lease_active", "plan step lease active type field"],
[types, "PlanStepBusinessRow", "plan step business row type"], [types, "PlanStepBusinessRow", "plan step business row type"],
[types, "BusinessViewResponse", "business view response type"], [types, "BusinessViewResponse", "business view response type"],
[commandDeck, "getPlanStepGraph", "home blocked plan step graph fetch"], [commandDeck, "getPlanStepGraph", "home blocked plan step graph fetch"],
[commandDeck, "planStepOrchestrationSummary", "home plan step orchestration summary"], [commandDeck, "planStepOrchestrationSummary", "home plan step orchestration summary"],
[commandDeck, "leasedStepCount", "home plan step lease count"],
[commandDeck, "controlPlanReadySteps", "home plan-scoped ready step control"], [commandDeck, "controlPlanReadySteps", "home plan-scoped ready step control"],
[commandDeck, "controlBlockedPlanStep", "home blocked plan step control handler"], [commandDeck, "controlBlockedPlanStep", "home blocked plan step control handler"],
[commandDeck, "de-blocked-step-control-strip", "home blocked plan step control strip"], [commandDeck, "de-blocked-step-control-strip", "home blocked plan step control strip"],
@@ -339,14 +345,26 @@ function checkDigitalPlanStepGraphDispatchPolicy() {
[stateService, "planStepDispatchPolicy", "plan step dispatch policy UI state"], [stateService, "planStepDispatchPolicy", "plan step dispatch policy UI state"],
[stateService, "update_plan_step_dispatch_policy", "plan step dispatch policy runtime event"], [stateService, "update_plan_step_dispatch_policy", "plan step dispatch policy runtime event"],
[stateService, "planIdFilter", "plan-scoped ready step candidate filter"], [stateService, "planIdFilter", "plan-scoped ready step candidate filter"],
[stateService, "dispatchLease", "plan step payload lease state"],
[stateService, "activeDigitalEmployeePlanStepLease", "plan step active lease helper"],
[stateService, "acquireDigitalEmployeePlanStepLease", "plan step lease acquire helper"],
[stateService, "releaseDigitalEmployeePlanStepLease", "plan step lease release helper"],
[stateService, "plan_step_lease_acquired", "plan step lease acquired event"],
[stateService, "plan_step_lease_released", "plan step lease released event"],
[stateService, "plan_step_lease_skipped", "plan step lease skipped event"],
[schedulerService, "bypassAutoDispatchSwitch", "manual plan dispatch auto switch bypass"], [schedulerService, "bypassAutoDispatchSwitch", "manual plan dispatch auto switch bypass"],
[schedulerService, "runDigitalEmployeePlanReadyStepsNow", "manual plan-scoped ready step dispatcher"], [schedulerService, "runDigitalEmployeePlanReadyStepsNow", "manual plan-scoped ready step dispatcher"],
[schedulerService, "acquireDigitalEmployeePlanStepLease", "scheduler acquire plan step lease"],
[schedulerService, "releaseDigitalEmployeePlanStepLease", "scheduler release plan step lease"],
[schedulerService, "triggerSource", "ready step dispatch trigger source"], [schedulerService, "triggerSource", "ready step dispatch trigger source"],
[schedulerService, "readDigitalEmployeePlanStepDispatchPolicy", "scheduler plan step dispatch policy reader"], [schedulerService, "readDigitalEmployeePlanStepDispatchPolicy", "scheduler plan step dispatch policy reader"],
[schedulerService, "max_per_sweep", "scheduler max per sweep policy"], [schedulerService, "max_per_sweep", "scheduler max per sweep policy"],
[ipcHandlers, "digitalEmployee:dispatchPlanReadySteps", "IPC plan-scoped ready step dispatch handler"], [ipcHandlers, "digitalEmployee:dispatchPlanReadySteps", "IPC plan-scoped ready step dispatch handler"],
[preload, "dispatchPlanReadySteps", "preload plan-scoped ready step bridge"], [preload, "dispatchPlanReadySteps", "preload plan-scoped ready step bridge"],
[syncService, "planStepBusinessView", "plan step sync business view"], [syncService, "planStepBusinessView", "plan step sync business view"],
[syncService, "lease_active", "plan step sync lease active field"],
[syncService, "planStepLeaseBusinessView", "plan step lease sync event view"],
[syncService, "plan_step_lease_acquired", "plan step lease acquired sync event kind"],
[syncService, "trigger_source", "plan step dispatch sync trigger source"], [syncService, "trigger_source", "plan step dispatch sync trigger source"],
[syncService, "orchestration_id", "plan step dispatch sync orchestration id"], [syncService, "orchestration_id", "plan step dispatch sync orchestration id"],
[syncService, "planStepDispatchPolicyBusinessView", "plan step dispatch policy sync view"], [syncService, "planStepDispatchPolicyBusinessView", "plan step dispatch policy sync view"],

View File

@@ -7,6 +7,8 @@ const mockState = vi.hoisted(() => ({
finishedRuns: [] as Array<Record<string, unknown>>, finishedRuns: [] as Array<Record<string, unknown>>,
dispatchEvents: [] as Array<Record<string, unknown>>, dispatchEvents: [] as Array<Record<string, unknown>>,
governanceCommands: [] as Array<Record<string, unknown>>, governanceCommands: [] as Array<Record<string, unknown>>,
acquiredLeases: [] as Array<Record<string, unknown>>,
releasedLeases: [] as Array<Record<string, unknown>>,
settings: { settings: {
enabled: true, enabled: true,
catch_up_on_startup: true, catch_up_on_startup: true,
@@ -54,6 +56,25 @@ vi.mock("./stateService", () => ({
mockState.dispatchEvents.push(input); mockState.dispatchEvents.push(input);
return { eventId: `dispatch-${input.status}`, kind: `plan_step_dispatch_${input.status}`, payload: input }; return { eventId: `dispatch-${input.status}`, kind: `plan_step_dispatch_${input.status}`, payload: input };
}, },
acquireDigitalEmployeePlanStepLease: (input: Record<string, unknown>) => {
mockState.acquiredLeases.push(input);
const candidate = mockState.readyCandidates.find((item) => item.stepId === input.stepId);
if (candidate?.leaseActive || candidate?.skipReason === "lease_active") {
return {
ok: false,
reason: "lease_active",
lease: { lease_id: `lease-${input.stepId}`, state: "active", owner_device_id: "device-remote", acquired_at: "2026-06-07T00:59:00.000Z", expires_at: "2026-06-07T01:04:00.000Z" },
};
}
return {
ok: true,
lease: { lease_id: `lease-${input.stepId}`, state: "active", owner_device_id: "device-001", acquired_at: input.occurredAt, expires_at: "2026-06-07T01:05:10.000Z" },
};
},
releaseDigitalEmployeePlanStepLease: (input: Record<string, unknown>) => {
mockState.releasedLeases.push(input);
return { ok: true, lease: { lease_id: input.leaseId, state: "released", owner_device_id: "device-001", acquired_at: "2026-06-07T01:00:10.000Z", expires_at: "2026-06-07T01:05:10.000Z", released_at: input.occurredAt, release_reason: input.reason } };
},
recordDigitalEmployeeGovernanceCommand: (input: Record<string, unknown>) => { recordDigitalEmployeeGovernanceCommand: (input: Record<string, unknown>) => {
mockState.governanceCommands.push(input); mockState.governanceCommands.push(input);
return { return {
@@ -72,6 +93,8 @@ describe("digital employee scheduler service", () => {
mockState.finishedRuns = []; mockState.finishedRuns = [];
mockState.dispatchEvents = []; mockState.dispatchEvents = [];
mockState.governanceCommands = []; mockState.governanceCommands = [];
mockState.acquiredLeases = [];
mockState.releasedLeases = [];
mockState.dispatchPolicy = { mockState.dispatchPolicy = {
enabled: true, enabled: true,
max_per_sweep: 3, max_per_sweep: 3,
@@ -156,6 +179,8 @@ describe("digital employee scheduler service", () => {
expect.objectContaining({ method: "POST" }), expect.objectContaining({ method: "POST" }),
); );
expect(mockState.dispatchEvents.map((event) => event.status)).toEqual(["started", "completed"]); expect(mockState.dispatchEvents.map((event) => event.status)).toEqual(["started", "completed"]);
expect(mockState.acquiredLeases).toEqual([expect.objectContaining({ stepId: "step-ready", triggerSource: "auto" })]);
expect(mockState.releasedLeases).toEqual([expect.objectContaining({ stepId: "step-ready", leaseId: "lease-step-ready", reason: "completed" })]);
expect(mockState.governanceCommands[0]).toMatchObject({ expect(mockState.governanceCommands[0]).toMatchObject({
commandKind: "start_run", commandKind: "start_run",
planId: "plan-1", planId: "plan-1",
@@ -179,9 +204,24 @@ describe("digital employee scheduler service", () => {
expect(result.readyStepDispatch).toMatchObject({ candidates: 1, dispatched: 0, failed: 1 }); 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(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.dispatchEvents.map((event) => event.status)).toEqual(["started", "failed"]);
expect(mockState.releasedLeases).toEqual([expect.objectContaining({ stepId: "step-ready", leaseId: "lease-step-ready", reason: "failed" })]);
expect(mockState.governanceCommands).toHaveLength(0); expect(mockState.governanceCommands).toHaveLength(0);
}); });
it("skips ready plan steps with active leases", async () => {
mockState.schedules = [];
mockState.readyCandidates = [readyStepCandidate({ dispatchable: false, skipReason: "lease_active", leaseActive: true })];
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, skipped: 1, failed: 0 });
expect(fetch).not.toHaveBeenCalled();
expect(mockState.acquiredLeases).toEqual([expect.objectContaining({ stepId: "step-ready", triggerSource: "auto" })]);
expect(mockState.releasedLeases).toHaveLength(0);
expect(mockState.dispatchEvents).toHaveLength(0);
});
it("honors disabled ready step dispatch policy", async () => { it("honors disabled ready step dispatch policy", async () => {
mockState.readyCandidates = [readyStepCandidate()]; mockState.readyCandidates = [readyStepCandidate()];
mockState.dispatchPolicy = { ...mockState.dispatchPolicy, enabled: false }; mockState.dispatchPolicy = { ...mockState.dispatchPolicy, enabled: false };

View File

@@ -2,6 +2,7 @@ import log from "electron-log";
import { LOCALHOST_HOSTNAME } from "../constants"; import { LOCALHOST_HOSTNAME } from "../constants";
import { getConfiguredPorts } from "../startupPorts"; import { getConfiguredPorts } from "../startupPorts";
import { import {
acquireDigitalEmployeePlanStepLease,
finishDigitalEmployeeScheduleRun, finishDigitalEmployeeScheduleRun,
listDigitalEmployeeReadyPlanStepDispatchCandidates, listDigitalEmployeeReadyPlanStepDispatchCandidates,
listDigitalEmployeeSchedules, listDigitalEmployeeSchedules,
@@ -12,7 +13,9 @@ import {
recordDigitalEmployeeGovernanceCommand, recordDigitalEmployeeGovernanceCommand,
recordDigitalEmployeePlanStepDispatch, recordDigitalEmployeePlanStepDispatch,
recordDigitalEmployeeScheduleRunStarted, recordDigitalEmployeeScheduleRunStarted,
releaseDigitalEmployeePlanStepLease,
upsertDigitalEmployeeSchedule, upsertDigitalEmployeeSchedule,
type DigitalEmployeePlanStepLease,
type DigitalEmployeeReadyPlanStepDispatchCandidate, type DigitalEmployeeReadyPlanStepDispatchCandidate,
type DigitalEmployeeScheduleRecord, type DigitalEmployeeScheduleRecord,
} from "./stateService"; } from "./stateService";
@@ -164,6 +167,17 @@ export async function dispatchReadyPlanSteps(
let attempts = 0; let attempts = 0;
for (const candidate of candidates) { for (const candidate of candidates) {
if (!candidate.dispatchable || !candidate.taskId) { if (!candidate.dispatchable || !candidate.taskId) {
if (candidate.skipReason === "lease_active") {
acquireDigitalEmployeePlanStepLease({
stepId: candidate.stepId,
planId: candidate.planId,
taskId: candidate.taskId,
triggerSource,
orchestrationId,
candidateCount: candidates.length,
occurredAt: now.toISOString(),
});
}
result.skipped += 1; result.skipped += 1;
result.skipped_step_ids?.push(candidate.stepId); result.skipped_step_ids?.push(candidate.stepId);
continue; continue;
@@ -173,9 +187,23 @@ export async function dispatchReadyPlanSteps(
result.skipped_step_ids?.push(candidate.stepId); result.skipped_step_ids?.push(candidate.stepId);
continue; continue;
} }
attempts += 1; const leaseResult = acquireDigitalEmployeePlanStepLease({
stepId: candidate.stepId,
planId: candidate.planId,
taskId: candidate.taskId,
triggerSource,
orchestrationId,
candidateCount: candidates.length,
occurredAt: now.toISOString(),
});
if (!leaseResult.ok || !leaseResult.lease) {
result.skipped += 1;
result.skipped_step_ids?.push(candidate.stepId);
continue;
}
try { try {
await dispatchReadyPlanStep(candidate, now, { triggerSource, orchestrationId, candidateCount: candidates.length }); attempts += 1;
await dispatchReadyPlanStep(candidate, now, { triggerSource, orchestrationId, candidateCount: candidates.length }, leaseResult.lease);
result.dispatched += 1; result.dispatched += 1;
result.dispatched_step_ids?.push(candidate.stepId); result.dispatched_step_ids?.push(candidate.stepId);
} catch (error) { } catch (error) {
@@ -183,6 +211,13 @@ export async function dispatchReadyPlanSteps(
result.failed += 1; result.failed += 1;
result.failed_step_ids?.push(candidate.stepId); result.failed_step_ids?.push(candidate.stepId);
result.errors.push({ stepId: candidate.stepId, error: message }); result.errors.push({ stepId: candidate.stepId, error: message });
releaseDigitalEmployeePlanStepLease({
stepId: candidate.stepId,
leaseId: leaseResult.lease.lease_id,
reason: "failed",
taskId: candidate.taskId,
occurredAt: now.toISOString(),
});
recordDigitalEmployeePlanStepDispatch({ recordDigitalEmployeePlanStepDispatch({
stepId: candidate.stepId, stepId: candidate.stepId,
planId: candidate.planId, planId: candidate.planId,
@@ -215,6 +250,7 @@ async function dispatchReadyPlanStep(
candidate: DigitalEmployeeReadyPlanStepDispatchCandidate, candidate: DigitalEmployeeReadyPlanStepDispatchCandidate,
now: Date, now: Date,
orchestration: { triggerSource: DigitalEmployeeReadyStepDispatchTriggerSource; orchestrationId: string; candidateCount: number }, orchestration: { triggerSource: DigitalEmployeeReadyStepDispatchTriggerSource; orchestrationId: string; candidateCount: number },
lease: DigitalEmployeePlanStepLease,
): Promise<void> { ): Promise<void> {
const occurredAt = now.toISOString(); const occurredAt = now.toISOString();
const commandId = `ready-step-${safeId(candidate.stepId)}-${safeId(occurredAt)}`; const commandId = `ready-step-${safeId(candidate.stepId)}-${safeId(occurredAt)}`;
@@ -276,6 +312,14 @@ async function dispatchReadyPlanStep(
}, },
occurredAt, occurredAt,
}); });
releaseDigitalEmployeePlanStepLease({
stepId: candidate.stepId,
leaseId: lease.lease_id,
reason: "completed",
taskId: candidate.taskId,
runId: ids.runId,
occurredAt,
});
} }
export async function runDigitalEmployeeScheduleNow( export async function runDigitalEmployeeScheduleNow(

View File

@@ -16,6 +16,10 @@ vi.mock("../../db", () => ({
writeSetting: (key: string, value: unknown) => mockState.settings.set(key, value), writeSetting: (key: string, value: unknown) => mockState.settings.set(key, value),
})); }));
vi.mock("../system/deviceId", () => ({
getDeviceId: vi.fn(() => "device-001"),
}));
describe("digital employee state service", () => { describe("digital employee state service", () => {
beforeEach(() => { beforeEach(() => {
vi.resetModules(); vi.resetModules();
@@ -1405,6 +1409,71 @@ describe("digital employee state service", () => {
]); ]);
}); });
it("acquires, respects, expires, and releases ready plan step leases", async () => {
const {
acquireDigitalEmployeePlanStepLease,
activeDigitalEmployeePlanStepLease,
listDigitalEmployeeReadyPlanStepDispatchCandidates,
releaseDigitalEmployeePlanStepLease,
} = await import("./stateService");
createGovernableTask({ taskStatus: "pending", stepStatus: "ready", runStatus: "completed", finishedAt: "2026-06-07T07:20:00.000Z" });
const acquired = acquireDigitalEmployeePlanStepLease({
stepId: "step-governance",
planId: "plan-governance",
taskId: "task-governance",
triggerSource: "manual",
orchestrationId: "manual-ready-steps-plan-governance",
candidateCount: 1,
occurredAt: "2026-06-07T08:10:00.000Z",
});
expect(acquired).toMatchObject({ ok: true, lease: expect.objectContaining({ state: "active", owner_device_id: "device-001" }) });
const leasedPayload = JSON.parse(mockState.db?.planSteps.get("step-governance")?.payload ?? "{}");
expect(activeDigitalEmployeePlanStepLease(leasedPayload, "2026-06-07T08:11:00.000Z")).toMatchObject({ lease_id: acquired.lease?.lease_id });
expect(listDigitalEmployeeReadyPlanStepDispatchCandidates()[0]).toMatchObject({ dispatchable: false, skipReason: "lease_active" });
expect(mockState.db?.outbox.get("plan_step:upsert:step-governance")).toMatchObject({ entity_type: "plan_step", status: "pending" });
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "plan_step_lease_acquired", plan_id: "plan-governance", task_id: "task-governance" }),
]));
const blocked = acquireDigitalEmployeePlanStepLease({
stepId: "step-governance",
planId: "plan-governance",
taskId: "task-governance",
occurredAt: "2026-06-07T08:11:00.000Z",
});
expect(blocked).toMatchObject({ ok: false, reason: "lease_active", lease: expect.objectContaining({ lease_id: acquired.lease?.lease_id }) });
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "plan_step_lease_skipped" }),
]));
const overwritten = acquireDigitalEmployeePlanStepLease({
stepId: "step-governance",
planId: "plan-governance",
taskId: "task-governance",
occurredAt: "2026-06-07T08:16:01.000Z",
});
expect(overwritten).toMatchObject({ ok: true, lease: expect.objectContaining({ state: "active" }) });
expect(overwritten.lease?.lease_id).not.toBe(acquired.lease?.lease_id);
const released = releaseDigitalEmployeePlanStepLease({
stepId: "step-governance",
leaseId: overwritten.lease?.lease_id,
reason: "completed",
taskId: "task-governance",
runId: "run-dispatch",
occurredAt: "2026-06-07T08:17:00.000Z",
});
expect(released).toMatchObject({ ok: true, lease: expect.objectContaining({ state: "released", release_reason: "completed" }) });
expect(activeDigitalEmployeePlanStepLease(JSON.parse(mockState.db?.planSteps.get("step-governance")?.payload ?? "{}"), "2026-06-07T08:17:01.000Z")).toBeNull();
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "plan_step_lease_released", run_id: "run-dispatch" }),
]));
});
it("records ready plan step dispatch events and updates step status", async () => { it("records ready plan step dispatch events and updates step status", async () => {
const { recordDigitalEmployeePlanStepDispatch } = await import("./stateService"); const { recordDigitalEmployeePlanStepDispatch } = await import("./stateService");
@@ -2431,6 +2500,20 @@ class TestDb {
return { changes: previous ? 1 : 0 }; return { changes: previous ? 1 : 0 };
} }
if (sql.startsWith("UPDATE digital_plan_steps SET payload")) {
const [payload, updatedAt, id] = args as [string, string, string];
const previous = this.planSteps.get(id);
if (previous) {
this.planSteps.set(id, {
...previous,
payload,
updated_at: updatedAt,
sync_status: "pending",
});
}
return { changes: previous ? 1 : 0 };
}
if (sql.startsWith("UPDATE digital_plan_steps")) { if (sql.startsWith("UPDATE digital_plan_steps")) {
const [status, payload, updatedAt, id] = args as [string, string, string, string]; const [status, payload, updatedAt, id] = args as [string, string, string, string];
const previous = this.planSteps.get(id); const previous = this.planSteps.get(id);

View File

@@ -1,8 +1,10 @@
import { ensureDigitalEmployeeSchema, getDb, readSetting, writeSetting } from "../../db"; import { ensureDigitalEmployeeSchema, getDb, readSetting, writeSetting } from "../../db";
import { getDeviceId } from "../system/deviceId";
const STATE_KEY = "digital_employee_state_v1"; const STATE_KEY = "digital_employee_state_v1";
const UI_STATE_KEY = "digital_employee_ui_state_v1"; const UI_STATE_KEY = "digital_employee_ui_state_v1";
const MAX_EVENTS = 200; const MAX_EVENTS = 200;
const PLAN_STEP_LEASE_TTL_MS = 5 * 60 * 1000;
export interface DigitalEmployeeServiceStatus { export interface DigitalEmployeeServiceStatus {
running?: boolean; running?: boolean;
@@ -304,6 +306,37 @@ export interface DigitalEmployeeReadyPlanStepDispatchCandidate {
plan?: DigitalEmployeePlanRecord | null; plan?: DigitalEmployeePlanRecord | null;
} }
export interface DigitalEmployeePlanStepLease {
lease_id: string;
state: "active" | "released" | "expired";
owner_device_id: string;
acquired_at: string;
expires_at: string;
released_at?: string | null;
release_reason?: string | null;
trigger_source?: string | null;
orchestration_id?: string | null;
candidate_count?: number | null;
}
export interface DigitalEmployeePlanStepLeaseResult {
ok: boolean;
lease?: DigitalEmployeePlanStepLease;
reason?: string | null;
}
export interface DigitalEmployeePlanStepLeaseInput {
stepId: string;
planId?: string | null;
taskId?: string | null;
runId?: string | null;
triggerSource?: string | null;
orchestrationId?: string | null;
candidateCount?: number | null;
occurredAt?: string | null;
ttlMs?: number | null;
}
export interface DigitalEmployeePlanStepDispatchInput { export interface DigitalEmployeePlanStepDispatchInput {
stepId: string; stepId: string;
planId?: string | null; planId?: string | null;
@@ -1384,6 +1417,124 @@ export function recordDigitalEmployeeApprovalAction(
return { eventId, kind, payload }; return { eventId, kind, payload };
} }
export function activeDigitalEmployeePlanStepLease(
payload: unknown,
now = new Date().toISOString(),
): DigitalEmployeePlanStepLease | null {
const record = objectRecord(payload) ?? {};
const lease = objectRecord(record.dispatchLease ?? record.dispatch_lease);
if (!lease) return null;
const state = stringValue(lease.state) || "active";
const expiresAt = stringValue(lease.expires_at ?? lease.expiresAt);
if (state !== "active" || !expiresAt || expiresAt <= now) return null;
const leaseId = stringValue(lease.lease_id ?? lease.leaseId);
const ownerDeviceId = stringValue(lease.owner_device_id ?? lease.ownerDeviceId);
if (!leaseId || !ownerDeviceId) return null;
return {
lease_id: leaseId,
state: "active",
owner_device_id: ownerDeviceId,
acquired_at: stringValue(lease.acquired_at ?? lease.acquiredAt) || now,
expires_at: expiresAt,
released_at: stringValue(lease.released_at ?? lease.releasedAt) || null,
release_reason: stringValue(lease.release_reason ?? lease.releaseReason) || null,
trigger_source: stringValue(lease.trigger_source ?? lease.triggerSource) || null,
orchestration_id: stringValue(lease.orchestration_id ?? lease.orchestrationId) || null,
candidate_count: numberValue(lease.candidate_count ?? lease.candidateCount),
};
}
export function acquireDigitalEmployeePlanStepLease(
input: DigitalEmployeePlanStepLeaseInput,
): DigitalEmployeePlanStepLeaseResult {
const db = getDigitalEmployeeDb();
if (!db) return { ok: false, reason: "database_unavailable" };
const now = input.occurredAt || new Date().toISOString();
const stepId = stringValue(input.stepId);
if (!stepId) return { ok: false, reason: "missing_step" };
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 { ok: false, reason: "missing_step" };
const previousPayload = objectRecord(parseStoredPayload(row.payload)) ?? {};
const activeLease = activeDigitalEmployeePlanStepLease(previousPayload, now);
if (activeLease) {
recordDigitalEmployeePlanStepLeaseEvent({ row, lease: activeLease, kind: "plan_step_lease_skipped", reason: "lease_active", now, taskId: input.taskId, runId: input.runId });
return { ok: false, lease: activeLease, reason: "lease_active" };
}
const ttlMs = Math.max(60_000, Math.min(Number(input.ttlMs) || PLAN_STEP_LEASE_TTL_MS, 30 * 60 * 1000));
const lease: DigitalEmployeePlanStepLease = {
lease_id: `plan-step-lease:${safeId(stepId)}:${safeId(now)}`,
state: "active",
owner_device_id: getDeviceId(),
acquired_at: now,
expires_at: new Date(new Date(now).getTime() + ttlMs).toISOString(),
trigger_source: stringValue(input.triggerSource) || null,
orchestration_id: stringValue(input.orchestrationId) || null,
candidate_count: numberValue(input.candidateCount),
};
const payload = { ...previousPayload, dispatchLease: lease };
db.prepare(`
UPDATE digital_plan_steps
SET payload = ?, sync_status = 'pending', updated_at = ?
WHERE id = ?
`).run(stringify(payload), now, stepId);
markPlanStepLeaseOutbox(row, payload, now);
recordDigitalEmployeePlanStepLeaseEvent({ row, lease, kind: "plan_step_lease_acquired", now, taskId: input.taskId, runId: input.runId });
return { ok: true, lease };
}
export function releaseDigitalEmployeePlanStepLease(input: {
stepId: string;
leaseId?: string | null;
reason?: string | null;
taskId?: string | null;
runId?: string | null;
occurredAt?: string | null;
}): DigitalEmployeePlanStepLeaseResult {
const db = getDigitalEmployeeDb();
if (!db) return { ok: false, reason: "database_unavailable" };
const now = input.occurredAt || new Date().toISOString();
const stepId = stringValue(input.stepId);
if (!stepId) return { ok: false, reason: "missing_step" };
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 { ok: false, reason: "missing_step" };
const previousPayload = objectRecord(parseStoredPayload(row.payload)) ?? {};
const currentLease = objectRecord(previousPayload.dispatchLease ?? previousPayload.dispatch_lease);
const currentLeaseId = stringValue(currentLease?.lease_id ?? currentLease?.leaseId);
const expectedLeaseId = stringValue(input.leaseId);
if (!currentLease || (expectedLeaseId && currentLeaseId !== expectedLeaseId)) {
return { ok: false, reason: "lease_mismatch" };
}
const lease: DigitalEmployeePlanStepLease = {
lease_id: currentLeaseId || `plan-step-lease:${safeId(stepId)}:${safeId(now)}`,
state: "released",
owner_device_id: stringValue(currentLease.owner_device_id ?? currentLease.ownerDeviceId) || getDeviceId(),
acquired_at: stringValue(currentLease.acquired_at ?? currentLease.acquiredAt) || now,
expires_at: stringValue(currentLease.expires_at ?? currentLease.expiresAt) || now,
released_at: now,
release_reason: stringValue(input.reason) || "completed",
trigger_source: stringValue(currentLease.trigger_source ?? currentLease.triggerSource) || null,
orchestration_id: stringValue(currentLease.orchestration_id ?? currentLease.orchestrationId) || null,
candidate_count: numberValue(currentLease.candidate_count ?? currentLease.candidateCount),
};
const payload = { ...previousPayload, dispatchLease: lease };
db.prepare(`
UPDATE digital_plan_steps
SET payload = ?, sync_status = 'pending', updated_at = ?
WHERE id = ?
`).run(stringify(payload), now, stepId);
markPlanStepLeaseOutbox(row, payload, now);
recordDigitalEmployeePlanStepLeaseEvent({ row, lease, kind: "plan_step_lease_released", reason: lease.release_reason, now, taskId: input.taskId, runId: input.runId });
return { ok: true, lease };
}
export function listDigitalEmployeeReadyPlanStepDispatchCandidates( export function listDigitalEmployeeReadyPlanStepDispatchCandidates(
options: number | { limit?: number; planId?: string | null } = 20, options: number | { limit?: number; planId?: string | null } = 20,
): DigitalEmployeeReadyPlanStepDispatchCandidate[] { ): DigitalEmployeeReadyPlanStepDispatchCandidate[] {
@@ -1408,6 +1559,7 @@ export function listDigitalEmployeeReadyPlanStepDispatchCandidates(
.map((run) => stringValue(run.taskId)) .map((run) => stringValue(run.taskId))
.filter(Boolean)); .filter(Boolean));
const openApprovals = records.approvals.filter((approval) => isOpenApprovalStatus(approval.status)); const openApprovals = records.approvals.filter((approval) => isOpenApprovalStatus(approval.status));
const now = new Date().toISOString();
return records.planSteps return records.planSteps
.filter((step) => !planIdFilter || step.planId === planIdFilter) .filter((step) => !planIdFilter || step.planId === planIdFilter)
@@ -1431,6 +1583,8 @@ export function listDigitalEmployeeReadyPlanStepDispatchCandidates(
|| stringValue(payload.stepId ?? payload.step_id) === step.id; || stringValue(payload.stepId ?? payload.step_id) === step.id;
}); });
const skipReason = readyPlanStepSkipReason({ step, task, activeRunsByTaskId, approvalOpen }); const skipReason = readyPlanStepSkipReason({ step, task, activeRunsByTaskId, approvalOpen });
const lease = activeDigitalEmployeePlanStepLease(stepPayload, now);
const effectiveSkipReason = lease ? "lease_active" : skipReason;
const title = step.title || task?.title || step.id; const title = step.title || task?.title || step.id;
const prompt = stringValue(stepPayload.prompt ?? stepPayload.description) const prompt = stringValue(stepPayload.prompt ?? stepPayload.description)
|| stringValue(taskPayload.prompt ?? taskPayload.description) || stringValue(taskPayload.prompt ?? taskPayload.description)
@@ -1443,8 +1597,8 @@ export function listDigitalEmployeeReadyPlanStepDispatchCandidates(
taskTitle: task?.title ?? null, taskTitle: task?.title ?? null,
prompt, prompt,
preferredSkillIds, preferredSkillIds,
dispatchable: !skipReason, dispatchable: !effectiveSkipReason,
skipReason, skipReason: effectiveSkipReason,
step, step,
task, task,
plan, plan,
@@ -1578,6 +1732,66 @@ function updatePlanStepDispatchStatus(
}, now); }, now);
} }
function markPlanStepLeaseOutbox(row: Record<string, unknown>, payload: Record<string, unknown>, now: string): void {
const stepId = stringValue(row.id);
markOutbox("plan_step", stepId, "upsert", {
id: stepId,
planId: stringValue(row.plan_id),
title: stringValue(row.title),
status: stringValue(row.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 recordDigitalEmployeePlanStepLeaseEvent(input: {
row: Record<string, unknown>;
lease: DigitalEmployeePlanStepLease;
kind: "plan_step_lease_acquired" | "plan_step_lease_released" | "plan_step_lease_skipped";
reason?: string | null;
now: string;
taskId?: string | null;
runId?: string | null;
}): void {
const stepId = stringValue(input.row.id);
const planId = stringValue(input.row.plan_id) || null;
const taskId = stringValue(input.taskId) || null;
const payload = {
source: "qimingclaw-plan-step-lease",
step_id: stepId,
plan_id: planId,
task_id: taskId,
run_id: stringValue(input.runId) || null,
lease: input.lease,
lease_id: input.lease.lease_id,
lease_state: input.lease.state,
lease_owner_device_id: input.lease.owner_device_id,
lease_expires_at: input.lease.expires_at,
reason: stringValue(input.reason) || input.lease.release_reason || null,
};
insertEvent(
{
event_id: `${stepId}:lease:${input.kind}:${safeId(input.lease.lease_id)}:${safeId(input.now)}`,
kind: input.kind,
occurred_at: input.now,
message: planStepLeaseMessage(input.kind, stepId, stringValue(input.reason) || input.lease.release_reason),
plan_id: planId,
task_id: taskId,
},
stringValue(input.runId) || null,
payload,
);
}
function planStepLeaseMessage(kind: string, stepId: string, reason?: string | null): string {
if (kind === "plan_step_lease_acquired") return `计划步骤租约已获取:${stepId}`;
if (kind === "plan_step_lease_released") return `计划步骤租约已释放:${stepId}${reason ? `${reason}` : ""}`;
return `计划步骤租约跳过派发:${stepId}${reason ? `${reason}` : ""}`;
}
function planStepDispatchMessage(status: string, stepId: string, reason: string): string { function planStepDispatchMessage(status: string, stepId: string, reason: string): string {
if (status === "started") return `计划步骤已自动派发:${stepId}`; if (status === "started") return `计划步骤已自动派发:${stepId}`;
if (status === "completed") return `计划步骤派发已触发执行:${stepId}`; if (status === "completed") return `计划步骤派发已触发执行:${stepId}`;

View File

@@ -342,6 +342,16 @@ describe("digital employee sync service", () => {
}, },
lastDispatch: { status: "failed" }, lastDispatch: { status: "failed" },
skipReason: "approval_pending", skipReason: "approval_pending",
dispatchLease: {
lease_id: "lease-step-1",
state: "active",
owner_device_id: "device-001",
acquired_at: "2026-06-07T07:59:00.000Z",
expires_at: "2026-06-07T08:04:00.000Z",
trigger_source: "manual",
orchestration_id: "manual-ready-steps-plan-1",
candidate_count: 1,
},
}, },
}), }),
); );
@@ -382,8 +392,14 @@ describe("digital employee sync service", () => {
waiting_dependency_count: 1, waiting_dependency_count: 1,
preferred_skill_ids: ["qimingclaw-mcp-tool-crm-crm-search"], preferred_skill_ids: ["qimingclaw-mcp-tool-crm-crm-search"],
dispatchable: false, dispatchable: false,
skip_reason: "approval_pending", skip_reason: "lease_active",
last_dispatch_status: "failed", last_dispatch_status: "failed",
lease_id: "lease-step-1",
lease_state: "active",
lease_owner_device_id: "device-001",
lease_acquired_at: "2026-06-07T07:59:00.000Z",
lease_expires_at: "2026-06-07T08:04:00.000Z",
lease_active: true,
}), }),
}), }),
], ],
@@ -567,6 +583,26 @@ describe("digital employee sync service", () => {
trigger_source: "manual", trigger_source: "manual",
orchestration_id: "manual-ready-steps-plan-1", orchestration_id: "manual-ready-steps-plan-1",
}); });
insertEventEntity("event-lease", "plan_step_lease_released", {
step_id: "step-1",
plan_id: "plan-1",
task_id: "task-1",
run_id: "run-1",
lease_id: "lease-step-1",
lease_state: "released",
lease_owner_device_id: "device-001",
lease_expires_at: "2026-06-07T08:04:00.000Z",
reason: "completed",
lease: {
lease_id: "lease-step-1",
state: "released",
owner_device_id: "device-001",
acquired_at: "2026-06-07T07:59:00.000Z",
expires_at: "2026-06-07T08:04:00.000Z",
released_at: "2026-06-07T08:01:00.000Z",
release_reason: "completed",
},
});
insertEventEntity("event-plan-step-ready", "plan_step_ready", { insertEventEntity("event-plan-step-ready", "plan_step_ready", {
stepId: "step-2", stepId: "step-2",
planId: "plan-1", planId: "plan-1",
@@ -712,6 +748,7 @@ describe("digital employee sync service", () => {
{ entity_type: "event", entity_id: "event-artifact" }, { entity_type: "event", entity_id: "event-artifact" },
{ entity_type: "event", entity_id: "event-skill" }, { entity_type: "event", entity_id: "event-skill" },
{ entity_type: "event", entity_id: "event-dispatch" }, { entity_type: "event", entity_id: "event-dispatch" },
{ entity_type: "event", entity_id: "event-lease" },
{ entity_type: "event", entity_id: "event-plan-step-ready" }, { entity_type: "event", entity_id: "event-plan-step-ready" },
{ entity_type: "event", entity_id: "event-governance-input" }, { entity_type: "event", entity_id: "event-governance-input" },
{ entity_type: "event", entity_id: "event-notification-read" }, { entity_type: "event", entity_id: "event-notification-read" },
@@ -800,6 +837,21 @@ describe("digital employee sync service", () => {
trigger_source: "manual", trigger_source: "manual",
orchestration_id: "manual-ready-steps-plan-1", orchestration_id: "manual-ready-steps-plan-1",
}); });
expect(businessView("event-lease")).toMatchObject({
category: "dispatch",
step_id: "step-1",
plan_id: "plan-1",
task_id: "task-1",
run_id: "run-1",
lease_id: "lease-step-1",
lease_state: "released",
lease_owner_device_id: "device-001",
lease_acquired_at: "2026-06-07T07:59:00.000Z",
lease_expires_at: "2026-06-07T08:04:00.000Z",
lease_released_at: "2026-06-07T08:01:00.000Z",
lease_release_reason: "completed",
reason: "completed",
});
expect(businessView("event-plan-step-ready")).toMatchObject({ expect(businessView("event-plan-step-ready")).toMatchObject({
category: "dispatch", category: "dispatch",
step_id: "step-2", step_id: "step-2",

View File

@@ -508,9 +508,14 @@ function planStepBusinessView(
const payload = objectRecord(record.payload) ?? {}; const payload = objectRecord(record.payload) ?? {};
const dependencyGraph = objectRecord(payload.dependencyGraph ?? payload.dependency_graph) ?? {}; const dependencyGraph = objectRecord(payload.dependencyGraph ?? payload.dependency_graph) ?? {};
const lastDispatch = objectRecord(payload.lastDispatch ?? payload.last_dispatch) ?? {}; const lastDispatch = objectRecord(payload.lastDispatch ?? payload.last_dispatch) ?? {};
const dispatchLease = objectRecord(payload.dispatchLease ?? payload.dispatch_lease) ?? {};
const dependsOn = stringArrayField(record.dependsOn ?? record.depends_on); const dependsOn = stringArrayField(record.dependsOn ?? record.depends_on);
const blockedDependencies = stringArrayField(dependencyGraph.blockedDependencies ?? dependencyGraph.blocked_dependencies); const blockedDependencies = stringArrayField(dependencyGraph.blockedDependencies ?? dependencyGraph.blocked_dependencies);
const waitingDependencies = stringArrayField(dependencyGraph.waitingDependencies ?? dependencyGraph.waiting_dependencies); const waitingDependencies = stringArrayField(dependencyGraph.waitingDependencies ?? dependencyGraph.waiting_dependencies);
const leaseState = stringField(dispatchLease.state) || null;
const leaseExpiresAt = stringField(dispatchLease.expires_at ?? dispatchLease.expiresAt) || null;
const leaseActive = leaseState === "active" && leaseExpiresAt !== null && leaseExpiresAt > new Date().toISOString();
const skipReason = stringField(payload.skipReason ?? payload.skip_reason) || null;
return { return {
...base, ...base,
plan_id: stringField(record.planId ?? record.plan_id) || null, plan_id: stringField(record.planId ?? record.plan_id) || null,
@@ -524,9 +529,18 @@ function planStepBusinessView(
waiting_dependencies: waitingDependencies, waiting_dependencies: waitingDependencies,
waiting_dependency_count: waitingDependencies.length, waiting_dependency_count: waitingDependencies.length,
preferred_skill_ids: stringArrayField(record.preferredSkillIds ?? record.preferred_skill_ids), preferred_skill_ids: stringArrayField(record.preferredSkillIds ?? record.preferred_skill_ids),
dispatchable: stringField(record.status) === "ready" && blockedDependencies.length === 0 && waitingDependencies.length === 0, dispatchable: !leaseActive && stringField(record.status) === "ready" && blockedDependencies.length === 0 && waitingDependencies.length === 0,
skip_reason: stringField(payload.skipReason ?? payload.skip_reason) || null, skip_reason: leaseActive ? "lease_active" : skipReason,
last_dispatch_status: stringField(lastDispatch.status) || null, last_dispatch_status: stringField(lastDispatch.status) || null,
lease: Object.keys(dispatchLease).length > 0 ? dispatchLease : null,
lease_id: stringField(dispatchLease.lease_id ?? dispatchLease.leaseId) || null,
lease_state: leaseState,
lease_owner_device_id: stringField(dispatchLease.owner_device_id ?? dispatchLease.ownerDeviceId) || null,
lease_acquired_at: stringField(dispatchLease.acquired_at ?? dispatchLease.acquiredAt) || null,
lease_expires_at: leaseExpiresAt,
lease_released_at: stringField(dispatchLease.released_at ?? dispatchLease.releasedAt) || null,
lease_release_reason: stringField(dispatchLease.release_reason ?? dispatchLease.releaseReason) || null,
lease_active: leaseActive,
}; };
} }
@@ -590,6 +604,7 @@ function eventSpecificBusinessView(
if (kind.startsWith("skill_call_")) return skillCallBusinessView(payload); if (kind.startsWith("skill_call_")) return skillCallBusinessView(payload);
if (isPlanStepDependencyEvent(kind)) return planStepDependencyBusinessView(payload); if (isPlanStepDependencyEvent(kind)) return planStepDependencyBusinessView(payload);
if (kind.startsWith("plan_step_dispatch_")) return planStepDispatchBusinessView(payload); if (kind.startsWith("plan_step_dispatch_")) return planStepDispatchBusinessView(payload);
if (isPlanStepLeaseEvent(kind)) return planStepLeaseBusinessView(payload);
if (isPlanStepDispatchPolicyEvent(kind)) return planStepDispatchPolicyBusinessView(payload); if (isPlanStepDispatchPolicyEvent(kind)) return planStepDispatchPolicyBusinessView(payload);
if (kind === "governance_provide_task_input") return governanceProvideTaskInputBusinessView(payload); if (kind === "governance_provide_task_input") return governanceProvideTaskInputBusinessView(payload);
if (isNotificationEvent(kind)) return notificationBusinessView(payload); if (isNotificationEvent(kind)) return notificationBusinessView(payload);
@@ -698,6 +713,24 @@ function planStepDispatchBusinessView(payload: Record<string, unknown>): Record<
}; };
} }
function planStepLeaseBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
const lease = objectRecord(payload.lease) ?? payload;
return {
step_id: stringField(payload.step_id ?? payload.stepId) || null,
plan_id: stringField(payload.plan_id ?? payload.planId) || null,
task_id: stringField(payload.task_id ?? payload.taskId) || null,
run_id: stringField(payload.run_id ?? payload.runId) || null,
lease_id: stringField(payload.lease_id ?? payload.leaseId ?? lease.lease_id ?? lease.leaseId) || null,
lease_state: stringField(payload.lease_state ?? payload.leaseState ?? lease.state) || null,
lease_owner_device_id: stringField(payload.lease_owner_device_id ?? payload.leaseOwnerDeviceId ?? lease.owner_device_id ?? lease.ownerDeviceId) || null,
lease_acquired_at: stringField(lease.acquired_at ?? lease.acquiredAt) || null,
lease_expires_at: stringField(payload.lease_expires_at ?? payload.leaseExpiresAt ?? lease.expires_at ?? lease.expiresAt) || null,
lease_released_at: stringField(lease.released_at ?? lease.releasedAt) || null,
lease_release_reason: stringField(lease.release_reason ?? lease.releaseReason) || null,
reason: stringField(payload.reason) || null,
};
}
function planStepDependencyBusinessView(payload: Record<string, unknown>): Record<string, unknown> { function planStepDependencyBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
const dependencyGraph = objectRecord(payload.dependencyGraph ?? payload.dependency_graph) ?? {}; const dependencyGraph = objectRecord(payload.dependencyGraph ?? payload.dependency_graph) ?? {};
const dependencies = stringArrayField(payload.dependsOn ?? payload.depends_on); const dependencies = stringArrayField(payload.dependsOn ?? payload.depends_on);
@@ -771,6 +804,7 @@ function eventBusinessCategory(kind: string): string {
if (kind.startsWith("skill_call_")) return "skill"; if (kind.startsWith("skill_call_")) return "skill";
if (isPlanStepDependencyEvent(kind)) return "dispatch"; if (isPlanStepDependencyEvent(kind)) return "dispatch";
if (kind.startsWith("plan_step_dispatch_")) return "dispatch"; if (kind.startsWith("plan_step_dispatch_")) return "dispatch";
if (isPlanStepLeaseEvent(kind)) return "dispatch";
if (isPlanStepDispatchPolicyEvent(kind)) return "dispatch"; if (isPlanStepDispatchPolicyEvent(kind)) return "dispatch";
if (isNotificationEvent(kind)) return "notification"; if (isNotificationEvent(kind)) return "notification";
if (kind.startsWith("governance_")) return "governance"; if (kind.startsWith("governance_")) return "governance";
@@ -789,6 +823,10 @@ function isPlanStepDispatchPolicyEvent(kind: string): boolean {
return kind === "digital_workday_update_plan_step_dispatch_policy"; return kind === "digital_workday_update_plan_step_dispatch_policy";
} }
function isPlanStepLeaseEvent(kind: string): boolean {
return kind === "plan_step_lease_acquired" || kind === "plan_step_lease_released" || kind === "plan_step_lease_skipped";
}
function isNotificationEvent(kind: string): boolean { function isNotificationEvent(kind: string): boolean {
return kind === "digital_workday_update_notification_preferences" return kind === "digital_workday_update_notification_preferences"
|| kind === "digital_workday_notification_read" || kind === "digital_workday_notification_read"

View File

@@ -540,9 +540,9 @@ GET /api/digital-employee/approvals
1. **PlanStep 与依赖图(依赖图调度已开始)** 1. **PlanStep 与依赖图(依赖图调度已开始)**
- 已吸收Plan / Task / Run 记录、计划模板、`create_plan` 中的 steps/tasks 可物化为任务PlanStep 已开始作为正式本地实体落入 `digital_plan_steps` - 已吸收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 并写入依赖图事件scheduler sweep 会识别可派发的 ready PlanStep调用本地 `/computer/chat` 并写入 `plan_step_dispatch_*``governance_start_run` 事件embedded 已提供 `/api/digital-employee/plan-steps``/api/digital-employee/plan-step-graph``/api/digital-employee/plans/:planId/dispatch-ready-steps``/api/plan-step/dispatch-policy`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` 事件;派发前会写入 5 分钟本地软租约 `payload.dispatchLease`,已有未过期租约的步骤会以 `lease_active` 跳过,派发完成或失败后释放租约并写入 `plan_step_lease_*` 事件;embedded 已提供 `/api/digital-employee/plan-steps``/api/digital-employee/plan-step-graph``/api/digital-employee/plans/:planId/dispatch-ready-steps``/api/plan-step/dispatch-policy`ready PlanStep 安全派发、计划级跨任务推进、管理端依赖图视图、本地派发策略、可观察租约状态和首页阻塞依赖处理入口已开始闭环。
- 后续缺口:多设备 lease 和远端策略拉取仍待吸收。 - 后续缺口:真正由管理端仲裁的跨设备强租约和远端策略拉取仍待吸收。
- 建议:下一轮围绕多设备 lease 或远端策略拉取,把本地可治理调度推进到跨端治理闭环。 - 建议:下一轮围绕管理端 lease 仲裁或远端策略拉取,把本地可治理调度推进到跨端治理闭环。
2. **调度 / 定时 / 周期任务** 2. **调度 / 定时 / 周期任务**
- 已吸收:`digital_schedules``digital_schedule_runs` 已进入 qimingclaw 本地 SQLite 与 outboxentity type 为 `schedule` / `schedule_run`;旧 `/api/cron``/api/scheduler/jobs` 和治理命令会优先读取真实本地调度记录。 - 已吸收:`digital_schedules``digital_schedule_runs` 已进入 qimingclaw 本地 SQLite 与 outboxentity type 为 `schedule` / `schedule_run`;旧 `/api/cron``/api/scheduler/jobs` 和治理命令会优先读取真实本地调度记录。