From e1df1c60b83722883e29a1c89a5ab531aa8df244 Mon Sep 17 00:00:00 2001 From: Mitsuyuki Osabe <24588751+carrotRakko@users.noreply.github.com> Date: Sun, 1 Mar 2026 02:09:12 +0900 Subject: [PATCH] fix: clear delivery routing state when creating isolated cron sessions (#27778) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: clear delivery routing state when creating isolated cron sessions When `resolveCronSession()` creates a new session (forceNew / isolated), the `...entry` spread preserves `lastThreadId`, `lastTo`, `lastChannel`, and `lastAccountId` from the prior session. This causes announce-mode cron deliveries to post as thread replies instead of channel top-level messages when `delivery.to` matches the channel of a prior conversation. Clear delivery routing metadata on new session creation so isolated cron sessions start with a clean delivery state. Closes #27751 ✍️ Author: Claude Code with @carrotRakko (AI-written, human-approved) * fix: also clear deliveryContext to prevent lastThreadId repopulation normalizeSessionEntryDelivery (called on store writes) repopulates lastThreadId from deliveryContext.threadId. Clearing only the last* fields is insufficient — deliveryContext must also be cleared when creating a new isolated session. ✍️ Author: Claude Code with @carrotRakko (AI-written, human-approved) --- src/cron/isolated-agent/session.test.ts | 88 +++++++++++++++++++++++++ src/cron/isolated-agent/session.ts | 13 ++++ 2 files changed, 101 insertions(+) diff --git a/src/cron/isolated-agent/session.test.ts b/src/cron/isolated-agent/session.test.ts index ead8313ee2a..08f273e8c41 100644 --- a/src/cron/isolated-agent/session.test.ts +++ b/src/cron/isolated-agent/session.test.ts @@ -143,6 +143,94 @@ describe("resolveCronSession", () => { expect(result.sessionEntry.providerOverride).toBe("anthropic"); }); + it("clears delivery routing metadata and deliveryContext when forceNew is true", () => { + const result = resolveWithStoredEntry({ + entry: { + sessionId: "existing-session-id-789", + updatedAt: NOW_MS - 1000, + systemSent: true, + lastChannel: "slack" as never, + lastTo: "channel:C0XXXXXXXXX", + lastAccountId: "acct-123", + lastThreadId: "1737500000.123456", + deliveryContext: { + channel: "slack", + to: "channel:C0XXXXXXXXX", + threadId: "1737500000.123456", + }, + modelOverride: "gpt-5.2", + }, + fresh: true, + forceNew: true, + }); + + expect(result.isNewSession).toBe(true); + // Delivery routing state must be cleared to prevent thread leaking. + // deliveryContext must also be cleared because normalizeSessionEntryDelivery + // repopulates lastThreadId from deliveryContext.threadId on store writes. + expect(result.sessionEntry.lastChannel).toBeUndefined(); + expect(result.sessionEntry.lastTo).toBeUndefined(); + expect(result.sessionEntry.lastAccountId).toBeUndefined(); + expect(result.sessionEntry.lastThreadId).toBeUndefined(); + expect(result.sessionEntry.deliveryContext).toBeUndefined(); + // Per-session overrides must be preserved + expect(result.sessionEntry.modelOverride).toBe("gpt-5.2"); + }); + + it("clears delivery routing metadata when session is stale", () => { + const result = resolveWithStoredEntry({ + entry: { + sessionId: "old-session-id", + updatedAt: NOW_MS - 86_400_000, + lastChannel: "slack" as never, + lastTo: "channel:C0XXXXXXXXX", + lastThreadId: "1737500000.999999", + deliveryContext: { + channel: "slack", + to: "channel:C0XXXXXXXXX", + threadId: "1737500000.999999", + }, + }, + fresh: false, + }); + + expect(result.isNewSession).toBe(true); + expect(result.sessionEntry.lastChannel).toBeUndefined(); + expect(result.sessionEntry.lastTo).toBeUndefined(); + expect(result.sessionEntry.lastAccountId).toBeUndefined(); + expect(result.sessionEntry.lastThreadId).toBeUndefined(); + expect(result.sessionEntry.deliveryContext).toBeUndefined(); + }); + + it("preserves delivery routing metadata when reusing fresh session", () => { + const result = resolveWithStoredEntry({ + entry: { + sessionId: "existing-session-id-101", + updatedAt: NOW_MS - 1000, + systemSent: true, + lastChannel: "slack" as never, + lastTo: "channel:C0XXXXXXXXX", + lastThreadId: "1737500000.123456", + deliveryContext: { + channel: "slack", + to: "channel:C0XXXXXXXXX", + threadId: "1737500000.123456", + }, + }, + fresh: true, + }); + + expect(result.isNewSession).toBe(false); + expect(result.sessionEntry.lastChannel).toBe("slack"); + expect(result.sessionEntry.lastTo).toBe("channel:C0XXXXXXXXX"); + expect(result.sessionEntry.lastThreadId).toBe("1737500000.123456"); + expect(result.sessionEntry.deliveryContext).toEqual({ + channel: "slack", + to: "channel:C0XXXXXXXXX", + threadId: "1737500000.123456", + }); + }); + it("creates new sessionId when entry exists but has no sessionId", () => { const result = resolveWithStoredEntry({ entry: { diff --git a/src/cron/isolated-agent/session.ts b/src/cron/isolated-agent/session.ts index 0f23c836c6d..b1c9fe3710d 100644 --- a/src/cron/isolated-agent/session.ts +++ b/src/cron/isolated-agent/session.ts @@ -65,6 +65,19 @@ export function resolveCronSession(params: { sessionId, updatedAt: params.nowMs, systemSent, + // When starting a fresh session (forceNew / isolated), clear delivery routing + // state inherited from prior sessions. Without this, lastThreadId leaks into + // the new session and causes announce-mode cron deliveries to post as thread + // replies instead of channel top-level messages. + // deliveryContext must also be cleared because normalizeSessionEntryDelivery + // repopulates lastThreadId from deliveryContext.threadId on store writes. + ...(isNewSession && { + lastChannel: undefined, + lastTo: undefined, + lastAccountId: undefined, + lastThreadId: undefined, + deliveryContext: undefined, + }), }; return { storePath, store, sessionEntry, systemSent, isNewSession }; }