From a480896890fa424e779ee5d44fc67e8f4fb72873 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Thu, 10 Sep 2026 21:01:04 -0700 Subject: [PATCH] fix(codex): ignore empty goal clear snapshots --- ...dex-structured-journal-goal-resume.test.ts | 22 +++++++++++++++++++ .../codex/codex-structured-journal-goals.ts | 11 ++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/main/codex/codex-structured-journal-goal-resume.test.ts b/src/main/codex/codex-structured-journal-goal-resume.test.ts index 4805589eb14..1d917ee7b2d 100644 --- a/src/main/codex/codex-structured-journal-goal-resume.test.ts +++ b/src/main/codex/codex-structured-journal-goal-resume.test.ts @@ -96,6 +96,28 @@ function texts(rows: readonly AgentJournalItemBody[]): string[] { } describe('codex goal lifecycle resume', () => { + it('does not append a cleared snapshot when the journal has no prior goal occurrence', async () => { + const journal = goalJournal() + journal.unbind() + const resumed = new CodexJournalGoals(journal.sink) + + expect( + resumed.handle({ + threadId: THREAD, + method: 'thread/goal/cleared', + params: { threadId: THREAD, turnId: null, clearedAt: 1789068999 } + }) + ).toEqual({ accepted: true }) + expect(journal.writes).toHaveLength(0) + + journal.rebind() + await journal.drained() + + expect(journal.writes).toHaveLength(0) + expect(journal.publishes()).toBe(0) + resumed.dispose() + }) + it('does not scan durable history for accounting-only updates', async () => { const journal = goalJournal() const goals = new CodexJournalGoals(journal.sink) diff --git a/src/main/codex/codex-structured-journal-goals.ts b/src/main/codex/codex-structured-journal-goals.ts index 9d48ebbb53e..6df1b63041f 100644 --- a/src/main/codex/codex-structured-journal-goals.ts +++ b/src/main/codex/codex-structured-journal-goals.ts @@ -57,7 +57,8 @@ function goalStateFromItem(itemId: string, thread: string): GoalThreadState | nu function persistedGoalIdentity( journal: Pick, thread: string, - signature: string + signature: string, + requirePrevious: boolean ): AgentJournalItemIdentity | null { const previousItemId = journal.latestItemIdMatching( (itemId) => goalStateFromItem(itemId, thread) !== null @@ -66,6 +67,11 @@ function persistedGoalIdentity( if (previous?.signature === signature) { return null } + // Codex sends a cleared snapshot while resuming threads that never had a goal. + // A clear is only a lifecycle occurrence when durable history proves one existed. + if (previous === null && requirePrevious) { + return null + } const occurrence = previous ? digest(JSON.stringify([previous.occurrence, signature])) : digest(JSON.stringify([thread, signature])) @@ -116,7 +122,8 @@ export class CodexJournalGoals { this.sink, goalIdentity(thread, signatureKey, occurrence), translated.body, - (journal) => persistedGoalIdentity(journal, thread, signatureKey) + (journal) => + persistedGoalIdentity(journal, thread, signatureKey, event.method === 'thread/goal/cleared') ) if (!admission.accepted) { return admission