fix(agents): re-admit runs on the configured generation after a catalog publication (#168410)

* fix(agents): derive run owners from admitted generations after catalog publication

* test(agents): cover superseded admitted generations with uncovered selections

* fix(agents): re-admit uncovered selections on the configured generation

* fix(agents): re-admit only superseded configured generations

* test(agents): keep derived parents confined after catalog publication

* fix(agents): bind re-admitted generation locally and prove revocation rejects re-admission

(cherry picked from commit 171df4e1c5)
This commit is contained in:
Dallin Romney 2026-10-10 11:07:52 -07:00
parent cda4572901
commit a293f2e850
No known key found for this signature in database
GPG key ID: 4EFBF9CC01D97191
3 changed files with 172 additions and 20 deletions

View file

@ -30,7 +30,10 @@ import {
preparedPluginGenerationReusesBase,
preparedPluginGenerationSupportsSelections,
} from "./prepared-model-runtime.plugin-generation.js";
import { retainPreparedPluginGeneration } from "./prepared-model-runtime.plugin-lifetime.js";
import {
configuredPreparedPluginGenerations,
retainPreparedPluginGeneration,
} from "./prepared-model-runtime.plugin-lifetime.js";
import {
retirePreparedModelRuntimeOwnerIfUnused,
type PreparedModelRuntimeOwnerRetention,
@ -99,6 +102,8 @@ export async function acquirePreparedModelRuntimeLeaseFromOwners(
});
}
};
// Re-admission on a superseded configured generation moves the run to its replacement.
let admittedGeneration = options.pluginGeneration;
const deriveSelections = options.deriveRuntimePluginSelections;
// Caller choices belong to this invocation; only config-derived choices may change after a wait.
const requestedSelections = deriveSelections
@ -135,6 +140,7 @@ export async function acquirePreparedModelRuntimeLeaseFromOwners(
currentOwner?.needsRefresh,
configuredCatalog,
lastExternalPublication,
admittedGeneration,
];
if (previousAttempt?.every((value, index) => value === attempt[index])) {
// Failed dynamic owners can disappear. A newly accepted catalog is still progress;
@ -159,7 +165,7 @@ export async function acquirePreparedModelRuntimeLeaseFromOwners(
}
if (
provenance === "run" &&
!options.pluginGeneration &&
!admittedGeneration &&
(replacement || context.getGatewayLifecycleActive())
) {
try {
@ -196,7 +202,7 @@ export async function acquirePreparedModelRuntimeLeaseFromOwners(
let pluginMetadataSnapshot = options.pluginMetadataSnapshot;
if (deriveSelections) {
pluginMetadataSnapshot =
options.pluginGeneration?.pluginMetadataSnapshot ??
admittedGeneration?.pluginMetadataSnapshot ??
pluginMetadataSnapshot ??
resolvePluginMetadataSnapshot({
config: input.config,
@ -221,7 +227,7 @@ export async function acquirePreparedModelRuntimeLeaseFromOwners(
);
}
key = ownerKey(input);
if (provenance === "run" && context.getGatewayLifecycleActive() && options.pluginGeneration) {
if (provenance === "run" && context.getGatewayLifecycleActive() && admittedGeneration) {
const configuredOwner = resolveConfiguredOwner(context.owners, input);
if (configuredOwner?.pending) {
assertPreparedModelRuntimeAdmissionCanWait(configuredOwner);
@ -234,14 +240,13 @@ export async function acquirePreparedModelRuntimeLeaseFromOwners(
}
if (
configuredOwner &&
(configuredOwner.needsRefresh ||
configuredOwner.pluginGeneration !== options.pluginGeneration)
(configuredOwner.needsRefresh || configuredOwner.pluginGeneration !== admittedGeneration)
) {
const borrowed = getPreparedModelRuntimeBorrowedSnapshot(options.pluginGeneration);
const borrowed = getPreparedModelRuntimeBorrowedSnapshot(admittedGeneration);
if (
!configuredOwner.needsRefresh &&
borrowed &&
borrowed.metadataSnapshot === options.pluginGeneration.pluginMetadataSnapshot &&
borrowed.metadataSnapshot === admittedGeneration.pluginMetadataSnapshot &&
preparedModelRuntimeConfigsMatch(borrowed.config, input.config) &&
borrowed.agentId === input.agentId &&
borrowed.agentDir === input.agentDir &&
@ -251,17 +256,27 @@ export async function acquirePreparedModelRuntimeLeaseFromOwners(
!input.readOnly &&
!input.loadRuntimePlugins &&
!input.skipCredentials &&
!input.env &&
preparedPluginGenerationSupportsSelections(options.pluginGeneration, input)
!input.env
) {
// A turn may finish under its still-open parent lease after reload. Its historic
// generation must never publish over the configured owner for newly admitted work.
assertAdmission();
return {
snapshot: borrowed,
pluginGeneration: options.pluginGeneration,
[Symbol.asyncDispose]: retainPreparedPluginGeneration(options.pluginGeneration),
};
if (preparedPluginGenerationSupportsSelections(admittedGeneration, input)) {
assertAdmission();
return {
snapshot: borrowed,
pluginGeneration: admittedGeneration,
[Symbol.asyncDispose]: retainPreparedPluginGeneration(admittedGeneration),
};
}
// Acquisition precedes run side effects. A run admitted on a superseded configured
// generation is admitted afresh on its replacement; nested parents stay confined.
if (
configuredOwner.pluginGeneration &&
configuredPreparedPluginGenerations.has(admittedGeneration)
) {
admittedGeneration = configuredOwner.pluginGeneration;
continue;
}
}
throw new PreparedModelRuntimePublicationSupersededError(
`prepared model runtime plugin generation was superseded for ${input.agentDir}`,
@ -272,7 +287,7 @@ export async function acquirePreparedModelRuntimeLeaseFromOwners(
provenance === "run" &&
context.getGatewayLifecycleActive() &&
options.catalogMode === "static" &&
!options.pluginGeneration &&
!admittedGeneration &&
!options.pluginMetadataSnapshot &&
!input.readOnly &&
!input.loadRuntimePlugins &&
@ -308,10 +323,10 @@ export async function acquirePreparedModelRuntimeLeaseFromOwners(
(existing.provenance === "run" || existing.provenance === "ephemeral");
// A static owner cannot satisfy explicit live discovery; publish a new exact generation.
const ownerGenerationChanged =
(options.pluginGeneration !== undefined &&
(admittedGeneration !== undefined &&
!preparedPluginGenerationReusesBase(
existing?.pending ? existing.pendingPluginGeneration : existing?.pluginGeneration,
options.pluginGeneration,
admittedGeneration,
)) ||
(options.catalogMode === "live" && existing?.catalogMode === "static");
if (existing?.pending && ownerGenerationChanged) {
@ -365,7 +380,7 @@ export async function acquirePreparedModelRuntimeLeaseFromOwners(
undefined,
provenance,
options.catalogMode,
options.pluginGeneration,
admittedGeneration,
pluginMetadataSnapshot,
);
// Publication installs its exact owner synchronously before exposing the pending promise.

View file

@ -226,6 +226,11 @@ export function retainPreparedPluginGeneration(
};
}
// Only configured publication grants fresh-admission authority. Parent-derived and run-owned
// generations stay confined to their exact selections after a newer configured publication.
export const configuredPreparedPluginGenerations =
new WeakSet<PreparedModelRuntimePluginGeneration>();
/** Publishing replaces one reference, while admitted leases retain their exact generation. */
export function publishPreparedPluginGeneration(
owner: PreparedModelRuntimeOwner,
@ -248,6 +253,9 @@ export function publishPreparedPluginGeneration(
);
}
const release = ownPreparedPluginGeneration(generation).retain();
if (owner.provenance === "configured") {
configuredPreparedPluginGenerations.add(generation);
}
const version = owner.generation;
const gatewayLenders = new Set<PluginRegistry>();
let signal: AbortSignal | undefined;

View file

@ -34,10 +34,13 @@ import { markPluginRegistryActive, quiescePluginRegistry } from "../plugins/regi
import { createPluginRegistryOwner } from "../plugins/runtime.js";
import { createPluginRecord } from "../plugins/status.test-helpers.js";
import { createTestGatewayScheduler } from "../test-utils/gateway-scheduler-clock.js";
import * as runtimePluginLoadPlan from "./harness/runtime-plugin-load-plan.js";
import * as catalogWorker from "./prepared-model-catalog-worker.js";
import { scopePreparedModelRuntimeLease } from "./prepared-model-runtime-generation-scope.js";
import { PreparedModelRuntimePublicationSupersededError } from "./prepared-model-runtime.errors.js";
import {
acquireAgentRunPreparedModelRuntime,
acquirePublishedPreparedModelRuntime,
acquireReadOnlyPreparedModelRuntime,
applyRemoteModelCatalogUpdate,
beginPreparedModelRuntimePluginDrain,
@ -216,6 +219,132 @@ it("delivers the first reply when catalog adoption retires its captured dispatch
expect(deliver.mock.calls.map(([payload]) => payload.text)).toEqual(["first reply completed"]);
});
it("re-admits an uncovered selection on the configured generation after a catalog publication", async () => {
await setup();
const input = fixture.agentInput("default", config);
// Admission retains the configured generation before the downloaded catalog commits.
await using parent = scopePreparedModelRuntimeLease(
await acquirePublishedPreparedModelRuntime(input),
);
expect(parent.pluginGeneration.remoteCatalog?.generatedAt).toBe(200);
expect(await applyRemoteModelCatalogUpdate(() => config)).toBe("published");
await using configured = await acquirePublishedPreparedModelRuntime(input);
expect(configured.pluginGeneration.remoteCatalog?.generatedAt).toBe(300);
// Provider-owner plugins activated per selection are absent from the admitted registry.
const ownersSpy = vi
.spyOn(runtimePluginLoadPlan, "resolveAgentRuntimePluginSelectionOwners")
.mockReturnValue({ pluginIds: ["openai"], forceActivatedPluginIds: ["openai"] });
try {
// The configured owner's own key, as cron and Gateway chat admission resolve it.
await using lease = await parent.run(() =>
acquireAgentRunPreparedModelRuntime(
{
...input,
workspaceDir: parent.snapshot.workspaceDir,
runtimePluginSelections: [
{ provider: "custom", modelId: "remote-200", agentId: "default" },
],
},
{ catalogMode: "static", pluginGeneration: parent.pluginGeneration },
),
);
expect(lease.pluginGeneration).toBe(configured.pluginGeneration);
} finally {
ownersSpy.mockRestore();
}
// Historic work never replaced the published owner for newly admitted work.
await using next = await acquirePublishedPreparedModelRuntime(input);
expect(next.pluginGeneration).toBe(configured.pluginGeneration);
});
it("rejects re-admission when the run is aborted while its selection resolves", async () => {
await setup();
const input = fixture.agentInput("default", config);
await using parent = scopePreparedModelRuntimeLease(
await acquirePublishedPreparedModelRuntime(input),
);
expect(await applyRemoteModelCatalogUpdate(() => config)).toBe("published");
const controller = new AbortController();
// Revocation lands inside acquisition, after the superseded generation was inspected.
const ownersSpy = vi
.spyOn(runtimePluginLoadPlan, "resolveAgentRuntimePluginSelectionOwners")
.mockImplementation(() => {
controller.abort(new Error("run revoked"));
return { pluginIds: ["openai"], forceActivatedPluginIds: ["openai"] };
});
try {
await expect(
parent.run(() =>
acquireAgentRunPreparedModelRuntime(
{
...input,
workspaceDir: parent.snapshot.workspaceDir,
runtimePluginSelections: [
{ provider: "custom", modelId: "remote-200", agentId: "default" },
],
},
{
catalogMode: "static",
pluginGeneration: parent.pluginGeneration,
abortSignal: controller.signal,
},
),
),
).rejects.toThrow("Prepared model runtime lease admission aborted");
expect(ownersSpy).toHaveBeenCalled();
} finally {
ownersSpy.mockRestore();
}
});
it("keeps derived parents confined to their selections after a catalog publication", async () => {
await setup();
const input = fixture.agentInput("default", config);
await using configured = await acquirePublishedPreparedModelRuntime(input);
const selected = {
...input,
workspaceDir: configured.snapshot.workspaceDir,
runtimePluginSelections: [{ provider: "custom", modelId: "selected", runtime: "first" }],
};
// Each selection resolves its own harness owner; the parent registry holds only "first".
const ownersSpy = vi
.spyOn(runtimePluginLoadPlan, "resolveAgentRuntimePluginSelectionOwners")
.mockImplementation(({ selections }) => {
const pluginIds = selections.map((selection) =>
selection.provider === "openai" ? "openai" : "first",
);
return { pluginIds, forceActivatedPluginIds: pluginIds };
});
const registry = createEmptyPluginRegistry();
registry.plugins.push(createPluginRecord({ id: "first", status: "loaded" }));
mocks.loadAgentRuntimePluginRegistryHandle.mockReturnValue(registry);
try {
await using parent = scopePreparedModelRuntimeLease(
await acquireAgentRunPreparedModelRuntime(selected, {
catalogMode: "static",
pluginGeneration: configured.pluginGeneration,
}),
);
expect(parent.pluginGeneration).not.toBe(configured.pluginGeneration);
expect(await applyRemoteModelCatalogUpdate(() => config)).toBe("published");
await expect(
parent.run(() =>
acquireAgentRunPreparedModelRuntime(
{
...selected,
runtimePluginSelections: [
{ provider: "openai", modelId: "gpt-5.6-luna", runtime: "openclaw" },
],
},
{ catalogMode: "static", pluginGeneration: parent.pluginGeneration },
),
),
).rejects.toThrow(PreparedModelRuntimePublicationSupersededError);
} finally {
ownersSpy.mockRestore();
}
});
it("keeps downloaded catalogs pending while plugin work drains", async ({ signal }) => {
await setup();
const preparing = createDeferred();