Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 8 additions & 15 deletions apps/server/src/orchestration/decider.settled.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {
Expand All @@ -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",
]);
Expand All @@ -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"]);
}),
);

Expand Down
22 changes: 14 additions & 8 deletions apps/server/src/orchestration/decider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Omit<OrchestrationEvent, "sequence">> = [];
if (targetThread.settledOverride !== null) {
if (targetThread.settledOverride === "settled") {
lifecycleResetEvents.push({
...(yield* withEventBase({
aggregateKind: "thread",
Expand Down Expand Up @@ -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<OrchestrationEvent, "sequence"> = {
Expand Down Expand Up @@ -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<OrchestrationEvent, "sequence"> = {
Expand Down
2 changes: 2 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading