From 4cbbccd0a0d20c11e123c2356db682dc51c4dde8 Mon Sep 17 00:00:00 2001 From: Koduri Mahesh Bhushan Chowdary <128616744+MaheshBhushan@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:15:07 +0200 Subject: [PATCH] fix(workboard): ignore caller-supplied archivedAt when creating a card (#116412) Archiving is a lifecycle transition owned by archive(), which routes through updateCard and appends the matching `archived` event. createDirect had no such guard: it passed input.metadata straight into normalizeMetadata, which treats archivedAt as an ordinary caller-supplied field. A card could therefore be created already archived. Archived cards are excluded from dispatch at every status, so the card was unstartable from the instant it existed, while its event log contained only `created` - no archive ever happened, so no `archived` event was ever recorded. The board reported work that could never start, and the missing event sent operators looking for a rogue archiver that did not exist. normalizeMetadata already carries an options bag for rules that are stricter on create than on update (allowDependencyLinks). Add allowArchivedAt alongside it and pass false from createDirect, so archivedAt falls back to the create fallback (undefined) instead of the caller's value. The update path is unchanged: patches that set archivedAt still archive the card and still emit the event. Fixes #116395 --- .../src/dispatcher-ownership.test.ts | 23 +++++++++++++++++++ extensions/workboard/src/store-core.ts | 2 +- extensions/workboard/src/store-normalizers.ts | 12 ++++++++-- extensions/workboard/src/store.test.ts | 18 +++++++++++++++ 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/extensions/workboard/src/dispatcher-ownership.test.ts b/extensions/workboard/src/dispatcher-ownership.test.ts index 8d5cc35b8cc0..5fc23072d698 100644 --- a/extensions/workboard/src/dispatcher-ownership.test.ts +++ b/extensions/workboard/src/dispatcher-ownership.test.ts @@ -24,6 +24,29 @@ function createMemoryStore(): WorkboardKeyedStore { } describe("Workboard dispatcher ownership", () => { + it("dispatches a card whose create input tried to inject archivedAt", async () => { + const store = new WorkboardStore(createMemoryStore()); + const now = 10; + const card = await store.create({ + title: "Injected archive", + status: "ready", + workspaceAccess: { unrestricted: true }, + metadata: { archivedAt: now }, + }); + const run = vi.fn().mockResolvedValue({ runId: "run-injected" }); + + await dispatchAndStartWorkboardCards({ + store, + subagent: { run }, + options: { now, maxStarts: 1 }, + }); + + expect(run).toHaveBeenCalledTimes(1); + await expect(store.get(card.id)).resolves.toMatchObject({ + execution: { runId: "run-injected" }, + }); + }); + it("falls back to one default owner for persisted blank and unassigned agents", async () => { const keyed = createMemoryStore(); const store = new WorkboardStore(keyed); diff --git a/extensions/workboard/src/store-core.ts b/extensions/workboard/src/store-core.ts index 96a9c92ee0fa..bbff7ea07299 100644 --- a/extensions/workboard/src/store-core.ts +++ b/extensions/workboard/src/store-core.ts @@ -389,7 +389,7 @@ export class WorkboardCoreStore { templateId: normalizeTemplateId(input.templateId), ...(childAutomation ? { automation: childAutomation } : {}), }, - { allowDependencyLinks: false }, + { allowDependencyLinks: false, allowArchivedAt: false }, ); const syncedMetadata = trimMetadataToBudget( syncExecutionAttemptMetadata(metadata, execution, now), diff --git a/extensions/workboard/src/store-normalizers.ts b/extensions/workboard/src/store-normalizers.ts index 1fcd4c5faca1..5627dbb68405 100644 --- a/extensions/workboard/src/store-normalizers.ts +++ b/extensions/workboard/src/store-normalizers.ts @@ -1024,7 +1024,11 @@ export function appendCompletionProof( export function normalizeMetadata( value: unknown, fallback: WorkboardMetadata = {}, - options: { allowDependencyLinks?: boolean; preserveProofId?: string } = {}, + options: { + allowDependencyLinks?: boolean; + allowArchivedAt?: boolean; + preserveProofId?: string; + } = {}, ): WorkboardMetadata { if (!value || typeof value !== "object" || Array.isArray(value)) { return trimMetadataToBudget(fallback, options); @@ -1034,7 +1038,11 @@ export function normalizeMetadata( record.stale && typeof record.stale === "object" && !Array.isArray(record.stale) ? (record.stale as Record) : null; - const hasArchivedAt = Object.hasOwn(record, "archivedAt"); + // Archival is a transition owned by archive(), which appends the matching + // `archived` event. Callers that cannot produce that event (create) must not + // be able to declare it, or the card is excluded from dispatch from the + // instant it exists with an event log recording only `created`. + const hasArchivedAt = Object.hasOwn(record, "archivedAt") && options.allowArchivedAt !== false; const hasStale = Object.hasOwn(record, "stale"); const hasLifecycleStatusSourceUpdatedAt = Object.hasOwn(record, "lifecycleStatusSourceUpdatedAt"); const links = Array.isArray(record.links) diff --git a/extensions/workboard/src/store.test.ts b/extensions/workboard/src/store.test.ts index b16b8694042d..8cf6c15c9cf0 100644 --- a/extensions/workboard/src/store.test.ts +++ b/extensions/workboard/src/store.test.ts @@ -1010,6 +1010,24 @@ describe("WorkboardStore", () => { expect(restored.events?.at(-1)).toMatchObject({ kind: "unarchived" }); }); + it("ignores caller-supplied archivedAt on create so no card is born archived", async () => { + const store = new WorkboardStore(createMemoryStore()); + const card = await store.create({ + title: "Injected archive", + metadata: { archivedAt: Date.now() }, + }); + + // Archival is a transition owned by archive(), which appends the matching + // event. Honouring it here would exclude the card from dispatch from birth + // with an event log recording only "created". + expect(card.metadata?.archivedAt).toBeUndefined(); + expect(card.events?.map((event) => event.kind)).toEqual(["created"]); + + const archived = await store.archive(card.id, true); + expect(archived.metadata?.archivedAt).toBeGreaterThan(0); + expect(archived.events?.at(-1)).toMatchObject({ kind: "archived" }); + }); + it("resolves matching unknown proof on completion without duplicating it", async () => { vi.useFakeTimers(); try {