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==}