From defb1553451e76ebe7ab769c144ada87e608e2f8 Mon Sep 17 00:00:00 2001 From: mattholla <295664201+mattholla@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:46:41 -0500 Subject: [PATCH] fix(server): automatic activity unsettle no longer clears a user's "active" pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An explicit Un-settle writes settledOverride: "active". Real activity (a new turn, a session coming alive, an approval/user-input activity) emits a thread.unsettled event with reason: "activity" to wake a settled thread, but the reducer treats any non-user reason as a plain reset to null — so the automatic event was silently clearing a user's "active" pin back to neutral, the same as if they had never pinned it. Guard each emit site in decider.ts so the automatic "activity" unsettle only fires when settledOverride is "settled", never "active". Found a third such site beyond the two described (thread.activity.append, in addition to thread.turn.start and thread.session.set) and applied the same guard there. Reducers are untouched; the fix is entirely in which events get emitted. Updated decider.settled.test.ts, which asserted the old behavior (activity clearing an "active" pin) for the turn-start, session-set, and activity-append paths. Co-Authored-By: Claude Fable 5 --- .../src/orchestration/decider.settled.test.ts | 23 +++++++------------ apps/server/src/orchestration/decider.ts | 22 +++++++++++------- pnpm-lock.yaml | 2 ++ 3 files changed, 24 insertions(+), 23 deletions(-) diff --git a/apps/server/src/orchestration/decider.settled.test.ts b/apps/server/src/orchestration/decider.settled.test.ts index 20bc3475613a..dab3b909cd01 100644 --- a/apps/server/src/orchestration/decider.settled.test.ts +++ b/apps/server/src/orchestration/decider.settled.test.ts @@ -428,7 +428,7 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { }), ); - it.effect("prepends activity unsets for turn starts and live session updates", () => + it.effect("prepends activity unsets for settled threads and leaves active pins alone", () => Effect.gen(function* () { const turnResult = yield* decideOrchestrationCommand({ command: { @@ -462,19 +462,16 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { session: makeSession("running"), createdAt: NOW, }, - // A keep-active pin is also an override: real activity clears it - // back to neutral so auto-settle can apply again later. + // A keep-active pin is a user pin, not an auto-settle guard: real + // activity must not clear it back to neutral. readModel: makeReadModel("active"), }); const sessionEvents = Array.isArray(sessionResult) ? sessionResult : [sessionResult]; - expect(sessionEvents.map((event) => event.type)).toEqual([ - "thread.unsettled", - "thread.session-set", - ]); + expect(sessionEvents.map((event) => event.type)).toEqual(["thread.session-set"]); }), ); - it.effect("clears a keep-active pin on real activity", () => + it.effect("does not clear a keep-active pin on real activity", () => Effect.gen(function* () { const turnResult = yield* decideOrchestrationCommand({ command: { @@ -494,10 +491,9 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { readModel: makeReadModel("active"), }); const turnEvents = Array.isArray(turnResult) ? turnResult : [turnResult]; - // The pin exists to suppress AUTO-settle, not to survive real work: - // activity resets it to neutral, restoring the default lifecycle. + // The pin is the user asking to keep this thread active: real work + // must not silently clear it back to neutral. expect(turnEvents.map((event) => event.type)).toEqual([ - "thread.unsettled", "thread.message-sent", "thread.turn-start-requested", ]); @@ -521,10 +517,7 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { readModel: makeReadModel("active"), }); const activityEvents = Array.isArray(activityResult) ? activityResult : [activityResult]; - expect(activityEvents.map((event) => event.type)).toEqual([ - "thread.unsettled", - "thread.activity-appended", - ]); + expect(activityEvents.map((event) => event.type)).toEqual(["thread.activity-appended"]); }), ); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 4f61955fa6aa..a74f2a3386e5 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -995,13 +995,15 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" createdAt: command.createdAt, }, }; - // Real activity resets ANY override: it wakes an explicitly settled - // thread, and it clears a keep-active pin back to neutral so the - // thread can auto-settle again after this burst of work goes stale. + // Real activity wakes an explicitly settled thread, but it must not + // clear a keep-active pin: the user asked for that thread to stay + // active, and an automatic "activity" unsettle is a no-op against a + // state that is already unsettled in spirit. Only a settled override + // is cleared here; an active override is left alone. // A snooze clears the same way — sending a message to a snoozed // thread is the user re-engaging, so the return ticket is spent. const lifecycleResetEvents: Array> = []; - if (targetThread.settledOverride !== null) { + if (targetThread.settledOverride === "settled") { lifecycleResetEvents.push({ ...(yield* withEventBase({ aggregateKind: "thread", @@ -1204,8 +1206,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" // as snoozed, without spending the return ticket. const isSessionActivity = command.session.status === "starting" || command.session.status === "running"; - // Real activity resets ANY override (settled wakes, active unpins). - if (thread.settledOverride === null || !isSessionActivity) { + // Real activity wakes a settled thread, but never clears an active + // pin: that override is the user asking to keep this thread active, + // and an automatic "activity" unsettle must not undo it. + if (thread.settledOverride !== "settled" || !isSessionActivity) { return sessionSetEvent; } const unsettledEvent: Omit = { @@ -1381,8 +1385,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" const wakesSettledThread = command.activity.kind === "approval.requested" || command.activity.kind === "user-input.requested"; - // Real activity resets ANY override (settled wakes, active unpins). - if (thread.settledOverride === null || !wakesSettledThread) { + // Real activity wakes a settled thread, but never clears an active + // pin: that override is the user asking to keep this thread active, + // and an automatic "activity" unsettle must not undo it. + if (thread.settledOverride !== "settled" || !wakesSettledThread) { return activityAppendedEvent; } const unsettledEvent: Omit = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b550bebb15e..2cf7f1e8f377 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5148,10 +5148,12 @@ packages: '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version '@xmldom/xmldom@0.9.10': resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} + deprecated: this version has critical issues, please update to the latest version '@yuuang/ffi-rs-android-arm64@1.3.2': resolution: {integrity: sha512-eDYLT0kVBkp7e2BwdRDmt6N1rkeDPUHDefk3ZX0/nok+GLsqfy1WBoSL3Yg7HVXN1EyW8OBVc2uK8Zq8HbmaSA==}