From 3c5cecee06c1e783f69497e53e9fd41ef4bb094f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 20 Aug 2026 10:30:21 -0700 Subject: [PATCH] refactor: compact recovery ownership and model metadata (#126751) * refactor: compact recovery ownership and model metadata * chore: respect release-owned changelog gate * test: skip unsupported Windows permission assertions --- extensions/ollama/openclaw.plugin.json | 162 ------------------ .../main-session-recovery-store.test.ts | 21 ++- .../main-session-recovery-store.ts | 39 ++--- ...registry.lifecycle-retry-grace.e2e.test.ts | 24 ++- .../reply/reply-turn-admission.test.ts | 3 +- src/auto-reply/reply/reply-turn-admission.ts | 4 +- ...-accessor.sqlite-replacement-projection.ts | 11 +- src/cron/isolated-agent/run.ts | 3 +- .../node-worker-transfer-client.test.ts | 10 +- .../provider-discovery.runtime.test.ts | 16 +- src/plugins/provider-discovery.runtime.ts | 25 +-- 11 files changed, 71 insertions(+), 247 deletions(-) diff --git a/extensions/ollama/openclaw.plugin.json b/extensions/ollama/openclaw.plugin.json index 4431a6151474..39113a72971e 100644 --- a/extensions/ollama/openclaw.plugin.json +++ b/extensions/ollama/openclaw.plugin.json @@ -86,16 +86,9 @@ "models": [ { "id": "kimi-k2.5", - "name": "kimi-k2.5", "status": "deprecated", "reasoning": true, "input": ["text", "image"], - "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - }, "contextWindow": 262144, "maxTokens": 8192, "compat": { @@ -105,15 +98,8 @@ }, { "id": "kimi-k2.6", - "name": "kimi-k2.6", "reasoning": true, "input": ["text", "image"], - "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - }, "contextWindow": 262144, "maxTokens": 8192, "compat": { @@ -123,15 +109,8 @@ }, { "id": "kimi-k2.7-code", - "name": "kimi-k2.7-code", "reasoning": true, "input": ["text", "image"], - "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - }, "contextWindow": 262144, "maxTokens": 8192, "compat": { @@ -141,7 +120,6 @@ }, { "id": "kimi-k3", - "name": "kimi-k3", "reasoning": true, "input": ["text", "image"], "cost": { @@ -159,15 +137,8 @@ }, { "id": "deepseek-v4-flash", - "name": "deepseek-v4-flash", "reasoning": true, "input": ["text"], - "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - }, "contextWindow": 1048576, "maxTokens": 8192, "compat": { @@ -178,15 +149,8 @@ }, { "id": "deepseek-v4-flash:0731", - "name": "deepseek-v4-flash:0731", "reasoning": true, "input": ["text"], - "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - }, "contextWindow": 1048576, "maxTokens": 8192, "compat": { @@ -196,15 +160,8 @@ }, { "id": "deepseek-v4-flash:preview", - "name": "deepseek-v4-flash:preview", "reasoning": true, "input": ["text"], - "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - }, "contextWindow": 1048576, "maxTokens": 8192, "compat": { @@ -214,15 +171,8 @@ }, { "id": "deepseek-v4-pro", - "name": "deepseek-v4-pro", "reasoning": true, "input": ["text"], - "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - }, "contextWindow": 1048576, "maxTokens": 8192, "compat": { @@ -233,15 +183,8 @@ }, { "id": "deepseek-v4-pro:0813", - "name": "deepseek-v4-pro:0813", "reasoning": true, "input": ["text"], - "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - }, "contextWindow": 1048576, "maxTokens": 8192, "compat": { @@ -251,15 +194,8 @@ }, { "id": "deepseek-v4-pro:preview", - "name": "deepseek-v4-pro:preview", "reasoning": true, "input": ["text"], - "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - }, "contextWindow": 524288, "maxTokens": 8192, "compat": { @@ -269,15 +205,8 @@ }, { "id": "gemma4", - "name": "gemma4", "reasoning": true, "input": ["text", "image"], - "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - }, "contextWindow": 262144, "maxTokens": 8192, "compat": { @@ -287,15 +216,8 @@ }, { "id": "gemma4:31b", - "name": "gemma4:31b", "reasoning": true, "input": ["text", "image"], - "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - }, "contextWindow": 262144, "maxTokens": 8192, "compat": { @@ -305,15 +227,8 @@ }, { "id": "glm-5.1", - "name": "glm-5.1", "reasoning": true, "input": ["text"], - "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - }, "contextWindow": 202752, "maxTokens": 8192, "compat": { @@ -324,15 +239,8 @@ }, { "id": "glm-5.2", - "name": "glm-5.2", "reasoning": true, "input": ["text"], - "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - }, "contextWindow": 1000000, "maxTokens": 8192, "compat": { @@ -343,15 +251,8 @@ }, { "id": "gpt-oss:120b", - "name": "gpt-oss:120b", "reasoning": true, "input": ["text"], - "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - }, "contextWindow": 131072, "maxTokens": 8192, "compat": { @@ -361,15 +262,8 @@ }, { "id": "gpt-oss:20b", - "name": "gpt-oss:20b", "reasoning": true, "input": ["text"], - "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - }, "contextWindow": 131072, "maxTokens": 8192, "compat": { @@ -379,15 +273,8 @@ }, { "id": "minimax-m2.7", - "name": "minimax-m2.7", "reasoning": true, "input": ["text"], - "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - }, "contextWindow": 196608, "maxTokens": 8192, "compat": { @@ -397,15 +284,8 @@ }, { "id": "minimax-m3", - "name": "minimax-m3", "reasoning": true, "input": ["text", "image"], - "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - }, "contextWindow": 524288, "maxTokens": 8192, "compat": { @@ -415,15 +295,8 @@ }, { "id": "mistral-large-3:675b", - "name": "mistral-large-3:675b", "reasoning": false, "input": ["text", "image"], - "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - }, "contextWindow": 262144, "maxTokens": 8192, "compat": { @@ -433,15 +306,8 @@ }, { "id": "nemotron-3-nano:30b", - "name": "nemotron-3-nano:30b", "reasoning": true, "input": ["text"], - "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - }, "contextWindow": 262144, "maxTokens": 8192, "compat": { @@ -451,15 +317,8 @@ }, { "id": "nemotron-3-super", - "name": "nemotron-3-super", "reasoning": true, "input": ["text"], - "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - }, "contextWindow": 262144, "maxTokens": 8192, "compat": { @@ -469,15 +328,8 @@ }, { "id": "nemotron-3-ultra", - "name": "nemotron-3-ultra", "reasoning": true, "input": ["text"], - "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - }, "contextWindow": 262144, "maxTokens": 8192, "compat": { @@ -487,15 +339,8 @@ }, { "id": "qwen3.5", - "name": "qwen3.5", "reasoning": true, "input": ["text", "image"], - "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - }, "contextWindow": 262144, "maxTokens": 8192, "compat": { @@ -505,15 +350,8 @@ }, { "id": "qwen3.5:397b", - "name": "qwen3.5:397b", "reasoning": true, "input": ["text", "image"], - "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - }, "contextWindow": 262144, "maxTokens": 8192, "compat": { diff --git a/src/agents/main-session-recovery/main-session-recovery-store.test.ts b/src/agents/main-session-recovery/main-session-recovery-store.test.ts index 724d0267fe90..8304857e5970 100644 --- a/src/agents/main-session-recovery/main-session-recovery-store.test.ts +++ b/src/agents/main-session-recovery/main-session-recovery-store.test.ts @@ -11,6 +11,7 @@ import { getAgentEventLifecycleGeneration, rotateAgentEventLifecycleGeneration, } from "../../infra/agent-events.js"; +import * as recoveryOwnerRelease from "./main-session-recovery-owner-release.js"; import { claimMainSessionRecoveryOwner, commitMainSessionRecovery, @@ -403,10 +404,10 @@ describe("main session recovery store", () => { }, ); - const onDeferredSuccess = vi.fn(); - const immediateRelease = releaseMainSessionRecoveryOwner(claim.lease, { - onDeferredSuccess, - }); + const schedulePending = vi + .spyOn(recoveryOwnerRelease, "scheduleMainSessionRecoveryPendingTarget") + .mockImplementation(() => {}); + const immediateRelease = releaseMainSessionRecoveryOwner(claim.lease); const immediateReleaseRejected = expect(immediateRelease).rejects.toThrow( "transient session-store failure", ); @@ -418,11 +419,13 @@ describe("main session recovery store", () => { await vi.waitFor(() => { expect(read().mainRestartRecovery?.foregroundClaims).toBeUndefined(); }); - expect(onDeferredSuccess).toHaveBeenCalledWith({ - sessionId: "session-1", - sessionKey, - storePath, - }); + await vi.waitFor(() => + expect(schedulePending).toHaveBeenCalledWith({ + sessionId: "session-1", + sessionKey, + storePath, + }), + ); } finally { vi.useRealTimers(); } diff --git a/src/agents/main-session-recovery/main-session-recovery-store.ts b/src/agents/main-session-recovery/main-session-recovery-store.ts index d3f8904f8018..04144dbfd093 100644 --- a/src/agents/main-session-recovery/main-session-recovery-store.ts +++ b/src/agents/main-session-recovery/main-session-recovery-store.ts @@ -340,35 +340,8 @@ async function releaseMainSessionRecoveryOwnerWithRetries( return { sessionId: entry.sessionId, sessionKey, storePath: lease.storePath }; } -function scheduleMainSessionRecoveryOwnerRelease( - lease: MainSessionRecoveryOwnerLease, - onDeferredSuccess?: ( - pending: MainSessionRecoveryPendingTarget | undefined, - ) => void | Promise, -): void { - // A token is process-owned but durably blocks recovery. Keep exact-token - // cleanup alive through transient writer outages until release or restart. - scheduleMainSessionRecoveryMutation({ - mutation: () => releaseMainSessionRecoveryOwnerWithRetries(lease), - onSuccess: - onDeferredSuccess ?? - (async (pending) => { - if (pending) { - const { scheduleMainSessionRecoveryPendingTarget } = - await import("./main-session-recovery-owner-release.js"); - scheduleMainSessionRecoveryPendingTarget(pending); - } - }), - }); -} - export async function releaseMainSessionRecoveryOwner( lease: MainSessionRecoveryOwnerLease | undefined, - options?: { - onDeferredSuccess?: ( - pending: MainSessionRecoveryPendingTarget | undefined, - ) => void | Promise; - }, ): Promise { if (!lease) { return undefined; @@ -376,7 +349,17 @@ export async function releaseMainSessionRecoveryOwner( try { return await releaseMainSessionRecoveryOwnerWithRetries(lease); } catch (error) { - scheduleMainSessionRecoveryOwnerRelease(lease, options?.onDeferredSuccess); + // Exact-token cleanup survives transient writer outages without blocking its caller. + scheduleMainSessionRecoveryMutation({ + mutation: () => releaseMainSessionRecoveryOwnerWithRetries(lease), + onSuccess: async (pending) => { + if (pending) { + const { scheduleMainSessionRecoveryPendingTarget } = + await import("./main-session-recovery-owner-release.js"); + scheduleMainSessionRecoveryPendingTarget(pending); + } + }, + }); throw error; } } diff --git a/src/agents/subagents/registry/subagent-registry.lifecycle-retry-grace.e2e.test.ts b/src/agents/subagents/registry/subagent-registry.lifecycle-retry-grace.e2e.test.ts index 55bfb02d94bb..5f0139b17340 100644 --- a/src/agents/subagents/registry/subagent-registry.lifecycle-retry-grace.e2e.test.ts +++ b/src/agents/subagents/registry/subagent-registry.lifecycle-retry-grace.e2e.test.ts @@ -1,6 +1,8 @@ // Lifecycle retry-grace e2e tests cover completion delivery retry behavior when // lifecycle events race gateway waits or transient announce failures. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { SessionDeliveryState } from "../../../config/sessions/types.js"; +import type { AgentRunTerminalReplySnapshot } from "../../agent-run-terminal-reply.js"; import { testing as subagentAnnounceDeliveryTesting } from "../announce/subagent-announce-delivery.test-support.js"; import { testing as subagentAnnounceOutputTesting } from "../announce/subagent-announce-output.test-support.js"; import { testing as subagentAnnounceTesting } from "../announce/subagent-announce.js"; @@ -17,6 +19,7 @@ type LifecycleData = { endedAt?: number; aborted?: boolean; error?: string; + terminalReply?: AgentRunTerminalReplySnapshot; }; type LifecycleEvent = { stream?: string; @@ -28,10 +31,7 @@ type LifecycleEvent = { type SessionStoreEntry = { sessionId: string; updatedAt: number; - channel?: string; - lastChannel?: string; - to?: string; - accountId?: string; + delivery?: SessionDeliveryState; }; type GatewayAgentInternalEvent = { @@ -149,10 +149,12 @@ describe("subagent registry lifecycle error grace", () => { "agent:main:main": { sessionId: "sess-main", updatedAt: 1, - channel: "discord", - lastChannel: "discord", - to: "user-1", - accountId: "default", + delivery: { + kind: "external", + route: { channel: "discord", accountId: "default", target: { to: "user-1" } }, + context: { channel: "discord", to: "user-1", accountId: "default" }, + origin: { provider: "discord", to: "user-1", accountId: "default" }, + }, }, }, { @@ -394,7 +396,11 @@ describe("subagent registry lifecycle error grace", () => { registerCompletionRun(runId, "completed-before-yield", "finish once", requesterTurnRunId); setAssistantOutput(childSessionKey, "child complete"); - emitLifecycleEvent(runId, { phase: "end", endedAt: Date.now() }); + emitLifecycleEvent(runId, { + phase: "end", + endedAt: Date.now(), + terminalReply: { disposition: "visible", text: "child complete" }, + }); await waitForDeliveredCleanup(runId); const completed = mod diff --git a/src/auto-reply/reply/reply-turn-admission.test.ts b/src/auto-reply/reply/reply-turn-admission.test.ts index dba69f8464be..41d8b0ca2c0d 100644 --- a/src/auto-reply/reply/reply-turn-admission.test.ts +++ b/src/auto-reply/reply/reply-turn-admission.test.ts @@ -47,10 +47,9 @@ vi.mock( ...actual, releaseMainSessionRecoveryOwner: async ( lease: Parameters[0], - options: Parameters[1], ) => { await recoveryOwnerReleaseMocks.beforeRelease(); - return await actual.releaseMainSessionRecoveryOwner(lease, options); + return await actual.releaseMainSessionRecoveryOwner(lease); }, }; }, diff --git a/src/auto-reply/reply/reply-turn-admission.ts b/src/auto-reply/reply/reply-turn-admission.ts index f7c48f43eb7e..00321987b3e8 100644 --- a/src/auto-reply/reply/reply-turn-admission.ts +++ b/src/auto-reply/reply/reply-turn-admission.ts @@ -63,9 +63,7 @@ async function releaseReplyRecoveryOwner( return undefined; } try { - return await releaseMainSessionRecoveryOwner(lease, { - onDeferredSuccess: scheduleMainSessionRecoveryPendingTarget, - }); + return await releaseMainSessionRecoveryOwner(lease); } catch (error) { log.warn(`failed to release main-session recovery reply owner: ${formatErrorMessage(error)}`); // The durable owner schedules exact-token retries. A completed reply must diff --git a/src/config/sessions/session-accessor.sqlite-replacement-projection.ts b/src/config/sessions/session-accessor.sqlite-replacement-projection.ts index f6ffa3fd6b40..345f5fbfa51c 100644 --- a/src/config/sessions/session-accessor.sqlite-replacement-projection.ts +++ b/src/config/sessions/session-accessor.sqlite-replacement-projection.ts @@ -166,15 +166,12 @@ async function applySqliteSessionEntryReplacementProjection( (transactionDb) => { const transactionEntries = new Map(); for (const sessionKey of validationKeys) { - const transactionEntry = readExactSessionEntryRow(transactionDb, sessionKey)?.entry; - if ( - readExactSessionEntryJson(transactionDb, sessionKey) !== - expectedEntryJson.get(sessionKey) - ) { + const transactionRow = readExactSessionEntryRow(transactionDb, sessionKey); + if (transactionRow?.row.entry_json !== expectedEntryJson.get(sessionKey)) { throw new Error(`SQLite session entry changed before replacement for ${sessionKey}`); } - if (transactionEntry) { - transactionEntries.set(sessionKey, transactionEntry); + if (transactionRow) { + transactionEntries.set(sessionKey, transactionRow.entry); } } for (const replacement of applicable) { diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index c615e11b1ffa..0bb6333e9c1e 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -340,7 +340,7 @@ export async function runCronIsolatedAgentTurn(params: { } finally { try { if (!cronRunSessionCleanupAttempted) { - const cleanupOutcome = await cleanupCronRunSessionAfterRun({ + await cleanupCronRunSessionAfterRun({ job: params.job, agentSessionKey: prepared.context.agentSessionKey, sessionId: prepared.context.currentRunSessionId(), @@ -349,7 +349,6 @@ export async function runCronIsolatedAgentTurn(params: { beforeDelete: prepared.context.sessionWorkAdmission.release, reason: "cron-delete-after-run-finally", }); - cronRunSessionCleanupAttempted = cleanupOutcome !== "not-requested"; } } finally { // Release runtime references after the run completes (success or failure). diff --git a/src/node-host/node-worker-transfer-client.test.ts b/src/node-host/node-worker-transfer-client.test.ts index 5f66cd801741..c94f46cd5e31 100644 --- a/src/node-host/node-worker-transfer-client.test.ts +++ b/src/node-host/node-worker-transfer-client.test.ts @@ -747,10 +747,12 @@ describe("node worker transfer client", () => { await expect(fs.readFile(path.join(workspaceDir, "tracked.txt"), "utf8")).resolves.toBe( changed ? "changed on gateway\n" : "tracked from gateway\n", ); - expect((await fs.stat(path.join(workspaceDir, "tracked.txt"))).mode & 0o777).toBe( - changed ? 0o755 : 0o644, - ); - expect((await fs.stat(path.join(workspaceDir, "script.sh"))).mode & 0o777).toBe(0o755); + if (process.platform !== "win32") { + expect((await fs.stat(path.join(workspaceDir, "tracked.txt"))).mode & 0o777).toBe( + changed ? 0o755 : 0o644, + ); + expect((await fs.stat(path.join(workspaceDir, "script.sh"))).mode & 0o777).toBe(0o755); + } await expect(fs.readlink(path.join(workspaceDir, "tracked-link"))).resolves.toBe( changed ? "script.sh" : "tracked.txt", ); diff --git a/src/plugins/provider-discovery.runtime.test.ts b/src/plugins/provider-discovery.runtime.test.ts index 85313aeb961f..53c500ec0a8d 100644 --- a/src/plugins/provider-discovery.runtime.test.ts +++ b/src/plugins/provider-discovery.runtime.test.ts @@ -751,7 +751,18 @@ describe("resolvePluginDiscoveryProvidersRuntime", () => { expect(mocks.resolvePluginProvidersCore).not.toHaveBeenCalled(); }); - it("defaults missing manifest model costs for static discovery entries", async () => { + it.each([ + { + name: "missing", + cost: undefined, + expectedCost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + { + name: "partial", + cost: { input: 3, output: 15, cacheRead: 0.3 }, + expectedCost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 }, + }, + ])("defaults only $name manifest model cost components", async ({ cost, expectedCost }) => { mocks.resolveDiscoveredProviderPluginIds.mockReturnValue(["anthropic"]); mocks.loadPluginMetadataSnapshot.mockReturnValue({ index: { plugins: [] }, @@ -772,6 +783,7 @@ describe("resolvePluginDiscoveryProvidersRuntime", () => { input: ["text"], contextWindow: 200000, maxTokens: 64000, + ...(cost ? { cost } : {}), }, ], }, @@ -799,7 +811,7 @@ describe("resolvePluginDiscoveryProvidersRuntime", () => { models: [ expect.objectContaining({ id: "claude-sonnet-4-6", - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + cost: expectedCost, }), ], }), diff --git a/src/plugins/provider-discovery.runtime.ts b/src/plugins/provider-discovery.runtime.ts index 39965edef472..058682118c85 100644 --- a/src/plugins/provider-discovery.runtime.ts +++ b/src/plugins/provider-discovery.runtime.ts @@ -114,26 +114,13 @@ function hasProviderAuthEnvCredential( function modelDefinitionCostFromManifestRow( row: NormalizedModelCatalogRow, ): ModelDefinitionConfig["cost"] { - if ( - !row.cost || - row.cost.input === undefined || - row.cost.output === undefined || - row.cost.cacheRead === undefined || - row.cost.cacheWrite === undefined - ) { - return { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }; - } + const cost = row.cost; return { - input: row.cost.input, - output: row.cost.output, - cacheRead: row.cost.cacheRead, - cacheWrite: row.cost.cacheWrite, - ...(row.cost.tieredPricing ? { tieredPricing: row.cost.tieredPricing } : {}), + input: cost?.input ?? 0, + output: cost?.output ?? 0, + cacheRead: cost?.cacheRead ?? 0, + cacheWrite: cost?.cacheWrite ?? 0, + ...(cost?.tieredPricing ? { tieredPricing: cost.tieredPricing } : {}), }; }