diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index b5f400dc3432f..3ed240d754cce 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,113 @@ 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("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("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({ + 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(); @@ -595,6 +704,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..e59065400b2d9 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -411,6 +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(); + // 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, @@ -429,6 +431,7 @@ export const make = Effect.gen(function* () { publishedRelayConfig = undefined; cachedRelayClient = undefined; publishConfirmDeadlines.clear(); + heldCompletions.clear(); yield* Ref.set(publishedStateByThreadRef, new Map()); }); @@ -542,6 +545,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, @@ -551,12 +555,14 @@ 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) { + if (snapshot.state?.phase === "completed") heldCompletions.add(threadId); const nowMs = (yield* DateTime.now).epochMilliseconds; const deadline = publishConfirmDeadlines.get(threadId); if (deadline === undefined) { @@ -581,8 +587,38 @@ export const make = Effect.gen(function* () { statePhase: snapshot.state?.phase ?? null, shell: describeThreadShellForAwareness(thread), }); + // 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, + }) + : null; + if (unsettled?.state?.phase === "completed") { + const completion = unsettled.state; + yield* publishState({ + 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(completion)), + ); + yield* schedulePublishConfirm(threadId); + return; + } } else { publishConfirmDeadlines.delete(threadId); + heldCompletions.delete(threadId); } if (snapshot.reason === "thread-not-found") { @@ -604,6 +640,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) {