From b0e9fd62522ec2b2e269bffed9d97f968e4a4bef Mon Sep 17 00:00:00 2001
From: licheer-zte
Date: Wed, 26 Aug 2026 00:20:12 +0800
Subject: [PATCH] fix(workboard): heal oversized persisted notifications
(#120999)
Preserve the existing UTF-16-safe notification cap at the canonical metadata normalization boundary so a legacy oversized row cannot abort dispatch or starve sibling cards. Move the existing capText helper into its normalization owner without production growth.
Co-authored-by: licheer-zte
Co-authored-by: Peter Steinberger
---
.../workboard/src/store-card-helpers.ts | 9 +---
extensions/workboard/src/store-enrichment.ts | 2 +-
extensions/workboard/src/store-normalizers.ts | 10 ++++-
extensions/workboard/src/store-workflow.ts | 2 +-
extensions/workboard/src/store.test.ts | 44 +++++++++++++++++++
extensions/workboard/src/store.ts | 3 +-
6 files changed, 57 insertions(+), 13 deletions(-)
diff --git a/extensions/workboard/src/store-card-helpers.ts b/extensions/workboard/src/store-card-helpers.ts
index e9745f7280c1..ce11ea2c0e9e 100644
--- a/extensions/workboard/src/store-card-helpers.ts
+++ b/extensions/workboard/src/store-card-helpers.ts
@@ -16,7 +16,6 @@ import {
} from "@openclaw/workboard-contract";
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
-import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import {
BLOCKED_TOO_LONG_MS,
MAX_CARD_ATTEMPTS,
@@ -26,6 +25,7 @@ import {
} from "./store-constants.js";
import type { WorkboardMutationScope } from "./store-inputs.js";
import {
+ capText,
metadataIsEmpty,
normalizeEvents,
normalizeTimestamp,
@@ -522,13 +522,6 @@ export function computeCardDiagnostics(card: WorkboardCard, now: number): Workbo
return diagnostics;
}
-export function capText(value: string | undefined, max: number): string | undefined {
- if (!value) {
- return undefined;
- }
- return value.length <= max ? value : `${truncateUtf16Safe(value, Math.max(0, max - 1))}…`;
-}
-
export function cardBoardId(card: WorkboardCard): string {
return card.metadata?.automation?.boardId ?? "default";
}
diff --git a/extensions/workboard/src/store-enrichment.ts b/extensions/workboard/src/store-enrichment.ts
index 136ada0ff2ef..15db426aaf52 100644
--- a/extensions/workboard/src/store-enrichment.ts
+++ b/extensions/workboard/src/store-enrichment.ts
@@ -8,7 +8,6 @@ import type {
import type { PersistedWorkboardAttachment } from "./persistence-types.js";
import {
assertCanMutateClaimedCard,
- capText,
cardRunId,
cardSessionKey,
closeRunningAttempts,
@@ -30,6 +29,7 @@ import type {
WorkboardWorkerLogInput,
} from "./store-inputs.js";
import {
+ capText,
clearDiagnostics,
normalizeArtifact,
normalizeAttachmentInput,
diff --git a/extensions/workboard/src/store-normalizers.ts b/extensions/workboard/src/store-normalizers.ts
index 35b1e5abf4ac..155a37e169fb 100644
--- a/extensions/workboard/src/store-normalizers.ts
+++ b/extensions/workboard/src/store-normalizers.ts
@@ -44,6 +44,7 @@ import {
} from "@openclaw/workboard-contract";
import { resolveNonNegativeIntegerOption } from "openclaw/plugin-sdk/number-runtime";
import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
+import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import {
MAX_ATTACHMENT_BYTES,
MAX_CARD_ARTIFACTS,
@@ -278,6 +279,13 @@ export function normalizeBoundedString(
return normalized;
}
+export function capText(value: string | undefined, max: number): string | undefined {
+ if (!value) {
+ return undefined;
+ }
+ return value.length <= max ? value : `${truncateUtf16Safe(value, Math.max(0, max - 1))}…`;
+}
+
export function normalizeStatus(value: unknown, fallback: WorkboardStatus): WorkboardStatus {
if (typeof value !== "string" || !value.trim()) {
return fallback;
@@ -941,7 +949,7 @@ function normalizeNotification(value: unknown): WorkboardNotification | null {
const kind = normalizeEnumValue(record.kind, WORKBOARD_NOTIFICATION_KINDS, undefined);
const createdAt = normalizeTimestamp(record.createdAt, Date.now());
const sequence = normalizeTimestamp(record.sequence, 0) || undefined;
- const message = normalizeBoundedString(record.message, undefined, 240, "notification message");
+ const message = capText(normalizeOptionalString(record.message), 240);
if (!kind || !message) {
return null;
}
diff --git a/extensions/workboard/src/store-workflow.ts b/extensions/workboard/src/store-workflow.ts
index 3c646d2efc13..1a52661790a7 100644
--- a/extensions/workboard/src/store-workflow.ts
+++ b/extensions/workboard/src/store-workflow.ts
@@ -13,7 +13,6 @@ import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
assertCanMutateClaimedCard,
- capText,
cardBoardId,
cardChildIds,
cardParentIds,
@@ -48,6 +47,7 @@ import type {
} from "./store-inputs.js";
import {
appendCompletionProof,
+ capText,
clearDiagnostics,
deriveChildIdempotencyKey,
normalizeArtifact,
diff --git a/extensions/workboard/src/store.test.ts b/extensions/workboard/src/store.test.ts
index 1568fc5eaa9d..143cdf84c67b 100644
--- a/extensions/workboard/src/store.test.ts
+++ b/extensions/workboard/src/store.test.ts
@@ -2748,6 +2748,50 @@ describe("WorkboardStore", () => {
expect(blocked.metadata?.notifications?.[0]?.message.length).toBeLessThanOrEqual(240);
});
+ it("heals oversized persisted notifications and keeps dispatching sibling cards", async () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-workboard-notification-"));
+ const dbPath = path.join(dir, "workboard.sqlite");
+ const stores = createWorkboardSqliteStores({ dbPath });
+ try {
+ const store = new WorkboardStore(stores.cards);
+ const poisoned = await store.create({ title: "Oversized notification", status: "ready" });
+ const sibling = await store.create({ title: "Unaffected sibling", status: "ready" });
+ const oversized = `${"x".repeat(238)}🦞${" tail".repeat(60)}`;
+ const rawDb = new DatabaseSync(dbPath);
+ try {
+ rawDb
+ .prepare(
+ "INSERT INTO workboard_card_notifications (id, card_id, ordinal, kind, message, created_at) VALUES (?, ?, ?, ?, ?, ?)",
+ )
+ .run("oversized", poisoned.id, 0, "failed", oversized, Date.now());
+ } finally {
+ rawDb.close();
+ }
+
+ expect((await store.get(poisoned.id))?.metadata?.notifications?.[0]?.message).toBe(oversized);
+ await expect(store.dispatch()).resolves.toBeDefined();
+
+ const repaired = await store.get(poisoned.id);
+ expect(repaired?.metadata?.notifications?.[0]?.message).toBe(`${"x".repeat(238)}…`);
+ expect(repaired?.metadata?.automation?.dispatchCount).toBe(1);
+ expect((await store.get(sibling.id))?.metadata?.automation?.dispatchCount).toBe(1);
+
+ const verifyDb = new DatabaseSync(dbPath, { readOnly: true });
+ try {
+ expect(
+ verifyDb
+ .prepare("SELECT message FROM workboard_card_notifications WHERE id = ?")
+ .get("oversized"),
+ ).toEqual({ message: `${"x".repeat(238)}…` });
+ } finally {
+ verifyDb.close();
+ }
+ } finally {
+ stores.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
it("dispatches ready cards and blocks expired or timed-out work", async () => {
vi.useFakeTimers();
try {
diff --git a/extensions/workboard/src/store.ts b/extensions/workboard/src/store.ts
index 01cb519cfdc1..540820e1f08c 100644
--- a/extensions/workboard/src/store.ts
+++ b/extensions/workboard/src/store.ts
@@ -21,7 +21,6 @@ import { createWorkboardSqliteStores } from "./sqlite-store.js";
import {
buildWorkerContext,
assertCanMutateClaimedCard,
- capText,
cardBoardId,
cardRunId,
cardSessionKey,
@@ -49,7 +48,7 @@ import type {
WorkboardDispatchResult,
WorkboardMutationScope,
} from "./store-inputs.js";
-import { normalizeBoardId, normalizeTimestamp } from "./store-normalizers.js";
+import { capText, normalizeBoardId, normalizeTimestamp } from "./store-normalizers.js";
import { WorkboardNotificationStore } from "./store-notifications.js";
export type { WorkboardDispatchResult } from "./store-inputs.js";