fix(update): accept shipped upgrade state

Recognize the shipped non-STRICT commitments schema, keep direct test state canonical, and require Doctor convergence for legacy agent and Discord config during published upgrades.

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
Peter Steinberger
2026-08-12 20:46:02 -07:00
parent d300cbd048
commit f587c09984
8 changed files with 356 additions and 62 deletions
@@ -407,11 +407,22 @@ function assertConfigSurvived() {
}
if (acceptsIntent(coverage, "agents")) {
const stage = process.env.OPENCLAW_UPGRADE_SURVIVOR_ASSERT_STAGE || "survival";
const legacyAgents = config.agents?.list ?? [];
const mainAgent =
config.agents?.entries?.main ?? legacyAgents.find((agent) => agent?.id === "main");
const opsAgent =
config.agents?.entries?.ops ?? legacyAgents.find((agent) => agent?.id === "ops");
const entries = config.agents?.entries;
if (stage !== "baseline") {
assert(config.agents?.ownership === "explicit", "agent ownership was not canonicalized");
assert(entries && typeof entries === "object", "keyed agent entries missing after Doctor");
assert(!Object.hasOwn(config.agents ?? {}, "list"), "legacy agent list survived Doctor");
for (const entry of Object.values(entries ?? {})) {
assert(
!Object.hasOwn(entry ?? {}, "default"),
"legacy agent default marker survived Doctor",
);
}
}
const mainAgent = entries?.main ?? legacyAgents.find((agent) => agent?.id === "main");
const opsAgent = entries?.ops ?? legacyAgents.find((agent) => agent?.id === "ops");
assert(mainAgent, "main agent missing");
assert(opsAgent, "ops agent missing");
if (hasCoverage(coverage)) {
@@ -467,10 +478,23 @@ function assertConfigSurvived() {
}
if (acceptsIntent(coverage, "discord-channel")) {
const stage = process.env.OPENCLAW_UPGRADE_SURVIVOR_ASSERT_STAGE || "survival";
const discord = config.channels?.discord;
assert(discord?.enabled === true, "discord enabled flag changed");
const discordAllowFrom = discord.allowFrom ?? discord.dm?.allowFrom;
const discordDmPolicy = discord.dmPolicy ?? discord.dm?.policy;
const discordAllowFrom =
stage === "baseline" ? (discord.allowFrom ?? discord.dm?.allowFrom) : discord.allowFrom;
const discordDmPolicy =
stage === "baseline" ? (discord.dmPolicy ?? discord.dm?.policy) : discord.dmPolicy;
if (stage !== "baseline") {
assert(
!Object.hasOwn(discord.dm ?? {}, "policy"),
"legacy Discord DM policy survived Doctor",
);
assert(
!Object.hasOwn(discord.dm ?? {}, "allowFrom"),
"legacy Discord allowFrom survived Doctor",
);
}
assert(discordDmPolicy === "allowlist", "discord DM policy changed");
assert(
Array.isArray(discordAllowFrom) && discordAllowFrom.includes("111111111111111111"),
+12 -18
View File
@@ -127,6 +127,7 @@ function scenarioConfig(scenario: string, options: TestStateOptions = {}) {
},
},
agents: {
ownership: "explicit",
defaults: {
model: {
primary: "openai/gpt-5.6-luna",
@@ -136,7 +137,6 @@ function scenarioConfig(scenario: string, options: TestStateOptions = {}) {
},
entries: {
main: {
default: true,
name: "Main",
workspace: "~/workspace",
model: {
@@ -176,10 +176,8 @@ function scenarioConfig(scenario: string, options: TestStateOptions = {}) {
discord: {
enabled: true,
token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" },
dm: {
policy: "allowlist",
allowFrom: ["111111111111111111"],
},
dmPolicy: "allowlist",
allowFrom: ["111111111111111111"],
groupPolicy: "allowlist",
guilds: {
"222222222222222222": {
@@ -472,6 +470,7 @@ OPENCLAW_TEST_STATE_JSON
}
},
"agents": {
"ownership": "explicit",
"defaults": {
"model": {
"primary": "openai/gpt-5.6-luna"
@@ -481,10 +480,8 @@ OPENCLAW_TEST_STATE_JSON
"memory"
]
},
"list": [
{
"id": "main",
"default": true,
"entries": {
"main": {
"name": "Main",
"workspace": "~/workspace",
"model": {
@@ -496,8 +493,7 @@ OPENCLAW_TEST_STATE_JSON
],
"contextTokens": 64000
},
{
"id": "ops",
"ops": {
"name": "Ops",
"workspace": "~/workspace/ops",
"model": {
@@ -505,7 +501,7 @@ OPENCLAW_TEST_STATE_JSON
},
"fastModeDefault": true
}
]
}
},
"skills": {
"allowBundled": [
@@ -545,12 +541,10 @@ OPENCLAW_TEST_STATE_JSON
"provider": "default",
"id": "DISCORD_BOT_TOKEN"
},
"dm": {
"policy": "allowlist",
"allowFrom": [
"111111111111111111"
]
},
"dmPolicy": "allowlist",
"allowFrom": [
"111111111111111111"
],
"groupPolicy": "allowlist",
"guilds": {
"222222222222222222": {
+17 -11
View File
@@ -1723,13 +1723,10 @@ describe("doctor config flow", () => {
expect(result.cfg.agents).not.toHaveProperty("list");
});
it("materializes ambient roles for a multi-agent configured default", async () => {
it("materializes ambient roles for the shipped multi-agent list default", async () => {
const rawConfig = {
agents: {
entries: {
ops: { default: true },
research: {},
},
list: [{ id: "main", default: true }, { id: "ops" }],
},
channels: { telegram: { enabled: true } },
talk: { provider: "test" },
@@ -1744,16 +1741,25 @@ describe("doctor config flow", () => {
expect(result.shouldWriteConfig).toBe(true);
expect(result.cfg.bindings).toEqual([
{ agentId: "ops", match: { channel: "telegram", accountId: "*" } },
{ agentId: "main", match: { channel: "telegram", accountId: "*" } },
]);
expect(result.cfg.agents?.defaults).toMatchObject({
heartbeat: { agentId: "ops" },
systemAgent: { agentId: "ops" },
authInheritance: { agentId: "ops" },
heartbeat: { agentId: "main" },
systemAgent: { agentId: "main" },
});
expect(result.cfg.talk).toMatchObject({ provider: "test", agentId: "ops" });
expect(result.cfg.agents?.entries?.ops).not.toHaveProperty("default");
expect(result.cfg.agents?.defaults?.authInheritance).toBeUndefined();
expect(result.cfg.talk).toMatchObject({ provider: "test", agentId: "main" });
expect(result.cfg.agents).not.toHaveProperty("list");
expect(result.cfg.agents?.entries?.main).not.toHaveProperty("default");
expect(result.cfg.agents?.ownership).toBe("explicit");
const secondRun = await runDoctorConfigWithInput({
config: result.cfg,
parsedConfig: result.cfg,
repair: true,
run: loadAndMaybeMigrateDoctorConfig,
});
expect(secondRun.shouldWriteConfig).toBe(false);
});
it("preserves shared all-agent heartbeat enrollment during materialization", async () => {
+13 -22
View File
@@ -81,7 +81,7 @@ CREATE INDEX idx_commitments_agent_sent
ON commitments(agent_id, status, sent_at_ms, session_key);
`;
const ADDITIVE_RETIRED_COMMITMENTS_SCHEMA_SQL = `
const SHIPPED_RETIRED_COMMITMENTS_SCHEMA_SQL = `
CREATE TABLE commitments (
id TEXT NOT NULL PRIMARY KEY,
agent_id TEXT NOT NULL,
@@ -91,22 +91,22 @@ CREATE TABLE commitments (
recipient_id TEXT,
thread_id TEXT,
sender_id TEXT,
kind TEXT NOT NULL DEFAULT 'followup',
sensitivity TEXT NOT NULL DEFAULT 'normal',
source TEXT NOT NULL DEFAULT 'unknown',
kind TEXT NOT NULL,
sensitivity TEXT NOT NULL,
source TEXT NOT NULL,
status TEXT NOT NULL,
reason TEXT NOT NULL DEFAULT '',
suggested_text TEXT NOT NULL DEFAULT '',
dedupe_key TEXT NOT NULL DEFAULT '',
confidence REAL NOT NULL DEFAULT 0,
reason TEXT NOT NULL,
suggested_text TEXT NOT NULL,
dedupe_key TEXT NOT NULL,
confidence REAL NOT NULL,
due_earliest_ms INTEGER NOT NULL,
due_latest_ms INTEGER NOT NULL,
due_timezone TEXT NOT NULL DEFAULT 'UTC',
due_timezone TEXT NOT NULL,
source_message_id TEXT,
source_run_id TEXT,
created_at_ms INTEGER NOT NULL DEFAULT 0,
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
attempts INTEGER NOT NULL,
last_attempt_at_ms INTEGER,
sent_at_ms INTEGER,
dismissed_at_ms INTEGER,
@@ -118,12 +118,8 @@ CREATE INDEX idx_commitments_scope_due
ON commitments(agent_id, session_key, status, due_earliest_ms, due_latest_ms);
CREATE INDEX idx_commitments_status_due
ON commitments(status, due_earliest_ms, due_latest_ms);
CREATE INDEX idx_commitments_agent_due
ON commitments(agent_id, status, due_earliest_ms, due_latest_ms, session_key);
CREATE INDEX idx_commitments_scope_dedupe
ON commitments(agent_id, session_key, channel, dedupe_key, status);
CREATE INDEX idx_commitments_agent_sent
ON commitments(agent_id, status, sent_at_ms, session_key);
`;
const RETIRED_COMMITMENTS_INDEX_NAMES = [
@@ -178,11 +174,6 @@ const RETIRED_COMMITMENTS_SCHEMA_COMPATIBILITY: SqliteSchemaCompatibility = {
allowedMissingIndexes: RETIRED_COMMITMENTS_INDEX_NAMES,
};
const ADDITIVE_RETIRED_COMMITMENTS_SCHEMA_COMPATIBILITY: SqliteSchemaCompatibility = {
allowedMissingColumns: RETIRED_COMMITMENTS_ADDITIVE_COLUMNS,
allowedMissingIndexes: RETIRED_COMMITMENTS_INDEX_NAMES,
};
function hasSupportedRetiredCommitmentsSchema(
db: DatabaseSync,
schemaSql: string,
@@ -233,9 +224,9 @@ function hasRecognizedRetiredCommitmentsSchema(db: DatabaseSync): boolean {
) ||
hasSupportedRetiredCommitmentsSchema(
db,
ADDITIVE_RETIRED_COMMITMENTS_SCHEMA_SQL,
SHIPPED_RETIRED_COMMITMENTS_SCHEMA_SQL,
RETIRED_COMMITMENTS_INDEX_NAMES,
ADDITIVE_RETIRED_COMMITMENTS_SCHEMA_COMPATIBILITY,
RETIRED_COMMITMENTS_SCHEMA_COMPATIBILITY,
)
);
}
+130
View File
@@ -318,6 +318,63 @@ function seedAdditiveV6CommitmentSchema(database: DatabaseSync): void {
markStateDatabaseAsV6(database);
}
function seedV2026_7_1_2CommitmentSchema(database: DatabaseSync): void {
database.exec(`
CREATE TABLE commitments (
id TEXT NOT NULL PRIMARY KEY,
agent_id TEXT NOT NULL,
session_key TEXT NOT NULL,
channel TEXT NOT NULL,
account_id TEXT,
recipient_id TEXT,
thread_id TEXT,
sender_id TEXT,
kind TEXT NOT NULL,
sensitivity TEXT NOT NULL,
source TEXT NOT NULL,
status TEXT NOT NULL,
reason TEXT NOT NULL,
suggested_text TEXT NOT NULL,
dedupe_key TEXT NOT NULL,
confidence REAL NOT NULL,
due_earliest_ms INTEGER NOT NULL,
due_latest_ms INTEGER NOT NULL,
due_timezone TEXT NOT NULL,
source_message_id TEXT,
source_run_id TEXT,
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL,
attempts INTEGER NOT NULL,
last_attempt_at_ms INTEGER,
sent_at_ms INTEGER,
dismissed_at_ms INTEGER,
snoozed_until_ms INTEGER,
expired_at_ms INTEGER,
record_json TEXT NOT NULL
);
CREATE INDEX idx_commitments_scope_due
ON commitments(agent_id, session_key, status, due_earliest_ms, due_latest_ms);
CREATE INDEX idx_commitments_status_due
ON commitments(status, due_earliest_ms, due_latest_ms);
CREATE INDEX idx_commitments_scope_dedupe
ON commitments(agent_id, session_key, channel, dedupe_key, status);
INSERT INTO commitments (
id, agent_id, session_key, channel, kind, sensitivity, source, status,
reason, suggested_text, dedupe_key, confidence, due_earliest_ms,
due_latest_ms, due_timezone, created_at_ms, updated_at_ms, attempts, record_json
) VALUES (
'released-commitment', 'main', 'agent:main:main', 'telegram', 'followup',
'normal', 'message', 'pending', 'inert', 'follow up', 'released-dedupe',
1.0, 10, 20, 'UTC', 1, 1, 0, '{}'
);
INSERT INTO state_leases (
scope, lease_key, owner, expires_at, heartbeat_at, payload_json, created_at, updated_at
) VALUES ('test', 'released-preserved-lease', 'migration-test', 100, 50, '{}', 1, 2);
PRAGMA user_version = 1;
UPDATE schema_meta SET schema_version = 1 WHERE meta_key = 'primary';
`);
}
function seedPartiallyAdditiveV6CommitmentSchema(database: DatabaseSync): void {
database.exec(`
CREATE TABLE commitments (
@@ -1583,6 +1640,79 @@ describe("openclaw state database", () => {
},
);
it.each(["runtime open", "doctor repair"] as const)(
"retires the shipped v2026.7.1-2 commitments layout through %s",
(migrationPath) => {
const stateDir = createTempStateDir();
const options = { env: { OPENCLAW_STATE_DIR: stateDir } };
const databasePath = materializeCurrentStateDatabase(stateDir);
const { DatabaseSync } = requireNodeSqlite();
const released = new DatabaseSync(databasePath);
seedV2026_7_1_2CommitmentSchema(released);
expect(readSqliteNumberPragma(released, "user_version")).toBe(1);
expect(
released.prepare("SELECT schema_version FROM schema_meta WHERE meta_key = 'primary'").get(),
).toEqual({ schema_version: 1 });
expect(
released.prepare("SELECT strict FROM pragma_table_list WHERE name = 'commitments'").get(),
).toEqual({ strict: 0 });
expect(
released
.prepare(
`SELECT name
FROM sqlite_schema
WHERE type = 'index'
AND tbl_name = 'commitments'
AND sql IS NOT NULL
ORDER BY name`,
)
.all(),
).toEqual([
{ name: "idx_commitments_scope_dedupe" },
{ name: "idx_commitments_scope_due" },
{ name: "idx_commitments_status_due" },
]);
released.close();
if (migrationPath === "doctor repair") {
const result = repairOpenClawStateDatabaseSchema(options);
expect(result.warnings).toEqual([]);
expect(result.changes).toContain("Retired shared state commitments table and indexes");
}
const migrated = openOpenClawStateDatabase(options);
expect(readSqliteNumberPragma(migrated.db, "user_version")).toBe(7);
expect(
migrated.db
.prepare("SELECT schema_version FROM schema_meta WHERE meta_key = 'primary'")
.get(),
).toEqual({ schema_version: 7 });
for (const name of RETIRED_COMMITMENT_SCHEMA_OBJECTS) {
expect(
migrated.db.prepare("SELECT name FROM sqlite_schema WHERE name = ?").get(name),
).toBeUndefined();
}
expect(
migrated.db
.prepare(
`SELECT scope, lease_key, owner, expires_at, heartbeat_at, payload_json,
created_at, updated_at
FROM state_leases
WHERE scope = 'test' AND lease_key = 'released-preserved-lease'`,
)
.get(),
).toEqual({
scope: "test",
lease_key: "released-preserved-lease",
owner: "migration-test",
expires_at: 100,
heartbeat_at: 50,
payload_json: "{}",
created_at: 1,
updated_at: 2,
});
},
);
it.each(["runtime open", "doctor repair"] as const)(
"retires a partially additive v6 commitments layout through %s",
(migrationPath) => {
+26 -4
View File
@@ -233,11 +233,17 @@ describe("scripts/lib/openclaw-test-state", () => {
source: "env",
},
});
expect(payload.config.agents.ownership).toBe("explicit");
expect(Object.keys(payload.config.agents.entries)).toEqual(["main", "ops"]);
expect(payload.config.agents).not.toHaveProperty("list");
for (const agent of Object.values(payload.config.agents.entries)) {
expect(agent).not.toHaveProperty("default");
}
expect(payload.config.channels.discord.enabled).toBe(true);
expect(payload.config.channels.discord.dm).toStrictEqual({
allowFrom: ["111111111111111111"],
policy: "allowlist",
});
expect(payload.config.channels.discord.dmPolicy).toBe("allowlist");
expect(payload.config.channels.discord.allowFrom).toEqual(["111111111111111111"]);
expect(payload.config.channels.discord.dm?.policy).toBeUndefined();
expect(payload.config.channels.discord.dm?.allowFrom).toBeUndefined();
expect(payload.config.channels.telegram.enabled).toBe(true);
expect(payload.config.channels.whatsapp.enabled).toBe(true);
} finally {
@@ -273,6 +279,22 @@ describe("scripts/lib/openclaw-test-state", () => {
expect(payload.secretKey).toMatch(secretKeyPattern);
expect(payload.config).toStrictEqual({});
const upgradeProbe = await execFileAsync("bash", [
"-lc",
`${cleanupTestStateHomeTrap()}; export OPENCLAW_TEST_STATE_TMPDIR=${shellQuote(path.join(tempRoot, "upgrade-function-tmp"))}; source ${shellQuote(snippetFile)}; openclaw_test_state_create "upgrade case" upgrade-survivor; node -e 'const fs=require("node:fs"); process.stdout.write(fs.readFileSync(process.env.OPENCLAW_CONFIG_PATH,"utf8"));'`,
]);
const upgradeConfig = JSON.parse(upgradeProbe.stdout);
expect(upgradeConfig.agents.ownership).toBe("explicit");
expect(Object.keys(upgradeConfig.agents.entries)).toEqual(["main", "ops"]);
expect(upgradeConfig.agents).not.toHaveProperty("list");
for (const agent of Object.values(upgradeConfig.agents.entries)) {
expect(agent).not.toHaveProperty("default");
}
expect(upgradeConfig.channels.discord.dmPolicy).toBe("allowlist");
expect(upgradeConfig.channels.discord.allowFrom).toEqual(["111111111111111111"]);
expect(upgradeConfig.channels.discord.dm?.policy).toBeUndefined();
expect(upgradeConfig.channels.discord.dm?.allowFrom).toBeUndefined();
const trailingTmpDir = path.join(tempRoot, "function-trailing-tmp");
const trailingProbe = await execFileAsync("bash", [
"-lc",
@@ -1,5 +1,5 @@
// Upgrade Survivor Assertions tests cover upgrade survivor assertions script behavior.
import { execFileSync } from "node:child_process";
import { execFileSync, spawnSync } from "node:child_process";
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -449,6 +449,118 @@ describe("upgrade survivor assertions", () => {
}
});
it("requires canonical agent and Discord config after Doctor", () => {
const root = mkdtempSync(join(tmpdir(), "openclaw-upgrade-survivor-config-stage-"));
try {
const configPath = join(root, "openclaw.json");
const coveragePath = join(root, "coverage.json");
writeJson(coveragePath, {
acceptedIntents: ["agents", "discord-channel"],
skippedIntents: [],
});
const discord = {
enabled: true,
groupPolicy: "allowlist",
guilds: {
"222222222222222222": {
channels: { "333333333333333333": { requireMention: true } },
},
},
threadBindings: { idleHours: 72 },
};
const legacy = {
agents: {
defaults: { contextTokens: 64000 },
list: [
{ id: "main", default: true, contextTokens: 64000 },
{ id: "ops", fastModeDefault: true },
],
},
channels: {
discord: {
...discord,
dm: { policy: "allowlist", allowFrom: ["111111111111111111"] },
},
},
};
const canonical = {
agents: {
ownership: "explicit",
defaults: { contextTokens: 64000 },
entries: {
main: { contextTokens: 64000 },
ops: { fastModeDefault: true },
},
},
channels: {
discord: {
...discord,
dmPolicy: "allowlist",
allowFrom: ["111111111111111111"],
},
},
};
const run = (config: unknown, stage: "baseline" | "survival") => {
writeJson(configPath, config);
return spawnSync(process.execPath, [ASSERTIONS_PATH, "assert-config"], {
encoding: "utf8",
env: {
...process.env,
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_UPGRADE_SURVIVOR_ASSERT_STAGE: stage,
OPENCLAW_UPGRADE_SURVIVOR_CONFIG_COVERAGE_JSON: coveragePath,
OPENCLAW_UPGRADE_SURVIVOR_SCENARIO: "base",
},
});
};
expect(run(legacy, "baseline").status).toBe(0);
expect(run(legacy, "survival").status).not.toBe(0);
expect(run(canonical, "survival").status).toBe(0);
expect(
run(
{
...canonical,
agents: {
...canonical.agents,
entries: {
...canonical.agents.entries,
main: { ...canonical.agents.entries.main, default: true },
},
},
},
"survival",
).status,
).not.toBe(0);
const {
allowFrom: _allowFrom,
dmPolicy: _dmPolicy,
...discordWithoutCanonicalDm
} = canonical.channels.discord;
expect(
run(
{
...canonical,
channels: {
discord: {
...discordWithoutCanonicalDm,
dm: {
policy: "allowlist",
allowFrom: ["111111111111111111"],
},
},
},
},
"survival",
).status,
).not.toBe(0);
} finally {
rmSync(root, { force: true, recursive: true });
}
});
it("accepts the ACPX OpenClaw tools bridge scenario during seed", () => {
const root = mkdtempSync(join(tmpdir(), "openclaw-upgrade-survivor-acpx-"));
try {
@@ -146,6 +146,21 @@ describe("upgrade survivor config recipe command resolution", () => {
]);
});
it("pins the shipped v2026.7.1-2 agent and Discord aliases", () => {
const recipeRoot = join(process.cwd(), "scripts/e2e/lib/upgrade-survivor/config-recipe");
const agents = JSON.parse(readFileSync(join(recipeRoot, "agents.json"), "utf8"));
const discord = JSON.parse(readFileSync(join(recipeRoot, "channels-discord.json"), "utf8"));
expect(agents.list.map((agent: { id: string }) => agent.id)).toEqual(["main", "ops"]);
expect(agents.list[0]).toMatchObject({ id: "main", default: true });
expect(agents).not.toHaveProperty("entries");
expect(discord.dm).toEqual({
policy: "allowlist",
allowFrom: ["111111111111111111"],
});
expect(discord).not.toHaveProperty("dmPolicy");
});
it("inserts scenario config before final validation", () => {
const steps = resolveUpgradeSurvivorConfigSteps("feishu-channel");
expect(steps.find((step) => step.id === "channels-discord")).toBeDefined();