Skip to content

Commit ef6dec9

Browse files
dmealingclaude
andcommitted
fix(java): the agent-context staleness nudge compared two version lines, so it fired forever (#347)
meta agent-docs COPIES the agent-instruction files into a consuming repo; it does not link. The copy freezes at whatever version wrote it and a dependency bump never touches it, so an agent reads old instructions and authors vocabulary the current loader rejects. The staleness advisory exists to break that silence. On the JVM it could never fire correctly. generatedBy is always an NPM version, because meta agent-docs is the canonical scaffolder for every port and the others redirect to it. installedVersion() reads the MAVEN artifact version, which carries a historical major of 7 by design. The comparison is exact equality -- deliberately, so prerelease drift still nudges -- so "7.24.1".equals("0.24.1") was false and always would be. The nudge fired on every mvn metaobjects:generate forever, including when the context was perfectly in sync. Not silent: permanently loud, which carries the same signal and is tuned out faster. C# and Python were never affected; both read a version on the 0.x line. Java and Kotlin are the broken pair, and only because the Maven major is deliberately different. This is the two-version-lines contract biting somewhere nobody looked. The fix is in the COORDINATE, not the comparison. stalenessAcrossVersionLines reduces both sides to the release they name and keeps the equality EXACT, so every property the original contract defends survives -- an RC-scaffolded context against a final release still nudges, and the message still names both real versions. staleness() is untouched; its javadoc forbids relaxing it into a semver compare and is right. Correct while the four registries share a minor.patch. That is the documented lockstep rule and is already relied on by scripts/prerelease.mjs:210 and scripts/release-verify.mjs:27, which both build the Maven version as `7.` + npm's remainder -- so this reuses a convention rather than inventing one, and is a third site for it rather than a first. It is a convention and not a gate; if lockstep ever breaks this reports in-sync across a real gap. That trade is accepted and stated in the javadoc, because it is strictly better than a check that can never match. Why it survived: every existing test used a generatedBy of "7.2.1" -- a shape production CANNOT produce, since only the Node CLI writes that field. The fixtures asserted a world where both operands sat on the Maven line, so the pure function looked correct while the deployed comparison was never exercised once. Six cases now use the real shape, including the in-sync case that is the whole point. Option A of four, chosen after a two-arm adjudication that converged independently on it. Rejected: comparing metamodelVersion instead -- it is identical across ports by construction, but 91 commits touched agent-context/skills/ in 90 days against 5 distinct values of that constant in the project's whole history, so its blind spot is dominant rather than marginal. Rejected: stamping every port's line into the manifest -- the Node scaffolder cannot know a consumer's Maven version. Rejected: a content digest -- the manifest does carry per-file hashes, but they exist to detect hand-edits, and the JVM ships no skill content at all, so the port with the bug cannot compute the current side. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NynrRND6ZUwGvq3ZUTCfxG
1 parent 2a6c417 commit ef6dec9

4 files changed

Lines changed: 154 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,44 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
77

88
## [Unreleased]
99

10+
### Fixed — the JVM agent-context staleness nudge compared two different version lines, so it fired forever ([#347](https://github.com/metaobjectsdev/metaobjects/issues/347))
11+
12+
`meta agent-docs` **copies** the agent-instruction files into a consuming repo; it does not
13+
link. So the copy freezes at whatever version wrote it and a dependency bump never touches it —
14+
an agent then reads old instructions and authors vocabulary the current loader rejects. A
15+
staleness advisory exists in all four ports to break that silence, and on `gen`/`verify` it
16+
compares the version that scaffolded the context against the installed one.
17+
18+
**On the JVM those are two different version lines and can never be equal.** `generatedBy` is
19+
always an **npm** version, because `meta agent-docs` is the canonical scaffolder for every port
20+
and the others redirect to it. `AgentContextScaffold.installedVersion()` reads the **Maven**
21+
artifact version, which carries a historical major of `7` by design. The comparison is exact
22+
equality — deliberately, so prerelease drift still nudges — so `"7.24.1".equals("0.24.1")` was
23+
false and always would be. The nudge fired on **every `mvn metaobjects:generate`, in
24+
perpetuity, including when the context was perfectly in sync.** Not silent: permanently loud,
25+
which carries the same amount of signal and gets tuned out faster.
26+
27+
C# and Python were never affected — both read a version on the `0.x` line. **Java and Kotlin
28+
are the broken pair**, and only because the Maven major is deliberately different.
29+
30+
**The fix is in the coordinate, not the comparison.** `stalenessAcrossVersionLines` reduces both
31+
sides to the release they name (`7.24.1` and `0.24.1` both → `24.1`) and keeps the equality
32+
EXACT, so every property the original contract defends survives — an RC-scaffolded context
33+
against a final release still nudges. The message still names both real versions. The plain
34+
`staleness` is untouched; its javadoc forbids relaxing it into a semver compare and is right.
35+
36+
Correct while the four registries share a `minor.patch` — the documented lockstep rule, already
37+
relied on by `scripts/prerelease.mjs` and `scripts/release-verify.mjs`, which both build the
38+
Maven version as `7.` + npm's remainder. It is a convention rather than a gate, and that is the
39+
accepted trade: reporting in-sync across a hypothetical future gap is strictly better than a
40+
check that can never match.
41+
42+
**Why it survived:** the existing tests used a `generatedBy` of `"7.2.1"` — a **shape production
43+
cannot produce**, since only the Node CLI writes that field. The fixture asserted a world where
44+
both operands were on the Maven line, so the pure function looked correct and the deployed
45+
comparison was never exercised. Six regression cases now use the real shape.
46+
47+
1048
### Added — a retired capability gets its status back: `@status: retired` (FR-039)
1149

1250
`@status` gains a fourth member, **`retired`**, and `@supersededBy` is registered again — this

server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,10 @@ protected void warnIfAgentContextStale() {
221221
AgentContextScaffold.Manifest manifest = new AgentContextScaffold.Manifest(
222222
version, generatedBy, new ArrayList<>(), new ArrayList<>(),
223223
new LinkedHashMap<>());
224-
String nudge = AgentContextScaffold.staleness(
224+
// ACROSS VERSION LINES on purpose: the manifest is stamped by the Node CLI with an
225+
// npm version (0.24.1) while installedVersion() is the Maven one (7.24.1), so plain
226+
// equality never matched and this nudged on every build forever. See the javadoc.
227+
String nudge = AgentContextScaffold.stalenessAcrossVersionLines(
225228
manifest, AgentContextScaffold.installedVersion());
226229
if (nudge != null) {
227230
getLog().warn(nudge);

server/java/metadata/src/main/java/com/metaobjects/agentcontext/AgentContextScaffold.java

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,65 @@ public static String staleness(Manifest manifest, String currentVersion) {
7777
+ "the .claude/skills docs.";
7878
}
7979

80+
/**
81+
* The comparable coordinate of a version — everything after the leading major.
82+
*
83+
* <p>{@code "7.24.1"} and {@code "0.24.1"} both yield {@code "24.1"}. Returns the input
84+
* unchanged when there is no {@code '.'}, so a malformed or sentinel value never becomes
85+
* something that accidentally matches.
86+
*/
87+
static String releaseCoordinate(String version) {
88+
if (version == null) {
89+
return null;
90+
}
91+
int dot = version.indexOf('.');
92+
return dot < 0 ? version : version.substring(dot + 1);
93+
}
94+
95+
/**
96+
* {@link #staleness} for a consumer whose installed version sits on a DIFFERENT VERSION
97+
* LINE from the one that stamped the manifest.
98+
*
99+
* <p>This exists because of a fact peculiar to the JVM. The manifest is always stamped by
100+
* the Node CLI — {@code meta agent-docs} is the canonical scaffolder for every port and the
101+
* others redirect to it — so {@code generatedBy} is always an npm version ({@code 0.24.1}).
102+
* The JVM's {@link #installedVersion()} reads the Maven artifact version ({@code 7.24.1}),
103+
* which carries a historical major of {@code 7} by design. Exact equality between those two
104+
* is false and always will be, so the plain {@link #staleness} nudged on EVERY build, in
105+
* perpetuity, including when the context was perfectly in sync. An advisory that cries wolf
106+
* in the inner loop gets tuned out, and then it is not there for the upgrade it exists for.
107+
*
108+
* <p>The fix is in the COORDINATE, not in the comparison: both sides are reduced to the
109+
* release they name and the equality stays EXACT, so every property {@link #staleness}'s
110+
* contract defends — prerelease and build-metadata drift still nudging — is preserved. Do
111+
* not instead relax {@link #staleness} into a semver compare; its javadoc forbids that, and
112+
* it is right.
113+
*
114+
* <p>Correct while the four registries share a {@code minor.patch}, which is the documented
115+
* lockstep rule and is already relied on by the release tooling
116+
* ({@code scripts/prerelease.mjs} and {@code scripts/release-verify.mjs} both construct the
117+
* Maven version as {@code 7.} + npm's remainder). It is a CONVENTION rather than a gate: if
118+
* lockstep ever breaks, this reports in-sync across a real gap. That is the accepted trade,
119+
* and it is strictly better than a check that can never match.
120+
*
121+
* @param manifest the parsed prior manifest, or {@code null} if none on disk.
122+
* @param currentVersion the installed version, on this port's own line.
123+
* @return the nudge message naming BOTH real versions, or {@code null} when in sync.
124+
*/
125+
public static String stalenessAcrossVersionLines(Manifest manifest, String currentVersion) {
126+
if (manifest == null) {
127+
return null;
128+
}
129+
String stamped = releaseCoordinate(manifest.generatedBy());
130+
// A null/absent generatedBy is legacy and must still nudge — delegate, which says so.
131+
if (stamped != null && stamped.equals(releaseCoordinate(currentVersion))) {
132+
return null;
133+
}
134+
// Message construction lives in ONE place; this only decides whether to ask for it.
135+
// The delegate cannot return null here: the coordinates differ, so the versions do.
136+
return staleness(manifest, currentVersion);
137+
}
138+
80139
/**
81140
* The installed MetaObjects version, resolved the idiomatic Maven way: read
82141
* {@code /META-INF/maven/com.metaobjects/metaobjects-metadata/pom.properties} from the

server/java/metadata/src/test/java/com/metaobjects/agentcontext/AgentContextStalenessTest.java

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import java.util.Map;
77

88
import static org.junit.Assert.assertEquals;
9+
import static org.junit.Assert.assertNotNull;
910
import static org.junit.Assert.assertNull;
1011
import static org.junit.Assert.assertTrue;
1112

@@ -28,6 +29,58 @@
2829
*/
2930
public class AgentContextStalenessTest {
3031

32+
// ---- across version lines (the JVM's actual situation) ---------------------
33+
//
34+
// Every fixture ABOVE uses a `7.x` generatedBy, and that shape CANNOT OCCUR: the manifest
35+
// is written only by the Node CLI, which stamps an npm version. Those cases pin the pure
36+
// function's contract and are correct as such — but they are also why nobody noticed that
37+
// the JVM was comparing `7.24.1` against `0.24.1` on every build and nudging forever.
38+
// These cases use the shape production actually produces.
39+
40+
@Test
41+
public void sameRelease_acrossLines_isNotStale() {
42+
assertNull("npm-stamped 0.24.1 vs Maven 7.24.1 is the SAME release — must not nudge",
43+
AgentContextScaffold.stalenessAcrossVersionLines(manifest("0.24.1"), "7.24.1"));
44+
}
45+
46+
@Test
47+
public void differentRelease_acrossLines_nudgesNamingBothRealVersions() {
48+
String msg = AgentContextScaffold.stalenessAcrossVersionLines(manifest("0.23.1"), "7.24.1");
49+
assertNotNull("a real release gap must still nudge", msg);
50+
// The message names the REAL versions, not the stripped coordinates — a reader has to
51+
// recognise what is installed and what is on disk.
52+
assertTrue("should name the stamped version", msg.contains("0.23.1"));
53+
assertTrue("should name the installed version", msg.contains("7.24.1"));
54+
}
55+
56+
@Test
57+
public void prereleaseDrift_acrossLines_stillNudges() {
58+
// The pure contract's exact-equality property survives the coordinate reduction:
59+
// 24.1-rc.1 != 24.1, so an RC-scaffolded context against a final release still nudges.
60+
assertNotNull(AgentContextScaffold.stalenessAcrossVersionLines(
61+
manifest("0.24.1-rc.1"), "7.24.1"));
62+
}
63+
64+
@Test
65+
public void absentGeneratedBy_acrossLines_stillNudges() {
66+
assertNotNull("a legacy manifest with no stamp must not read as in-sync",
67+
AgentContextScaffold.stalenessAcrossVersionLines(manifest(null), "7.24.1"));
68+
}
69+
70+
@Test
71+
public void nullManifest_acrossLines_isNotStale() {
72+
assertNull(AgentContextScaffold.stalenessAcrossVersionLines(null, "7.24.1"));
73+
}
74+
75+
@Test
76+
public void releaseCoordinate_stripsOnlyTheLeadingMajor() {
77+
assertEquals("24.1", AgentContextScaffold.releaseCoordinate("7.24.1"));
78+
assertEquals("24.1", AgentContextScaffold.releaseCoordinate("0.24.1"));
79+
assertEquals("24.1-rc.1", AgentContextScaffold.releaseCoordinate("0.24.1-rc.1"));
80+
// No dot: returned unchanged, so a sentinel cannot accidentally match a real version.
81+
assertEquals("unknown", AgentContextScaffold.releaseCoordinate("unknown"));
82+
}
83+
3184
private static AgentContextScaffold.Manifest manifest(String generatedBy) {
3285
return new AgentContextScaffold.Manifest(
3386
1, generatedBy, List.of("java"), List.of("react"), Map.of());

0 commit comments

Comments
 (0)