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
This commit is contained in:
Koduri Mahesh Bhushan Chowdary
2026-08-01 18:15:07 +02:00
committed by GitHub
parent 22a902c4cd
commit 4cbbccd0a0
4 changed files with 52 additions and 3 deletions
@@ -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);
+1 -1
View File
@@ -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),
+10 -2
View File
@@ -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<string, unknown>)
: 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)
+18
View File
@@ -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 {