Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/windows-recovery.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: scripts/ci-workflow-policy.test.mjs lines 946-947 still assert # tests 93 and # pass 93 against this workflow, so the required test job fails. Bump those assertions to 94 together with this change.

Write-Error 'Skill catalog gate did not run exactly 94 passing Windows tests'
exit 1
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
22 changes: 19 additions & 3 deletions packages/runtime-host/src/server/skill-catalog-transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -213,7 +214,7 @@ export class SkillCatalogTransactionWriter {
await removePreIntentTransaction(prepared.transactions, directory);
continue;
}
await replayTransaction(prepared, transaction);
await replayTransaction(prepared, transaction, this.#failpoint, true);
}
});
}
Expand Down Expand Up @@ -357,6 +358,7 @@ async function replayTransaction(
root: PreparedRoot,
transaction: PreparedTransaction,
failpoint?: (point: SkillCatalogTransactionFailpoint) => void,
recovering = false,
): Promise<void> {
await verifyTransactionEntries(transaction);
switch (transaction.intent.kind) {
Expand All @@ -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;
}
}
Expand Down Expand Up @@ -585,6 +587,7 @@ async function replayDelete(
root: PreparedRoot,
transaction: PreparedTransaction,
failpoint?: (point: SkillCatalogTransactionFailpoint) => void,
recovering = false,
): Promise<void> {
const intent = transaction.intent as DeleteIntent;
const target = join(root.skills, intent.skillId);
Expand All @@ -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))) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This cancels the user's deletion on any rename error during recovery, including transient ones, and nothing records that it happened. Consider emitting a diagnostic when an intent is cancelled, and keeping the rename errno as cause if the re-verification below throws.

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');
Expand Down
4 changes: 2 additions & 2 deletions scripts/ci-workflow-policy.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down