From d3431e07d8d5840cca4eaea8f7e81f174d36fc14 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 21 Jul 2026 14:45:44 -0700 Subject: [PATCH 1/5] Add server-side settled model (ported from #3960) thread.settle / thread.unsettle commands, thread.settled/unsettled events, settled_override + settled_at projection columns (migration 033), and activity auto-unsettle in the decider: a user message, a session coming alive, or a new approval/user-input request emits thread.unsettled(reason: activity) ahead of the triggering event. Settled threads stay in the live shell stream (unlike archived ones): settledOverride/settledAt ride the existing thread shell schema, so readable settled history requires no separate snapshot path. Co-Authored-By: Claude Fable 5 --- .../archive/archivedThreadList.test.ts | 2 + .../src/features/home/homeListItems.test.ts | 2 + .../src/features/home/homeThreadList.test.ts | 2 + .../src/features/threads/threadListV2.test.ts | 2 + apps/mobile/src/lib/repositoryGroups.test.ts | 2 + apps/mobile/src/lib/threadActivity.test.ts | 2 + apps/mobile/src/state/use-thread-selection.ts | 2 + .../Layers/OrchestrationEngine.test.ts | 2 + .../Layers/ProjectionPipeline.ts | 34 ++ .../Layers/ProjectionSnapshotQuery.test.ts | 4 + .../Layers/ProjectionSnapshotQuery.ts | 20 ++ apps/server/src/orchestration/Schemas.ts | 4 + .../orchestration/commandInvariants.test.ts | 4 + .../src/orchestration/decider.settled.test.ts | 322 ++++++++++++++++++ apps/server/src/orchestration/decider.ts | 128 ++++++- .../orchestration/projector.settled.test.ts | 88 +++++ .../src/orchestration/projector.test.ts | 2 + apps/server/src/orchestration/projector.ts | 28 ++ .../Layers/ProjectionRepositories.test.ts | 2 + .../persistence/Layers/ProjectionThreads.ts | 10 + apps/server/src/persistence/Migrations.ts | 2 + .../033_ProjectionThreadsSettled.ts | 23 ++ .../persistence/Services/ProjectionThreads.ts | 2 + .../Layers/ProviderSessionReaper.test.ts | 2 + .../src/relay/AgentAwarenessRelay.test.ts | 6 + apps/server/src/server.test.ts | 6 + .../web/src/components/ChatView.logic.test.ts | 2 + apps/web/src/components/ChatView.logic.ts | 2 + .../components/CommandPalette.logic.test.ts | 2 + apps/web/src/components/Sidebar.logic.test.ts | 2 + apps/web/src/lib/threadSort.test.ts | 2 + apps/web/src/worktreeCleanup.test.ts | 2 + .../src/operations/commands.test.ts | 39 ++- .../client-runtime/src/operations/commands.ts | 22 ++ .../client-runtime/src/state/entities.test.ts | 2 + .../src/state/shellReducer.test.ts | 2 + .../src/state/threadCommands.ts | 18 + .../client-runtime/src/state/threadDetail.ts | 2 + .../src/state/threadReducer.test.ts | 58 ++++ .../client-runtime/src/state/threadReducer.ts | 24 ++ .../src/state/threadSettled.test.ts | 2 + .../src/state/threads-sync.test.ts | 2 + packages/contracts/src/orchestration.test.ts | 105 ++++++ packages/contracts/src/orchestration.ts | 49 +++ 44 files changed, 1034 insertions(+), 6 deletions(-) create mode 100644 apps/server/src/orchestration/decider.settled.test.ts create mode 100644 apps/server/src/orchestration/projector.settled.test.ts create mode 100644 apps/server/src/persistence/Migrations/033_ProjectionThreadsSettled.ts diff --git a/apps/mobile/src/features/archive/archivedThreadList.test.ts b/apps/mobile/src/features/archive/archivedThreadList.test.ts index 6cd530ab37d..697d13e7c47 100644 --- a/apps/mobile/src/features/archive/archivedThreadList.test.ts +++ b/apps/mobile/src/features/archive/archivedThreadList.test.ts @@ -41,6 +41,8 @@ function makeThread( hasPendingUserInput: false, hasActionableProposedPlan: false, ...input, + settledOverride: input.settledOverride ?? null, + settledAt: input.settledAt ?? null, }; } diff --git a/apps/mobile/src/features/home/homeListItems.test.ts b/apps/mobile/src/features/home/homeListItems.test.ts index 36e98ef129f..c5a9f2c6bbc 100644 --- a/apps/mobile/src/features/home/homeListItems.test.ts +++ b/apps/mobile/src/features/home/homeListItems.test.ts @@ -47,6 +47,8 @@ function makeThread(id: string, projectId: ProjectId): EnvironmentThreadShell { createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, session: null, latestUserMessageAt: null, hasPendingApprovals: false, diff --git a/apps/mobile/src/features/home/homeThreadList.test.ts b/apps/mobile/src/features/home/homeThreadList.test.ts index 8f13000b9e2..46d1173b0dd 100644 --- a/apps/mobile/src/features/home/homeThreadList.test.ts +++ b/apps/mobile/src/features/home/homeThreadList.test.ts @@ -41,6 +41,8 @@ function makeThread( hasPendingUserInput: false, hasActionableProposedPlan: false, ...input, + settledOverride: input.settledOverride ?? null, + settledAt: input.settledAt ?? null, }; } diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 3a656d0d201..fe5e3ddbc5c 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -25,6 +25,8 @@ function makeThread( createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, session: null, latestUserMessageAt: null, hasPendingApprovals: false, diff --git a/apps/mobile/src/lib/repositoryGroups.test.ts b/apps/mobile/src/lib/repositoryGroups.test.ts index 8cea5df2307..ab4311524ce 100644 --- a/apps/mobile/src/lib/repositoryGroups.test.ts +++ b/apps/mobile/src/lib/repositoryGroups.test.ts @@ -38,6 +38,8 @@ function makeThread( hasPendingUserInput: false, hasActionableProposedPlan: false, ...input, + settledOverride: input.settledOverride ?? null, + settledAt: input.settledAt ?? null, }; } diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 23b47fc625f..5f9f099a46c 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -50,6 +50,8 @@ function makeThread( checkpoints: [], session: null, ...input, + settledOverride: input.settledOverride ?? null, + settledAt: input.settledAt ?? null, }; } diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index d369fbc4377..80bc1916b11 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -58,6 +58,8 @@ function threadDetailToShell( createdAt: thread.createdAt, updatedAt: thread.updatedAt, archivedAt: thread.archivedAt, + settledOverride: thread.settledOverride, + settledAt: thread.settledAt, session: thread.session, latestUserMessageAt: latestUserMessageAt(thread), hasPendingApprovals: false, diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index b00c00e0d3f..731002ea783 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -146,6 +146,8 @@ describe("OrchestrationEngine", () => { createdAt: "2026-03-03T00:00:02.000Z", updatedAt: "2026-03-03T00:00:03.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [], proposedPlans: [], diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index f12df850941..3ceae0ea43b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -607,6 +607,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, archivedAt: null, + settledOverride: null, + settledAt: null, latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, @@ -645,6 +647,38 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.settled": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + settledOverride: "settled", + settledAt: event.payload.settledAt, + updatedAt: event.payload.updatedAt, + }); + return; + } + + case "thread.unsettled": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + settledOverride: event.payload.reason === "user" ? "active" : null, + settledAt: null, + updatedAt: event.payload.updatedAt, + }); + return; + } + case "thread.meta-updated": { const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 9a136b06872..220665c557c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -308,6 +308,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { createdAt: "2026-02-24T00:00:02.000Z", updatedAt: "2026-02-24T00:00:03.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [ { @@ -418,6 +420,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { createdAt: "2026-02-24T00:00:02.000Z", updatedAt: "2026-02-24T00:00:03.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, session: { threadId: ThreadId.make("thread-1"), status: "running", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 32210436e67..155e9ab0013 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -334,6 +334,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -362,6 +364,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -392,6 +396,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -754,6 +760,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -1186,6 +1194,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, deletedAt: row.deletedAt, messages: messagesByThread.get(row.threadId) ?? [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -1384,6 +1394,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, deletedAt: row.deletedAt, messages: [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -1513,6 +1525,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -1647,6 +1661,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -1887,6 +1903,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, archivedAt: threadRow.value.archivedAt, + settledOverride: threadRow.value.settledOverride, + settledAt: threadRow.value.settledAt, session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, latestUserMessageAt: threadRow.value.latestUserMessageAt, hasPendingApprovals: threadRow.value.pendingApprovalCount > 0, @@ -1981,6 +1999,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, archivedAt: threadRow.value.archivedAt, + settledOverride: threadRow.value.settledOverride, + settledAt: threadRow.value.settledAt, deletedAt: null, messages: messageRows.map((row) => { const message = { diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index f7ebf693440..0d0d7bdd5e4 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -4,11 +4,13 @@ import { ProjectDeletedPayload as ContractsProjectDeletedPayloadSchema, ThreadCreatedPayload as ContractsThreadCreatedPayloadSchema, ThreadArchivedPayload as ContractsThreadArchivedPayloadSchema, + ThreadSettledPayload as ContractsThreadSettledPayloadSchema, ThreadMetaUpdatedPayload as ContractsThreadMetaUpdatedPayloadSchema, ThreadRuntimeModeSetPayload as ContractsThreadRuntimeModeSetPayloadSchema, ThreadInteractionModeSetPayload as ContractsThreadInteractionModeSetPayloadSchema, ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema, ThreadUnarchivedPayload as ContractsThreadUnarchivedPayloadSchema, + ThreadUnsettledPayload as ContractsThreadUnsettledPayloadSchema, ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, @@ -29,11 +31,13 @@ export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema; export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema; export const ThreadArchivedPayload = ContractsThreadArchivedPayloadSchema; +export const ThreadSettledPayload = ContractsThreadSettledPayloadSchema; export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema; export const ThreadRuntimeModeSetPayload = ContractsThreadRuntimeModeSetPayloadSchema; export const ThreadInteractionModeSetPayload = ContractsThreadInteractionModeSetPayloadSchema; export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema; export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema; +export const ThreadUnsettledPayload = ContractsThreadUnsettledPayloadSchema; export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index 9c6c8bd2a18..9531cd5c3af 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -68,6 +68,8 @@ const readModel: OrchestrationReadModel = { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, latestTurn: null, messages: [], session: null, @@ -91,6 +93,8 @@ const readModel: OrchestrationReadModel = { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, latestTurn: null, messages: [], session: null, diff --git a/apps/server/src/orchestration/decider.settled.test.ts b/apps/server/src/orchestration/decider.settled.test.ts new file mode 100644 index 00000000000..d4ecfba6a40 --- /dev/null +++ b/apps/server/src/orchestration/decider.settled.test.ts @@ -0,0 +1,322 @@ +import { + CommandId, + EventId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationReadModel, + type OrchestrationSession, + type OrchestrationThread, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +const NOW = "2026-01-01T00:00:00.000Z"; +const SETTLED_AT = "2025-12-30T00:00:00.000Z"; + +function makeReadModel( + settledOverride: OrchestrationThread["settledOverride"], + archivedAt: string | null = null, +): OrchestrationReadModel { + return { + snapshotSequence: 0, + projects: [], + threads: [ + { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt, + settledOverride, + settledAt: settledOverride === "settled" ? SETTLED_AT : null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }, + ], + updatedAt: NOW, + }; +} + +function makeSession(status: OrchestrationSession["status"]): OrchestrationSession { + return { + threadId: ThreadId.make("thread-1"), + status, + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }; +} + +it.layer(NodeServices.layer)("settled thread decider", (it) => { + it.effect("settles active threads and re-emits idempotently for settled ones", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events).toHaveLength(1); + expect(events[0]?.type).toBe("thread.settled"); + if (events[0]?.type === "thread.settled") { + expect(events[0].payload.settledAt).toBe(events[0].payload.updatedAt); + } + + // Already settled: the engine rejects zero-event commands, so idempotency + // is by re-emission — preserving the original settledAt. + const reEmit = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-again"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel("settled"), + }); + const reEmitEvents = Array.isArray(reEmit) ? reEmit : [reEmit]; + expect(reEmitEvents).toHaveLength(1); + expect(reEmitEvents[0]?.type).toBe("thread.settled"); + if (reEmitEvents[0]?.type === "thread.settled") { + expect(reEmitEvents[0].payload.settledAt).toBe(SETTLED_AT); + expect(reEmitEvents[0].payload.updatedAt).toBe(SETTLED_AT); + } + }), + ); + + it.effect("rejects settling and unsettling archived threads", () => + Effect.gen(function* () { + const settleError = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-archived"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, NOW), + }).pipe(Effect.flip); + expect(settleError._tag).toBe("OrchestrationCommandInvariantError"); + + const unsettleError = yield* decideOrchestrationCommand({ + command: { + type: "thread.unsettle", + commandId: CommandId.make("cmd-unsettle-archived"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }, + readModel: makeReadModel("settled", NOW), + }).pipe(Effect.flip); + expect(unsettleError._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("maps unsettle reasons to overrides and re-emits idempotently", () => + Effect.gen(function* () { + const userEvent = yield* decideOrchestrationCommand({ + command: { + type: "thread.unsettle", + commandId: CommandId.make("cmd-unsettle-user"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }, + readModel: makeReadModel("settled"), + }); + const userEvents = Array.isArray(userEvent) ? userEvent : [userEvent]; + expect(userEvents).toHaveLength(1); + expect(userEvents[0]?.type).toBe("thread.unsettled"); + if (userEvents[0]?.type === "thread.unsettled") { + expect(userEvents[0].payload.reason).toBe("user"); + } + + // Re-dispatching against the already-reached state re-emits rather than + // producing zero events (the engine rejects empty commands). + const userAgain = yield* decideOrchestrationCommand({ + command: { + type: "thread.unsettle", + commandId: CommandId.make("cmd-unsettle-user-again"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }, + readModel: makeReadModel("active"), + }); + const userAgainEvents = Array.isArray(userAgain) ? userAgain : [userAgain]; + expect(userAgainEvents).toHaveLength(1); + expect(userAgainEvents[0]?.type).toBe("thread.unsettled"); + }), + ); + + it.effect("prepends activity unsets for turn starts and live session updates", () => + Effect.gen(function* () { + const turnResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: MessageId.make("message-1"), + role: "user", + text: "Continue", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: NOW, + }, + readModel: makeReadModel("settled"), + }); + const turnEvents = Array.isArray(turnResult) ? turnResult : [turnResult]; + expect(turnEvents.map((event) => event.type)).toEqual([ + "thread.unsettled", + "thread.message-sent", + "thread.turn-start-requested", + ]); + + const sessionResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set"), + threadId: ThreadId.make("thread-1"), + session: makeSession("running"), + createdAt: NOW, + }, + readModel: makeReadModel("active"), + }); + const sessionEvents = Array.isArray(sessionResult) ? sessionResult : [sessionResult]; + expect(sessionEvents.map((event) => event.type)).toEqual(["thread.session-set"]); + }), + ); + + it.effect("preserves an explicit active override during activity", () => + Effect.gen(function* () { + const turnResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: CommandId.make("cmd-active-turn-start"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: MessageId.make("message-active"), + role: "user", + text: "Continue", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: NOW, + }, + readModel: makeReadModel("active"), + }); + const turnEvents = Array.isArray(turnResult) ? turnResult : [turnResult]; + expect(turnEvents.map((event) => event.type)).toEqual([ + "thread.message-sent", + "thread.turn-start-requested", + ]); + + const activityResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.activity.append", + commandId: CommandId.make("cmd-active-approval"), + threadId: ThreadId.make("thread-1"), + activity: { + id: EventId.make("activity-active"), + tone: "approval", + kind: "approval.requested", + summary: "Command approval requested", + payload: null, + turnId: null, + createdAt: NOW, + }, + createdAt: NOW, + }, + readModel: makeReadModel("active"), + }); + const activityEvents = Array.isArray(activityResult) ? activityResult : [activityResult]; + expect(activityEvents.map((event) => event.type)).toEqual(["thread.activity-appended"]); + }), + ); + + it.effect("does not unsettle for session stop/error status writes", () => + Effect.gen(function* () { + for (const status of ["stopped", "error", "ready", "idle"] as const) { + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.session.set", + commandId: CommandId.make(`cmd-session-${status}`), + threadId: ThreadId.make("thread-1"), + session: makeSession(status), + createdAt: NOW, + }, + readModel: makeReadModel("settled"), + }); + const events = Array.isArray(result) ? result : [result]; + expect(events.map((event) => event.type)).toEqual(["thread.session-set"]); + } + }), + ); + + it.effect("unsettles for approval and user-input activities but not others", () => + Effect.gen(function* () { + const approvalResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.activity.append", + commandId: CommandId.make("cmd-activity-approval"), + threadId: ThreadId.make("thread-1"), + activity: { + id: EventId.make("activity-1"), + tone: "approval", + kind: "approval.requested", + summary: "Command approval requested", + payload: null, + turnId: null, + createdAt: NOW, + }, + createdAt: NOW, + }, + readModel: makeReadModel("settled"), + }); + const approvalEvents = Array.isArray(approvalResult) ? approvalResult : [approvalResult]; + expect(approvalEvents.map((event) => event.type)).toEqual([ + "thread.unsettled", + "thread.activity-appended", + ]); + + const routineResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.activity.append", + commandId: CommandId.make("cmd-activity-routine"), + threadId: ThreadId.make("thread-1"), + activity: { + id: EventId.make("activity-2"), + tone: "info", + kind: "tool.completed", + summary: "Tool completed", + payload: null, + turnId: null, + createdAt: NOW, + }, + createdAt: NOW, + }, + readModel: makeReadModel("settled"), + }); + const routineEvents = Array.isArray(routineResult) ? routineResult : [routineResult]; + expect(routineEvents.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 1730494ecc6..33663c59cee 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -327,6 +327,61 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.settle": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + const occurredAt = yield* nowIso; + // Settling an already-settled thread re-emits with the original + // settledAt: the engine rejects zero-event commands, and bulk-settle / + // double-click must stay silent no-ops rather than surface errors. + const settledAt = + thread.settledOverride === "settled" && thread.settledAt !== null + ? thread.settledAt + : occurredAt; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.settled", + payload: { + threadId: command.threadId, + settledAt, + updatedAt: settledAt, + }, + }; + } + + case "thread.unsettle": { + yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + // Idempotent by re-emission (see thread.settle): reducing the event a + // second time lands on the same override state. + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: command.reason, + updatedAt: occurredAt, + }, + }; + } + case "thread.meta.update": { const thread = yield* requireThread({ readModel, @@ -479,7 +534,24 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" createdAt: command.createdAt, }, }; - return [userMessageEvent, turnStartRequestedEvent]; + if (targetThread.settledOverride !== "settled") { + return [userMessageEvent, turnStartRequestedEvent]; + } + const unsettledEvent: Omit = { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: "activity", + updatedAt: command.createdAt, + }, + }; + return [unsettledEvent, userMessageEvent, turnStartRequestedEvent]; } case "thread.turn.interrupt": { @@ -600,12 +672,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.session.set": { - yield* requireThread({ + const thread = yield* requireThread({ readModel, command, threadId: command.threadId, }); - return { + const sessionSetEvent: Omit = { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, @@ -619,6 +691,29 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" session: command.session, }, }; + // Only a session coming alive is activity worth waking a settled thread + // for — status writes like ready/stopped/error arrive after the fact and + // must not fight a user's explicit settle. + const isSessionActivity = + command.session.status === "starting" || command.session.status === "running"; + if (thread.settledOverride !== "settled" || !isSessionActivity) { + return sessionSetEvent; + } + const unsettledEvent: Omit = { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: "activity", + updatedAt: command.createdAt, + }, + }; + return [unsettledEvent, sessionSetEvent]; } case "thread.message.assistant.delta": { @@ -745,7 +840,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.activity.append": { - yield* requireThread({ + const thread = yield* requireThread({ readModel, command, threadId: command.threadId, @@ -758,7 +853,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ? ((command.activity.payload as { requestId: string }) .requestId as OrchestrationEvent["metadata"]["requestId"]) : undefined; - return { + const activityAppendedEvent: Omit = { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, @@ -772,6 +867,29 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" activity: command.activity, }, }; + // An approval or user-input request is blocked-on-you work — it must + // never stay hidden inside a settled slim row. + const wakesSettledThread = + command.activity.kind === "approval.requested" || + command.activity.kind === "user-input.requested"; + if (thread.settledOverride !== "settled" || !wakesSettledThread) { + return activityAppendedEvent; + } + const unsettledEvent: Omit = { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: "activity", + updatedAt: command.createdAt, + }, + }; + return [unsettledEvent, activityAppendedEvent]; } default: { diff --git a/apps/server/src/orchestration/projector.settled.test.ts b/apps/server/src/orchestration/projector.settled.test.ts new file mode 100644 index 00000000000..2070c44418a --- /dev/null +++ b/apps/server/src/orchestration/projector.settled.test.ts @@ -0,0 +1,88 @@ +import { + CommandId, + EventId, + ProjectId, + ThreadId, + type OrchestrationEvent, +} from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +function makeEvent(input: { + readonly sequence: number; + readonly type: OrchestrationEvent["type"]; + readonly payload: unknown; +}): OrchestrationEvent { + return { + sequence: input.sequence, + eventId: EventId.make(`event-${input.sequence}`), + type: input.type, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + occurredAt: "2026-01-01T00:00:00.000Z", + commandId: CommandId.make(`command-${input.sequence}`), + causationEventId: null, + correlationId: null, + metadata: {}, + payload: input.payload as never, + } as OrchestrationEvent; +} + +it.effect("projects settled lifecycle events", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const created = yield* projectEvent( + createEmptyReadModel(now), + makeEvent({ + sequence: 1, + type: "thread.created", + payload: { + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { provider: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + const settled = yield* projectEvent( + created, + makeEvent({ + sequence: 2, + type: "thread.settled", + payload: { threadId: ThreadId.make("thread-1"), settledAt: now, updatedAt: now }, + }), + ); + expect(settled.threads[0]?.settledOverride).toBe("settled"); + expect(settled.threads[0]?.settledAt).toBe(now); + + const userUnsettled = yield* projectEvent( + settled, + makeEvent({ + sequence: 3, + type: "thread.unsettled", + payload: { threadId: ThreadId.make("thread-1"), reason: "user", updatedAt: now }, + }), + ); + expect(userUnsettled.threads[0]?.settledOverride).toBe("active"); + expect(userUnsettled.threads[0]?.settledAt).toBeNull(); + + const activityUnsettled = yield* projectEvent( + userUnsettled, + makeEvent({ + sequence: 4, + type: "thread.unsettled", + payload: { threadId: ThreadId.make("thread-1"), reason: "activity", updatedAt: now }, + }), + ); + expect(activityUnsettled.threads[0]?.settledOverride).toBeNull(); + expect(activityUnsettled.threads[0]?.settledAt).toBeNull(); + }), +); diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index fadd5078026..e9a55c0796b 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -89,6 +89,8 @@ describe("orchestration projector", () => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [], proposedPlans: [], diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index fc6ab8f6fcf..c8f47dcebbf 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -22,7 +22,9 @@ import { ThreadMetaUpdatedPayload, ThreadProposedPlanUpsertedPayload, ThreadRuntimeModeSetPayload, + ThreadSettledPayload, ThreadUnarchivedPayload, + ThreadUnsettledPayload, ThreadRevertedPayload, ThreadSessionSetPayload, ThreadTurnDiffCompletedPayload, @@ -286,6 +288,8 @@ export function projectEvent( createdAt: payload.createdAt, updatedAt: payload.updatedAt, archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [], activities: [], @@ -337,6 +341,30 @@ export function projectEvent( })), ); + case "thread.settled": + return decodeForEvent(ThreadSettledPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + settledOverride: "settled", + settledAt: payload.settledAt, + updatedAt: payload.updatedAt, + }), + })), + ); + + case "thread.unsettled": + return decodeForEvent(ThreadUnsettledPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + settledOverride: payload.reason === "user" ? "active" : null, + settledAt: null, + updatedAt: payload.updatedAt, + }), + })), + ); + case "thread.meta-updated": return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => ({ diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index a2069e62a14..0403e55d9ee 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -91,6 +91,8 @@ projectionRepositoriesLayer("Projection repositories", (it) => { createdAt: "2026-03-24T00:00:00.000Z", updatedAt: "2026-03-24T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 1baeb375c15..e0c85e91494 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -43,6 +43,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at, updated_at, archived_at, + settled_override, + settled_at, latest_user_message_at, pending_approval_count, pending_user_input_count, @@ -62,6 +64,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.createdAt}, ${row.updatedAt}, ${row.archivedAt}, + ${row.settledOverride}, + ${row.settledAt}, ${row.latestUserMessageAt}, ${row.pendingApprovalCount}, ${row.pendingUserInputCount}, @@ -81,6 +85,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at = excluded.created_at, updated_at = excluded.updated_at, archived_at = excluded.archived_at, + settled_override = excluded.settled_override, + settled_at = excluded.settled_at, latest_user_message_at = excluded.latest_user_message_at, pending_approval_count = excluded.pending_approval_count, pending_user_input_count = excluded.pending_user_input_count, @@ -107,6 +113,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -135,6 +143,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index d468838d5d4..cacb2c85b83 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -45,6 +45,7 @@ import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexe import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; +import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts"; /** * Migration loader with all migrations defined inline. @@ -89,6 +90,7 @@ export const migrationEntries = [ [30, "ProjectionThreadShellArchiveIndexes", Migration0030], [31, "AuthAuthorizationScopes", Migration0031], [32, "AuthPairingProofKeyThumbprint", Migration0032], + [33, "ProjectionThreadsSettled", Migration0033], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/033_ProjectionThreadsSettled.ts b/apps/server/src/persistence/Migrations/033_ProjectionThreadsSettled.ts new file mode 100644 index 00000000000..e93407defe2 --- /dev/null +++ b/apps/server/src/persistence/Migrations/033_ProjectionThreadsSettled.ts @@ -0,0 +1,23 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "settled_override")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN settled_override TEXT + `; + } + + if (!columns.some((column) => column.name === "settled_at")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN settled_at TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 44fdc147a4a..8057d434950 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -36,6 +36,8 @@ export const ProjectionThread = Schema.Struct({ createdAt: IsoDateTime, updatedAt: IsoDateTime, archivedAt: Schema.NullOr(IsoDateTime), + settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])), + settledAt: Schema.NullOr(IsoDateTime), latestUserMessageAt: Schema.NullOr(IsoDateTime), pendingApprovalCount: NonNegativeInt, pendingUserInputCount: NonNegativeInt, diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 166a88ef17b..3843c8acbcd 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -100,6 +100,8 @@ function makeReadModel( createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, latestUserMessageAt: null, hasPendingApprovals: false, hasPendingUserInput: false, diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 0c261c8f21e..74a4de594a1 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -305,6 +305,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, session: null, latestUserMessageAt: null, hasPendingApprovals: false, @@ -451,6 +453,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, session: { threadId, status: "running", @@ -607,6 +611,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, session: { threadId, status: "running", diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 6122c498145..8a5e0b713e6 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -164,6 +164,8 @@ const makeDefaultOrchestrationReadModel = () => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, latestTurn: null, messages: [], session: null, @@ -193,6 +195,8 @@ const makeDefaultOrchestrationThreadShell = ( createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, session: null, latestUserMessageAt: null, hasPendingApprovals: false, @@ -5499,6 +5503,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, latestTurn: null, messages: [], session: null, diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index bc7487cee29..c578e69c450 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -49,6 +49,8 @@ function makeThread(overrides: Partial = {}): Thread { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, latestTurn: null, branch: null, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 325f9afa90a..6b74eae9ff4 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -45,6 +45,8 @@ export function buildLocalDraftThread( createdAt: draftThread.createdAt, updatedAt: draftThread.createdAt, archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, latestTurn: null, branch: draftThread.branch, diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 651fe34e4b4..6609017f91c 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -24,6 +24,8 @@ function makeThread(overrides: Partial = {}): Thread { proposedPlans: [], createdAt: "2026-03-01T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, updatedAt: "2026-03-01T00:00:00.000Z", latestTurn: null, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 5eebe7375a5..a9b32a887c0 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -996,6 +996,8 @@ function makeThread(overrides: Partial = {}): Thread { proposedPlans: [], createdAt: "2026-03-09T10:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, updatedAt: "2026-03-09T10:00:00.000Z", latestTurn: null, diff --git a/apps/web/src/lib/threadSort.test.ts b/apps/web/src/lib/threadSort.test.ts index b9981bc2e3e..ca9a5986c66 100644 --- a/apps/web/src/lib/threadSort.test.ts +++ b/apps/web/src/lib/threadSort.test.ts @@ -26,6 +26,8 @@ function makeThread(overrides: Partial = {}): Thread { proposedPlans: [], createdAt: "2026-03-09T10:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, updatedAt: "2026-03-09T10:00:00.000Z", latestTurn: null, diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 13ff2f0f73e..89734357889 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -26,6 +26,8 @@ function makeThread(overrides: Partial = {}): Thread { createdAt: "2026-02-13T00:00:00.000Z", updatedAt: "2026-02-13T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, latestTurn: null, branch: null, diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index 5cc3f0c1a86..0cb1650066c 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -21,7 +21,13 @@ import { import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import * as RpcSession from "../rpc/session.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; -import { archiveThread, createProject, stopThreadSession } from "./commands.ts"; +import { + archiveThread, + createProject, + settleThread, + stopThreadSession, + unsettleThread, +} from "./commands.ts"; const TEST_CRYPTO_LAYER = Layer.succeed( Crypto.Crypto, @@ -134,4 +140,35 @@ describe("environment commands", () => { ]); }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), ); + + it.effect("dispatches settle and unsettle commands without timestamps", () => + Effect.gen(function* () { + const dispatched: ClientOrchestrationCommand[] = []; + const supervisor = yield* makeSupervisor(dispatched); + + yield* settleThread({ + commandId: CommandId.make("settle-command"), + threadId: ThreadId.make("thread-1"), + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + yield* unsettleThread({ + commandId: CommandId.make("unsettle-command"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + + expect(dispatched).toEqual([ + { + type: "thread.settle", + commandId: "settle-command", + threadId: "thread-1", + }, + { + type: "thread.unsettle", + commandId: "unsettle-command", + threadId: "thread-1", + reason: "user", + }, + ]); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); }); diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index a0c3cbe771f..ef767854804 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -35,6 +35,8 @@ export type CreateThreadInput = CommandInput<"thread.create">; export type DeleteThreadInput = CommandInput<"thread.delete">; export type ArchiveThreadInput = CommandInput<"thread.archive">; export type UnarchiveThreadInput = CommandInput<"thread.unarchive">; +export type SettleThreadInput = CommandInput<"thread.settle">; +export type UnsettleThreadInput = CommandInput<"thread.unsettle">; export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">; export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">; export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">; @@ -153,6 +155,26 @@ export const unarchiveThread: (input: UnarchiveThreadInput) => CommandEffect = E }); }); +export const settleThread: (input: SettleThreadInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.settleThread", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "thread.settle", + commandId: yield* commandId(input), + }); +}); + +export const unsettleThread: (input: UnsettleThreadInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.unsettleThread", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "thread.unsettle", + commandId: yield* commandId(input), + }); +}); + export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn( "EnvironmentCommands.updateThreadMetadata", )(function* (input) { diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index c772d134a67..e08fd9e552f 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -96,6 +96,8 @@ const THREAD_SHELL = { createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, session: null, latestUserMessageAt: null, hasPendingApprovals: false, diff --git a/packages/client-runtime/src/state/shellReducer.test.ts b/packages/client-runtime/src/state/shellReducer.test.ts index a069460e63c..fdccc4c47dd 100644 --- a/packages/client-runtime/src/state/shellReducer.test.ts +++ b/packages/client-runtime/src/state/shellReducer.test.ts @@ -36,6 +36,8 @@ const stubThread = { createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, latestUserMessageAt: null, hasPendingApprovals: false, hasPendingUserInput: false, diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index aab5110e9cf..ea87298e98f 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -12,9 +12,11 @@ import { type RevertThreadCheckpointInput, type SetThreadInteractionModeInput, type SetThreadRuntimeModeInput, + type SettleThreadInput, type StartThreadTurnInput, type StopThreadSessionInput, type UnarchiveThreadInput, + type UnsettleThreadInput, type UpdateThreadMetadataInput, archiveThread, createThread, @@ -25,9 +27,11 @@ import { revertThreadCheckpoint, setThreadInteractionMode, setThreadRuntimeMode, + settleThread, startThreadTurn, stopThreadSession, unarchiveThread, + unsettleThread, updateThreadMetadata, } from "../operations/commands.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; @@ -42,9 +46,11 @@ export type { RevertThreadCheckpointInput, SetThreadInteractionModeInput, SetThreadRuntimeModeInput, + SettleThreadInput, StartThreadTurnInput, StopThreadSessionInput, UnarchiveThreadInput, + UnsettleThreadInput, UpdateThreadMetadataInput, } from "../operations/commands.ts"; @@ -82,6 +88,18 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + settle: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:settle", + execute: (input: SettleThreadInput) => settleThread(input), + scheduler, + concurrency, + }), + unsettle: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:unsettle", + execute: (input: UnsettleThreadInput) => unsettleThread(input), + scheduler, + concurrency, + }), updateMetadata: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:update-metadata", execute: (input: UpdateThreadMetadataInput) => updateThreadMetadata(input), diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts index 430900bdbb0..770738ad9bb 100644 --- a/packages/client-runtime/src/state/threadDetail.ts +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -57,6 +57,8 @@ export function mergeEnvironmentThread( createdAt: shell.createdAt, updatedAt: shell.updatedAt, archivedAt: shell.archivedAt, + settledOverride: shell.settledOverride, + settledAt: shell.settledAt, session: shell.session, }; } diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 94eb1c65370..211f8748f4e 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -34,6 +34,8 @@ const baseThread: OrchestrationThread = { createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [], proposedPlans: [], @@ -164,6 +166,62 @@ describe("applyThreadDetailEvent", () => { }); }); + describe("thread.settled / thread.unsettled", () => { + it("sets the settled override and timestamp", () => { + const settledAt = "2026-04-01T05:00:00.000Z"; + const result = applyThreadDetailEvent(baseThread, { + ...baseEventFields, + sequence: 5, + occurredAt: settledAt, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.settled", + payload: { + threadId: ThreadId.make("thread-1"), + settledAt, + updatedAt: settledAt, + }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.settledOverride).toBe("settled"); + expect(result.thread.settledAt).toBe(settledAt); + } + }); + + it.each([ + ["user", "active"], + ["activity", null], + ] as const)("unsettles for %s with override %s", (reason, settledOverride) => { + const settledThread: OrchestrationThread = { + ...baseThread, + settledOverride: "settled", + settledAt: "2026-04-01T05:00:00.000Z", + }; + const updatedAt = "2026-04-01T06:00:00.000Z"; + const result = applyThreadDetailEvent(settledThread, { + ...baseEventFields, + sequence: 6, + occurredAt: updatedAt, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.unsettled", + payload: { + threadId: ThreadId.make("thread-1"), + reason, + updatedAt, + }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.settledOverride).toBe(settledOverride); + expect(result.thread.settledAt).toBeNull(); + } + }); + }); + describe("thread.meta-updated", () => { it("patches title and branch", () => { const result = applyThreadDetailEvent(baseThread, { diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 670540fee70..7fdee37c0e6 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -72,6 +72,8 @@ export function applyThreadDetailEvent( createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [], proposedPlans: [], @@ -100,6 +102,28 @@ export function applyThreadDetailEvent( thread: { ...thread, archivedAt: null, updatedAt: event.payload.updatedAt }, }; + case "thread.settled": + return { + kind: "updated", + thread: { + ...thread, + settledOverride: "settled", + settledAt: event.payload.settledAt, + updatedAt: event.payload.updatedAt, + }, + }; + + case "thread.unsettled": + return { + kind: "updated", + thread: { + ...thread, + settledOverride: event.payload.reason === "user" ? "active" : null, + settledAt: null, + updatedAt: event.payload.updatedAt, + }, + }; + // ── Thread metadata ───────────────────────────────────────────── case "thread.meta-updated": return { diff --git a/packages/client-runtime/src/state/threadSettled.test.ts b/packages/client-runtime/src/state/threadSettled.test.ts index 19b17bdd8d2..d7c845ae468 100644 --- a/packages/client-runtime/src/state/threadSettled.test.ts +++ b/packages/client-runtime/src/state/threadSettled.test.ts @@ -48,6 +48,8 @@ function makeShell(input: { createdAt: "2026-04-01T00:00:00.000Z", updatedAt: NOW, archivedAt: input.archivedAt, + settledOverride: null, + settledAt: null, session: input.sessionStatus === undefined ? null diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index f3c4c4e6338..8d0e4d0a35c 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -69,6 +69,8 @@ const BASE_THREAD: OrchestrationThread = { createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [], proposedPlans: [], diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 29a732ca69b..bdc7169821e 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -15,6 +15,8 @@ import { ProjectMetaUpdatedPayload, OrchestrationProposedPlan, OrchestrationSession, + OrchestrationThread, + OrchestrationThreadShell, ProjectCreateCommand, ThreadMetaUpdatedPayload, ThreadTurnStartCommand, @@ -37,6 +39,8 @@ const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect( const decodeOrchestrationLatestTurn = Schema.decodeUnknownEffect(OrchestrationLatestTurn); const decodeOrchestrationProposedPlan = Schema.decodeUnknownEffect(OrchestrationProposedPlan); const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession); +const decodeOrchestrationThread = Schema.decodeUnknownEffect(OrchestrationThread); +const decodeOrchestrationThreadShell = Schema.decodeUnknownEffect(OrchestrationThreadShell); const encodeThreadCreatedPayload = Schema.encodeEffect(ThreadCreatedPayload); function getOptionValue( @@ -344,6 +348,65 @@ it.effect("decodes thread archive and unarchive commands", () => }), ); +it.effect("decodes thread settle and unsettle commands", () => + Effect.gen(function* () { + const settle = yield* decodeOrchestrationCommand({ + type: "thread.settle", + commandId: "cmd-settle-1", + threadId: "thread-1", + }); + const unsettle = yield* decodeOrchestrationCommand({ + type: "thread.unsettle", + commandId: "cmd-unsettle-1", + threadId: "thread-1", + reason: "activity", + }); + + assert.strictEqual(settle.type, "thread.settle"); + assert.strictEqual(unsettle.type, "thread.unsettle"); + }), +); + +it.effect("defaults settled fields when decoding historical thread data", () => + Effect.gen(function* () { + const common = { + id: "thread-1", + projectId: "project-1", + title: "Historical thread", + modelSelection: { provider: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + archivedAt: null, + session: null, + }; + const thread = yield* decodeOrchestrationThread({ + ...common, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + }); + const shell = yield* decodeOrchestrationThreadShell({ + ...common, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }); + + assert.strictEqual(thread.settledOverride, null); + assert.strictEqual(thread.settledAt, null); + assert.strictEqual(shell.settledOverride, null); + assert.strictEqual(shell.settledAt, null); + }), +); + it.effect("decodes thread archived and unarchived events", () => Effect.gen(function* () { const archived = yield* decodeOrchestrationEvent({ @@ -388,6 +451,48 @@ it.effect("decodes thread archived and unarchived events", () => }), ); +it.effect("decodes thread settled and unsettled events", () => + Effect.gen(function* () { + const settled = yield* decodeOrchestrationEvent({ + sequence: 1, + eventId: "event-settle-1", + aggregateKind: "thread", + aggregateId: "thread-1", + type: "thread.settled", + occurredAt: "2026-01-01T00:00:00.000Z", + commandId: "cmd-settle-1", + causationEventId: null, + correlationId: "cmd-settle-1", + metadata: {}, + payload: { + threadId: "thread-1", + settledAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + }); + const unsettled = yield* decodeOrchestrationEvent({ + sequence: 2, + eventId: "event-unsettle-1", + aggregateKind: "thread", + aggregateId: "thread-1", + type: "thread.unsettled", + occurredAt: "2026-01-02T00:00:00.000Z", + commandId: "cmd-unsettle-1", + causationEventId: null, + correlationId: "cmd-unsettle-1", + metadata: {}, + payload: { + threadId: "thread-1", + reason: "user", + updatedAt: "2026-01-02T00:00:00.000Z", + }, + }); + + assert.strictEqual(settled.type, "thread.settled"); + assert.strictEqual(unsettled.type, "thread.unsettled"); + }), +); + it.effect("accepts provider-scoped model options in thread.turn.start", () => Effect.gen(function* () { const parsed = yield* decodeThreadTurnStartCommand({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index c0c9c62d080..0fcfa55d98c 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -356,6 +356,10 @@ export const OrchestrationThread = Schema.Struct({ createdAt: IsoDateTime, updatedAt: IsoDateTime, archivedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), + settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), + settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), deletedAt: Schema.NullOr(IsoDateTime), messages: Schema.Array(OrchestrationMessage), proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe( @@ -402,6 +406,10 @@ export const OrchestrationThreadShell = Schema.Struct({ createdAt: IsoDateTime, updatedAt: IsoDateTime, archivedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), + settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), + settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), session: Schema.NullOr(OrchestrationSession), latestUserMessageAt: Schema.NullOr(IsoDateTime), hasPendingApprovals: Schema.Boolean, @@ -558,6 +566,19 @@ const ThreadUnarchiveCommand = Schema.Struct({ threadId: ThreadId, }); +const ThreadSettleCommand = Schema.Struct({ + type: Schema.Literal("thread.settle"), + commandId: CommandId, + threadId: ThreadId, +}); + +const ThreadUnsettleCommand = Schema.Struct({ + type: Schema.Literal("thread.unsettle"), + commandId: CommandId, + threadId: ThreadId, + reason: Schema.Literals(["user", "activity"]), +}); + const ThreadMetaUpdateCommand = Schema.Struct({ type: Schema.Literal("thread.meta.update"), commandId: CommandId, @@ -700,6 +721,8 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadDeleteCommand, ThreadArchiveCommand, ThreadUnarchiveCommand, + ThreadSettleCommand, + ThreadUnsettleCommand, ThreadMetaUpdateCommand, ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, @@ -721,6 +744,8 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadDeleteCommand, ThreadArchiveCommand, ThreadUnarchiveCommand, + ThreadSettleCommand, + ThreadUnsettleCommand, ThreadMetaUpdateCommand, ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, @@ -823,6 +848,8 @@ export const OrchestrationEventType = Schema.Literals([ "thread.deleted", "thread.archived", "thread.unarchived", + "thread.settled", + "thread.unsettled", "thread.meta-updated", "thread.runtime-mode-set", "thread.interaction-mode-set", @@ -902,6 +929,18 @@ export const ThreadUnarchivedPayload = Schema.Struct({ updatedAt: IsoDateTime, }); +export const ThreadSettledPayload = Schema.Struct({ + threadId: ThreadId, + settledAt: IsoDateTime, + updatedAt: IsoDateTime, +}); + +export const ThreadUnsettledPayload = Schema.Struct({ + threadId: ThreadId, + reason: Schema.Literals(["user", "activity"]), + updatedAt: IsoDateTime, +}); + export const ThreadMetaUpdatedPayload = Schema.Struct({ threadId: ThreadId, title: Schema.optional(TrimmedNonEmptyString), @@ -1069,6 +1108,16 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.unarchived"), payload: ThreadUnarchivedPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.settled"), + payload: ThreadSettledPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.unsettled"), + payload: ThreadUnsettledPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.meta-updated"), From 13d459e5d4b8ef87f1d5ca441057889ee3bb667e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 21 Jul 2026 15:07:53 -0700 Subject: [PATCH 2/5] Migrate clients to the server-backed settled lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit settle/unsettle now dispatch thread.settle / thread.unsettle instead of riding archive/unarchive, and effectiveSettled keys on the projected settledOverride/settledAt. Because settled threads stay in the live shell stream, the archive-era workarounds are deleted: - settledHolds optimistic-hold machinery (web + mobile) - archived-snapshot merging into the v2 lists - unarchive-on-open when activating a settled row — opening is plain navigation now, and history stays readable without un-settling - the auto-settled un-settle special case: reason 'user' pins any settled row active until real activity clears the pin Archive regains its original meaning (hidden from lists) and is no longer offered as settle's alias anywhere in v2. Co-Authored-By: Claude Fable 5 --- .../src/features/home/HomeRouteScreen.tsx | 21 +- apps/mobile/src/features/home/HomeScreen.tsx | 127 ++--------- .../src/features/home/useThreadListActions.ts | 56 +++-- .../features/threads/thread-list-v2-items.tsx | 9 +- .../src/features/threads/threadListV2.test.ts | 20 +- apps/web/src/components/SidebarV2.tsx | 213 +++--------------- apps/web/src/hooks/useThreadActions.ts | 25 +- .../src/state/threadSettled.test.ts | 64 +++--- .../client-runtime/src/state/threadSettled.ts | 30 +-- 9 files changed, 154 insertions(+), 411 deletions(-) diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index c048a908444..502d2bab1b5 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -123,21 +123,12 @@ export function HomeRouteScreen() { onProjectSortOrderChange={setProjectSortOrder} onSearchQueryChange={setSearchQuery} onSelectThread={(thread) => { - // Archived (= manually settled) rows are invisible to the live - // thread queries — opening the screen without unarchiving leaves - // it unresolvable and can drop outbox sends. Opening is - // activity, and activity un-settles: unarchive first, then - // navigate. Auto-settled rows (archivedAt null) are still live. - void (async () => { - if (thread.archivedAt !== null) { - const unsettled = await unsettleThread(thread); - if (!unsettled) return; - } - navigation.navigate("Thread", { - environmentId: thread.environmentId, - threadId: thread.id, - }); - })(); + // Settled threads are live shells: opening one is plain + // navigation, and sending a message un-settles server-side. + navigation.navigate("Thread", { + environmentId: thread.environmentId, + threadId: thread.id, + }); }} onSelectPendingTask={openPendingTask} onDeletePendingTask={confirmDeletePendingTask} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 78b415edab9..2a08fca1100 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -24,8 +24,7 @@ import { AppText as Text } from "../../components/AppText"; import { EmptyState } from "../../components/EmptyState"; import type { WorkspaceState } from "../../state/workspaceModel"; import type { SavedRemoteConnection } from "../../lib/connection"; -import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; -import { useArchivedThreadSnapshots } from "../archive/useArchivedThreadSnapshots"; +import { scopedProjectKey } from "../../lib/scopedEntities"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; @@ -291,15 +290,9 @@ export function HomeScreen(props: HomeScreenProps) { // Thread List v2 (beta): one flat list in creation order, no grouping. // Settled threads collapse into a recency tail below the card block. - // Settle = archive in the client-only model, and the live shell stream - // drops archived threads — merge them back from the archived snapshot so - // they render as the settled tail. Live shells win on overlap. - const archivedEnvironmentIds = useMemo( - () => - threadListV2Enabled ? props.environments.map((environment) => environment.environmentId) : [], - [props.environments, threadListV2Enabled], - ); - const { snapshots: archivedSnapshots } = useArchivedThreadSnapshots(archivedEnvironmentIds); + // Settled threads stay in the live shell stream (settled ≠ archived), so + // the partition works directly off live shells — no snapshot merging or + // optimistic holds. // PR states stream in per-row (rows own the VCS subscriptions); a merged or // closed PR auto-settles its thread on the next partition (mirrors web). const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< @@ -320,84 +313,14 @@ export function HomeScreen(props: HomeScreenProps) { }, [], ); - // Bridge the gap between the live stream dropping a just-settled thread - // and the archived snapshot returning it: hold the shell we settled, - // marked archived, until the snapshot carries it. Held explicitly at - // settle time so deleted threads are never resurrected. - const [settledHolds, setSettledHolds] = useState>( - () => new Map(), - ); const handleSettleThread = useCallback( (thread: EnvironmentThreadShell) => { - const threadKey = scopedThreadKey(thread.environmentId, thread.id); - // An existing hold means a settle for this thread is already in flight - // (or done and awaiting its snapshot). Re-triggering would fail the - // executor's in-flight check and the rollback below would strip the - // first settle's hold, flickering the row out of the settled tail. - if (settledHolds.has(threadKey)) { - return; - } - setSettledHolds((current) => - new Map(current).set(threadKey, { - ...thread, - archivedAt: thread.archivedAt ?? new Date().toISOString(), - }), - ); - void (async () => { - // Roll the optimistic hold back if the settle was blocked or failed — - // otherwise a never-archived thread would render settled forever. - const succeeded = await props.onSettleThread(thread); - if (!succeeded) { - setSettledHolds((current) => { - const next = new Map(current); - next.delete(threadKey); - return next; - }); - } - })(); + void props.onSettleThread(thread); }, - [props.onSettleThread, settledHolds], + [props.onSettleThread], ); - // Delete and un-settle both invalidate any hold for the thread. - const dropSettledHold = useCallback((thread: EnvironmentThreadShell) => { - setSettledHolds((current) => { - const threadKey = scopedThreadKey(thread.environmentId, thread.id); - if (!current.has(threadKey)) return current; - const next = new Map(current); - next.delete(threadKey); - return next; - }); - }, []); - const handleDeleteThread = useCallback( - (thread: EnvironmentThreadShell) => { - dropSettledHold(thread); - props.onDeleteThread(thread); - }, - [dropSettledHold, props.onDeleteThread], - ); - const handleUnsettleThread = useCallback( - (thread: EnvironmentThreadShell) => { - dropSettledHold(thread); - props.onUnsettleThread(thread); - }, - [dropSettledHold, props.onUnsettleThread], - ); - useEffect(() => { - if (settledHolds.size === 0) return; - const covered = new Set(); - for (const { environmentId, snapshot } of archivedSnapshots) { - for (const thread of snapshot.threads) { - covered.add(scopedThreadKey(environmentId, thread.id)); - } - } - if ([...settledHolds.keys()].some((threadKey) => covered.has(threadKey))) { - setSettledHolds((current) => { - const next = new Map(current); - for (const threadKey of covered) next.delete(threadKey); - return next; - }); - } - }, [archivedSnapshots, settledHolds]); + const handleDeleteThread = props.onDeleteThread; + const handleUnsettleThread = props.onUnsettleThread; // The settled tail renders in pages; expansion resets when the filter // context changes so environment/search flips never inherit a deep page. const [settledVisibleCount, setSettledVisibleCount] = useState( @@ -424,21 +347,10 @@ export function HomeScreen(props: HomeScreenProps) { }, [threadListV2Enabled]); const threadListV2Layout = useMemo(() => { if (!threadListV2Enabled) return { items: [], hiddenSettledCount: 0 }; - const merged = new Map(); - for (const { environmentId, snapshot } of archivedSnapshots) { - for (const thread of snapshot.threads) { - merged.set(scopedThreadKey(environmentId, thread.id), { ...thread, environmentId }); - } - } - for (const thread of props.threads) { - merged.set(scopedThreadKey(thread.environmentId, thread.id), thread); - } - for (const [threadKey, shell] of settledHolds) { - if (merged.has(threadKey)) continue; - merged.set(threadKey, shell); - } + // Settled threads are live shells; archived threads keep their original + // "hidden from lists" meaning. return buildThreadListV2Items({ - threads: [...merged.values()], + threads: props.threads.filter((thread) => thread.archivedAt === null), environmentId: props.selectedEnvironmentId, searchQuery: props.searchQuery, changeRequestStateByKey, @@ -448,9 +360,7 @@ export function HomeScreen(props: HomeScreenProps) { }, [ changeRequestStateByKey, nowMinute, - settledHolds, settledVisibleCount, - archivedSnapshots, props.searchQuery, props.selectedEnvironmentId, props.threads, @@ -591,17 +501,12 @@ export function HomeScreen(props: HomeScreenProps) { const keyExtractor = useCallback((item: HomeListItem) => item.key, []); /* Empty states */ - // v2 shows archived threads as its settled tail, so an archived-only - // workspace still has a list to render there. The signal must ignore the - // search/environment filters: an active query that matches nothing needs - // the in-list "No results" state, not the full-page "No threads yet". + // The signal must ignore the search/environment filters: an active query + // that matches nothing needs the in-list "No results" state, not the + // full-page "No threads yet". Settled threads are unarchived live shells, + // so the v1 check already covers v2. const hasAnyThreads = - props.threads.some((thread) => thread.archivedAt === null) || - props.pendingTasks.length > 0 || - (threadListV2Enabled && - (props.threads.length > 0 || - settledHolds.size > 0 || - archivedSnapshots.some(({ snapshot }) => snapshot.threads.length > 0))); + props.threads.some((thread) => thread.archivedAt === null) || props.pendingTasks.length > 0; const hasResults = projectGroups.length > 0; const selectedEnvironmentLabel = props.selectedEnvironmentId === null diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 8a5c893a72e..7f39b48d9d4 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -48,10 +48,8 @@ function useThreadActionExecutor( const archiveMutation = useAtomCommand(threadEnvironment.archive, { reportFailure: false }); const unarchiveMutation = useAtomCommand(threadEnvironment.unarchive, { reportFailure: false }); const deleteMutation = useAtomCommand(threadEnvironment.delete, { reportFailure: false }); - // Client-only settled model: settle/unsettle ride the archive lifecycle so - // no server upgrade is required. See client-runtime threadSettled.ts. - const settleMutation = archiveMutation; - const unsettleMutation = unarchiveMutation; + const settleMutation = useAtomCommand(threadEnvironment.settle, { reportFailure: false }); + const unsettleMutation = useAtomCommand(threadEnvironment.unsettle, { reportFailure: false }); const inFlightThreadKeys = useRef(new Set()); const executeAction = useCallback( @@ -87,35 +85,35 @@ function useThreadActionExecutor( ); return false; } - // Auto-settled rows (inactivity / merged PR) are not archived; - // unarchiving them would be rejected. Nothing to undo — no-op. - if (action === "unsettle" && thread.archivedAt === null) { - return false; - } - const mutation = - action === "settle" - ? settleMutation - : action === "unsettle" - ? unsettleMutation - : action === "archive" - ? archiveMutation - : action === "unarchive" - ? unarchiveMutation - : deleteMutation; - const result = await mutation({ - environmentId: thread.environmentId, - input: { threadId: thread.id }, - }); + const result = + action === "unsettle" + ? // reason "user" pins the thread active: auto-settle stays + // suppressed until real activity clears the pin server-side. + await unsettleMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id, reason: "user" }, + }) + : await ( + action === "settle" + ? settleMutation + : action === "archive" + ? archiveMutation + : action === "unarchive" + ? unarchiveMutation + : deleteMutation + )({ + environmentId: thread.environmentId, + input: { threadId: thread.id }, + }); if (result._tag === "Failure") { Alert.alert(actionFailureTitle(action), actionFailureMessage(action, result.cause)); return false; } - // Archived threads leave the live shell stream, and the v2 list - // renders them from the archived snapshot — keep it fresh for every - // action that changes what that snapshot should contain (delete - // included, or a deleted settled row lingers until some later - // refresh). - refreshArchivedThreadsForEnvironment(thread.environmentId); + // Settled threads stay in the live shell stream; only the archive + // lifecycle still feeds the archived-snapshot surface. + if (action === "archive" || action === "unarchive" || action === "delete") { + refreshArchivedThreadsForEnvironment(thread.environmentId); + } onCompleted?.(action, thread); return true; } finally { diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 73f077feac2..f66ee9bca35 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -187,11 +187,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { [handleDelete, handleSettle, handleUnsettle], ); - // Swipe: the v2 primary action is the lifecycle transition. Un-settle only - // exists when there is an archive to undo; an auto-settled slim row - // (inactivity / merged PR, archivedAt null) offers Settle, which archives - // it — the explicit "keep it settled" the row can actually deliver. - const canUnsettle = variant === "slim" && thread.archivedAt !== null; + // Swipe: the v2 primary action is the lifecycle transition. Every settled + // row can un-settle — explicit settles clear the override, auto-settled + // rows get pinned active until real activity clears the pin. + const canUnsettle = variant === "slim"; const primaryAction = useMemo( () => canUnsettle diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index fe5e3ddbc5c..2b562f0e7e6 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -77,19 +77,21 @@ describe("sortThreadsForListV2", () => { }); describe("buildThreadListV2Items", () => { - it("partitions archived (settled) threads into a slim tail with one divider", () => { + it("partitions settled threads into a slim tail with one divider", () => { const { items } = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("active"), title: "Active" }), makeThread({ id: ThreadId.make("settled"), title: "Settled", - archivedAt: NOW, + settledOverride: "settled", + settledAt: NOW, }), makeThread({ id: ThreadId.make("settled-2"), title: "Settled 2", - archivedAt: NOW, + settledOverride: "settled", + settledAt: NOW, }), ], environmentId: null, @@ -129,15 +131,16 @@ describe("buildThreadListV2Items", () => { expect(items.map((item) => item.thread.id)).toEqual(["newer-created", "older-created"]); }); - it("keeps archived threads in the tail and filters by search query", () => { + it("keeps settled threads in the tail and filters by search query", () => { const { items } = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("match"), title: "Fix login bug" }), makeThread({ id: ThreadId.make("miss"), title: "Greeting" }), makeThread({ - id: ThreadId.make("archived"), + id: ThreadId.make("settled"), title: "Fix login again", - archivedAt: NOW, + settledOverride: "settled", + settledAt: NOW, }), ], environmentId: null, @@ -147,7 +150,7 @@ describe("buildThreadListV2Items", () => { expect(items.map((item) => [item.thread.id, item.variant])).toEqual([ ["match", "card"], - ["archived", "slim"], + ["settled", "slim"], ]); }); }); @@ -160,7 +163,8 @@ describe("buildThreadListV2Items settled paging", () => { makeThread({ id: ThreadId.make(`settled-${index}`), title: `Settled ${index}`, - archivedAt: NOW, + settledOverride: "settled", + settledAt: NOW, latestUserMessageAt: `2026-06-01T0${index}:00:00.000Z`, }), ), diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index bcd882666f6..948b6506d31 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -43,10 +43,6 @@ import { readLocalApi } from "../localApi"; import { useUiStateStore } from "../uiStateStore"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { useThreadActions } from "../hooks/useThreadActions"; -import { - refreshArchivedThreadsForEnvironment, - useArchivedThreadSnapshots, -} from "../lib/archivedThreadsState"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; import { onOpenNewThreadPicker } from "../newThreadPickerBus"; @@ -610,7 +606,7 @@ export default function SidebarV2() { const keybindings = useAtomValue(primaryServerKeybindingsAtom); const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); - const { settleThread, unsettleThread, archiveThread, deleteThread } = useThreadActions(); + const { settleThread, unsettleThread, deleteThread } = useThreadActions(); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); @@ -716,70 +712,18 @@ export default function SidebarV2() { } }, [projectScopeKey, projects]); - // Archived threads ARE the settled tail in the client-only settled model, - // but the live shell stream drops a thread the moment it is archived - // (thread.archived → thread-removed). Merge them back in from the archived - // snapshot query; live shells win on the (brief) overlap. - const archivedEnvironmentIds = useMemo( - () => environments.map((environment) => environment.environmentId), - [environments], - ); - const { snapshots: archivedSnapshots } = useArchivedThreadSnapshots(archivedEnvironmentIds); - // Bridge the gap between the live stream dropping a just-settled thread - // (thread.archived → thread-removed) and the archived snapshot returning - // it: hold the shell we settled, marked archived, until either source - // carries the thread again. Held explicitly at settle time — not inferred - // from disappearance — so deleted threads are never resurrected. - const [settledHolds, setSettledHolds] = useState>( - () => new Map(), - ); - const allThreads = useMemo(() => { - const merged = new Map(); - for (const { environmentId, snapshot } of archivedSnapshots) { - for (const thread of snapshot.threads) { - merged.set(scopedThreadKey(scopeThreadRef(environmentId, thread.id)), { - ...thread, - environmentId, - }); - } - } - // Live shells win over snapshot copies. - for (const thread of threads) { - merged.set(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread); - } - for (const [threadKey, shell] of settledHolds) { - if (merged.has(threadKey)) continue; - merged.set(threadKey, shell); - } - return [...merged.values()]; - }, [archivedSnapshots, settledHolds, threads]); - // Drop a hold once the archived snapshot carries the thread — the steady - // state. (Not the live stream: the thread is still live when the hold is - // created, and holds are inert while a real source has the thread anyway.) - useEffect(() => { - if (settledHolds.size === 0) return; - const covered = new Set(); - for (const { environmentId, snapshot } of archivedSnapshots) { - for (const thread of snapshot.threads) { - covered.add(scopedThreadKey(scopeThreadRef(environmentId, thread.id))); - } - } - if ([...settledHolds.keys()].some((threadKey) => covered.has(threadKey))) { - setSettledHolds((current) => { - const next = new Map(current); - for (const threadKey of covered) next.delete(threadKey); - return next; - }); - } - }, [archivedSnapshots, settledHolds]); - + // Settled threads stay in the live shell stream (settled ≠ archived), so + // the partition works directly off live shells: no archived-snapshot + // merging, no optimistic holds. Archived threads remain hidden here — + // archive keeps its original "remove from sidebar" meaning. const { activeThreads, settledThreads } = useMemo(() => { const now = `${nowMinute}:00.000Z`; - const visible = allThreads.filter( + const visible = threads.filter( (thread) => - scopedProject === null || - (thread.environmentId === scopedProject.environmentId && - thread.projectId === scopedProject.id), + thread.archivedAt === null && + (scopedProject === null || + (thread.environmentId === scopedProject.environmentId && + thread.projectId === scopedProject.id)), ); const active: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; @@ -800,7 +744,7 @@ export default function SidebarV2() { firstValidTimestampMs(left.latestUserMessageAt, left.updatedAt), ), }; - }, [allThreads, autoSettleAfterDays, changeRequestStateByKey, nowMinute, scopedProject]); + }, [autoSettleAfterDays, changeRequestStateByKey, nowMinute, scopedProject, threads]); // The settled tail renders in pages: history shouldn't dominate the // sidebar, and the common lookups are recent. Expansion resets when the @@ -883,6 +827,9 @@ export default function SidebarV2() { }, [keybindings, orderedThreadKeys]); const [showJumpHints, setShowJumpHints] = useState(false); + // Settled threads are live shells, so opening one is plain navigation: + // history stays readable without un-settling, and sending a message or + // starting a session un-settles server-side. const navigateToThread = useCallback( (threadRef: ScopedThreadRef) => { if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { @@ -892,44 +839,12 @@ export default function SidebarV2() { if (isMobile) { setOpenMobile(false); } - void (async () => { - // Archived (= manually settled) threads are invisible to the live - // thread detail path — navigating first would bounce to "/". Opening - // one is real activity, and activity un-settles: unarchive, then - // navigate. Auto-settled rows (archivedAt null) are still live and - // navigate directly. - const threadKey = scopedThreadKey(threadRef); - const shell = threadByKeyRef.current.get(threadKey); - if (shell && shell.archivedAt !== null) { - const result = await unsettleThread(threadRef); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to open settled thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - return; - } - setSettledHolds((current) => { - if (!current.has(threadKey)) return current; - const next = new Map(current); - next.delete(threadKey); - return next; - }); - refreshArchivedThreadsForEnvironment(threadRef.environmentId); - } - void router.navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(threadRef), - }); - })(); + void router.navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + }); }, - [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor, unsettleThread], + [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor], ); const [renamingThreadKey, setRenamingThreadKey] = useState(null); @@ -992,7 +907,7 @@ export default function SidebarV2() { ); // A settle per thread at a time: double clicks and repeated menu picks - // must not dispatch a second archive that fails and toasts a false error. + // must not dispatch a second settle that fails and toasts a false error. const settlingThreadKeysRef = useRef(new Set()); const attemptSettle = useCallback( (threadRef: ScopedThreadRef, opts: { coSettlingKeys?: ReadonlySet } = {}) => { @@ -1001,23 +916,12 @@ export default function SidebarV2() { if (settlingThreadKeysRef.current.has(threadKey)) return; settlingThreadKeysRef.current.add(threadKey); try { - // Hold the shell before dispatching: the live stream drops the - // thread on archive, and the settled tail must not flicker while - // the archived snapshot refresh is in flight. - const shell = threadByKeyRef.current.get(threadKey); - if (shell) { - setSettledHolds((current) => - new Map(current).set(threadKey, { - ...shell, - archivedAt: shell.archivedAt ?? new Date().toISOString(), - }), - ); - } // Settling the thread you're looking at moves you forward: the next // remaining card (never a settled row, never one settling in the - // same batch), or the new-thread screen when this was the last - // active one. Snapshot the target before the settle mutates the - // partition. Background settles never navigate. + // same batch), or a fresh draft in this project when it was the + // last active one. Snapshot the target before the settle mutates + // the partition. Background settles never navigate. + const shell = threadByKeyRef.current.get(threadKey); let navigateAfterSettle: (() => void) | null = null; if (routeThreadKey === threadKey) { const orderedKeys = orderedThreadKeysRef.current; @@ -1033,9 +937,7 @@ export default function SidebarV2() { const nextThread = nextCardKey ? threadByKeyRef.current.get(nextCardKey) : null; navigateAfterSettle = nextThread ? () => navigateToThread(scopeThreadRef(nextThread.environmentId, nextThread.id)) - : // Settling the last active card matches archive: a fresh draft - // in the same project, not the bare no-thread screen. - shell + : shell ? () => void handleNewThreadRef.current( scopeProjectRef(shell.environmentId, shell.projectId), @@ -1044,14 +946,7 @@ export default function SidebarV2() { } const result = await settleThread(threadRef); if (result._tag === "Failure") { - // The archive did not happen (failed or interrupted): drop the - // optimistic hold — no archived snapshot will ever cover it — and - // never navigate away from a thread that is still active. - setSettledHolds((current) => { - const next = new Map(current); - next.delete(threadKey); - return next; - }); + // Never navigate away from a thread that did not settle. if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( @@ -1064,9 +959,6 @@ export default function SidebarV2() { } return; } - // Settle = archive: the shell stream drops the thread, so pull the - // archived snapshot immediately to keep it visible as a settled row. - refreshArchivedThreadsForEnvironment(threadRef.environmentId); navigateAfterSettle?.(); } finally { settlingThreadKeysRef.current.delete(threadKey); @@ -1088,17 +980,7 @@ export default function SidebarV2() { description: error instanceof Error ? error.message : "An error occurred.", }), ); - return; } - // Un-settling invalidates any optimistic settled hold for the thread. - setSettledHolds((current) => { - const threadKey = scopedThreadKey(threadRef); - if (!current.has(threadKey)) return current; - const next = new Map(current); - next.delete(threadKey); - return next; - }); - refreshArchivedThreadsForEnvironment(threadRef.environmentId); })(); }, [unsettleThread], @@ -1126,12 +1008,12 @@ export default function SidebarV2() { if (clicked.value === "settle") { // Post-settle navigation must skip threads settling in this same // batch — they are all leaving the card block together. Rows that - // are already settled (archived) are skipped: re-settling them - // would dispatch a failing archive on a valid mixed selection. + // are already explicitly settled are skipped: nothing to do on a + // valid mixed selection. const coSettlingKeys = new Set(threadKeys); for (const threadKey of threadKeys) { const thread = threadByKeyRef.current.get(threadKey); - if (!thread || thread.archivedAt !== null) continue; + if (!thread || thread.settledOverride === "settled") continue; attemptSettle(scopeThreadRef(thread.environmentId, thread.id), { coSettlingKeys }); } clearSelection(); @@ -1177,15 +1059,6 @@ export default function SidebarV2() { } return; } - // Deleted threads must not survive as settled holds or stale - // archived-snapshot rows. - setSettledHolds((current) => { - if (!current.has(threadKey)) return current; - const next = new Map(current); - next.delete(threadKey); - return next; - }); - refreshArchivedThreadsForEnvironment(thread.environmentId); } removeFromSelection(threadKeys); }, @@ -1212,12 +1085,10 @@ export default function SidebarV2() { } const thread = threadByKeyRef.current.get(threadKey); if (!thread) return; - // Un-settle appears only when there is an archive to undo; an - // auto-settled (unarchived) slim row gets Settle, which archives it. - const isSettled = settledThreadKeysRef.current.has(threadKey) && thread.archivedAt !== null; - // No separate Archive item: settle IS archive in the client-only - // model, and offering both invites a failing double-archive on rows - // that are already settled. + // Un-settle works on every settled row: for explicit settles it + // clears the override, for auto-settled rows it pins the thread + // active until real activity clears the pin. + const isSettled = settledThreadKeysRef.current.has(threadKey); const clicked = await settlePromise(() => api.contextMenu.show( [ @@ -1269,15 +1140,6 @@ export default function SidebarV2() { ); return; } - // A deleted thread must not survive as a settled hold or a stale - // archived-snapshot row. - setSettledHolds((current) => { - if (!current.has(threadKey)) return current; - const next = new Map(current); - next.delete(threadKey); - return next; - }); - refreshArchivedThreadsForEnvironment(threadRef.environmentId); return; } default: @@ -1286,7 +1148,6 @@ export default function SidebarV2() { })(); }, [ - archiveThread, attemptSettle, attemptUnsettle, confirmThreadDelete, @@ -1576,11 +1437,9 @@ export default function SidebarV2() { key={threadKey} thread={thread} variant={isCard ? "card" : "slim"} - // Un-settle only when there is an archive to undo. An - // auto-settled row (inactivity / merged PR, archivedAt - // null) offers Settle: archiving is the explicit "keep it - // settled" the row can actually deliver. - variantAction={isSettledRow && thread.archivedAt !== null ? "unsettle" : "settle"} + // Every settled row can un-settle: explicit settles clear + // the override, auto-settled rows get pinned active. + variantAction={isSettledRow ? "unsettle" : "settle"} isActive={routeThreadKey === threadKey} jumpLabel={showJumpHints ? (jumpLabelByKey.get(threadKey) ?? null) : null} currentEnvironmentId={primaryEnvironmentId} diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 736b7bc8b3c..f480780e9ae 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -51,12 +51,10 @@ export function useThreadActions() { const deleteThreadMutation = useAtomCommand(threadEnvironment.delete, { reportFailure: false, }); - // Client-only settled model: settle/unsettle ride the pre-existing archive - // lifecycle so no server upgrade is required. See threadSettled.ts. - const settleThreadMutation = useAtomCommand(threadEnvironment.archive, { + const settleThreadMutation = useAtomCommand(threadEnvironment.settle, { reportFailure: false, }); - const unsettleThreadMutation = useAtomCommand(threadEnvironment.unarchive, { + const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { reportFailure: false, }); const stopThreadSession = useAtomCommand(threadEnvironment.stopSession); @@ -359,7 +357,7 @@ export function useThreadActions() { const resolved = resolveThreadTarget(target); // Settle may only target what effectiveSettled could classify as // settled: not starting/running sessions, not threads waiting on - // approvals or user input. Anything else would archive live work. + // approvals or user input. Anything else would hide live work. if (resolved && !canSettle(resolved.thread)) { return AsyncResult.failure( Cause.fail( @@ -371,9 +369,7 @@ export function useThreadActions() { ); } // Settle is a high-frequency lifecycle action and stays silent — no - // toast. (The old "Thread settled" toast offering worktree removal was - // noise; disk cleanup belongs in an explicit surface, not a - // notification.) + // toast. return settleThreadMutation({ environmentId: target.environmentId, input: { threadId: target.threadId }, @@ -384,19 +380,14 @@ export function useThreadActions() { const unsettleThread = useCallback( async (target: ScopedThreadRef) => { - // Auto-settled rows (inactivity / merged PR) are not archived; sending - // unarchive for them would be rejected by the server. There is nothing - // to undo client-side, so succeed as a no-op. - const resolved = resolveThreadTarget(target); - if (resolved && resolved.thread.archivedAt === null) { - return AsyncResult.success(undefined); - } + // reason "user" pins the thread active: auto-settle (PR merged / + // inactivity) stays suppressed until real activity clears the pin. return unsettleThreadMutation({ environmentId: target.environmentId, - input: { threadId: target.threadId }, + input: { threadId: target.threadId, reason: "user" }, }); }, - [resolveThreadTarget, unsettleThreadMutation], + [unsettleThreadMutation], ); const confirmAndDeleteThread = useCallback( diff --git a/packages/client-runtime/src/state/threadSettled.test.ts b/packages/client-runtime/src/state/threadSettled.test.ts index d7c845ae468..b0e2a1e6b3f 100644 --- a/packages/client-runtime/src/state/threadSettled.test.ts +++ b/packages/client-runtime/src/state/threadSettled.test.ts @@ -19,7 +19,7 @@ const FRESH = "2026-04-09T00:00:00.000Z"; const STALE = "2026-04-06T23:59:59.999Z"; function makeShell(input: { - readonly archivedAt: string | null; + readonly settledOverride?: "settled" | "active" | null; readonly activityAt: string | null; readonly sessionStatus?: "starting" | "running"; readonly pending?: "approval" | "user-input"; @@ -47,9 +47,9 @@ function makeShell(input: { }, createdAt: "2026-04-01T00:00:00.000Z", updatedAt: NOW, - archivedAt: input.archivedAt, - settledOverride: null, - settledAt: null, + archivedAt: null, + settledOverride: input.settledOverride ?? null, + settledAt: input.settledOverride === "settled" ? NOW : null, session: input.sessionStatus === undefined ? null @@ -71,7 +71,7 @@ function makeShell(input: { describe("threadLastActivityAt", () => { it("returns the latest real user or turn activity and ignores thread/session updates", () => { - const shell = makeShell({ archivedAt: null, activityAt: null, sessionStatus: "running" }); + const shell = makeShell({ activityAt: null, sessionStatus: "running" }); const withActivity: OrchestrationThreadShell = { ...shell, latestUserMessageAt: "2026-04-04T00:00:00.000Z", @@ -91,7 +91,7 @@ describe("threadLastActivityAt", () => { }); describe("effectiveSettled", () => { - const archivedCases = [null, NOW] as const; + const overrideCases = [null, "settled", "active"] as const; const changeRequestStates = [undefined, "open", "merged"] as const; const inactivityCases = [ ["fresh", FRESH], @@ -100,23 +100,27 @@ describe("effectiveSettled", () => { ] as const; const runningCases = [false, true] as const; const pendingCases = [undefined, "approval", "user-input"] as const; - const truthTable = archivedCases.flatMap((archivedAt) => + const truthTable = overrideCases.flatMap((settledOverride) => changeRequestStates.flatMap((changeRequestState) => inactivityCases.flatMap(([inactivity, activityAt]) => runningCases.flatMap((running) => pendingCases.map((pending) => ({ - archivedAt, + settledOverride, changeRequestState, inactivity, activityAt, running, pending, // Settled iff nothing blocks (pending work / live session) AND - // any positive signal holds: archived, merged PR, or staleness. + // the override says settled, or (with no override) a merged PR + // or staleness auto-settles. The "active" pin suppresses both + // auto signals. expected: pending === undefined && !running && - (archivedAt !== null || changeRequestState === "merged" || inactivity === "stale"), + (settledOverride === "settled" || + (settledOverride === null && + (changeRequestState === "merged" || inactivity === "stale"))), })), ), ), @@ -124,10 +128,10 @@ describe("effectiveSettled", () => { ); it.each(truthTable)( - "archived=$archivedAt pr=$changeRequestState inactivity=$inactivity running=$running pending=$pending", - ({ archivedAt, changeRequestState, activityAt, running, pending, expected }) => { + "override=$settledOverride pr=$changeRequestState inactivity=$inactivity running=$running pending=$pending", + ({ settledOverride, changeRequestState, activityAt, running, pending, expected }) => { const shell = makeShell({ - archivedAt, + settledOverride, activityAt, ...(running ? { sessionStatus: "running" as const } : {}), ...(pending === undefined ? {} : { pending }), @@ -148,7 +152,7 @@ describe("effectiveSettled", () => { ); it("treats closed change requests like merged ones", () => { - const shell = makeShell({ archivedAt: null, activityAt: null }); + const shell = makeShell({ activityAt: null }); expect( effectiveSettled(shell, { now: NOW, @@ -158,9 +162,9 @@ describe("effectiveSettled", () => { ).toBe(true); }); - it("never settles a starting session, even when archived", () => { + it("never settles a starting session, even with a settled override", () => { const shell = makeShell({ - archivedAt: NOW, + settledOverride: "settled", activityAt: STALE, sessionStatus: "starting", }); @@ -175,10 +179,9 @@ describe("effectiveSettled", () => { it("uses a strict inactivity boundary and honors a null threshold", () => { const boundary = makeShell({ - archivedAt: null, activityAt: "2026-04-07T00:00:00.000Z", }); - const stale = makeShell({ archivedAt: null, activityAt: STALE }); + const stale = makeShell({ activityAt: STALE }); expect(effectiveSettled(boundary, { now: NOW, autoSettleAfterDays: 3 })).toBe(false); expect(effectiveSettled(stale, { now: NOW, autoSettleAfterDays: null })).toBe(false); @@ -187,25 +190,18 @@ describe("effectiveSettled", () => { describe("canSettle", () => { it("blocks every state effectiveSettled refuses to classify as settled", () => { - expect(canSettle(makeShell({ archivedAt: null, activityAt: FRESH }))).toBe(true); - expect( - canSettle(makeShell({ archivedAt: null, activityAt: FRESH, sessionStatus: "starting" })), - ).toBe(false); - expect( - canSettle(makeShell({ archivedAt: null, activityAt: FRESH, sessionStatus: "running" })), - ).toBe(false); - expect(canSettle(makeShell({ archivedAt: null, activityAt: FRESH, pending: "approval" }))).toBe( - false, - ); - expect( - canSettle(makeShell({ archivedAt: null, activityAt: FRESH, pending: "user-input" })), - ).toBe(false); + expect(canSettle(makeShell({ activityAt: FRESH }))).toBe(true); + expect(canSettle(makeShell({ activityAt: FRESH, sessionStatus: "starting" }))).toBe(false); + expect(canSettle(makeShell({ activityAt: FRESH, sessionStatus: "running" }))).toBe(false); + expect(canSettle(makeShell({ activityAt: FRESH, pending: "approval" }))).toBe(false); + expect(canSettle(makeShell({ activityAt: FRESH, pending: "user-input" }))).toBe(false); }); - it("agrees with effectiveSettled's blockers for archived shells", () => { - // Anything canSettle rejects must render as active even when archived. + it("agrees with effectiveSettled's blockers for explicitly settled shells", () => { + // Anything canSettle rejects must render as active even when the user + // settled it earlier. const blocked = makeShell({ - archivedAt: FRESH, + settledOverride: "settled", activityAt: FRESH, pending: "user-input", }); diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 5f97f401fef..1e8fa6c7bd4 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -27,11 +27,11 @@ export function threadLastActivityAt(shell: OrchestrationThreadShell): string | } /** - * A thread may be settled (= archived) only when none of effectiveSettled's - * activity blockers hold. This is deliberately the same list: anything the - * partition refuses to CLASSIFY as settled must also be refused as a settle - * TARGET, or settling would archive live work (stopping its provider - * session) that would immediately render as active anyway. + * A thread may be settled only when none of effectiveSettled's activity + * blockers hold. This is deliberately the same list: anything the partition + * refuses to CLASSIFY as settled must also be refused as a settle TARGET. + * The server enforces its own invariants; this client-side twin exists so + * the UI can disable/reject before a round trip. */ export function canSettle( shell: Pick, @@ -42,15 +42,12 @@ export function canSettle( } /** - * Client-only settled resolution, backed by the pre-existing archive - * lifecycle instead of dedicated settle commands — no server, protocol, or - * database changes required. "Settled" here means: the user archived the - * thread, its PR merged/closed, or it has been inactive past the auto-settle - * window. - * - * Trade-offs vs the event-sourced settled model (kept on the main feature - * branch): activity does not auto-un-settle an archived thread, and there is - * no distinct "keep active" override — un-settling is just unarchiving. + * Settled resolution over the server-backed settled lifecycle. The explicit + * user override (thread.settle / thread.unsettle commands, projected into + * settledOverride + settledAt) wins in both directions; without one, a + * thread auto-settles on a merged/closed PR or inactivity past the window. + * The server un-settles on real activity (user message, session start, + * approval/user-input request), so an override never goes stale silently. */ export function effectiveSettled( shell: OrchestrationThreadShell, @@ -62,7 +59,10 @@ export function effectiveSettled( ): boolean { // Blocked work must remain visible even when a user explicitly settled it. if (!canSettle(shell)) return false; - if (shell.archivedAt !== null) return true; + if (shell.settledOverride === "settled") return true; + // "active" is the explicit keep-active pin: it suppresses auto-settle + // until real activity clears it server-side. + if (shell.settledOverride === "active") return false; if (options.changeRequestState === "merged" || options.changeRequestState === "closed") { return true; } From b8fba7fe844968766fb41a7c9ccab32405b06cc4 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 21 Jul 2026 16:38:02 -0700 Subject: [PATCH 3/5] Address #4243 review: pin clearing, capability gating, decider hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Real activity now clears ANY settled override, not just 'settled': the keep-active pin resets to neutral on a user message, live session, or approval/user-input request, so auto-settle applies again once that work goes stale. The pin exists to suppress AUTO-settle, not to survive real work. (The three tests codifying the old behavior are reversed.) - New threadSettlement capability on the environment descriptor (absent = unsupported): web and mobile refuse to dispatch settle/unsettle to a pre-settlement server with a clear message instead of a raw protocol rejection. - Idempotent re-settle keeps the original settledAt but no longer rewinds updatedAt to it (sorting and relative-time labels key on updatedAt). - Server-side twin of canSettle's session check: thread.settle is rejected while a session is starting/running. Pending approval/user-input blocking stays client-side, backed by effectiveSettled rendering blocked threads active and the activity gate auto-un-settling on new requests. - ThreadUnsettleCommand reason narrowed to 'user': activity resets are decided server-side and cannot be forged by a client. - Persistence round-trip test for non-null settled values (upsert → getById across both override states); stale settle-is-archive comments removed. Co-Authored-By: Claude Fable 5 --- .../src/features/home/useThreadListActions.ts | 21 ++++++++ .../features/threads/thread-list-v2-items.tsx | 4 +- .../src/features/threads/threadListV2.ts | 4 +- .../src/environment/ServerEnvironment.ts | 1 + .../src/orchestration/decider.settled.test.ts | 51 ++++++++++++++++-- apps/server/src/orchestration/decider.ts | 31 +++++++++-- .../Layers/ProjectionRepositories.test.ts | 54 +++++++++++++++++++ apps/web/src/hooks/useThreadActions.ts | 41 +++++++++++++- apps/web/src/state/entities.ts | 10 ++++ packages/contracts/src/environment.ts | 4 ++ packages/contracts/src/orchestration.ts | 5 +- 11 files changed, 211 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 7f39b48d9d4..7380868309e 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -8,9 +8,20 @@ import { Alert } from "react-native"; import { showConfirmDialog } from "../../components/ConfirmDialogHost"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { environmentServerConfigsAtom } from "../../state/server"; import { threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; +/** Version skew: never send settle/unsettle to a server that predates them + (capability defaults false on decode for older servers). */ +function environmentSupportsSettlement(environmentId: EnvironmentThreadShell["environmentId"]) { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadSettlement === true + ); +} + type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle"; const ACTION_VERBS: Record = { @@ -62,6 +73,16 @@ function useThreadActionExecutor( inFlightThreadKeys.current.add(key); selectionHaptic(); try { + if ( + (action === "settle" || action === "unsettle") && + !environmentSupportsSettlement(thread.environmentId) + ) { + Alert.alert( + actionFailureTitle(action), + "This environment's server does not support settling yet. Update the server to use Settle.", + ); + return false; + } // Settle may only target what effectiveSettled could classify as // settled: not starting/running sessions, not threads waiting on // approvals or user input. Anything else would archive live work. diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index f66ee9bca35..362254899bc 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -58,8 +58,8 @@ function threadTimeLabel(thread: EnvironmentThreadShell, status: ThreadListV2Sta return relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt); } -// No separate Archive item: settle IS archive in the client-only model, -// and offering both invites a failing double-archive on settled rows. +// Menus stay lifecycle-focused: settle/un-settle plus delete. Archive keeps +// its own surface (thread screen / settings) rather than crowding the row. const CARD_MENU_ACTIONS: MenuAction[] = [ { id: "settle", title: "Settle", image: "checkmark" }, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 99c7dda625c..2d9706339d0 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -105,8 +105,8 @@ export function buildThreadListV2Items(input: { const active: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; for (const thread of input.threads) { - // Archived threads stay in the list: in the client-only settled model, - // archive IS settle, so they render as the settled tail. + // Callers pass live (unarchived) shells; settled threads are among them + // and partition into the tail via effectiveSettled. if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue; if (query.length > 0 && !thread.title.toLocaleLowerCase().includes(query)) continue; const changeRequestState = diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 1c0d34ea5bc..01567b98d32 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -136,6 +136,7 @@ export const make = Effect.gen(function* () { capabilities: { repositoryIdentity: true, connectionProbe: true, + threadSettlement: true, }, }; diff --git a/apps/server/src/orchestration/decider.settled.test.ts b/apps/server/src/orchestration/decider.settled.test.ts index d4ecfba6a40..b2bc0f5a924 100644 --- a/apps/server/src/orchestration/decider.settled.test.ts +++ b/apps/server/src/orchestration/decider.settled.test.ts @@ -21,6 +21,7 @@ const SETTLED_AT = "2025-12-30T00:00:00.000Z"; function makeReadModel( settledOverride: OrchestrationThread["settledOverride"], archivedAt: string | null = null, + session: OrchestrationSession | null = null, ): OrchestrationReadModel { return { snapshotSequence: 0, @@ -46,7 +47,7 @@ function makeReadModel( proposedPlans: [], activities: [], checkpoints: [], - session: null, + session, }, ], updatedAt: NOW, @@ -98,11 +99,40 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { expect(reEmitEvents[0]?.type).toBe("thread.settled"); if (reEmitEvents[0]?.type === "thread.settled") { expect(reEmitEvents[0].payload.settledAt).toBe(SETTLED_AT); - expect(reEmitEvents[0].payload.updatedAt).toBe(SETTLED_AT); + // updatedAt must NOT rewind to the historical settledAt: sorting and + // relative-time labels key on it. + expect(reEmitEvents[0].payload.updatedAt).not.toBe(SETTLED_AT); } }), ); + it.effect("rejects settling a thread with a live session", () => + Effect.gen(function* () { + for (const status of ["starting", "running"] as const) { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make(`cmd-settle-live-${status}`), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, makeSession(status)), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + } + // Stopped/error sessions are settleable — only live work is protected. + const settled = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-stopped"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, makeSession("stopped")), + }); + const settledEvents = Array.isArray(settled) ? settled : [settled]; + expect(settledEvents[0]?.type).toBe("thread.settled"); + }), + ); + it.effect("rejects settling and unsettling archived threads", () => Effect.gen(function* () { const settleError = yield* decideOrchestrationCommand({ @@ -197,14 +227,19 @@ 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. readModel: makeReadModel("active"), }); const sessionEvents = Array.isArray(sessionResult) ? sessionResult : [sessionResult]; - expect(sessionEvents.map((event) => event.type)).toEqual(["thread.session-set"]); + expect(sessionEvents.map((event) => event.type)).toEqual([ + "thread.unsettled", + "thread.session-set", + ]); }), ); - it.effect("preserves an explicit active override during activity", () => + it.effect("clears a keep-active pin on real activity", () => Effect.gen(function* () { const turnResult = yield* decideOrchestrationCommand({ command: { @@ -224,7 +259,10 @@ 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. expect(turnEvents.map((event) => event.type)).toEqual([ + "thread.unsettled", "thread.message-sent", "thread.turn-start-requested", ]); @@ -248,7 +286,10 @@ 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.activity-appended"]); + expect(activityEvents.map((event) => event.type)).toEqual([ + "thread.unsettled", + "thread.activity-appended", + ]); }), ); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 33663c59cee..02b012023b7 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -333,6 +333,21 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); + // Server-side twin of the client's canSettle session check: a stale + // or raced client must not settle a thread whose session is coming + // alive or working. (Pending approval/user-input blocking stays + // client-side — deriving it here would replay activity streams — but + // it is defense-in-depth twice over: effectiveSettled renders blocked + // threads as active regardless of the override, and a new + // approval/user-input request auto-un-settles via the activity gate.) + if (thread.session?.status === "starting" || thread.session?.status === "running") { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} has an active session and cannot be settled`, + }), + ); + } const occurredAt = yield* nowIso; // Settling an already-settled thread re-emits with the original // settledAt: the engine rejects zero-event commands, and bulk-settle / @@ -352,7 +367,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" payload: { threadId: command.threadId, settledAt, - updatedAt: settledAt, + // Always the command time: a re-settle keeps the original + // settledAt but must not rewind updatedAt (sorting and relative- + // time labels key on it). + updatedAt: occurredAt, }, }; } @@ -534,7 +552,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" createdAt: command.createdAt, }, }; - if (targetThread.settledOverride !== "settled") { + // 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. + if (targetThread.settledOverride === null) { return [userMessageEvent, turnStartRequestedEvent]; } const unsettledEvent: Omit = { @@ -696,7 +717,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" // must not fight a user's explicit settle. const isSessionActivity = command.session.status === "starting" || command.session.status === "running"; - if (thread.settledOverride !== "settled" || !isSessionActivity) { + // Real activity resets ANY override (settled wakes, active unpins). + if (thread.settledOverride === null || !isSessionActivity) { return sessionSetEvent; } const unsettledEvent: Omit = { @@ -872,7 +894,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" const wakesSettledThread = command.activity.kind === "approval.requested" || command.activity.kind === "user-input.requested"; - if (thread.settledOverride !== "settled" || !wakesSettledThread) { + // Real activity resets ANY override (settled wakes, active unpins). + if (thread.settledOverride === null || !wakesSettledThread) { return activityAppendedEvent; } const unsettledEvent: Omit = { diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index 0403e55d9ee..497aa8b7d2c 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -130,4 +130,58 @@ projectionRepositoriesLayer("Projection repositories", (it) => { }); }), ); + + it.effect("round-trips non-null settlement values through the thread row", () => + Effect.gen(function* () { + const threads = yield* ProjectionThreadRepository; + + yield* threads.upsert({ + threadId: ThreadId.make("thread-settled"), + projectId: ProjectId.make("project-1"), + title: "Settled thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurnId: null, + createdAt: "2026-03-24T00:00:00.000Z", + updatedAt: "2026-03-25T00:00:00.000Z", + archivedAt: null, + settledOverride: "settled", + settledAt: "2026-03-25T00:00:00.000Z", + latestUserMessageAt: null, + pendingApprovalCount: 0, + pendingUserInputCount: 0, + hasActionableProposedPlan: 0, + deletedAt: null, + }); + + const persisted = yield* threads.getById({ + threadId: ThreadId.make("thread-settled"), + }); + const row = Option.getOrNull(persisted); + if (!row) { + return yield* Effect.die("Expected settled projection_threads row to exist."); + } + assert.strictEqual(row.settledOverride, "settled"); + assert.strictEqual(row.settledAt, "2026-03-25T00:00:00.000Z"); + + // Un-settle to the keep-active pin and confirm the flip persists. + yield* threads.upsert({ + ...row, + settledOverride: "active", + settledAt: null, + }); + const repersisted = yield* threads.getById({ + threadId: ThreadId.make("thread-settled"), + }); + const updated = Option.getOrNull(repersisted); + assert.strictEqual(updated?.settledOverride, "active"); + assert.strictEqual(updated?.settledAt, null); + }), + ); }); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index f480780e9ae..e866b18b4cb 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -20,7 +20,12 @@ import { vcsEnvironment } from "../state/vcs"; import { useNewThreadHandler } from "./useHandleNewThread"; import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState"; import { readLocalApi } from "../localApi"; -import { readEnvironmentThreadRefs, readProject, readThreadShell } from "../state/entities"; +import { + readEnvironmentSupportsSettlement, + readEnvironmentThreadRefs, + readProject, + readThreadShell, +} from "../state/entities"; import { useTerminalUiStateStore } from "../terminalUiStateStore"; import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; @@ -40,6 +45,18 @@ export class ThreadArchiveBlockedError extends Schema.TaggedErrorClass()( + "ThreadSettlementUnsupportedError", + { + environmentId: EnvironmentId, + threadId: ThreadId, + }, +) { + override get message(): string { + return "This environment's server does not support settling yet. Update the server to use Settle."; + } +} + export function useThreadActions() { const closeTerminal = useAtomCommand(terminalEnvironment.close); const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, { @@ -354,6 +371,18 @@ export function useThreadActions() { const settleThread = useCallback( async (target: ScopedThreadRef) => { + // Version skew: never send the command to a server that predates it — + // the raw protocol rejection would read as a random failure. + if (!readEnvironmentSupportsSettlement(target.environmentId)) { + return AsyncResult.failure( + Cause.fail( + new ThreadSettlementUnsupportedError({ + environmentId: target.environmentId, + threadId: target.threadId, + }), + ), + ); + } const resolved = resolveThreadTarget(target); // Settle may only target what effectiveSettled could classify as // settled: not starting/running sessions, not threads waiting on @@ -380,6 +409,16 @@ export function useThreadActions() { const unsettleThread = useCallback( async (target: ScopedThreadRef) => { + if (!readEnvironmentSupportsSettlement(target.environmentId)) { + return AsyncResult.failure( + Cause.fail( + new ThreadSettlementUnsupportedError({ + environmentId: target.environmentId, + threadId: target.threadId, + }), + ), + ); + } // reason "user" pins the thread active: auto-settle (PR merged / // inactivity) stays suppressed until real activity clears the pin. return unsettleThreadMutation({ diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index db895e37c4a..47e63ca2cd3 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -185,6 +185,16 @@ export function readThreadShell(ref: ScopedThreadRef): EnvironmentThreadShell | return appAtomRegistry.get(environmentThreadShells.threadShellAtom(ref)); } +/** Whether the environment's server understands thread.settle/unsettle. + False for pre-settlement servers (capability defaults false on decode), + so clients under version skew fall back instead of erroring. */ +export function readEnvironmentSupportsSettlement(environmentId: EnvironmentId): boolean { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadSettlement === true + ); +} + export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null { return appAtomRegistry.get(environmentThreadDetails.detailAtom(ref)); } diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index bc8db25a95a..6fc0c914d8a 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -23,6 +23,10 @@ export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.T export const ExecutionEnvironmentCapabilities = Schema.Struct({ repositoryIdentity: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), connectionProbe: Schema.optionalKey(Schema.Boolean), + /** Server understands thread.settle / thread.unsettle commands. Absent on + pre-settlement servers, so clients treat missing as unsupported and + never send the commands under version skew. */ + threadSettlement: Schema.optionalKey(Schema.Boolean), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 0fcfa55d98c..b01df310062 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -576,7 +576,10 @@ const ThreadUnsettleCommand = Schema.Struct({ type: Schema.Literal("thread.unsettle"), commandId: CommandId, threadId: ThreadId, - reason: Schema.Literals(["user", "activity"]), + // Commands only carry "user": activity un-settles are decided server-side + // (the decider emits thread.unsettled(reason: "activity") events directly, + // never through this command), so a client cannot forge the neutral reset. + reason: Schema.Literal("user"), }); const ThreadMetaUpdateCommand = Schema.Struct({ From afcce06629563bded2aad6db957f60a9a171e240 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 21 Jul 2026 16:49:34 -0700 Subject: [PATCH 4/5] Fix contracts test for user-only unsettle reason; deepen settled coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CI break: 'decodes thread settle and unsettle commands' still decoded reason 'activity' after the command schema was narrowed to 'user'. The test now decodes 'user' and asserts 'activity' is rejected (server-owned, event-only). Also per review: snapshot-query coverage proving a settled thread stays in the LIVE shell snapshot with non-null settledOverride/ settledAt through the row aliases, and appears in the command read model. (The repository round-trip for both override states landed in the previous commit; cursor's other two findings — active-pin clearing and updatedAt rewind — were fixed there too and are stale against this head.) Co-Authored-By: Claude Fable 5 --- .../Layers/ProjectionSnapshotQuery.test.ts | 109 ++++++++++++++++++ packages/contracts/src/orchestration.test.ts | 12 +- 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 220665c557c..15ded458e22 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -566,6 +566,115 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { }), ); + it.effect("keeps settled threads in the shell snapshot with non-null settlement fields", () => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_state`; + + yield* sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + default_model_selection_json, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES ( + 'project-settled-test', + 'Settled Test', + '/tmp/settled-test', + '{"provider":"codex","model":"gpt-5-codex"}', + '[]', + '2026-04-06T00:00:00.000Z', + '2026-04-06T00:00:01.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + created_at, + updated_at, + archived_at, + settled_override, + settled_at, + deleted_at + ) + VALUES ( + 'thread-settled', + 'project-settled-test', + 'Settled Thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + NULL, + 0, + 0, + 0, + '2026-04-06T00:00:02.000Z', + '2026-04-06T00:00:05.000Z', + NULL, + 'settled', + '2026-04-06T00:00:04.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_state (projector, last_applied_sequence, updated_at) + VALUES + (${ORCHESTRATION_PROJECTOR_NAMES.projects}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threads}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadMessages}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadProposedPlans}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadActivities}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadSessions}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.checkpoints}, 4, '2026-04-06T00:00:07.000Z') + `; + + // Settled ≠ archived: the thread must appear in the LIVE shell + // snapshot, carrying its settlement fields through the row aliases. + const shellSnapshot = yield* snapshotQuery.getShellSnapshot(); + assert.deepEqual( + shellSnapshot.threads.map((thread) => thread.id), + [ThreadId.make("thread-settled")], + ); + assert.equal(shellSnapshot.threads[0]?.settledOverride, "settled"); + assert.equal(shellSnapshot.threads[0]?.settledAt, "2026-04-06T00:00:04.000Z"); + + // And the full command read model carries them too. + const readModel = yield* snapshotQuery.getCommandReadModel(); + const thread = readModel.threads.find( + (candidate) => candidate.id === ThreadId.make("thread-settled"), + ); + assert.equal(thread?.settledOverride, "settled"); + assert.equal(thread?.settledAt, "2026-04-06T00:00:04.000Z"); + }), + ); + it.effect( "reads targeted project, thread, and count queries without hydrating the full snapshot", () => diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index bdc7169821e..1ebc23a483b 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -359,11 +359,21 @@ it.effect("decodes thread settle and unsettle commands", () => type: "thread.unsettle", commandId: "cmd-unsettle-1", threadId: "thread-1", - reason: "activity", + reason: "user", }); assert.strictEqual(settle.type, "thread.settle"); assert.strictEqual(unsettle.type, "thread.unsettle"); + + // "activity" is server-owned: it exists on the event, never on the + // command, so a client cannot forge the neutral reset. + const forged = yield* decodeOrchestrationCommand({ + type: "thread.unsettle", + commandId: "cmd-unsettle-2", + threadId: "thread-1", + reason: "activity", + }).pipe(Effect.flip); + assert.ok(forged); }), ); From 04c07915f27fbff32fdb4cbc449b985e5b012e92 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 21 Jul 2026 18:22:14 -0700 Subject: [PATCH 5/5] Address round-2 stack review: queued turns, capability gating, races MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Queued turn starts are unsettleable: new hasQueuedTurnStart (client) detects a user message no turn has picked up (newest user message strictly newer than every latestTurn timestamp; a failed session start clears the block); canSettle and effectiveSettled refuse it, and the decider enforces the same invariant server-side, so just-requested work can be neither settled nor auto-settled by a closed PR. - Capability-aware classification: threads on environments without threadSettlement (old server, or descriptor not yet loaded) never partition into the settled tail on web or mobile — a tail with no working un-settle/pin affordances was strandable. Unknown is treated as unsupported. - Post-settle navigation validates the CURRENT route before firing: a navigation made while the settle was in flight wins. - Scope flips clear the thread selection, and the bulk context menu derives one exact actionable set (rendered rows only) so labels count exactly what actions will touch. - Settle/unsettle re-emissions are projected no-ops: duplicates keep the existing updatedAt instead of churning ordering. - Web settle-blocked failures use a settlement-specific error matching mobile copy instead of ThreadArchiveBlockedError's archive wording. - New coverage: queued-turn tests (client + decider), settled event round-trip through the DB ProjectionPipeline, capability-gated partition fixtures. Co-Authored-By: Claude Fable 5 --- apps/mobile/src/features/home/HomeScreen.tsx | 15 +++++ .../src/features/home/useThreadListActions.ts | 2 +- .../src/features/threads/threadListV2.test.ts | 12 +++- .../src/features/threads/threadListV2.ts | 10 ++- .../Layers/ProjectionPipeline.test.ts | 64 +++++++++++++++++++ apps/server/src/orchestration/decider.ts | 61 ++++++++++++++---- apps/web/src/components/SidebarV2.tsx | 47 ++++++++++++-- apps/web/src/hooks/useThreadActions.ts | 14 +++- .../src/state/threadSettled.test.ts | 61 ++++++++++++++++++ .../client-runtime/src/state/threadSettled.ts | 33 +++++++++- 10 files changed, 297 insertions(+), 22 deletions(-) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 2a08fca1100..2f8a2cbe984 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -27,6 +27,7 @@ import type { SavedRemoteConnection } from "../../lib/connection"; import { scopedProjectKey } from "../../lib/scopedEntities"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; +import { environmentServerConfigsAtom } from "../../state/server"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { PendingTaskListRow, @@ -345,6 +346,18 @@ export function HomeScreen(props: HomeScreenProps) { const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); return () => clearInterval(id); }, [threadListV2Enabled]); + // Threads on servers without the settlement capability never classify as + // settled (the user could neither un-settle nor pin them). + const serverConfigs = useAtomValue(environmentServerConfigsAtom); + const settlementEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadSettlement === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); const threadListV2Layout = useMemo(() => { if (!threadListV2Enabled) return { items: [], hiddenSettledCount: 0 }; // Settled threads are live shells; archived threads keep their original @@ -354,6 +367,7 @@ export function HomeScreen(props: HomeScreenProps) { environmentId: props.selectedEnvironmentId, searchQuery: props.searchQuery, changeRequestStateByKey, + settlementEnvironmentIds, settledLimit: settledVisibleCount, now: `${nowMinute}:00.000Z`, }); @@ -361,6 +375,7 @@ export function HomeScreen(props: HomeScreenProps) { changeRequestStateByKey, nowMinute, settledVisibleCount, + settlementEnvironmentIds, props.searchQuery, props.selectedEnvironmentId, props.threads, diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 7380868309e..c73fb7796fd 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -85,7 +85,7 @@ function useThreadActionExecutor( } // Settle may only target what effectiveSettled could classify as // settled: not starting/running sessions, not threads waiting on - // approvals or user input. Anything else would archive live work. + // approvals or user input. Anything else would hide live work. if (action === "settle" && !canSettle(thread)) { Alert.alert( actionFailureTitle(action), diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 2b562f0e7e6..c9277d2c109 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -1,5 +1,5 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId, TurnId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { @@ -166,6 +166,16 @@ describe("buildThreadListV2Items settled paging", () => { settledOverride: "settled", settledAt: NOW, latestUserMessageAt: `2026-06-01T0${index}:00:00.000Z`, + // A turn adopted the message (same requestedAt): without it the + // thread reads as a queued turn start, which never settles. + latestTurn: { + turnId: TurnId.make(`turn-${index}`), + state: "completed", + requestedAt: `2026-06-01T0${index}:00:00.000Z`, + startedAt: `2026-06-01T0${index}:00:00.000Z`, + completedAt: `2026-06-01T0${index}:10:00.000Z`, + assistantMessageId: null, + }, }), ), ]; diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 2d9706339d0..7bf62c5d73b 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -92,6 +92,10 @@ export function buildThreadListV2Items(input: { readonly searchQuery: string; /** Per-row PR state reported up by visible rows ("env:threadId" keys). */ readonly changeRequestStateByKey?: ReadonlyMap; + /** Environments whose server supports thread.settle/unsettle. Threads on + other environments never classify as settled — the user could neither + un-settle nor pin them. Absent = no gating (tests). */ + readonly settlementEnvironmentIds?: ReadonlySet; readonly autoSettleAfterDays?: number; /** Max settled rows to render; the rest are counted, not built. */ readonly settledLimit?: number; @@ -109,9 +113,13 @@ export function buildThreadListV2Items(input: { // and partition into the tail via effectiveSettled. if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue; if (query.length > 0 && !thread.title.toLocaleLowerCase().includes(query)) continue; + const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true; const changeRequestState = input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null; - if (effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })) { + if ( + supportsSettlement && + effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) + ) { settled.push(thread); } else { active.push(thread); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 0999000ed4f..a9d7317999a 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -171,6 +171,70 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { for (const row of stateRows) { assert.equal(row.lastAppliedSequence, 3); } + + // Settled lifecycle through the DB pipeline: thread.settled writes the + // override + timestamp, thread.unsettled(user) flips to the active pin. + yield* eventStore.append({ + type: "thread.settled", + eventId: EventId.make("evt-settle-1"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + occurredAt: "2026-01-01T00:00:01.000Z", + commandId: CommandId.make("cmd-settle-1"), + causationEventId: null, + correlationId: CommandId.make("cmd-settle-1"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-1"), + settledAt: "2026-01-01T00:00:01.000Z", + updatedAt: "2026-01-01T00:00:01.000Z", + }, + }); + yield* projectionPipeline.bootstrap; + + const settledRows = yield* sql<{ + readonly settledOverride: string | null; + readonly settledAt: string | null; + }>` + SELECT + settled_override AS "settledOverride", + settled_at AS "settledAt" + FROM projection_threads + WHERE thread_id = 'thread-1' + `; + assert.deepEqual(settledRows, [ + { settledOverride: "settled", settledAt: "2026-01-01T00:00:01.000Z" }, + ]); + + yield* eventStore.append({ + type: "thread.unsettled", + eventId: EventId.make("evt-unsettle-1"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + occurredAt: "2026-01-01T00:00:02.000Z", + commandId: CommandId.make("cmd-unsettle-1"), + causationEventId: null, + correlationId: CommandId.make("cmd-unsettle-1"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-1"), + reason: "user", + updatedAt: "2026-01-01T00:00:02.000Z", + }, + }); + yield* projectionPipeline.bootstrap; + + const unsettledRows = yield* sql<{ + readonly settledOverride: string | null; + readonly settledAt: string | null; + }>` + SELECT + settled_override AS "settledOverride", + settled_at AS "settledAt" + FROM projection_threads + WHERE thread_id = 'thread-1' + `; + assert.deepEqual(unsettledRows, [{ settledOverride: "active", settledAt: null }]); }), ); }); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 02b012023b7..9fd225f935c 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -348,14 +348,49 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }), ); } + // A queued turn start — a user message no turn has picked up yet — is + // work in flight even though session is still null (turn.start emits + // message-sent + turn-start-requested; the session arrives later). + // Settling in that window would hide just-requested work. Detection + // mirrors the client's hasQueuedTurnStart: the newest user message is + // strictly newer than every latestTurn timestamp (adoption stamps the + // new turn's requestedAt with the message time, clearing this). A + // failed session start (status "error") clears the block so the + // thread does not become permanently unsettleable. + const latestUserMessageAtMs = thread.messages.reduce( + (latest, message) => + message.role === "user" ? Math.max(latest, Date.parse(message.createdAt)) : latest, + Number.NEGATIVE_INFINITY, + ); + const latestTurnAtMs = + thread.latestTurn === null + ? Number.NEGATIVE_INFINITY + : Math.max( + ...[ + thread.latestTurn.requestedAt, + thread.latestTurn.startedAt, + thread.latestTurn.completedAt, + ].map((candidate) => + candidate == null ? Number.NEGATIVE_INFINITY : Date.parse(candidate), + ), + ); + const hasQueuedTurnStart = + thread.session?.status !== "error" && + Number.isFinite(latestUserMessageAtMs) && + latestUserMessageAtMs > latestTurnAtMs; + if (hasQueuedTurnStart) { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} has a queued turn start and cannot be settled`, + }), + ); + } const occurredAt = yield* nowIso; // Settling an already-settled thread re-emits with the original // settledAt: the engine rejects zero-event commands, and bulk-settle / // double-click must stay silent no-ops rather than surface errors. - const settledAt = - thread.settledOverride === "settled" && thread.settledAt !== null - ? thread.settledAt - : occurredAt; + const alreadySettled = thread.settledOverride === "settled" && thread.settledAt !== null; return { ...(yield* withEventBase({ aggregateKind: "thread", @@ -366,23 +401,25 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" type: "thread.settled", payload: { threadId: command.threadId, - settledAt, - // Always the command time: a re-settle keeps the original - // settledAt but must not rewind updatedAt (sorting and relative- - // time labels key on it). - updatedAt: occurredAt, + settledAt: alreadySettled ? thread.settledAt : occurredAt, + // A re-emission is a projected no-op: keep the existing updatedAt + // so duplicate settles neither rewind nor churn ordering. A fresh + // settle stamps the command time. + updatedAt: alreadySettled ? thread.updatedAt : occurredAt, }, }; } case "thread.unsettle": { - yield* requireThreadNotArchived({ + const thread = yield* requireThreadNotArchived({ readModel, command, threadId: command.threadId, }); // Idempotent by re-emission (see thread.settle): reducing the event a - // second time lands on the same override state. + // second time lands on the same override state. A re-emission keeps + // the existing updatedAt so duplicates do not churn ordering. + const alreadyPinnedActive = thread.settledOverride === "active"; const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ @@ -395,7 +432,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" payload: { threadId: command.threadId, reason: command.reason, - updatedAt: occurredAt, + updatedAt: alreadyPinnedActive ? thread.updatedAt : occurredAt, }, }; } diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 948b6506d31..da49c864ca7 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -55,7 +55,7 @@ import { import { useClientSettings } from "../hooks/useSettings"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useThreadShells } from "../state/entities"; -import { primaryServerKeybindingsAtom } from "../state/server"; +import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; import { vcsEnvironment } from "../state/vcs"; import { threadEnvironment } from "../state/threads"; import { useEnvironmentQuery } from "../state/query"; @@ -624,6 +624,11 @@ export default function SidebarV2() { select: (params) => resolveThreadRouteRef(params), }); const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; + // Post-settle navigation validates against the CURRENT route, not the one + // captured when the settle started: if the user navigated elsewhere while + // the command was in flight, completing it must not yank them away. + const routeThreadKeyRef = useRef(routeThreadKey); + routeThreadKeyRef.current = routeThreadKey; const environmentLabelById = useMemo( () => @@ -711,11 +716,17 @@ export default function SidebarV2() { setProjectScopeKey(null); } }, [projectScopeKey, projects]); + // Scope flips drop the selection: rows selected under the old scope may be + // hidden now, and bulk actions must never count or touch invisible rows. + useEffect(() => { + clearSelection(); + }, [clearSelection, projectScopeKey]); // Settled threads stay in the live shell stream (settled ≠ archived), so // the partition works directly off live shells: no archived-snapshot // merging, no optimistic holds. Archived threads remain hidden here — // archive keeps its original "remove from sidebar" meaning. + const serverConfigs = useAtomValue(environmentServerConfigsAtom); const { activeThreads, settledThreads } = useMemo(() => { const now = `${nowMinute}:00.000Z`; const visible = threads.filter( @@ -728,9 +739,18 @@ export default function SidebarV2() { const active: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; for (const thread of visible) { + // Threads on servers without the settlement capability (old server, + // or descriptor not loaded yet) never classify as settled: the user + // could neither un-settle nor pin them, so auto-settling them would + // strand rows in a tail with no working affordances. + const supportsSettlement = + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; - if (effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })) { + if ( + supportsSettlement && + effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) + ) { settled.push(thread); } else { active.push(thread); @@ -744,7 +764,14 @@ export default function SidebarV2() { firstValidTimestampMs(left.latestUserMessageAt, left.updatedAt), ), }; - }, [autoSettleAfterDays, changeRequestStateByKey, nowMinute, scopedProject, threads]); + }, [ + autoSettleAfterDays, + changeRequestStateByKey, + nowMinute, + scopedProject, + serverConfigs, + threads, + ]); // The settled tail renders in pages: history shouldn't dominate the // sidebar, and the common lookups are recent. Expansion resets when the @@ -959,7 +986,11 @@ export default function SidebarV2() { } return; } - navigateAfterSettle?.(); + // Only move forward if the user is still on the settled thread — + // a navigation made during the await wins over ours. + if (routeThreadKeyRef.current === threadKey) { + navigateAfterSettle?.(); + } } finally { settlingThreadKeysRef.current.delete(threadKey); } @@ -991,7 +1022,13 @@ export default function SidebarV2() { async (position: { x: number; y: number }) => { const api = readLocalApi(); if (!api) return; - const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys]; + // One exact actionable set: keys whose rows are actually rendered + // right now. Selections can outlive their rows (settled-tail paging, + // thread deletion elsewhere) and the menu labels must count only what + // the actions will touch. + const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys].filter( + (threadKey) => threadByKeyRef.current.has(threadKey), + ); if (threadKeys.length === 0) return; const count = threadKeys.length; const clicked = await settlePromise(() => diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index e866b18b4cb..0f0a71bd205 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -57,6 +57,18 @@ export class ThreadSettlementUnsupportedError extends Schema.TaggedErrorClass()( + "ThreadSettleBlockedError", + { + environmentId: EnvironmentId, + threadId: ThreadId, + }, +) { + override get message(): string { + return "This thread still needs attention. Resolve or interrupt it first, then try again."; + } +} + export function useThreadActions() { const closeTerminal = useAtomCommand(terminalEnvironment.close); const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, { @@ -390,7 +402,7 @@ export function useThreadActions() { if (resolved && !canSettle(resolved.thread)) { return AsyncResult.failure( Cause.fail( - new ThreadArchiveBlockedError({ + new ThreadSettleBlockedError({ environmentId: resolved.threadRef.environmentId, threadId: resolved.threadRef.threadId, }), diff --git a/packages/client-runtime/src/state/threadSettled.test.ts b/packages/client-runtime/src/state/threadSettled.test.ts index b0e2a1e6b3f..0996223fd6b 100644 --- a/packages/client-runtime/src/state/threadSettled.test.ts +++ b/packages/client-runtime/src/state/threadSettled.test.ts @@ -10,6 +10,7 @@ import { describe, expect, it } from "vite-plus/test"; import { canSettle, effectiveSettled, + hasQueuedTurnStart, threadLastActivityAt, type ChangeRequestStateLike, } from "./threadSettled.ts"; @@ -188,6 +189,49 @@ describe("effectiveSettled", () => { }); }); +describe("hasQueuedTurnStart", () => { + const QUEUED_AT = "2026-04-09T12:00:00.000Z"; + + it("flags a user message no turn has picked up", () => { + const noTurn = { latestUserMessageAt: QUEUED_AT, latestTurn: null, session: null }; + expect(hasQueuedTurnStart(noTurn)).toBe(true); + + const staleTurn = { + ...makeShell({ activityAt: FRESH }), + latestUserMessageAt: QUEUED_AT, + }; + expect(hasQueuedTurnStart(staleTurn)).toBe(true); + }); + + it("clears once a turn adopts the message or the start fails", () => { + const adopted = { + ...makeShell({ activityAt: QUEUED_AT }), + latestUserMessageAt: QUEUED_AT, + }; + expect(hasQueuedTurnStart(adopted)).toBe(false); + + const failed = makeShell({ activityAt: FRESH }); + const failedShell = { + ...failed, + latestUserMessageAt: QUEUED_AT, + session: { + threadId: failed.id, + status: "error" as const, + providerName: "Codex", + runtimeMode: "full-access" as const, + activeTurnId: null, + lastError: "boom", + updatedAt: NOW, + }, + }; + expect(hasQueuedTurnStart(failedShell)).toBe(false); + }); + + it("is quiet without user messages", () => { + expect(hasQueuedTurnStart(makeShell({ activityAt: FRESH }))).toBe(false); + }); +}); + describe("canSettle", () => { it("blocks every state effectiveSettled refuses to classify as settled", () => { expect(canSettle(makeShell({ activityAt: FRESH }))).toBe(true); @@ -197,6 +241,23 @@ describe("canSettle", () => { expect(canSettle(makeShell({ activityAt: FRESH, pending: "user-input" }))).toBe(false); }); + it("blocks settling a queued turn start", () => { + const queued = { + ...makeShell({ activityAt: FRESH }), + latestUserMessageAt: "2026-04-09T12:00:00.000Z", + }; + expect(canSettle(queued)).toBe(false); + // effectiveSettled must agree: queued work never auto-settles either, + // even with a merged PR. + expect( + effectiveSettled(queued, { + now: NOW, + autoSettleAfterDays: 3, + changeRequestState: "merged", + }), + ).toBe(false); + }); + it("agrees with effectiveSettled's blockers for explicitly settled shells", () => { // Anything canSettle rejects must render as active even when the user // settled it earlier. diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 1e8fa6c7bd4..714a889c294 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -26,6 +26,31 @@ export function threadLastActivityAt(shell: OrchestrationThreadShell): string | return latest; } +/** + * A user message no turn has picked up yet: the turn.start command was + * dispatched (message-sent + turn-start-requested) but no session has + * adopted it, so `session` is still null and the pending work is invisible + * to the session-status checks. Detectable as a user message strictly newer + * than every timestamp on the latest turn — on adoption the new turn's + * requestedAt equals the message time, clearing the condition. + */ +export function hasQueuedTurnStart( + shell: Pick, +): boolean { + if (shell.latestUserMessageAt == null) return false; + // A failed session start clears the queued state: the failure is already + // visible (status edge / error), and holding the queue marker would make + // the thread permanently unsettleable. + if (shell.session?.status === "error") return false; + const messageAt = Date.parse(shell.latestUserMessageAt); + if (Number.isNaN(messageAt)) return false; + const turn = shell.latestTurn; + if (turn === null) return true; + return [turn.requestedAt, turn.startedAt, turn.completedAt].every( + (candidate) => candidate == null || Date.parse(candidate) < messageAt, + ); +} + /** * A thread may be settled only when none of effectiveSettled's activity * blockers hold. This is deliberately the same list: anything the partition @@ -34,10 +59,16 @@ export function threadLastActivityAt(shell: OrchestrationThreadShell): string | * the UI can disable/reject before a round trip. */ export function canSettle( - shell: Pick, + shell: Pick< + OrchestrationThreadShell, + "hasPendingApprovals" | "hasPendingUserInput" | "session" | "latestUserMessageAt" | "latestTurn" + >, ): boolean { if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false; if (shell.session?.status === "starting" || shell.session?.status === "running") return false; + // Queued work is as blocked-on-progress as a live session: settling it + // (or auto-settling it on a closed PR) would hide a just-requested turn. + if (hasQueuedTurnStart(shell)) return false; return true; }