From cd87bf3cc789b3baf493873cab590d416d7db5cf Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 2 Oct 2026 21:25:08 -0700 Subject: [PATCH 1/2] fix(server): subagent threads stop publishing tombstones to the relay Subagent threads never project agent activity, so the relay never holds a row for them. Every subagent event still published a null state, and each publish makes the relay re-deliver the user's aggregate to every device. A finishing thread with six audit subagents sent over a hundred such publishes in an hour, each a chance to re-alert. Skip the publish when a subagent thread has no published state to clear. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/relay/AgentAwarenessRelay.test.ts | 20 +++++++++++++++++++ apps/server/src/relay/AgentAwarenessRelay.ts | 10 ++++++++++ 2 files changed, 30 insertions(+) diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 30ada288bd76..3c0d2117a7b9 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -659,6 +659,26 @@ describe("AgentAwarenessRelay", () => { }), ); + it.effect("never publishes tombstones for subagent threads", () => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay(); + yield* Ref.set( + currentShell, + shell({ + lineage: { + rootThreadId: THREAD_ID, + parentThreadId: THREAD_ID, + relationshipToParent: "subagent", + }, + }), + ); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.equal(publications.length, 0); + }), + ); + it.effect("confirms a first completed state and respects disabling during confirmation", () => Effect.gen(function* () { const { relay, secrets, currentShell, publications } = yield* makeTestRelay(); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 71e74d11b913..0242f3014601 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -537,6 +537,16 @@ export const make = Effect.gen(function* () { }); const publishIdentity = agentAwarenessPublishIdentity(snapshot.state); const publishedStateByThread = yield* Ref.get(publishedStateByThreadRef); + if ( + Option.isSome(thread) && + thread.value.lineage.relationshipToParent === "subagent" && + !publishedStateByThread.has(threadId) + ) { + // Subagents never project activity, so the relay holds no row to clear. + // Their events would otherwise publish a tombstone each, and every + // publish re-delivers the user's aggregate. + return; + } if ( (snapshot.state?.phase === "completed" || snapshot.state?.phase === "failed") && !publishedStateByThread.has(threadId) From 62197c3087f926353473ecaf97e7f8aeb050be90 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 2 Oct 2026 21:31:58 -0700 Subject: [PATCH 2/2] fix(server): archived subagents also skip the relay tombstone The subagent check ran after archived shells became Option.none, so archiving an unpublished subagent still published a null state. Read the lineage from the raw shell instead. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/relay/AgentAwarenessRelay.test.ts | 6 +++++- apps/server/src/relay/AgentAwarenessRelay.ts | 20 +++++++++---------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 3c0d2117a7b9..a90226f67c7e 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -659,7 +659,10 @@ describe("AgentAwarenessRelay", () => { }), ); - it.effect("never publishes tombstones for subagent threads", () => + it.effect.each([ + { label: "live", archived: false }, + { label: "archived", archived: true }, + ])("never publishes tombstones for $label subagent threads", ({ archived }) => Effect.gen(function* () { const { relay, currentShell, publications } = yield* makeTestRelay(); yield* Ref.set( @@ -670,6 +673,7 @@ describe("AgentAwarenessRelay", () => { parentThreadId: THREAD_ID, relationshipToParent: "subagent", }, + ...(archived ? { archivedAt: yield* DateTime.now } : {}), }), ); yield* relay.publishThread(THREAD_ID); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 0242f3014601..bc0e3d233119 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -522,6 +522,16 @@ export const make = Effect.gen(function* () { // domain event, so materializing the full shell here would make the cost // of one thread's activity proportional to how many threads exist. const threadShell = yield* threads.getThreadShell(threadId); + if ( + threadShell?.lineage.relationshipToParent === "subagent" && + !(yield* Ref.get(publishedStateByThreadRef)).has(threadId) + ) { + // Subagents never project activity, so the relay holds no row to clear. + // Their events would otherwise publish a tombstone each, and every + // publish re-delivers the user's aggregate. Checked before the archive + // filter so archiving one stays quiet too. + return; + } const thread = threadShell === null || threadShell.archivedAt !== null ? Option.none() @@ -537,16 +547,6 @@ export const make = Effect.gen(function* () { }); const publishIdentity = agentAwarenessPublishIdentity(snapshot.state); const publishedStateByThread = yield* Ref.get(publishedStateByThreadRef); - if ( - Option.isSome(thread) && - thread.value.lineage.relationshipToParent === "subagent" && - !publishedStateByThread.has(threadId) - ) { - // Subagents never project activity, so the relay holds no row to clear. - // Their events would otherwise publish a tombstone each, and every - // publish re-delivers the user's aggregate. - return; - } if ( (snapshot.state?.phase === "completed" || snapshot.state?.phase === "failed") && !publishedStateByThread.has(threadId)