fix(workboard): avoid duplicate proof entries on completion (#111324)

* fix(workboard): resolve duplicate completion proof

* fix(workboard): import proof cap from constants

* fix(workboard): correlate completion proof by id

* fix(workboard): reuse terminal completion proof

* fix(workboard): retain correlated proof under budget
This commit is contained in:
Peter Steinberger
2026-07-19 03:00:37 -07:00
committed by GitHub
parent 5c9916950e
commit d685037c6e
12 changed files with 377 additions and 36 deletions
+4
View File
@@ -83,6 +83,10 @@ openclaw workboard show 7f4a2c10 --json
Text output prints the compact card line and notes. JSON output returns the full card record, including execution metadata, attempts, comments, links, proof, artifacts, worker logs, protocol state, diagnostics, and automation metadata.
Proof statuses in JSON are worker-reported outcomes. `passed` records the worker's
self-assessment of the attached command or check; it is not an independent verification
result.
## `move`
```bash
+10
View File
@@ -161,6 +161,16 @@ rule as linked sessions (see [Session lifecycle sync](#session-lifecycle-sync)).
| `workboard_move` | Move a card to another status; claimed cards require the caller's agent claim scope. |
| `workboard_dispatch` | Nudge dependency promotion or stale-claim cleanup without launching workers; worker launch uses Gateway or slash-command dispatch. |
Proof statuses are worker-reported outcomes, not independent verification. A `passed`
entry means the worker reports that its command or check succeeded; consumers that need
an independent quality gate should inspect the attached command, URL, or artifact and
run their own verifier. `workboard_proof` returns the new record's `proofId`. When
`workboard_complete` reports that same proof's terminal status, pass `proofId` so the
pending record is resolved in place without losing its identity or timestamp. A proof that
already has the same terminal status is reused unchanged. Completion proof without
`proofId` remains append-only, so a later retry cannot rewrite older history merely because
its command or note is identical.
Claimed cards reject agent-tool mutations from other agents unless the caller
holds the claim token returned by `workboard_claim`. Every card returned by an
agent tool or Gateway RPC call redacts `metadata.claim.token` to `[redacted]`
@@ -824,6 +824,7 @@ describe("dispatchAndStartWorkboardCards", () => {
});
expect(run.mock.calls[0]?.[0]?.message).toContain("Claim token:");
expect(run.mock.calls[0]?.[0]?.message).toContain("workboard_complete with the card id");
expect(run.mock.calls[0]?.[0]?.message).toContain("returned proofId");
expect(run.mock.calls[0]?.[0]?.message).not.toContain("ownerId and token");
await expect(store.get(first.id)).resolves.toMatchObject({
status: "running",
+1
View File
@@ -203,6 +203,7 @@ function buildWorkerPrompt(params: {
"",
"Heartbeat with workboard_heartbeat using the card id and token while working.",
"When done, call workboard_complete with the card id, token, summary, and proof.",
"If you called workboard_proof separately, pass its returned proofId to workboard_complete.",
"If blocked, call workboard_block with the card id, token, and reason.",
"",
params.context,
+16 -6
View File
@@ -122,13 +122,14 @@ export class WorkboardCoreStore {
protected async updateMetadata(
id: string,
mutate: (existing: WorkboardCard) => WorkboardMetadata,
options: { preserveProofId?: string } = {},
): Promise<WorkboardCard> {
return await this.enqueueMutation(async () => {
const existing = await this.get(id);
if (!existing) {
throw new Error(`card not found: ${id}`);
}
return await this.updateCard(id, { metadata: mutate(existing) });
return await this.updateCard(id, { metadata: mutate(existing) }, options);
});
}
@@ -461,7 +462,11 @@ export class WorkboardCoreStore {
protected async updateCard(
id: string,
patch: WorkboardCardPatch,
options: { allowMetadataDependencyLinks?: boolean; enforceStatusHolds?: boolean } = {},
options: {
allowMetadataDependencyLinks?: boolean;
enforceStatusHolds?: boolean;
preserveProofId?: string;
} = {},
): Promise<WorkboardCard> {
const existing = await this.get(id);
if (!existing) {
@@ -519,6 +524,7 @@ export class WorkboardCoreStore {
: normalizeExecution(effectivePatch.execution);
let metadata = normalizeMetadata(effectivePatch.metadata, existing.metadata, {
allowDependencyLinks: options.allowMetadataDependencyLinks !== false,
preserveProofId: options.preserveProofId,
});
if (status !== existing.status && !hasFreshLifecycleStatusSource) {
// Status patches often spread existing metadata. Only a newly supplied
@@ -543,10 +549,13 @@ export class WorkboardCoreStore {
}
}
if (Object.keys(automationPatch).length > 0) {
metadata = trimMetadataToBudget({
...metadata,
automation: normalizeAutomationPatch(automationPatch, metadata.automation),
});
metadata = trimMetadataToBudget(
{
...metadata,
automation: normalizeAutomationPatch(automationPatch, metadata.automation),
},
options,
);
}
const next = removeUndefinedCardFields({
...existing,
@@ -595,6 +604,7 @@ export class WorkboardCoreStore {
});
next.metadata = trimMetadataToBudget(
syncExecutionAttemptMetadata(next.metadata ?? {}, execution, now),
options,
);
next.events = appendEvent(next, updateEvent(existing, next), now);
if (options.enforceStatusHolds && effectivePatch.status !== undefined) {
+25 -17
View File
@@ -45,14 +45,18 @@ export class WorkboardEnrichmentStore extends WorkboardCoreStore {
): Promise<WorkboardCard> {
const now = Date.now();
const proof = normalizeProofInput(input, now);
return await this.updateMetadata(id, (existing) => {
assertCanMutateClaimedCard(existing, scope);
const metadata = clearDiagnostics(existing.metadata, ["missing_proof"]);
return {
...metadata,
proof: [...(metadata.proof ?? []), proof].slice(-MAX_CARD_PROOF),
};
});
return await this.updateMetadata(
id,
(existing) => {
assertCanMutateClaimedCard(existing, scope);
const metadata = clearDiagnostics(existing.metadata, ["missing_proof"]);
return {
...metadata,
proof: [...(metadata.proof ?? []), proof].slice(-MAX_CARD_PROOF),
};
},
{ preserveProofId: proof.id },
);
}
async addProofWithArtifact(
@@ -67,15 +71,19 @@ export class WorkboardEnrichmentStore extends WorkboardCoreStore {
if (!artifact) {
throw new Error("artifact url or path is required.");
}
return await this.updateMetadata(id, (existing) => {
assertCanMutateClaimedCard(existing, scope);
const metadata = clearDiagnostics(existing.metadata, ["missing_proof"]);
return {
...metadata,
proof: [...(metadata.proof ?? []), proof].slice(-MAX_CARD_PROOF),
artifacts: [...(metadata.artifacts ?? []), artifact].slice(-MAX_CARD_ARTIFACTS),
};
});
return await this.updateMetadata(
id,
(existing) => {
assertCanMutateClaimedCard(existing, scope);
const metadata = clearDiagnostics(existing.metadata, ["missing_proof"]);
return {
...metadata,
proof: [...(metadata.proof ?? []), proof].slice(-MAX_CARD_PROOF),
artifacts: [...(metadata.artifacts ?? []), artifact].slice(-MAX_CARD_ARTIFACTS),
};
},
{ preserveProofId: proof.id },
);
}
async addArtifact(
+1
View File
@@ -110,6 +110,7 @@ export type WorkboardCompleteInput = {
token?: unknown;
summary?: unknown;
proof?: unknown;
proofId?: unknown;
artifacts?: unknown;
createdCardIds?: unknown;
};
+74 -7
View File
@@ -983,13 +983,51 @@ export function normalizeProofInput(input: WorkboardProofInput, now: number): Wo
};
}
function completionProofConflicts(existing: WorkboardProof, completion: WorkboardProof): boolean {
return (["label", "command", "url", "note"] as const).some(
(field) => completion[field] !== undefined && completion[field] !== existing[field],
);
}
export function appendCompletionProof(
existing: readonly WorkboardProof[] | undefined,
proof: WorkboardProof,
proofId?: string,
): WorkboardProof[] {
const entries = [...(existing ?? [])];
if (!proofId) {
return [...entries, proof].slice(-MAX_CARD_PROOF);
}
const index = entries.findIndex((entry) => entry.id === proofId);
const pending = index >= 0 ? entries[index] : undefined;
if (!pending) {
throw new Error(`proof not found: ${proofId}`);
}
if (proof.status === "unknown") {
throw new Error("completion proof status must be passed, failed, or skipped.");
}
if (completionProofConflicts(pending, proof)) {
throw new Error(`completion proof does not match pending proof: ${proofId}`);
}
if (pending.status !== "unknown") {
if (pending.status !== proof.status) {
throw new Error(`completion proof status does not match existing proof: ${proofId}`);
}
return entries.slice(-MAX_CARD_PROOF);
}
// A proof id is the durable correlation boundary between a separately recorded check and its
// completion. Preserve the original evidence identity and timestamp while resolving its status.
entries[index] = { ...pending, status: proof.status };
return entries.slice(-MAX_CARD_PROOF);
}
export function normalizeMetadata(
value: unknown,
fallback: WorkboardMetadata = {},
options: { allowDependencyLinks?: boolean } = {},
options: { allowDependencyLinks?: boolean; preserveProofId?: string } = {},
): WorkboardMetadata {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return trimMetadataToBudget(fallback);
return trimMetadataToBudget(fallback, options);
}
const record = value as Record<string, unknown>;
const stale =
@@ -1017,7 +1055,7 @@ export function normalizeMetadata(
];
})()
: links.slice(-MAX_CARD_LINKS);
return trimMetadataToBudget({
const normalized = {
attempts: Array.isArray(record.attempts)
? record.attempts
.map(normalizeAttempt)
@@ -1102,7 +1140,8 @@ export function normalizeMetadata(
typeof record.failureCount === "number" && Number.isFinite(record.failureCount)
? Math.max(0, Math.trunc(record.failureCount))
: fallback.failureCount,
});
};
return trimMetadataToBudget(normalized, options);
}
export function normalizeExecution(value: unknown): WorkboardExecution | undefined {
@@ -1260,6 +1299,21 @@ function dropFirst<T>(items: readonly T[] | undefined): T[] | undefined {
return next.length ? next : undefined;
}
function dropFirstProofExcept(
items: readonly WorkboardProof[] | undefined,
preserveProofId: string | undefined,
): WorkboardProof[] | undefined {
if (!items?.length) {
return undefined;
}
const index = preserveProofId ? items.findIndex((proof) => proof.id !== preserveProofId) : 0;
if (index < 0) {
return items.slice();
}
const next = items.filter((_, itemIndex) => itemIndex !== index);
return next.length ? next : undefined;
}
function dropFirstNonDependencyLink(
items: readonly WorkboardLink[] | undefined,
): WorkboardLink[] | undefined {
@@ -1289,7 +1343,10 @@ export function appendLinkPreservingDependencies(
return next.filter((_, index) => index !== dropIndex);
}
export function trimMetadataToBudget(metadata: WorkboardMetadata): WorkboardMetadata {
export function trimMetadataToBudget(
metadata: WorkboardMetadata,
options: { preserveProofId?: string } = {},
): WorkboardMetadata {
let next = removeUndefinedMetadataFields(metadata);
while (metadataByteSize(next) > MAX_CARD_METADATA_BYTES) {
const currentSize = metadataByteSize(next);
@@ -1302,8 +1359,13 @@ export function trimMetadataToBudget(metadata: WorkboardMetadata): WorkboardMeta
...next,
notifications: dropFirst(next.notifications),
});
} else if (next.proof?.length) {
next = removeUndefinedMetadataFields({ ...next, proof: dropFirst(next.proof) });
} else if (
next.proof?.some((proof) => !options.preserveProofId || proof.id !== options.preserveProofId)
) {
next = removeUndefinedMetadataFields({
...next,
proof: dropFirstProofExcept(next.proof, options.preserveProofId),
});
} else if (next.artifacts?.length) {
next = removeUndefinedMetadataFields({ ...next, artifacts: dropFirst(next.artifacts) });
} else if (next.attachments?.length) {
@@ -1322,8 +1384,13 @@ export function trimMetadataToBudget(metadata: WorkboardMetadata): WorkboardMeta
}
} else if (next.comments?.length) {
next = removeUndefinedMetadataFields({ ...next, comments: dropFirst(next.comments) });
} else if (options.preserveProofId) {
throw new Error(`card metadata cannot retain proof: ${options.preserveProofId}`);
}
if (metadataByteSize(next) >= currentSize) {
if (options.preserveProofId) {
throw new Error(`card metadata cannot retain proof: ${options.preserveProofId}`);
}
break;
}
}
+13 -3
View File
@@ -26,7 +26,6 @@ import {
MAX_CARD_ARTIFACTS,
MAX_CARD_COMMENTS,
MAX_CARD_NOTIFICATIONS,
MAX_CARD_PROOF,
secondsToDurationMs,
} from "./store-constants.js";
import type {
@@ -44,6 +43,7 @@ import type {
WorkboardSpecifyInput,
} from "./store-inputs.js";
import {
appendCompletionProof,
clearDiagnostics,
deriveChildIdempotencyKey,
normalizeArtifact,
@@ -248,6 +248,13 @@ export class WorkboardWorkflowStore extends WorkboardPromoteStore {
input.proof && typeof input.proof === "object" && !Array.isArray(input.proof)
? (input.proof as WorkboardProofInput)
: undefined;
const proofId = normalizeBoundedString(input.proofId, undefined, 120, "proof id");
if (input.proofId !== undefined && !proofId) {
throw new Error("proofId must be a non-empty string.");
}
if (proofId && !proofInput) {
throw new Error("proof is required when proofId is provided.");
}
const proof = proofInput ? normalizeProofInput(proofInput, now) : undefined;
const artifacts = Array.isArray(input.artifacts)
? input.artifacts
@@ -293,7 +300,7 @@ export class WorkboardWorkflowStore extends WorkboardPromoteStore {
{ id: randomUUID(), body: summary, createdAt: now },
].slice(-MAX_CARD_COMMENTS)
: metadata.comments,
proof: proof ? [...(metadata.proof ?? []), proof].slice(-MAX_CARD_PROOF) : metadata.proof,
proof: proof ? appendCompletionProof(metadata.proof, proof, proofId) : metadata.proof,
artifacts: artifacts.length
? [...(metadata.artifacts ?? []), ...artifacts].slice(-MAX_CARD_ARTIFACTS)
: metadata.artifacts,
@@ -302,7 +309,10 @@ export class WorkboardWorkflowStore extends WorkboardPromoteStore {
),
},
},
{ enforceStatusHolds: true },
{
enforceStatusHolds: true,
...(proof ? { preserveProofId: proofId ?? proof.id } : {}),
},
);
}
+200
View File
@@ -1010,6 +1010,206 @@ describe("WorkboardStore", () => {
expect(restored.events?.at(-1)).toMatchObject({ kind: "unarchived" });
});
it("resolves matching unknown proof on completion without duplicating it", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(1_000);
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({ title: "Resolve worker proof", status: "running" });
const claimed = await store.claim(card.id, { ownerId: "main", token: "token-1" });
const proofInput = {
label: "Haiku syllable check",
command: "review poem",
note: "Checked each line.",
};
const pending = await store.addProof(claimed.card.id, proofInput, {
ownerId: "main",
token: "token-1",
});
vi.setSystemTime(6_000);
const completed = await store.complete(claimed.card.id, {
ownerId: "main",
token: "token-1",
summary: "Poem complete.",
proofId: pending.metadata?.proof?.[0]?.id,
proof: { ...proofInput, status: "passed" },
});
expect(completed.metadata?.proof).toEqual([
{
...pending.metadata?.proof?.[0],
status: "passed",
},
]);
} finally {
vi.useRealTimers();
}
});
it("retains the correlated proof when metadata budget trimming is required", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Keep proof under metadata pressure",
status: "running",
metadata: {
artifacts: Array.from({ length: 12 }, (_, index) => ({
id: `artifact-${index}`,
createdAt: index + 1,
url: `https://example.com/${index}/${"x".repeat(1900)}`,
})),
},
});
const claimed = await store.claim(card.id, { ownerId: "main", token: "token-1" });
const artifactCountBefore = claimed.card.metadata?.artifacts?.length ?? 0;
expect(artifactCountBefore).toBeGreaterThan(5);
const proofInput = {
command: "pnpm test extensions/workboard",
note: "y".repeat(1800),
};
const pending = await store.addProof(card.id, proofInput, {
ownerId: "main",
token: "token-1",
});
const pendingProof = pending.metadata?.proof?.at(-1);
expect(pendingProof).toMatchObject({ status: "unknown", command: proofInput.command });
expect(pending.metadata?.artifacts?.length ?? 0).toBeLessThan(artifactCountBefore);
expect(Buffer.byteLength(JSON.stringify(pending.metadata), "utf8")).toBeLessThanOrEqual(
24 * 1024,
);
const completed = await store.complete(card.id, {
ownerId: "main",
token: "token-1",
summary: "Proof survived metadata trimming.",
proofId: pendingProof?.id,
proof: { ...proofInput, status: "passed" },
});
expect(completed.metadata?.proof).toEqual([{ ...pendingProof, status: "passed" }]);
expect(Buffer.byteLength(JSON.stringify(completed.metadata), "utf8")).toBeLessThanOrEqual(
24 * 1024,
);
});
it("keeps identical completion proof append-only without an explicit proof id", async () => {
const store = new WorkboardStore(createMemoryStore());
const proofInput = { command: "pnpm test extensions/workboard", note: "94 tests" };
const card = await store.create({
title: "Preserve historical proof",
metadata: {
proof: [{ id: "proof-unknown", status: "unknown", createdAt: 1_000, ...proofInput }],
},
});
const failed = await store.complete(card.id, {
summary: "A later run failed.",
proof: { ...proofInput, status: "failed" },
});
expect(failed.metadata?.proof?.map((entry) => [entry.id, entry.status])).toEqual([
["proof-unknown", "unknown"],
[expect.any(String), "failed"],
]);
});
it("resolves only the explicitly correlated proof across identical retries", async () => {
const store = new WorkboardStore(createMemoryStore());
const proofInput = { command: "review poem", note: "Checked each line." };
const card = await store.create({
title: "Keep unresolved proof history",
metadata: {
proof: [
{ id: "proof-older", status: "unknown", createdAt: 1_000, ...proofInput },
{ id: "proof-latest", status: "unknown", createdAt: 2_000, ...proofInput },
],
},
});
const completed = await store.complete(card.id, {
summary: "Latest check passed.",
proofId: "proof-latest",
proof: { ...proofInput, status: "passed" },
});
expect(completed.metadata?.proof?.map((entry) => [entry.id, entry.status])).toEqual([
["proof-older", "unknown"],
["proof-latest", "passed"],
]);
});
it("reuses an explicitly correlated terminal proof with the same status", async () => {
const store = new WorkboardStore(createMemoryStore());
const proof = {
id: "proof-passed",
status: "passed" as const,
createdAt: 1_000,
command: "pnpm test extensions/workboard",
};
const card = await store.create({
title: "Reuse terminal proof",
metadata: { proof: [proof] },
});
const completed = await store.complete(card.id, {
summary: "Already passed.",
proofId: proof.id,
proof: { status: "passed", command: proof.command },
});
expect(completed.metadata?.proof).toEqual([proof]);
});
it("rejects an explicitly correlated terminal proof with a different status", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Reject terminal status rewrite",
metadata: {
proof: [{ id: "proof-passed", status: "passed", createdAt: 1_000 }],
},
});
await expect(
store.complete(card.id, {
proofId: "proof-passed",
proof: { status: "failed" },
}),
).rejects.toThrow("completion proof status does not match existing proof: proof-passed");
await expect(store.get(card.id)).resolves.toMatchObject({
status: "todo",
metadata: { proof: [{ id: "proof-passed", status: "passed" }] },
});
});
it("rejects a completion proof id when its evidence does not match", async () => {
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Reject mismatched proof",
metadata: {
proof: [
{
id: "proof-pending",
status: "unknown",
createdAt: 1_000,
command: "pnpm test extensions/workboard",
},
],
},
});
await expect(
store.complete(card.id, {
proofId: "proof-pending",
proof: { status: "passed", command: "pnpm test extensions/other" },
}),
).rejects.toThrow("completion proof does not match pending proof: proof-pending");
await expect(store.get(card.id)).resolves.toMatchObject({
status: "todo",
metadata: { proof: [{ id: "proof-pending", status: "unknown" }] },
});
});
it("stores attachments in the plugin kv namespace and adds worker context", async () => {
const attachments = createMemoryStore<PersistedWorkboardAttachment>();
const store = new WorkboardStore(createMemoryStore(), { attachments });
+13 -1
View File
@@ -409,16 +409,28 @@ describe("workboard tools", () => {
await tools.get("workboard_claim")?.execute("call-3", { id: parent.id }),
);
const token = claimed.token as string;
const pendingProof = readPayload(
await tools.get("workboard_proof")?.execute("call-proof", {
id: parent.id,
token,
command: "pnpm test extensions/workboard",
}),
);
expect(pendingProof.proofId).toEqual(expect.any(String));
const completed = readPayload(
await tools.get("workboard_complete")?.execute("call-4", {
id: parent.id,
token,
summary: "Done.",
createdCardIds: [child.id],
proofId: pendingProof.proofId,
proof: { status: "passed", command: "pnpm test extensions/workboard" },
}),
);
expect(completed.card).toMatchObject({ status: "done" });
expect(completed.card).toMatchObject({
status: "done",
metadata: { proof: [{ id: pendingProof.proofId, status: "passed" }] },
});
const dispatch = readPayload(await tools.get("workboard_dispatch")?.execute("call-5", {}));
expect(dispatch.promoted).toEqual([expect.objectContaining({ id: child.id, status: "ready" })]);
+19 -2
View File
@@ -168,6 +168,17 @@ function redactedRawCardResult(card: WorkboardCard) {
return jsonResult(redactClaimToken(card));
}
function redactedProofResult(card: WorkboardCard) {
const proofId = card.metadata?.proof?.at(-1)?.id;
if (!proofId) {
throw new Error("proof was not retained in card metadata.");
}
return jsonResult({
card: redactClaimToken(card),
proofId,
});
}
const CardIdSchema = Type.Object(
{
id: cardIdField(),
@@ -439,7 +450,7 @@ export function createWorkboardTools(params: {
name: "workboard_proof",
label: "Workboard Proof",
description:
"Attach proof or artifact metadata to a Workboard card after running tests, checks, or producing screenshots/logs.",
"Attach proof or artifact metadata to a Workboard card after running tests, checks, or producing screenshots/logs. Returns proofId; pass it to workboard_complete when that call reports the terminal status for this proof.",
parameters: Type.Object(
{
id: cardIdField(),
@@ -474,7 +485,7 @@ export function createWorkboardTools(params: {
scope,
)
: await store.addProof(id, record, scope);
return redactedCardResult(card);
return redactedProofResult(card);
},
},
{
@@ -487,6 +498,12 @@ export function createWorkboardTools(params: {
id: cardIdField(),
token: claimTokenField(),
summary: Type.Optional(Type.String({ description: "Completion summary." })),
proofId: Type.Optional(
Type.String({
description:
"Proof id returned by workboard_proof when resolving that pending proof.",
}),
),
proof: Type.Optional(
Type.Object(
{