From db7853e069a85162783fa7bc4198bf8aed858eb9 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 24 Oct 2026 23:58:48 -0700 Subject: [PATCH 1/5] fix(server): mobile push no longer says "completed" between runs The relay sends a push on every move into completed, and the server confirmed only a thread's first completion. A run that ended moments before its continuation started sent a "completed" push, then another one when the work really finished. Every completion now waits the existing 5s confirm window and is dropped if the thread is running again. Co-Authored-By: Claude Opus 5.5 --- .../src/relay/AgentAwarenessRelay.test.ts | 31 +++++++++++++++++++ apps/server/src/relay/AgentAwarenessRelay.ts | 5 +-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index b5f400dc3432f..312e1d47a6a26 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -392,6 +392,8 @@ describe("AgentAwarenessRelay", () => { const terminal = shell({ status }); yield* Ref.set(currentShell, terminal); yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; assert.equal(publications[1]?.state?.phase, status); const now = yield* DateTime.now; @@ -419,6 +421,33 @@ describe("AgentAwarenessRelay", () => { }).pipe(Effect.scoped), ); + it.effect("holds a completion through a continuation gap, then publishes it once", () => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay(); + yield* relay.publishThread(THREAD_ID); + yield* Ref.set(currentShell, shell({ status: "completed" })); + yield* relay.publishThread(THREAD_ID); + yield* Ref.set(currentShell, shell()); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state?.phase), + ["running"], + ); + + yield* Ref.set(currentShell, shell({ status: "completed" })); + yield* relay.publishThread(THREAD_ID); + assert.equal(publications.length, 1); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state?.phase), + ["running", "completed"], + ); + }), + ); + it.effect("keeps settled live work until its awaited monitor finishes", () => Effect.gen(function* () { const { relay, currentShell, publications } = yield* makeTestRelay(); @@ -595,6 +624,8 @@ describe("AgentAwarenessRelay", () => { yield* deliver( itemEvent("event:held-item:end", item("item:tool", { ...background, status: "completed" })), ); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; assert.deepEqual( publications.map((publication) => publication.state?.phase), ["running", "completed"], diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 93f3a73b21929..b99a2fce82b47 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -551,11 +551,12 @@ export const make = Effect.gen(function* () { } // Confirm tombstones over live state to avoid removing active cards. - // Confirm first-state completion because sessions briefly project completed before their first turn. + // Confirm completion: a run can end moments before its continuation starts, and the relay + // alerts on every move into completed. const requiresConfirmation = (snapshot.state === null && publishedStateByThread.get(threadId) !== agentAwarenessPublishIdentity(null)) || - (snapshot.state?.phase === "completed" && !publishedStateByThread.has(threadId)); + snapshot.state?.phase === "completed"; if (requiresConfirmation) { const nowMs = (yield* DateTime.now).epochMilliseconds; const deadline = publishConfirmDeadlines.get(threadId); From 8e3c3146dd24d89bbf07e9f5a9c5975658fd59f7 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 00:07:31 -0700 Subject: [PATCH 2/5] fix(server): a completion that settles during the hold still alerts Auto-settle on merge can settle a thread within milliseconds of its run ending, so the 5s re-check saw a hidden thread and dropped the held completion. A held completion is now dropped only when the thread is live again. When the thread settled instead, the completion publishes first and the card clears on a new tombstone window. Co-Authored-By: Claude Opus 5.5 --- .../src/relay/AgentAwarenessRelay.test.ts | 30 +++++++++++++++++++ apps/server/src/relay/AgentAwarenessRelay.ts | 24 +++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 312e1d47a6a26..61fb573de7abd 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -448,6 +448,36 @@ describe("AgentAwarenessRelay", () => { }), ); + it.effect("still publishes a completion when the thread settles during the hold", () => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay(); + yield* relay.publishThread(THREAD_ID); + const completed = shell({ status: "completed" }); + yield* Ref.set(currentShell, completed); + yield* relay.publishThread(THREAD_ID); + yield* Ref.set(currentShell, { + ...completed, + settledOverride: "settled", + settledAt: yield* DateTime.now, + lastVisitedAt: null, + }); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state?.phase ?? null), + ["running", "completed"], + ); + + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state?.phase ?? null), + ["running", "completed", null], + ); + }), + ); + it.effect("keeps settled live work until its awaited monitor finishes", () => Effect.gen(function* () { const { relay, currentShell, publications } = yield* makeTestRelay(); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index b99a2fce82b47..43110ea7aa79e 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -411,6 +411,11 @@ export const make = Effect.gen(function* () { // Confirm through the same worker so tombstones cannot race live updates; recovery clears the deadline. const publishConfirmDeadlines = new Map(); + // The completion a confirmation holds, so a thread that settles during the hold still alerts. + const heldCompletions = new Map< + ThreadId, + ReturnType + >(); let schedulePublishConfirm: (threadId: ThreadId) => Effect.Effect = () => Effect.void; const publishRetries = new Map< ThreadId, @@ -542,6 +547,7 @@ export const make = Effect.gen(function* () { if (publishedStateByThread.get(threadId) === publishIdentity) { // Clear moot deadlines so later transient nulls cannot skip their deferral window. publishConfirmDeadlines.delete(threadId); + heldCompletions.delete(threadId); yield* Effect.logDebug("agent activity publish skipped; projected state unchanged", { environmentId, threadId, @@ -558,6 +564,7 @@ export const make = Effect.gen(function* () { publishedStateByThread.get(threadId) !== agentAwarenessPublishIdentity(null)) || snapshot.state?.phase === "completed"; if (requiresConfirmation) { + if (snapshot.state?.phase === "completed") heldCompletions.set(threadId, snapshot); const nowMs = (yield* DateTime.now).epochMilliseconds; const deadline = publishConfirmDeadlines.get(threadId); if (deadline === undefined) { @@ -582,9 +589,26 @@ export const make = Effect.gen(function* () { statePhase: snapshot.state?.phase ?? null, shell: describeThreadShellForAwareness(thread), }); + const held = heldCompletions.get(threadId); + // Settled during the hold, not resumed: alert the completion, then clear the card on a new window. + if (snapshot.state === null && snapshot.reason === "snapshot" && held?.state != null) { + yield* publishState({ + projectId: held.projectId, + state: held.state, + reason: held.reason, + }).pipe(withRelayClientTracing); + heldCompletions.delete(threadId); + publishConfirmDeadlines.set(threadId, nowMs + 5_000); + yield* Ref.update(publishedStateByThreadRef, (publishedStates) => + new Map(publishedStates).set(threadId, agentAwarenessPublishIdentity(held.state)), + ); + yield* schedulePublishConfirm(threadId); + return; + } } else { publishConfirmDeadlines.delete(threadId); } + heldCompletions.delete(threadId); if (snapshot.reason === "thread-not-found") { yield* Effect.logDebug("publishing agent activity tombstone; thread not found", { From 5be002ae21bdba6335a464805ba60fd8e56fb82c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 00:09:53 -0700 Subject: [PATCH 3/5] fix(server): keep a held completion until it publishes The held completion was dropped before its publish succeeded, so a failed publish followed by a settle retried only the tombstone. It now stays until it publishes, or until the thread is live again. Co-Authored-By: Claude Opus 5.5 --- .../src/relay/AgentAwarenessRelay.test.ts | 33 +++++++++++++++++++ apps/server/src/relay/AgentAwarenessRelay.ts | 3 +- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 61fb573de7abd..7ea30230a6be2 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -478,6 +478,39 @@ describe("AgentAwarenessRelay", () => { }), ); + it.effect("retries a failed completion even after the thread settles", () => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay({ + respond: (attempt) => + attempt === 2 + ? new Response("unavailable", { status: 503 }) + : Response.json({ ok: true, deliveries: [] }), + }); + yield* relay.publishThread(THREAD_ID); + const completed = shell({ status: "completed" }); + yield* Ref.set(currentShell, completed); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + yield* Ref.set(currentShell, { + ...completed, + settledOverride: "settled", + settledAt: yield* DateTime.now, + lastVisitedAt: null, + }); + yield* TestClock.adjust("1 second"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state?.phase ?? null), + ["running", "completed", "completed"], + ); + + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.equal(publications.at(-1)?.state ?? null, null); + }), + ); + it.effect("keeps settled live work until its awaited monitor finishes", () => Effect.gen(function* () { const { relay, currentShell, publications } = yield* makeTestRelay(); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 43110ea7aa79e..96431165e1205 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -607,8 +607,8 @@ export const make = Effect.gen(function* () { } } else { publishConfirmDeadlines.delete(threadId); + heldCompletions.delete(threadId); } - heldCompletions.delete(threadId); if (snapshot.reason === "thread-not-found") { yield* Effect.logDebug("publishing agent activity tombstone; thread not found", { @@ -629,6 +629,7 @@ export const make = Effect.gen(function* () { reason: snapshot.reason, }).pipe(withRelayClientTracing); publishConfirmDeadlines.delete(threadId); + heldCompletions.delete(threadId); yield* Ref.update(publishedStateByThreadRef, (publishedStates) => { const nextPublishedStates = new Map(publishedStates); if (snapshot.state === null) { From b2a418bc9e92412f157888a6d6792eb152830d27 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 00:11:38 -0700 Subject: [PATCH 4/5] fix(server): replay a held completion only when settlement hid it A queued continuation or a silent run also projects to no card, and the replay treated that as a settle, so it sent a stale "completed". The held completion now replays only when the thread is settled and would still read as completed without the settle. Held completions also clear on a relay connection reset. Co-Authored-By: Claude Opus 5.5 --- .../src/relay/AgentAwarenessRelay.test.ts | 17 +++++++++++++++++ apps/server/src/relay/AgentAwarenessRelay.ts | 13 ++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 7ea30230a6be2..3ed240d754cce 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -478,6 +478,23 @@ describe("AgentAwarenessRelay", () => { }), ); + it.effect("drops a held completion when a queued continuation hides the thread", () => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay(); + yield* relay.publishThread(THREAD_ID); + yield* Ref.set(currentShell, shell({ status: "completed" })); + yield* relay.publishThread(THREAD_ID); + yield* Ref.set(currentShell, shell({ status: "queued" })); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.notInclude( + publications.map((publication) => publication.state?.phase ?? null), + "completed", + ); + }), + ); + it.effect("retries a failed completion even after the thread settles", () => Effect.gen(function* () { const { relay, currentShell, publications } = yield* makeTestRelay({ diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 96431165e1205..0fb6c53fefbfd 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -434,6 +434,7 @@ export const make = Effect.gen(function* () { publishedRelayConfig = undefined; cachedRelayClient = undefined; publishConfirmDeadlines.clear(); + heldCompletions.clear(); yield* Ref.set(publishedStateByThreadRef, new Map()); }); @@ -591,7 +592,17 @@ export const make = Effect.gen(function* () { }); const held = heldCompletions.get(threadId); // Settled during the hold, not resumed: alert the completion, then clear the card on a new window. - if (snapshot.state === null && snapshot.reason === "snapshot" && held?.state != null) { + const hiddenOnlyBySettlement = + snapshot.state === null && + Option.isSome(thread) && + thread.value.settledOverride === "settled" && + resolveAgentAwarenessRelayPublishSnapshot({ + environmentId, + threadId, + thread: Option.some({ ...thread.value, settledOverride: null }), + project, + }).state?.phase === "completed"; + if (hiddenOnlyBySettlement && held?.state != null) { yield* publishState({ projectId: held.projectId, state: held.state, From 8ba03a94334772489cbec811390c5aedf0e91cd8 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 00:15:35 -0700 Subject: [PATCH 5/5] fix(server): a settled completion alerts with the current run The settle replay sent the snapshot held from the first confirmation, which can describe an earlier run. It now publishes the thread's current completion, read as if it were not settled, so the hold only needs to remember which threads hold a completion. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/relay/AgentAwarenessRelay.ts | 42 ++++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 0fb6c53fefbfd..e59065400b2d9 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -411,11 +411,8 @@ export const make = Effect.gen(function* () { // Confirm through the same worker so tombstones cannot race live updates; recovery clears the deadline. const publishConfirmDeadlines = new Map(); - // The completion a confirmation holds, so a thread that settles during the hold still alerts. - const heldCompletions = new Map< - ThreadId, - ReturnType - >(); + // Threads whose confirmation holds a completion, so a thread that settles during the hold still alerts. + const heldCompletions = new Set(); let schedulePublishConfirm: (threadId: ThreadId) => Effect.Effect = () => Effect.void; const publishRetries = new Map< ThreadId, @@ -565,7 +562,7 @@ export const make = Effect.gen(function* () { publishedStateByThread.get(threadId) !== agentAwarenessPublishIdentity(null)) || snapshot.state?.phase === "completed"; if (requiresConfirmation) { - if (snapshot.state?.phase === "completed") heldCompletions.set(threadId, snapshot); + if (snapshot.state?.phase === "completed") heldCompletions.add(threadId); const nowMs = (yield* DateTime.now).epochMilliseconds; const deadline = publishConfirmDeadlines.get(threadId); if (deadline === undefined) { @@ -590,28 +587,31 @@ export const make = Effect.gen(function* () { statePhase: snapshot.state?.phase ?? null, shell: describeThreadShellForAwareness(thread), }); - const held = heldCompletions.get(threadId); - // Settled during the hold, not resumed: alert the completion, then clear the card on a new window. - const hiddenOnlyBySettlement = + // Settled during the hold, not resumed: alert the current completion, then clear the card + // on a new window. Only settlement may hide it; queued or silent runs also project to null. + const unsettled = snapshot.state === null && + heldCompletions.has(threadId) && Option.isSome(thread) && - thread.value.settledOverride === "settled" && - resolveAgentAwarenessRelayPublishSnapshot({ - environmentId, - threadId, - thread: Option.some({ ...thread.value, settledOverride: null }), - project, - }).state?.phase === "completed"; - if (hiddenOnlyBySettlement && held?.state != null) { + thread.value.settledOverride === "settled" + ? resolveAgentAwarenessRelayPublishSnapshot({ + environmentId, + threadId, + thread: Option.some({ ...thread.value, settledOverride: null }), + project, + }) + : null; + if (unsettled?.state?.phase === "completed") { + const completion = unsettled.state; yield* publishState({ - projectId: held.projectId, - state: held.state, - reason: held.reason, + projectId: unsettled.projectId, + state: completion, + reason: unsettled.reason, }).pipe(withRelayClientTracing); heldCompletions.delete(threadId); publishConfirmDeadlines.set(threadId, nowMs + 5_000); yield* Ref.update(publishedStateByThreadRef, (publishedStates) => - new Map(publishedStates).set(threadId, agentAwarenessPublishIdentity(held.state)), + new Map(publishedStates).set(threadId, agentAwarenessPublishIdentity(completion)), ); yield* schedulePublishConfirm(threadId); return;