From eb312b099cd2c96b4681ff5e6a1d49ac734d7f4d Mon Sep 17 00:00:00 2001 From: Garvit Arora Date: Fri, 9 Oct 2026 01:10:34 +0530 Subject: [PATCH 1/3] fix(runtime-host): recover failed workspace skill deletion --- .../skill-catalog-transaction.test.ts | 18 +++++++++++++++ .../src/server/skill-catalog-transaction.ts | 22 ++++++++++++++++--- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/packages/runtime-host/src/__tests__/skill-catalog-transaction.test.ts b/packages/runtime-host/src/__tests__/skill-catalog-transaction.test.ts index 5407b0e0b4d..eccac7202d8 100644 --- a/packages/runtime-host/src/__tests__/skill-catalog-transaction.test.ts +++ b/packages/runtime-host/src/__tests__/skill-catalog-transaction.test.ts @@ -786,6 +786,24 @@ test('workspace deletion refuses a changed tree before tombstoning it', async () assert.equal(await readFile(join(directory, 'SKILL.md'), 'utf8'), '# third-party edit\n'); }); +test('recovery cancels an unapplied workspace deletion when the rename fails', async () => { + const root = await tempRoot(); + const directory = join(root, 'skills', 'workspace-skill'); + await mkdir(directory, { recursive: true }); + await writeFile(join(directory, 'SKILL.md'), '# workspace\n'); + + await assert.rejects( + deleteWorkspaceSkill(writer(root, 'before_delete_rename'), root, 'workspace-skill'), + isTransactionError('commit_outcome_unknown'), + ); + + const recovery = writer(root, 'before_delete_rename'); + await recovery.recover(); + + assert.equal(await readFile(join(directory, 'SKILL.md'), 'utf8'), '# workspace\n'); + assert.deepEqual(await transactionEntries(root), []); +}); + test('workspace deletion compares the caller snapshot before publishing intent', async () => { const root = await tempRoot(); const directory = join(root, 'skills', 'workspace-skill'); diff --git a/packages/runtime-host/src/server/skill-catalog-transaction.ts b/packages/runtime-host/src/server/skill-catalog-transaction.ts index 04b8729aac9..a1f391f1b0a 100644 --- a/packages/runtime-host/src/server/skill-catalog-transaction.ts +++ b/packages/runtime-host/src/server/skill-catalog-transaction.ts @@ -109,6 +109,7 @@ export type SkillCatalogTransactionFailpoint = | 'after_managed_handoff_transaction_sync_before_skills_sync' | 'after_managed_handoff' | 'after_delete_rename_before_directory_sync' + | 'before_delete_rename' | 'after_delete_skills_directory_sync_before_transaction_sync' | 'after_gc_handoff_rename_before_directory_sync' | 'after_gc_handoff' @@ -213,7 +214,7 @@ export class SkillCatalogTransactionWriter { await removePreIntentTransaction(prepared.transactions, directory); continue; } - await replayTransaction(prepared, transaction); + await replayTransaction(prepared, transaction, this.#failpoint, true); } }); } @@ -357,6 +358,7 @@ async function replayTransaction( root: PreparedRoot, transaction: PreparedTransaction, failpoint?: (point: SkillCatalogTransactionFailpoint) => void, + recovering = false, ): Promise { await verifyTransactionEntries(transaction); switch (transaction.intent.kind) { @@ -367,7 +369,7 @@ async function replayTransaction( await replayReplace(root, transaction, failpoint); return; case 'delete-workspace': - await replayDelete(root, transaction, failpoint); + await replayDelete(root, transaction, failpoint, recovering); return; } } @@ -585,6 +587,7 @@ async function replayDelete( root: PreparedRoot, transaction: PreparedTransaction, failpoint?: (point: SkillCatalogTransactionFailpoint) => void, + recovering = false, ): Promise { const intent = transaction.intent as DeleteIntent; const target = join(root.skills, intent.skillId); @@ -602,7 +605,20 @@ async function replayDelete( } const targetReal = await requireContainedDirectory(root.skills, target); await verifyTreeManifest(targetReal, intent.expected); - await rename(targetReal, tombstone); + try { + failpoint?.('before_delete_rename'); + await rename(targetReal, tombstone); + } catch (error) { + if (recovering && !(await safeLstat(tombstone))) { + const unchangedTarget = await safeLstat(target); + if (unchangedTarget?.isDirectory() && !unchangedTarget.isSymbolicLink()) { + await verifyTreeManifest(target, intent.expected); + await finishTransaction(root.transactions, transaction.directory, failpoint); + return; + } + } + throw error; + } failpoint?.('after_delete_rename_before_directory_sync'); await syncDirectory(root.skills); failpoint?.('after_delete_skills_directory_sync_before_transaction_sync'); From b6d84029b8258db4e12fd843a2528a146cc2e459 Mon Sep 17 00:00:00 2001 From: Garvit Arora Date: Fri, 9 Oct 2026 01:16:59 +0530 Subject: [PATCH 2/3] ci(windows): update Skill catalog test count --- .github/workflows/windows-recovery.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/windows-recovery.yml b/.github/workflows/windows-recovery.yml index 27f19c06777..dbcf4a34153 100644 --- a/.github/workflows/windows-recovery.yml +++ b/.github/workflows/windows-recovery.yml @@ -229,8 +229,8 @@ jobs: $exitCode = $LASTEXITCODE if ($exitCode -ne 0) { exit $exitCode } $output = Get-Content "$env:RUNNER_TEMP/skill-catalog.tap" - if ($output -notcontains '# tests 93' -or $output -notcontains '# pass 93' -or $output -notcontains '# skipped 0') { - Write-Error 'Skill catalog gate did not run exactly 93 passing Windows tests' + if ($output -notcontains '# tests 94' -or $output -notcontains '# pass 94' -or $output -notcontains '# skipped 0') { + Write-Error 'Skill catalog gate did not run exactly 94 passing Windows tests' exit 1 } From 49d4f539f01552788664852a819542db9b355b4a Mon Sep 17 00:00:00 2001 From: Garvit Arora Date: Fri, 9 Oct 2026 23:48:43 +0530 Subject: [PATCH 3/3] test(windows): align Skill suite count policy --- scripts/ci-workflow-policy.test.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/ci-workflow-policy.test.mjs b/scripts/ci-workflow-policy.test.mjs index 52371951719..3595ef39807 100644 --- a/scripts/ci-workflow-policy.test.mjs +++ b/scripts/ci-workflow-policy.test.mjs @@ -943,8 +943,8 @@ test('Windows recovery executes the complete Skill catalog suite', () => { assert.match(recovery, /skill-catalog-repository\.test\.js/u); assert.match(recovery, /skill-catalog-transaction\.test\.js/u); assert.match(recovery, /skill-catalog-two-client-uds\.test\.js/u); - assert.match(recovery, /# tests 93/u); - assert.match(recovery, /# pass 93/u); + assert.match(recovery, /# tests 94/u); + assert.match(recovery, /# pass 94/u); assert.match(recovery, /# skipped 0/u); });