Skip to content

Commit 6bfce54

Browse files
Merge pull request #41 from agentplugins/agent/release-policy
Define suite releases and record report version provenance
2 parents 7dedecc + 2af24e7 commit 6bfce54

9 files changed

Lines changed: 160 additions & 10 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ The suite includes these plugins:
1717

1818
To include the optional Streamable HTTP and legacy HTTP+SSE checks, follow the [Core fixture's HTTP setup](plugins/agent-plugins-conformance-core/#optional-http-setup) **before the client loads the plugins**. This starts a bundled local server in a separate terminal; CI can launch the same command. Without the server, HTTP checks remain `not_verified` and the agent continues collecting the other checks.
1919

20-
Install and load the plugins through the client's Agent Plugins support, then ask the agent:
20+
Install and load the plugins from the same [release tag](docs/release-policy.md#release-tags) through the client's Agent Plugins support, then ask the agent:
2121

2222
> Run the run-conformance skill from Agent Plugins Conformance and save the JSON report to /tmp/agent-plugins-conformance/report.json.
2323

‎docs/release-policy.md‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Release policy
2+
3+
The suite’s plugins are released together as one suite. Their `plugin.json` versions and the root development package version match the suite release. Install the plugins from the same release tag.
4+
5+
Suite versions advance independently of Agent Plugins specification versions. Each suite release targets one exact published specification release; several suite releases can improve coverage of the same specification.
6+
7+
## Versioning
8+
9+
The suite uses [Semantic Versioning](https://semver.org/). Before `1.0.0`, consumer-facing interfaces are considered unstable. For stable releases:
10+
11+
| Change | Increment |
12+
| --- | --- |
13+
| Correct a check, improve collection guidance, or fix reporting without breaking a documented consumer interface | Patch |
14+
| Add checks or other functionality while preserving existing consumer interfaces and prerequisites | Minor |
15+
| Break the documented saved-report format, remove or redefine check IDs, change required installation or runtime prerequisites incompatibly, or replace the targeted specification version | Major |
16+
17+
The collection interface between the guide and reporter, and the protocols between suite components, are internal. They can change together without requiring a major release. The documented saved-report fields and check IDs are interfaces that consumers use for acceptance queries.
18+
19+
A corrective patch can change a verdict. New checks can change the result of an acceptance query that selects an entire ID prefix. Therefore, pin an exact suite release for reproducible acceptance testing.
20+
21+
## Specification releases
22+
23+
A new specification tag does not automatically retarget or release the suite. Adopting it requires validating the suite against that published release and keeping the declared schema targets, evaluated requirements, and report's `specVersion` consistent.
24+
25+
Replacing the target specification is a breaking change because clients supporting the previous target are not required to recognize the new schema identifiers. For stable suite versions, this requires a major increment. Earlier suite tags remain available for testing earlier specification versions. Fixes can be backported when needed; publishing a new target does not promise ongoing maintenance of every older release line.
26+
27+
## Release tags
28+
29+
Each suite release has an annotated `vX.Y.Z` tag matching the root package and all plugin versions. Its GitHub release identifies the exact specification tag being targeted.
30+
31+
Release tags and released plugin contents are immutable; corrections require a new suite version. `main` is development state; use release tags for repeatable runs.

‎plugins/agent-plugins-conformance/README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ This abbreviated example shows three results and their summary counts; observati
2626
```json
2727
{
2828
"schemaVersion": 1,
29+
"suiteVersion": "0.1.0",
2930
"specVersion": "1.0.0",
3031
"results": [
3132
{
@@ -59,6 +60,8 @@ This abbreviated example shows three results and their summary counts; observati
5960
}
6061
```
6162

63+
`suiteVersion` records the reporting plugin's version to help investigate saved results; `specVersion` identifies the specification being tested, and `schemaVersion` identifies the report format. Older reports may omit `suiteVersion`.
64+
6265
Each object in `results` identifies its case through `id` and `label`, explains the outcome in `detail`, and cites the relevant specification sections in `specSections`. A result object may also contain a `warning` describing a probe cleanup failure, including the probe file's path and error. This warning does not change the result's `status`.
6366

6467
The three statuses mean:

‎plugins/agent-plugins-conformance/skills/run-conformance/scripts/report.mjs‎

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ async function withReportLock(outputPath, update) {
8282
if (owners.length) {
8383
const ownerPath = join(lockPath, owners[0]);
8484
try {
85-
if (Date.now() - (await stat(ownerPath)).mtimeMs > staleAfter) await unlink(ownerPath);
85+
if (Date.now() - (await stat(ownerPath)).mtimeMs > staleAfter) await removeLockOwner(ownerPath);
8686
} catch (cleanupError) {
8787
if (cleanupError.code !== 'ENOENT') throw cleanupError;
8888
}
@@ -97,19 +97,47 @@ async function withReportLock(outputPath, update) {
9797
try {
9898
await update();
9999
} finally {
100-
await rm(join(lockPath, owner), { force: true });
100+
await removeLockOwner(join(lockPath, owner));
101101
await removeEmptyLock(lockPath);
102102
}
103103
} finally {
104104
await rm(candidate, { recursive: true, force: true });
105105
}
106106
}
107107

108+
async function removeLockOwner(ownerPath) {
109+
for (let attempt = 0; ; attempt++) {
110+
try {
111+
await unlink(ownerPath);
112+
return;
113+
} catch (error) {
114+
if (error.code === 'ENOENT') return;
115+
if (error.code !== 'EPERM' || attempt === 10) throw error;
116+
// Retry Windows permission errors briefly on this exact owner path.
117+
await setTimeout(100);
118+
}
119+
}
120+
}
121+
108122
async function removeEmptyLock(lockPath) {
109-
try {
110-
await rmdir(lockPath);
111-
} catch (error) {
112-
if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error;
123+
for (let attempt = 0; ; attempt++) {
124+
try {
125+
await rmdir(lockPath);
126+
return;
127+
} catch (error) {
128+
if (['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code)) return;
129+
if (error.code !== 'EPERM') throw error;
130+
// Retry Windows permission errors briefly when removing the empty lock.
131+
// A new owner may also have filled the directory in the meantime.
132+
try {
133+
if ((await readdir(lockPath)).length > 0) return;
134+
} catch (readError) {
135+
if (readError.code === 'ENOENT') return;
136+
if (readError.code !== 'EPERM') throw readError;
137+
}
138+
if (attempt === 10) throw error;
139+
await setTimeout(100);
140+
}
113141
}
114142
}
115143

‎plugins/agent-plugins-conformance/src/report-format.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export function validateSavedReport(report) {
2222
object(report, 'report');
2323
if (report.schemaVersion !== 1) invalid('report.schemaVersion', 'expected 1');
2424
string(report.specVersion, 'report.specVersion');
25+
if (Object.hasOwn(report, 'suiteVersion')) string(report.suiteVersion, 'report.suiteVersion');
2526
if (!Array.isArray(report.observations)) invalid('report.observations', 'expected an array');
2627
if (!Array.isArray(report.results)) invalid('report.results', 'expected an array');
2728
const counts = { pass: 0, fail: 0, not_verified: 0, total: report.results.length };

‎plugins/agent-plugins-conformance/src/report.mjs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1+
import { readFileSync } from 'node:fs';
12
import path from 'node:path';
23
import { CASES, MCP_CWD_VARIANTS } from './cases.mjs';
34

45
export { CASES, CASE_IDS } from './cases.mjs';
6+
const SUITE_VERSION = JSON.parse(readFileSync(new URL('../plugin.json', import.meta.url), 'utf8')).version;
7+
if (typeof SUITE_VERSION !== 'string' || !SUITE_VERSION.trim()) {
8+
throw new TypeError('plugin.json.version: expected a nonempty string');
9+
}
510
const CORE_SERVERS = Object.keys(MCP_CWD_VARIANTS);
611
const COMMAND_TOKEN_SERVERS = Object.freeze({
712
'command-token-posix': Object.freeze({
@@ -847,6 +852,7 @@ export function buildReport(input) {
847852
return {
848853
schemaVersion: 1,
849854
specVersion: '1.0.0',
855+
suiteVersion: SUITE_VERSION,
850856
observations: [
851857
...Object.keys(SKILLS).filter((skill) => skills.has(skill)).map((skill) => ({
852858
kind: 'skill', skill, marker: skills.get(skill),

‎test/record-report.test.mjs‎

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -668,6 +668,8 @@ test('malformed saved state is rejected even when its bad observation would be r
668668
await mkdir(join(f.directory, 'nested directory'));
669669
const malformed = [
670670
'{', 'null', JSON.stringify({ schemaVersion: 1, observations: [] }),
671+
JSON.stringify({ ...expected([]), suiteVersion: '' }),
672+
JSON.stringify({ ...expected([]), suiteVersion: 123 }),
671673
JSON.stringify({ ...expected([]), summary: { pass: 999, fail: 0, not_verified: 0, total: 999 } }),
672674
JSON.stringify({ ...expected([skill()]), observations: [{ ...skill(), unexpected: true }] }),
673675
];
@@ -680,6 +682,21 @@ test('malformed saved state is rejected even when its bad observation would be r
680682
}
681683
});
682684

685+
test('legacy saved reports remain readable and acquire suite version when recorded again', async (t) => {
686+
const f = await fixture(t);
687+
await mkdir(join(f.directory, 'nested directory'));
688+
const legacy = expected([skill('alpha')]);
689+
delete legacy.suiteVersion;
690+
await writeFile(f.outputPath, `${JSON.stringify(legacy)}\n`);
691+
const summaryScript = fileURLToPath(new URL(
692+
'../plugins/agent-plugins-conformance/skills/run-conformance/scripts/summarize.mjs', import.meta.url));
693+
const summary = spawnSync(process.execPath, [summaryScript, f.outputPath], { encoding: 'utf8' });
694+
assert.equal(summary.status, 0, summary.stderr);
695+
assert.equal(summary.stdout.trimEnd(), formatReport(legacy));
696+
f.record(skill('beta'));
697+
assert.deepEqual(await f.read(), expected([skill('alpha'), skill('beta')]));
698+
});
699+
683700
test('write errors report failure and remove sibling temporary files', async (t) => {
684701
const f = await fixture(t);
685702
await mkdir(f.outputPath, { recursive: true });
@@ -699,14 +716,19 @@ test('copied primary plugin records and summarizes without sibling plugins or ru
699716
const f = await fixture(t);
700717
const copiedPlugin = join(f.directory, 'copied plugin');
701718
await cp(new URL('../plugins/agent-plugins-conformance', import.meta.url), copiedPlugin, { recursive: true });
719+
const manifestPath = join(copiedPlugin, 'plugin.json');
720+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
721+
const copiedVersion = '9.8.7';
722+
await writeFile(manifestPath, JSON.stringify({ ...manifest, version: copiedVersion }));
702723
assert.equal((await readdir(copiedPlugin)).includes('node_modules'), false);
703724
const copiedScript = join(copiedPlugin, 'skills/run-conformance/scripts/report.mjs');
704725
f.success(start, copiedScript);
726+
assert.equal((await f.read()).suiteVersion, copiedVersion);
705727
f.success({ action: 'record', observation: skill('alpha') }, copiedScript);
706728
f.success({ action: 'record', observation: skill('beta') }, copiedScript);
707729
f.success({ action: 'record', observation: discovery() }, copiedScript);
708730
const report = await f.read();
709-
assert.deepEqual(report, expected([skill('alpha'), skill('beta'), discovery()]));
731+
assert.deepEqual(report, { ...expected([skill('alpha'), skill('beta'), discovery()]), suiteVersion: copiedVersion });
710732
assert.equal(report.results.find(({ id }) => id === 'skills.discovery.immediate-children').status, 'pass');
711733
const summary = spawnSync(process.execPath, [join(copiedPlugin, 'skills/run-conformance/scripts/summarize.mjs'), f.outputPath], {
712734
cwd: f.directory, encoding: 'utf8', timeout: 10_000,
@@ -718,7 +740,10 @@ test('copied primary plugin records and summarizes without sibling plugins or ru
718740
const result = health.run({ action: 'record', observation: missingHttp() }, copiedScript);
719741
assert.equal(result.status, 0, result.stderr);
720742
const withHttp = await f.read();
721-
assert.deepEqual(withHttp, expected([skill('alpha'), skill('beta'), discovery(), { ...missingHttp(), serverHealthCheck: 'passed' }]));
743+
assert.deepEqual(withHttp, {
744+
...expected([skill('alpha'), skill('beta'), discovery(), { ...missingHttp(), serverHealthCheck: 'passed' }]),
745+
suiteVersion: copiedVersion,
746+
});
722747
// Reading saved evidence must remain deterministic after fixture state changes.
723748
await health.setResponse({ error: 'fixture stopped after collection' });
724749
const savedSummary = health.run('', join(copiedPlugin, 'skills/run-conformance/scripts/summarize.mjs'));

‎test/report-concurrency.test.mjs‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import assert from 'node:assert/strict';
22
import { spawn } from 'node:child_process';
33
import { randomUUID } from 'node:crypto';
4-
import { mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, writeFile } from 'node:fs/promises';
4+
import { mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, utimes, writeFile } from 'node:fs/promises';
55
import { tmpdir } from 'node:os';
66
import { basename, dirname, join } from 'node:path';
77
import { setTimeout as delay } from 'node:timers/promises';
@@ -315,6 +315,48 @@ test('a writer waits for a held report lock and proceeds after release', async (
315315
await assertUnlocked(fixture);
316316
});
317317

318+
for (const operation of ['unlink', 'rmdir']) test(`a transient ${operation} permission error while removing an expired lock is retried`, async (t) => {
319+
const oldObservation = skill('alpha');
320+
const newObservation = skill('beta');
321+
const fixture = await makeFixture(t, [oldObservation]);
322+
const destination = join(await realpath(dirname(fixture.outputPath)), basename(fixture.outputPath));
323+
const lockPath = `${destination}.lock`;
324+
await mkdir(lockPath);
325+
const ownerPath = join(lockPath, randomUUID());
326+
await writeFile(ownerPath, '');
327+
const expired = new Date(Date.now() - 20_000);
328+
await utimes(ownerPath, expired, expired);
329+
330+
const preload = join(fixture.directory, `transient-${operation}.mjs`);
331+
await writeFile(preload, `
332+
import fs from 'node:fs/promises';
333+
import { syncBuiltinESMExports } from 'node:module';
334+
335+
const operation = ${JSON.stringify(operation)};
336+
const target = ${JSON.stringify(operation === 'unlink' ? ownerPath : lockPath)};
337+
const originalRemove = fs[operation].bind(fs);
338+
let failed = false;
339+
fs[operation] = async function remove(path, ...args) {
340+
if (!failed && String(path) === target) {
341+
failed = true;
342+
const error = new Error('operation not permitted');
343+
error.code = 'EPERM';
344+
throw error;
345+
}
346+
return originalRemove(path, ...args);
347+
};
348+
syncBuiltinESMExports();
349+
`);
350+
351+
assertSuccess(await runReporter(fixture, {
352+
action: 'record', observation: newObservation,
353+
}, ['--import', pathToFileURL(preload).href]));
354+
assert.deepEqual(await readReport(fixture), buildReport({
355+
schemaVersion: 1, observations: [oldObservation, newObservation],
356+
}));
357+
await assertUnlocked(fixture);
358+
});
359+
318360
test('writers recover an expired lock after its owner is killed and preserve every record', { timeout: 30_000 }, async (t) => {
319361
const oldObservation = skill('alpha');
320362
const fixture = await makeFixture(t, [oldObservation]);

‎test/report.test.mjs‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,21 @@
11
import test from 'node:test';
22
import assert from 'node:assert/strict';
3+
import { readFileSync } from 'node:fs';
34
import path from 'node:path';
45
import { buildReport, CASE_IDS } from '../plugins/agent-plugins-conformance/src/report.mjs';
56

7+
const rootVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
8+
9+
test('all four plugin manifests use the root package version', () => {
10+
for (const name of [
11+
'agent-plugins-conformance', 'agent-plugins-conformance-core',
12+
'agent-plugins-conformance-recovery', 'agent-plugins-conformance-invalid-mcp',
13+
]) {
14+
const manifest = JSON.parse(readFileSync(new URL(`../plugins/${name}/plugin.json`, import.meta.url), 'utf8'));
15+
assert.equal(manifest.version, rootVersion, name);
16+
}
17+
});
18+
619
function input(root = '/fixture/plugin', data = '/state/plugin') {
720
const flavor = root.startsWith('/') ? path.posix : path.win32;
821
const platform = flavor === path.win32 ? 'windows' : 'posix';
@@ -95,6 +108,7 @@ test('complete stdio and skill core evidence passes its cases and preserves cano
95108
assert.deepEqual(report.summary, { pass: 19, fail: 0, not_verified: 39, total: 58 });
96109
assert.deepEqual(report.results.map(({ id }) => id), CASE_IDS);
97110
assert.equal(report.specVersion, '1.0.0');
111+
assert.equal(report.suiteVersion, rootVersion);
98112
assert.deepEqual(result(report, 'mcp.stdio.env.plugin-root').specSections, ['9.1']);
99113
assert.deepEqual(report.observations, value.observations);
100114
runtime(value).argv.push('after report');

0 commit comments

Comments
 (0)