From 2971ec3209d7ef1b00d7ded70fe6342816b1539f Mon Sep 17 00:00:00 2001 From: eimexdev <130890337+eimexdev@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:19:44 -0500 Subject: [PATCH] fix(server): preserve automatic settlement timestamps (#9254) Server-side auto-settlement stamped settledAt with the sweep time, so old threads looked newly settled on every startup and sorted wrong in the Settled shelf. The reactor now carries the thread's last user/turn activity through the internal auto-settle command and the decider persists it as settledAt, while updatedAt stays the command time. Migration 046 repairs projection_threads.settled_at for identifiable past sweeps without touching the event log. Web and mobile sort and label settled rows through a shared client-runtime resolver. Refs #9057. Pairs with #9081. Made with GPT-5.6 Sol in T3 Code through the Codex harness; follow-up and merge by Claude Fable 5 in Claude Code. Co-authored-by: eimexdev --- .../features/threads/thread-list-v2-items.tsx | 11 +- .../src/features/threads/threadListV2.test.ts | 28 ++- .../src/features/threads/threadListV2.ts | 16 +- .../Layers/OrchestrationEngine.test.ts | 18 +- .../Layers/ProviderCommandReactor.test.ts | 1 + .../ThreadSettlementPolicy.test.ts | 43 +++- .../orchestration/ThreadSettlementPolicy.ts | 23 +- .../ThreadSettlementReactor.test.ts | 8 +- .../orchestration/ThreadSettlementReactor.ts | 20 +- .../src/orchestration/decider.settled.test.ts | 22 ++ apps/server/src/orchestration/decider.ts | 6 +- apps/server/src/persistence/Migrations.ts | 2 + ...epairAutomaticSettlementTimestamps.test.ts | 218 ++++++++++++++++++ ...046_RepairAutomaticSettlementTimestamps.ts | 70 ++++++ apps/web/src/components/Sidebar.logic.ts | 34 +-- apps/web/src/components/Sidebar.tsx | 6 +- apps/web/src/lib/threadSort.ts | 2 + docs/internals/overview.md | 3 +- docs/user/thread-sidebar.md | 4 + .../src/state/threadSort.test.ts | 33 +++ .../client-runtime/src/state/threadSort.ts | 32 ++- packages/contracts/src/orchestration.ts | 1 + 22 files changed, 519 insertions(+), 82 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/046_RepairAutomaticSettlementTimestamps.test.ts create mode 100644 apps/server/src/persistence/Migrations/046_RepairAutomaticSettlementTimestamps.ts 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 5ea43000bf1b35aa58692a46fb161457618476e2..2403eb02544bb8f11aeb07e66922c1e1ce3a1ad2 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -4,6 +4,7 @@ import type { } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; +import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort"; import type { MenuAction } from "@react-native-menu/menu"; import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react"; import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native"; @@ -406,7 +407,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const status = resolveThreadListV2Status(thread); const statusLabel = STATUS_LABEL_BY_STATUS[status]; - const timeLabel = threadTimeLabel(thread); + // Settled rows label by the same stamp they sort by, so order and label + // can't disagree. updatedAt is always present, so the resolver never + // returns null here. + const settledTimestamp = + variant === "slim" && !snoozedRow ? resolveSettledThreadTimestamp(thread) : null; + const timeLabel = + settledTimestamp !== null ? relativeTime(settledTimestamp) : threadTimeLabel(thread); const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); const handleRegenerateTitle = useCallback( @@ -892,7 +899,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { > {snoozedRow && props.snoozeWakeLabelText !== undefined ? props.snoozeWakeLabelText - : relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt)} + : timeLabel} diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 48edf3906002624fdcaf16aa06276a633b01b925..33ae27cc0638b53d0ca374eb6d302d8bbd173a0e 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -641,6 +641,32 @@ describe("buildThreadListV2Items", () => { expect(items.map((item) => item.thread.id)).toEqual(["newer-created", "older-created"]); }); + it("sorts settled threads by their persisted settlement timestamp", () => { + const { items } = buildThreadListV2Items({ + threads: [ + makeThread({ + id: ThreadId.make("settled-newer"), + title: "Settled newer", + settledOverride: "settled", + settledAt: "2026-06-01T12:00:00.000Z", + latestUserMessageAt: "2026-06-01T08:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("settled-older"), + title: "Settled older", + settledOverride: "settled", + settledAt: "2026-06-01T10:00:00.000Z", + latestUserMessageAt: "2026-06-01T09:00:00.000Z", + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + }); + + expect(items.map((item) => item.thread.id)).toEqual(["settled-newer", "settled-older"]); + }); + it("keeps settled threads in the tail and filters by search query", () => { const { items } = buildThreadListV2Items({ threads: [ @@ -738,7 +764,7 @@ describe("buildThreadListV2Items settled paging", () => { id: ThreadId.make(`settled-${index}`), title: `Settled ${index}`, settledOverride: "settled", - settledAt: NOW, + settledAt: `2026-06-01T0${index}:10:00.000Z`, 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. diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index cf284b41605a18fdea83e0fe3dccaf821315e31e..f17b46b1d18b0741bbcc0ec268a930fe5a322086 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -10,6 +10,7 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { activeThreadAnchorTimestampMs, + resolveSettledThreadTimestamp, sortPinnedThreadsByOrderKey, } from "@t3tools/client-runtime/state/thread-sort"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; @@ -149,17 +150,6 @@ function parseTimestampMs(isoDate: string): number { return Number.isNaN(parsed) ? 0 : parsed; } -/** First VALID timestamp wins: a present-yet-malformed string falls through - to the next candidate rather than sinking the row to the epoch. */ -function firstValidTimestampMs(...candidates: ReadonlyArray): number { - for (const candidate of candidates) { - if (candidate == null) continue; - const parsed = Date.parse(candidate); - if (!Number.isNaN(parsed)) return parsed; - } - return 0; -} - /** * v2 sort: static order, newest anchor on top. Activity NEVER reorders the * list — a row holds its position between lifecycle transitions. The anchor @@ -410,8 +400,8 @@ export function buildThreadListV2Items(input: { ); const orderedSettled = [...settled].sort( (left, right) => - firstValidTimestampMs(right.latestUserMessageAt, right.updatedAt) - - firstValidTimestampMs(left.latestUserMessageAt, left.updatedAt), + parseTimestampMs(resolveSettledThreadTimestamp(right) ?? "") - + parseTimestampMs(resolveSettledThreadTimestamp(left) ?? ""), ); const settledLimit = input.settledLimit ?? Number.POSITIVE_INFINITY; const pagedSettled = diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 3810ee378c4a7c642dc424d09050f201838661e9..a4c7942cfc181b48704cd277e6b5d12e17ae131a 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -380,12 +380,15 @@ describe("OrchestrationEngine", () => { originalUpdatedAt, ); + // Automatic settlement stamps the last activity, never the sweep time. + const lastActivityAt = "2025-12-20T00:00:00.000Z"; const staleError = yield* engine .dispatch({ type: "thread.auto-settle", commandId: CommandId.make("cmd-auto-settle-stale-snapshot"), threadId: guardedThreadId, snapshotSequence, + settledAt: lastActivityAt, }) .pipe(Effect.flip); expect(staleError._tag).toBe("OrchestrationCommandInvariantError"); @@ -413,6 +416,7 @@ describe("OrchestrationEngine", () => { commandId: CommandId.make(`cmd-auto-settle-${expectedLiveness}`), threadId: liveThreadId, snapshotSequence: livenessSnapshotSequence, + settledAt: lastActivityAt, }) .pipe(Effect.flip); expect(livenessError._tag).toBe("OrchestrationCommandInvariantError"); @@ -425,6 +429,7 @@ describe("OrchestrationEngine", () => { commandId: CommandId.make("cmd-auto-settle-after-liveness-cleared"), threadId: liveThreadId, snapshotSequence: livenessSnapshotSequence, + settledAt: lastActivityAt, }); const freshSnapshotSequence = yield* engine.latestSequence; @@ -439,15 +444,16 @@ describe("OrchestrationEngine", () => { commandId: CommandId.make("cmd-auto-settle-after-unrelated-update"), threadId: guardedThreadId, snapshotSequence: freshSnapshotSequence, + settledAt: lastActivityAt, }); const settled = yield* snapshots.getSnapshot(); - expect( - settled.threads.find((thread) => thread.id === guardedThreadId)?.settledOverride, - ).toBe("settled"); - expect(settled.threads.find((thread) => thread.id === liveThreadId)?.settledOverride).toBe( - "settled", - ); + for (const threadId of [guardedThreadId, liveThreadId]) { + const thread = settled.threads.find((candidate) => candidate.id === threadId); + expect(thread?.settledOverride).toBe("settled"); + expect(thread?.settledAt).toBe(lastActivityAt); + expect(thread?.updatedAt).toBe(now()); + } }).pipe(Effect.provide(makeOrchestrationLayer())), ); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 42db044c28d639107c00b37169a219a8695c2826..cad80f1d3bca1f15fdfe239ea6f94eae1b0bf0d9 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -3371,6 +3371,7 @@ describe("ProviderCommandReactor", () => { commandId: CommandId.make("cmd-auto-settle-with-session"), threadId: ThreadId.make("thread-1"), snapshotSequence: beforeSettlement.snapshotSequence, + settledAt: now, }); yield* Deferred.await(sessionStopped); diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts index 08d2d2af24af73c6c2523104aba23971511e1d86..a70fb9f68176dba6434a35202b22d5e19467bfad 100644 --- a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts @@ -6,7 +6,7 @@ import { TurnId, type OrchestrationThreadShell, } from "@t3tools/contracts"; -import { shouldAutoSettleThread } from "./ThreadSettlementPolicy.ts"; +import { resolveAutoSettlementAt } from "./ThreadSettlementPolicy.ts"; const NOW = "2026-08-28T12:00:00.000Z"; const makeThread = ( @@ -39,15 +39,52 @@ const decide = ( pullRequest: { state: "open" | "closed" | "merged"; updatedAt: string | null } | null = null, settings: { days?: number | null; merge?: boolean } = {}, ) => - shouldAutoSettleThread({ + resolveAutoSettlementAt({ thread, pullRequest, now: NOW, autoSettleAfterDays: settings.days === undefined ? 3 : settings.days, autoSettleOnMerge: settings.merge ?? true, + }) !== null; + +describe("resolveAutoSettlementAt", () => { + it("returns the last activity time for persisted settlement", () => { + expect( + resolveAutoSettlementAt({ + thread: makeThread({ + latestTurn: { + turnId: TurnId.make("turn-terminal"), + state: "completed", + requestedAt: "2026-08-19T00:00:00.000Z", + startedAt: "2026-08-19T00:01:00.000Z", + completedAt: "2026-08-21T00:00:00.000Z", + assistantMessageId: null, + }, + }), + pullRequest: null, + now: NOW, + autoSettleAfterDays: 3, + autoSettleOnMerge: true, + }), + ).toBe("2026-08-21T00:00:00.000Z"); + }); + + it("uses creation time for PR settlement when the thread has no activity", () => { + expect( + resolveAutoSettlementAt({ + thread: makeThread({ + latestUserMessageAt: null, + latestTurn: null, + updatedAt: "2026-08-27T00:00:00.000Z", + }), + pullRequest: { state: "closed", updatedAt: NOW }, + now: NOW, + autoSettleAfterDays: null, + autoSettleOnMerge: true, + }), + ).toBe("2026-08-01T00:00:00.000Z"); }); -describe("shouldAutoSettleThread", () => { it("settles inactive threads and leaves never-used threads active", () => { expect(decide(makeThread())).toBe(true); expect(decide(makeThread({ latestUserMessageAt: null }))).toBe(false); diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.ts index 5a10307956aa0d0e74ed8cc71f95c375bfb492ed..5270c30eebe2f04408cfa79856f4fe35d5733ea0 100644 --- a/apps/server/src/orchestration/ThreadSettlementPolicy.ts +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.ts @@ -62,28 +62,31 @@ function pullRequestSettles( return pullRequestAt >= userAnchorAt; } -export function shouldAutoSettleThread(input: { +export function resolveAutoSettlementAt(input: { readonly thread: OrchestrationThreadShell; readonly pullRequest: SettlementPullRequest | null; readonly now: string; readonly autoSettleAfterDays: number | null; readonly autoSettleOnMerge: boolean; -}): boolean { +}): string | null { const { thread, pullRequest } = input; - if (!isAutoSettlementCandidate(thread, input.now)) return false; - if (pullRequest !== null) { - if (pullRequestSettles(thread, pullRequest, input.autoSettleOnMerge)) return true; - if (pullRequest.state === "open") return false; - } - if (input.autoSettleAfterDays === null) return false; + if (!isAutoSettlementCandidate(thread, input.now)) return null; const activityAt = latestTimestamp([ thread.latestUserMessageAt, thread.latestTurn?.requestedAt, thread.latestTurn?.startedAt, thread.latestTurn?.completedAt, ]); - if (activityAt === null) return false; - return Date.parse(activityAt) < Date.parse(input.now) - input.autoSettleAfterDays * DAY_MS; + if (pullRequest !== null) { + if (pullRequestSettles(thread, pullRequest, input.autoSettleOnMerge)) { + return activityAt ?? thread.createdAt; + } + if (pullRequest.state === "open") return null; + } + if (input.autoSettleAfterDays === null || activityAt === null) return null; + return Date.parse(activityAt) < Date.parse(input.now) - input.autoSettleAfterDays * DAY_MS + ? activityAt + : null; } /** Cheap checks that run before any source control lookup. */ diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index 37ea643328c10c0a8f988fb60eeb3fd1553aaaaa..e0aabe973e6020f3e3d2b88b36cc139c178dee2f 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -301,16 +301,22 @@ describe("ThreadSettlementReactor", () => { const commands = yield* Ref.get(fixture.commands); assert.deepStrictEqual( commands - .map(({ threadId, snapshotSequence }) => ({ threadId, snapshotSequence })) + .map(({ threadId, snapshotSequence, settledAt }) => ({ + threadId, + snapshotSequence, + settledAt, + })) .sort((left, right) => left.threadId.localeCompare(right.threadId)), [ { threadId: ThreadId.make("closed-pr"), snapshotSequence: 1, + settledAt: "2026-08-20T00:00:00.000Z", }, { threadId: ThreadId.make("inactive"), snapshotSequence: 1, + settledAt: "2026-08-20T00:00:00.000Z", }, ], ); diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index edd52a8abd0ab5091372c9809e6ea2844de0737e..4971ae643c0a3692d4f1ed14da4764cd66a38921 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -18,7 +18,7 @@ import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts"; import { isAutoSettlementCandidate, - shouldAutoSettleThread, + resolveAutoSettlementAt, type SettlementPullRequest, } from "./ThreadSettlementPolicy.ts"; @@ -101,15 +101,14 @@ export const make = Effect.gen(function* () { Effect.gen(function* () { const settings = yield* settingsService.getSettings; const decisionNow = DateTime.formatIso(yield* DateTime.now); - if ( - !shouldAutoSettleThread({ - thread, - pullRequest, - now: decisionNow, - autoSettleAfterDays: settings.sidebarAutoSettleAfterDays, - autoSettleOnMerge: settings.sidebarAutoSettleOnMerge, - }) - ) { + const settledAt = resolveAutoSettlementAt({ + thread, + pullRequest, + now: decisionNow, + autoSettleAfterDays: settings.sidebarAutoSettleAfterDays, + autoSettleOnMerge: settings.sidebarAutoSettleOnMerge, + }); + if (settledAt === null) { return; } const uuid = yield* crypto.randomUUIDv4; @@ -118,6 +117,7 @@ export const make = Effect.gen(function* () { commandId: CommandId.make(`server:auto-settle:${thread.id}:${uuid}`), threadId: thread.id, snapshotSequence: snapshot.snapshotSequence, + settledAt, }); }).pipe( Effect.catchCause((cause) => diff --git a/apps/server/src/orchestration/decider.settled.test.ts b/apps/server/src/orchestration/decider.settled.test.ts index e470ba33c790a402c11e39b2fcdeed6e1cbc6b89..ebca8434c34be0fc383199aa0dc39285da4cd98b 100644 --- a/apps/server/src/orchestration/decider.settled.test.ts +++ b/apps/server/src/orchestration/decider.settled.test.ts @@ -81,6 +81,27 @@ function makeSession(status: OrchestrationSession["status"]): OrchestrationSessi } it.layer(NodeServices.layer)("settled thread decider", (it) => { + it.effect("preserves the activity stamp when automatically settling", () => + Effect.gen(function* () { + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.auto-settle", + commandId: CommandId.make("cmd-auto-settle-inactive"), + threadId: ThreadId.make("thread-1"), + snapshotSequence: 0, + settledAt: SETTLED_AT, + }, + readModel: makeReadModel(null), + }); + const events = Array.isArray(result) ? result : [result]; + const settled = events.find((event) => event.type === "thread.settled"); + expect(settled?.payload.settledAt).toBe(SETTLED_AT); + // updatedAt stays the command time so the row still moves on settle. + expect(settled?.payload.updatedAt).toBe(settled?.occurredAt); + expect(settled?.payload.updatedAt).not.toBe(SETTLED_AT); + }), + ); + it.effect("rejects an automatic settle when the thread is pinned active", () => Effect.gen(function* () { const command = { @@ -88,6 +109,7 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { commandId: CommandId.make("cmd-auto-settle"), threadId: ThreadId.make("thread-1"), snapshotSequence: 0, + settledAt: SETTLED_AT, }; const pinnedActive = yield* decideOrchestrationCommand({ command, diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 2e863cc1cfdf8a56fd07162327d96af2d88f3648..89d5138f16fae454d9e86a9a1f5feba622a403cf 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -469,7 +469,11 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" type: "thread.settled" as const, payload: { threadId: command.threadId, - settledAt: alreadySettled ? thread.settledAt : occurredAt, + settledAt: alreadySettled + ? thread.settledAt + : command.type === "thread.auto-settle" + ? command.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. diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 17a160037e74d1c05644f53d0caac01c0f4a1a2b..69e02c1d51f61b8554e8b467a4ccb8935a0b2092 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -57,6 +57,7 @@ import Migration0042 from "./Migrations/042_ProjectionThreadLinkedPullRequest.ts import Migration0043 from "./Migrations/043_ProjectionThreadsUnsettledAt.ts"; import Migration0044 from "./Migrations/044_ClearAutomaticProjectModelDefaults.ts"; import Migration0045 from "./Migrations/045_ProjectionProjectsAutoPull.ts"; +import Migration0046 from "./Migrations/046_RepairAutomaticSettlementTimestamps.ts"; /** * Migration loader with all migrations defined inline. @@ -114,6 +115,7 @@ export const migrationEntries = [ [43, "ProjectionThreadsUnsettledAt", Migration0043], [44, "ClearAutomaticProjectModelDefaults", Migration0044], [45, "ProjectionProjectsAutoPull", Migration0045], + [46, "RepairAutomaticSettlementTimestamps", Migration0046], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/046_RepairAutomaticSettlementTimestamps.test.ts b/apps/server/src/persistence/Migrations/046_RepairAutomaticSettlementTimestamps.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f562fa655a118d90f0767b56336b51804dccee2e --- /dev/null +++ b/apps/server/src/persistence/Migrations/046_RepairAutomaticSettlementTimestamps.test.ts @@ -0,0 +1,218 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +const MODEL_SELECTION = '{"instanceId":"codex","model":"gpt-5.6-sol"}'; + +layer("046_RepairAutomaticSettlementTimestamps", (it) => { + it.effect("repairs automatic stamps and leaves manual settlement alone", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 45 }); + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + latest_turn_id, + created_at, + updated_at, + latest_user_message_at, + settled_override, + settled_at, + deleted_at + ) + VALUES + ( + 'thread-auto', 'project-1', 'Automatic', ${MODEL_SELECTION}, 'turn-auto', + '2026-05-01T00:00:00.000Z', '2026-09-01T00:00:00.000Z', '2026-06-01T00:00:00.000Z', + 'settled', '2026-09-01T00:00:00.000Z', NULL + ), + ( + 'thread-auto-no-activity', 'project-1', 'Automatic without activity', ${MODEL_SELECTION}, NULL, + '2026-05-02T00:00:00.000Z', '2026-09-01T00:00:00.000Z', NULL, + 'settled', '2026-09-01T00:00:00.000Z', NULL + ), + ( + 'thread-auto-later-activity', 'project-1', 'Automatic then active', ${MODEL_SELECTION}, NULL, + '2026-05-03T00:00:00.000Z', '2026-09-03T00:00:00.000Z', '2026-09-03T00:00:00.000Z', + 'settled', '2026-09-01T00:00:00.000Z', NULL + ), + ( + 'thread-manual', 'project-1', 'Manual', ${MODEL_SELECTION}, NULL, + '2026-05-01T00:00:00.000Z', '2026-08-10T00:00:00.000Z', '2026-06-10T00:00:00.000Z', + 'settled', '2026-08-10T00:00:00.000Z', NULL + ), + ( + 'thread-resettled', 'project-1', 'Manually re-settled', ${MODEL_SELECTION}, NULL, + '2026-05-01T00:00:00.000Z', '2026-09-02T00:00:00.000Z', '2026-06-05T00:00:00.000Z', + 'settled', '2026-09-02T00:00:00.000Z', NULL + ) + `; + + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, is_streaming, created_at, updated_at + ) + VALUES + ('message-auto', 'thread-auto', 'turn-auto', 'user', 'Prompt', 0, '2026-06-01T00:00:00.000Z', '2026-06-01T00:00:00.000Z'), + ('message-later-old', 'thread-auto-later-activity', NULL, 'user', 'Prompt', 0, '2026-06-20T00:00:00.000Z', '2026-06-20T00:00:00.000Z'), + ('message-later-new', 'thread-auto-later-activity', NULL, 'user', 'Prompt', 0, '2026-09-03T00:00:00.000Z', '2026-09-03T00:00:00.000Z'), + ('message-manual', 'thread-manual', NULL, 'user', 'Prompt', 0, '2026-06-10T00:00:00.000Z', '2026-06-10T00:00:00.000Z'), + ('message-resettled', 'thread-resettled', NULL, 'user', 'Prompt', 0, '2026-06-05T00:00:00.000Z', '2026-06-05T00:00:00.000Z') + `; + + yield* sql` + INSERT INTO projection_turns ( + thread_id, turn_id, state, requested_at, started_at, completed_at, checkpoint_files_json + ) + VALUES ( + 'thread-auto', 'turn-auto', 'completed', + '2026-06-02T00:00:00.000Z', '2026-06-02T00:01:00.000Z', '2026-06-03T00:00:00.000Z', '[]' + ) + `; + + const settledEvent = ( + eventId: string, + threadId: string, + version: number, + occurredAt: string, + commandId: string, + settledAt: string, + ) => + sql` + INSERT INTO orchestration_events ( + event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at, + command_id, causation_event_id, correlation_id, actor_kind, payload_json, metadata_json + ) + VALUES ( + ${eventId}, 'thread', ${threadId}, ${version}, 'thread.settled', ${occurredAt}, + ${commandId}, NULL, ${commandId}, + ${commandId.startsWith("server:") ? "server" : "client"}, + ${JSON.stringify({ threadId, settledAt, updatedAt: occurredAt })}, '{}' + ) + `; + const automatic = (threadId: string) => `server:auto-settle:${threadId}:uuid`; + const sweptAt = "2026-09-01T00:00:00.000Z"; + + yield* settledEvent( + "event-auto", + "thread-auto", + 0, + sweptAt, + automatic("thread-auto"), + sweptAt, + ); + // A later manual settle re-emits the bad stamp; only the projection matters. + yield* settledEvent( + "event-auto-repeat", + "thread-auto", + 1, + "2026-09-01T00:00:05.000Z", + "command-repeat", + sweptAt, + ); + yield* settledEvent( + "event-auto-no-activity", + "thread-auto-no-activity", + 0, + sweptAt, + automatic("thread-auto-no-activity"), + sweptAt, + ); + yield* settledEvent( + "event-auto-later-activity", + "thread-auto-later-activity", + 0, + sweptAt, + automatic("thread-auto-later-activity"), + sweptAt, + ); + yield* settledEvent( + "event-manual", + "thread-manual", + 0, + "2026-08-10T00:00:00.000Z", + "command-manual", + "2026-08-10T00:00:00.000Z", + ); + yield* settledEvent( + "event-resettled-auto", + "thread-resettled", + 0, + sweptAt, + automatic("thread-resettled"), + sweptAt, + ); + yield* settledEvent( + "event-resettled-manual", + "thread-resettled", + 1, + "2026-09-02T00:00:00.000Z", + "command-resettled-manual", + "2026-09-02T00:00:00.000Z", + ); + + const eventsBefore = + yield* sql`SELECT payload_json FROM orchestration_events ORDER BY event_id`; + + yield* runMigrations({ toMigrationInclusive: 46 }); + + const threads = yield* sql<{ + readonly threadId: string; + readonly settledAt: string; + readonly updatedAt: string; + }>` + SELECT + thread_id AS "threadId", + settled_at AS "settledAt", + updated_at AS "updatedAt" + FROM projection_threads + ORDER BY thread_id + `; + assert.deepStrictEqual(threads, [ + // Latest activity at or before the sweep: the turn completion. + { + threadId: "thread-auto", + settledAt: "2026-06-03T00:00:00.000Z", + updatedAt: "2026-09-01T00:00:00.000Z", + }, + // Activity after the sweep is ignored; the older message wins. + { + threadId: "thread-auto-later-activity", + settledAt: "2026-06-20T00:00:00.000Z", + updatedAt: "2026-09-03T00:00:00.000Z", + }, + // No messages or turns: fall back to creation, matching the reactor. + { + threadId: "thread-auto-no-activity", + settledAt: "2026-05-02T00:00:00.000Z", + updatedAt: "2026-09-01T00:00:00.000Z", + }, + { + threadId: "thread-manual", + settledAt: "2026-08-10T00:00:00.000Z", + updatedAt: "2026-08-10T00:00:00.000Z", + }, + // Manually re-settled after the sweep keeps the manual stamp. + { + threadId: "thread-resettled", + settledAt: "2026-09-02T00:00:00.000Z", + updatedAt: "2026-09-02T00:00:00.000Z", + }, + ]); + + const eventsAfter = + yield* sql`SELECT payload_json FROM orchestration_events ORDER BY event_id`; + assert.deepStrictEqual(eventsAfter, eventsBefore); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/046_RepairAutomaticSettlementTimestamps.ts b/apps/server/src/persistence/Migrations/046_RepairAutomaticSettlementTimestamps.ts new file mode 100644 index 0000000000000000000000000000000000000000..4bc3b9d523df1f50f1141b75e935999baa4830c4 --- /dev/null +++ b/apps/server/src/persistence/Migrations/046_RepairAutomaticSettlementTimestamps.ts @@ -0,0 +1,70 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +// Server auto-settlement used to stamp settledAt with the sweep time instead +// of the thread's last activity. Repair the projection only: the engine and +// projectors bootstrap from projection rows and cursors, never a full replay, +// so the historical event payloads can stay as they were recorded. The +// decider stamped settledAt and occurred_at from the same clock read, which +// is how an unrepaired automatic settlement is identified below. +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + WITH activity_timestamps AS ( + SELECT thread_id, created_at AS activity_at + FROM projection_thread_messages + WHERE role = 'user' + UNION ALL + SELECT thread_id, requested_at + FROM projection_turns + UNION ALL + SELECT thread_id, started_at + FROM projection_turns + WHERE started_at IS NOT NULL + UNION ALL + SELECT thread_id, completed_at + FROM projection_turns + WHERE completed_at IS NOT NULL + ), + automatic_settlements AS ( + SELECT + stream_id AS thread_id, + occurred_at, + json_extract(payload_json, '$.settledAt') AS settled_at + FROM orchestration_events + WHERE aggregate_kind = 'thread' + AND event_type = 'thread.settled' + AND actor_kind = 'server' + AND command_id LIKE 'server:auto-settle:%' + AND json_type(payload_json, '$.settledAt') = 'text' + AND json_extract(payload_json, '$.settledAt') = occurred_at + ) + UPDATE projection_threads AS thread + SET settled_at = ( + SELECT COALESCE( + ( + SELECT activity.activity_at + FROM activity_timestamps AS activity + WHERE activity.thread_id = thread.thread_id + AND julianday(activity.activity_at) IS NOT NULL + AND julianday(activity.activity_at) <= julianday(automatic.occurred_at) + ORDER BY julianday(activity.activity_at) DESC + LIMIT 1 + ), + thread.created_at + ) + FROM automatic_settlements AS automatic + WHERE automatic.thread_id = thread.thread_id + AND automatic.settled_at = thread.settled_at + LIMIT 1 + ) + WHERE thread.settled_override = 'settled' + AND EXISTS ( + SELECT 1 + FROM automatic_settlements AS automatic + WHERE automatic.thread_id = thread.thread_id + AND automatic.settled_at = thread.settled_at + ) + `; +}); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index a4a2d57a8e967dce33187000c01ee7666e4d8147..b5bb7bc77cc969ed0bc73039139022e057cf2e5d 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -5,8 +5,10 @@ import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/c import { activeThreadAnchorTimestampMs, getThreadSortTimestamp, + resolveSettledThreadTimestamp, sortThreads, toSortableTimestamp, + type SettledThreadTimestampInput, type ThreadSortInput, } from "../lib/threadSort"; import type { SidebarThreadSummary, Thread } from "../types"; @@ -664,41 +666,13 @@ export function reduceSidebarProjectScopeMenuState( } } -type SettledTimestampInput = Pick< - SidebarThreadSummary, - "settledAt" | "latestUserMessageAt" | "latestTurn" | "updatedAt" ->; - -/** The timestamp a settled row sorts and labels by: settledAt when stamped, - otherwise the latest message or turn stamp. updatedAt is the final net. */ -export function resolveSettledTimestamp(thread: SettledTimestampInput): string | null { - const settledAt = firstValidTimestamp(thread.settledAt); - if (settledAt !== null) return settledAt; - let latest: string | null = null; - let latestMs = Number.NEGATIVE_INFINITY; - for (const candidate of [ - thread.latestUserMessageAt, - thread.latestTurn?.requestedAt, - thread.latestTurn?.startedAt, - thread.latestTurn?.completedAt, - ]) { - if (candidate == null) continue; - const parsed = Date.parse(candidate); - if (!Number.isNaN(parsed) && parsed > latestMs) { - latest = candidate; - latestMs = parsed; - } - } - return latest ?? firstValidTimestamp(thread.updatedAt); -} - // Settled rows are history, so they order by when the work ENDED, not when // the thread was created or last touched. export function sortSettledThreadsForSidebar< - T extends SettledTimestampInput & { readonly id: string }, + T extends SettledThreadTimestampInput & { readonly id: string }, >(threads: readonly T[]): T[] { const timestampMs = (thread: T) => { - const timestamp = resolveSettledTimestamp(thread); + const timestamp = resolveSettledThreadTimestamp(thread); return timestamp === null ? 0 : Date.parse(timestamp); }; return [...threads].toSorted( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 98f2f997875b1ae8bed5be0f5c5cd31f8bc2a086..aca914620d9328709b34d236910c49b85e0a6dd5 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -22,6 +22,7 @@ import { effectiveSnoozed, threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; +import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; import { scopeProjectRef, @@ -134,7 +135,6 @@ import { planPinnedReorder, reduceSidebarProjectScopeMenuState, resolveAdjacentThreadId, - resolveSettledTimestamp, resolveSidebarThreadStatus, searchSidebarThreadsByTitle, shouldCreateNewThreadInCurrentProject, @@ -221,10 +221,10 @@ function threadTimeLabel(thread: SidebarThreadSummary): string { } // Settled rows read "how long ago did this wrap up", matching their sort -// key: both go through resolveSettledTimestamp so label and order can't +// key: both go through resolveSettledThreadTimestamp so label and order can't // disagree. function settledTimeLabel(thread: SidebarThreadSummary): string { - const timestamp = resolveSettledTimestamp(thread); + const timestamp = resolveSettledThreadTimestamp(thread); return timestamp === null ? "" : compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp)); } diff --git a/apps/web/src/lib/threadSort.ts b/apps/web/src/lib/threadSort.ts index 53438305c3211242d6289717a1600aa18ba10855..2644ea67adec9c20423e88740f7b37f0f9fa2e56 100644 --- a/apps/web/src/lib/threadSort.ts +++ b/apps/web/src/lib/threadSort.ts @@ -2,7 +2,9 @@ export { activeThreadAnchorTimestampMs, getLatestThreadForProject, getThreadSortTimestamp, + resolveSettledThreadTimestamp, sortThreads, toSortableTimestamp, + type SettledThreadTimestampInput, type ThreadSortInput, } from "@t3tools/client-runtime/state/thread-sort"; diff --git a/docs/internals/overview.md b/docs/internals/overview.md index 65b6c1424a50f25ae3a139c534e5f848badd2191..be64bfa44003c116ba6b55a191db82cd8130054a 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -97,7 +97,8 @@ when a connected environment drifts. once per minute, including when no client is connected. It dispatches the guarded internal `thread.auto-settle` command, which uses the existing settlement event lifecycle. Automatic settlement excludes live background work and requires a comparable PR timestamp for immediate PR -settlement. The command also rejects any later event for its thread after the reactor's snapshot. +settlement. The command carries the latest activity timestamp and rejects any later event for its +thread after the reactor's snapshot. Clients render the persisted settlement state and do not derive settlement from PR or inactivity state. A committed `thread.settled` event also lets `ProviderCommandReactor` stop an idle provider session. diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 2f24e22b3b9f83c1e44b4a64b95fde4e0935f679..2a940606bf5b9757af3116d6fc82aaa42ab9ba0b 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -18,6 +18,10 @@ live background work keep the thread active. T3 Code settles from a closed or me only when its timestamp is not older than the user's latest activity. If that timestamp is not available, the inactivity rule still applies. A manual un-settle also keeps the thread active. +**Settled** lists threads by when their work finished, newest first. A thread you settle yourself +sorts by the moment you settled it. A thread that settled on its own sorts by its last message or +turn, not by when the server noticed it was inactive. + Change these rules in **Settings > General**. The change is written to every environment you are connected to at that moment. An environment that is offline keeps its old value. When a connected environment holds a different value, **Settings > General** shows a warning that names it. Choose diff --git a/packages/client-runtime/src/state/threadSort.test.ts b/packages/client-runtime/src/state/threadSort.test.ts index f4ea270a001e1f99e96336f41a6435be7f694f9d..a51d4cfed09348a2bbea139f581b7799095d5e6e 100644 --- a/packages/client-runtime/src/state/threadSort.test.ts +++ b/packages/client-runtime/src/state/threadSort.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { planPinnedMove, + resolveSettledThreadTimestamp, sortPinnedThreadsByOrderKey, sortThreads, type ThreadSortInput, @@ -20,6 +21,38 @@ function makeThread(overrides: Partial = {}): TestThread { }; } +describe("resolveSettledThreadTimestamp", () => { + it("prefers the persisted settlement stamp over later activity", () => { + expect( + resolveSettledThreadTimestamp({ + settledAt: "2026-03-09T10:00:00.000Z", + latestUserMessageAt: "2026-03-09T11:00:00.000Z", + latestTurn: null, + updatedAt: "2026-03-09T12:00:00.000Z", + }), + ).toBe("2026-03-09T10:00:00.000Z"); + }); + + it("falls back to the latest activity when the stamp is missing or malformed", () => { + expect( + resolveSettledThreadTimestamp({ + settledAt: "invalid", + latestUserMessageAt: "2026-03-09T11:00:00.000Z", + latestTurn: null, + updatedAt: "2026-03-09T12:00:00.000Z", + }), + ).toBe("2026-03-09T11:00:00.000Z"); + expect( + resolveSettledThreadTimestamp({ + settledAt: null, + latestUserMessageAt: null, + latestTurn: null, + updatedAt: "2026-03-09T12:00:00.000Z", + }), + ).toBe("2026-03-09T12:00:00.000Z"); + }); +}); + describe("sortThreads", () => { it("falls back to updatedAt and createdAt when latestUserMessageAt is invalid and there are no messages", () => { const sorted = sortThreads( diff --git a/packages/client-runtime/src/state/threadSort.ts b/packages/client-runtime/src/state/threadSort.ts index aaac254031f35b615cd400ea7f0a39129b54418b..e54343a45628fd20bace3572c912282d61eb71bc 100644 --- a/packages/client-runtime/src/state/threadSort.ts +++ b/packages/client-runtime/src/state/threadSort.ts @@ -1,4 +1,4 @@ -import type { ProjectId } from "@t3tools/contracts"; +import type { OrchestrationThreadShell, ProjectId } from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import * as Arr from "effect/Array"; import * as Order from "effect/Order"; @@ -19,6 +19,36 @@ export function toSortableTimestamp(iso: string | undefined): number | null { return Number.isFinite(ms) ? ms : null; } +export type SettledThreadTimestampInput = Pick< + OrchestrationThreadShell, + "settledAt" | "latestUserMessageAt" | "latestTurn" | "updatedAt" +>; + +/** The timestamp a settled row sorts and labels by on every client: settledAt + when stamped, otherwise the latest message or turn stamp, then updatedAt. */ +export function resolveSettledThreadTimestamp(thread: SettledThreadTimestampInput): string | null { + if (thread.settledAt != null && toSortableTimestamp(thread.settledAt) !== null) { + return thread.settledAt; + } + + let latest: string | null = null; + let latestMs = Number.NEGATIVE_INFINITY; + for (const candidate of [ + thread.latestUserMessageAt, + thread.latestTurn?.requestedAt, + thread.latestTurn?.startedAt, + thread.latestTurn?.completedAt, + ]) { + const parsed = toSortableTimestamp(candidate ?? undefined); + if (candidate != null && parsed !== null && parsed > latestMs) { + latest = candidate; + latestMs = parsed; + } + } + if (latest !== null) return latest; + return toSortableTimestamp(thread.updatedAt) === null ? null : thread.updatedAt; +} + function getFirstSortableTimestamp(...values: Array): number | null { for (const value of values) { const timestamp = toSortableTimestamp(value ?? undefined); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 2d792876f54b08235c630fbbe780fba5694e89c5..55252cef6b1b95468a1b5dbf1d3251ee3dc360bc 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -784,6 +784,7 @@ const ThreadAutoSettleCommand = Schema.Struct({ commandId: CommandId, threadId: ThreadId, snapshotSequence: NonNegativeInt, + settledAt: IsoDateTime, }); const ThreadUnsettleCommand = Schema.Struct({ -- 2.54.0