mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-31 20:47:00 +00:00
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:
parent
cda4572901
commit
a293f2e850
3 changed files with 172 additions and 20 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue