fix(skills): avoid recursive workshop recovery locks

This commit is contained in:
Vincent Koc
2026-07-29 19:02:46 +08:00
parent ee1b919c2c
commit 43e1fb0096
6 changed files with 47 additions and 10 deletions

View File

@@ -83,7 +83,10 @@ export type SkillProposalApplyTransitionDependencies = {
workspaceDir?: string,
env?: NodeJS.ProcessEnv,
agentId?: string,
config?: OpenClawConfig,
readOptions?: {
config?: OpenClawConfig;
reconcile?: boolean;
},
) => Promise<SkillProposalReadResult>;
};
@@ -113,12 +116,17 @@ export async function applySkillProposalTransition(
input: SkillProposalActionInput,
dependencies: SkillProposalApplyTransitionDependencies,
): Promise<SkillProposalApplyResult> {
const recoveryReadOptions = input.config ? { config: input.config } : undefined;
const lockedReadOptions = {
...(input.config ? { config: input.config } : {}),
reconcile: false,
};
const initial = await dependencies.readRequiredProposal(
input.proposalId,
input.workspaceDir,
input.env,
input.agentId,
input.config,
recoveryReadOptions,
);
if (initial.record.status !== "pending") {
throw new Error(
@@ -149,7 +157,7 @@ export async function applySkillProposalTransition(
input.workspaceDir,
input.env,
input.agentId,
input.config,
lockedReadOptions,
);
if (
current.record.status === "pending" &&
@@ -190,7 +198,7 @@ export async function applySkillProposalTransition(
input.workspaceDir,
input.env,
input.agentId,
input.config,
lockedReadOptions,
);
const { record, content } = read;
if (record.status !== "pending") {

View File

@@ -57,6 +57,7 @@ export async function evaluateSkillProposal(
input.workspaceDir,
input.env,
input.agentId,
{ reconcile: false },
);
if (read.record.status !== "pending") {
throw new Error(
@@ -154,6 +155,7 @@ export async function evaluateSkillProposal(
input.workspaceDir,
input.env,
input.agentId,
{ reconcile: false },
);
if (
current.record.status !== "pending" ||

View File

@@ -16,6 +16,11 @@ type SkillProposalScopeOptions = {
workspaceDir?: string;
};
type RequiredProposalReadOptions = {
config?: OpenClawConfig;
reconcile?: boolean;
};
function storeOptions(env?: NodeJS.ProcessEnv) {
return env ? { env } : {};
}
@@ -123,7 +128,7 @@ export async function readRequiredProposal(
workspaceDir?: string,
env?: NodeJS.ProcessEnv,
agentId?: string,
config?: OpenClawConfig,
readOptions: RequiredProposalReadOptions = {},
): Promise<SkillProposalReadResult> {
const read = await readSkillProposal(
proposalId,
@@ -132,7 +137,7 @@ export async function readRequiredProposal(
...(agentId ? { agentId } : {}),
...(workspaceDir ? { workspaceDir } : {}),
},
config,
readOptions,
);
if (!read) {
throw new Error(`Skill proposal not found: ${proposalId}`);

View File

@@ -1159,6 +1159,16 @@ describe("skill workshop proposals", () => {
"# External change\n",
);
await expect(readSkillProposalRollback(proposal.record.id)).resolves.not.toBeNull();
const startedAt = Date.now();
await expect(
rejectSkillProposal({
workspaceDir,
proposalId: proposal.record.id,
reason: "external target retained",
}),
).resolves.toMatchObject({ status: "rejected" });
expect(Date.now() - startedAt).toBeLessThan(2_000);
});
it("reconciles an update apply interrupted after the live skill write", async () => {

View File

@@ -642,12 +642,17 @@ async function withPendingSkillProposalMutation<T>(
action: "applied" | "quarantined" | "rejected" | "revised",
fn: (read: SkillProposalReadResult) => Promise<T>,
): Promise<T> {
const recoveryReadOptions = input.config ? { config: input.config } : undefined;
const lockedReadOptions = {
...(input.config ? { config: input.config } : {}),
reconcile: false,
};
const initial = await readRequiredProposal(
input.proposalId,
input.workspaceDir,
input.env,
input.agentId,
input.config,
recoveryReadOptions,
);
return await withSkillProposalTargetLock(
initial.record,
@@ -657,7 +662,7 @@ async function withPendingSkillProposalMutation<T>(
input.workspaceDir,
input.env,
input.agentId,
input.config,
lockedReadOptions,
);
if (read.record.status !== "pending") {
throw new Error(

View File

@@ -71,6 +71,11 @@ type SkillProposalLookupScope = {
workspaceDir?: string;
};
type SkillProposalReadOptions = {
config?: OpenClawConfig;
reconcile?: boolean;
};
export type PreparedSkillProposalSupportFile = SkillProposalSupportFile & {
content: string;
};
@@ -178,13 +183,15 @@ export async function readSkillProposal(
proposalId: string,
options: SkillWorkshopStoreOptions = {},
scope: SkillProposalLookupScope = {},
recoveryConfig?: OpenClawConfig,
readOptions: SkillProposalReadOptions = {},
): Promise<SkillProposalReadResult | null> {
let stored = readStoredProposal(proposalId, options);
if (!stored || !isStoredProposalVisible(stored.row, scope)) {
return null;
}
await reconcileInterruptedApply(proposalId, options, recoveryConfig);
if (readOptions.reconcile !== false) {
await reconcileInterruptedApply(proposalId, options, readOptions.config);
}
stored = readStoredProposal(proposalId, options);
if (!stored || !isStoredProposalVisible(stored.row, scope)) {
return null;