authorgravatar for 130890337+eimexdev@users.noreply.github.comeimexdev <130890337+eimexdev@users.noreply.github.com> 2026-09-02 19:19:44-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 17:19:44-07:00
log2971ec3209d7ef1b00d7ded70fe6342816b1539f
treeb279a4496a9e2cd20384ee63272c2f9e334b21f3
parent15fea6c5f40331c8325f33bd08b2dcbf924e935a
signature Signed by PGP key B5690EEEBB952194

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 <eimexdev@users.noreply.github.com>

22 files changed, 519 insertions(+), 82 deletions(-)

apps/mobile/src/features/threads/thread-list-v2-items.tsx+9-2
...@@ -4,6 +4,7 @@ import type {...@@ -4,6 +4,7 @@ import type {
4} from "@t3tools/client-runtime/state/shell";4} from "@t3tools/client-runtime/state/shell";
5import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";5import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
6import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";6import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
7import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort";
7import type { MenuAction } from "@react-native-menu/menu";8import type { MenuAction } from "@react-native-menu/menu";
8import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";9import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";
9import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native";10import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native";
...@@ -406,7 +407,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -406,7 +407,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
406407
407 const status = resolveThreadListV2Status(thread);408 const status = resolveThreadListV2Status(thread);
408 const statusLabel = STATUS_LABEL_BY_STATUS[status];409 const statusLabel = STATUS_LABEL_BY_STATUS[status];
409 const timeLabel = threadTimeLabel(thread);410 // Settled rows label by the same stamp they sort by, so order and label
411 // can't disagree. updatedAt is always present, so the resolver never
412 // returns null here.
413 const settledTimestamp =
414 variant === "slim" && !snoozedRow ? resolveSettledThreadTimestamp(thread) : null;
415 const timeLabel =
416 settledTimestamp !== null ? relativeTime(settledTimestamp) : threadTimeLabel(thread);
410417
411 const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]);418 const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]);
412 const handleRegenerateTitle = useCallback(419 const handleRegenerateTitle = useCallback(
...@@ -892,7 +899,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -892,7 +899,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
892 >899 >
893 {snoozedRow && props.snoozeWakeLabelText !== undefined900 {snoozedRow && props.snoozeWakeLabelText !== undefined
894 ? props.snoozeWakeLabelText901 ? props.snoozeWakeLabelText
895 : relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt)}902 : timeLabel}
896 </Text>903 </Text>
897 </View>904 </View>
898 </Pressable>905 </Pressable>
apps/mobile/src/features/threads/threadListV2.test.ts+27-1
...@@ -641,6 +641,32 @@ describe("buildThreadListV2Items", () => {...@@ -641,6 +641,32 @@ describe("buildThreadListV2Items", () => {
641 expect(items.map((item) => item.thread.id)).toEqual(["newer-created", "older-created"]);641 expect(items.map((item) => item.thread.id)).toEqual(["newer-created", "older-created"]);
642 });642 });
643643
644 it("sorts settled threads by their persisted settlement timestamp", () => {
645 const { items } = buildThreadListV2Items({
646 threads: [
647 makeThread({
648 id: ThreadId.make("settled-newer"),
649 title: "Settled newer",
650 settledOverride: "settled",
651 settledAt: "2026-06-01T12:00:00.000Z",
652 latestUserMessageAt: "2026-06-01T08:00:00.000Z",
653 }),
654 makeThread({
655 id: ThreadId.make("settled-older"),
656 title: "Settled older",
657 settledOverride: "settled",
658 settledAt: "2026-06-01T10:00:00.000Z",
659 latestUserMessageAt: "2026-06-01T09:00:00.000Z",
660 }),
661 ],
662 environmentId: null,
663 searchQuery: "",
664 now: NOW,
665 });
666
667 expect(items.map((item) => item.thread.id)).toEqual(["settled-newer", "settled-older"]);
668 });
669
644 it("keeps settled threads in the tail and filters by search query", () => {670 it("keeps settled threads in the tail and filters by search query", () => {
645 const { items } = buildThreadListV2Items({671 const { items } = buildThreadListV2Items({
646 threads: [672 threads: [
...@@ -738,7 +764,7 @@ describe("buildThreadListV2Items settled paging", () => {...@@ -738,7 +764,7 @@ describe("buildThreadListV2Items settled paging", () => {
738 id: ThreadId.make(`settled-${index}`),764 id: ThreadId.make(`settled-${index}`),
739 title: `Settled ${index}`,765 title: `Settled ${index}`,
740 settledOverride: "settled",766 settledOverride: "settled",
741 settledAt: NOW,767 settledAt: `2026-06-01T0${index}:10:00.000Z`,
742 latestUserMessageAt: `2026-06-01T0${index}:00:00.000Z`,768 latestUserMessageAt: `2026-06-01T0${index}:00:00.000Z`,
743 // A turn adopted the message (same requestedAt): without it the769 // A turn adopted the message (same requestedAt): without it the
744 // thread reads as a queued turn start, which never settles.770 // thread reads as a queued turn start, which never settles.
apps/mobile/src/features/threads/threadListV2.ts+3-13
...@@ -10,6 +10,7 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell...@@ -10,6 +10,7 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell
10import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";10import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
11import {11import {
12 activeThreadAnchorTimestampMs,12 activeThreadAnchorTimestampMs,
13 resolveSettledThreadTimestamp,
13 sortPinnedThreadsByOrderKey,14 sortPinnedThreadsByOrderKey,
14} from "@t3tools/client-runtime/state/thread-sort";15} from "@t3tools/client-runtime/state/thread-sort";
15import type { EnvironmentId, ProjectId } from "@t3tools/contracts";16import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
...@@ -149,17 +150,6 @@ function parseTimestampMs(isoDate: string): number {...@@ -149,17 +150,6 @@ function parseTimestampMs(isoDate: string): number {
149 return Number.isNaN(parsed) ? 0 : parsed;150 return Number.isNaN(parsed) ? 0 : parsed;
150}151}
151152
152/** First VALID timestamp wins: a present-yet-malformed string falls through
153 to the next candidate rather than sinking the row to the epoch. */
154function firstValidTimestampMs(...candidates: ReadonlyArray<string | null | undefined>): number {
155 for (const candidate of candidates) {
156 if (candidate == null) continue;
157 const parsed = Date.parse(candidate);
158 if (!Number.isNaN(parsed)) return parsed;
159 }
160 return 0;
161}
162
163/**153/**
164 * v2 sort: static order, newest anchor on top. Activity NEVER reorders the154 * v2 sort: static order, newest anchor on top. Activity NEVER reorders the
165 * list — a row holds its position between lifecycle transitions. The anchor155 * list — a row holds its position between lifecycle transitions. The anchor
...@@ -410,8 +400,8 @@ export function buildThreadListV2Items(input: {...@@ -410,8 +400,8 @@ export function buildThreadListV2Items(input: {
410 );400 );
411 const orderedSettled = [...settled].sort(401 const orderedSettled = [...settled].sort(
412 (left, right) =>402 (left, right) =>
413 firstValidTimestampMs(right.latestUserMessageAt, right.updatedAt) -403 parseTimestampMs(resolveSettledThreadTimestamp(right) ?? "") -
414 firstValidTimestampMs(left.latestUserMessageAt, left.updatedAt),404 parseTimestampMs(resolveSettledThreadTimestamp(left) ?? ""),
415 );405 );
416 const settledLimit = input.settledLimit ?? Number.POSITIVE_INFINITY;406 const settledLimit = input.settledLimit ?? Number.POSITIVE_INFINITY;
417 const pagedSettled =407 const pagedSettled =
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+12-6
...@@ -380,12 +380,15 @@ describe("OrchestrationEngine", () => {...@@ -380,12 +380,15 @@ describe("OrchestrationEngine", () => {
380 originalUpdatedAt,380 originalUpdatedAt,
381 );381 );
382382
383 // Automatic settlement stamps the last activity, never the sweep time.
384 const lastActivityAt = "2025-12-20T00:00:00.000Z";
383 const staleError = yield* engine385 const staleError = yield* engine
384 .dispatch({386 .dispatch({
385 type: "thread.auto-settle",387 type: "thread.auto-settle",
386 commandId: CommandId.make("cmd-auto-settle-stale-snapshot"),388 commandId: CommandId.make("cmd-auto-settle-stale-snapshot"),
387 threadId: guardedThreadId,389 threadId: guardedThreadId,
388 snapshotSequence,390 snapshotSequence,
391 settledAt: lastActivityAt,
389 })392 })
390 .pipe(Effect.flip);393 .pipe(Effect.flip);
391 expect(staleError._tag).toBe("OrchestrationCommandInvariantError");394 expect(staleError._tag).toBe("OrchestrationCommandInvariantError");
...@@ -413,6 +416,7 @@ describe("OrchestrationEngine", () => {...@@ -413,6 +416,7 @@ describe("OrchestrationEngine", () => {
413 commandId: CommandId.make(`cmd-auto-settle-${expectedLiveness}`),416 commandId: CommandId.make(`cmd-auto-settle-${expectedLiveness}`),
414 threadId: liveThreadId,417 threadId: liveThreadId,
415 snapshotSequence: livenessSnapshotSequence,418 snapshotSequence: livenessSnapshotSequence,
419 settledAt: lastActivityAt,
416 })420 })
417 .pipe(Effect.flip);421 .pipe(Effect.flip);
418 expect(livenessError._tag).toBe("OrchestrationCommandInvariantError");422 expect(livenessError._tag).toBe("OrchestrationCommandInvariantError");
...@@ -425,6 +429,7 @@ describe("OrchestrationEngine", () => {...@@ -425,6 +429,7 @@ describe("OrchestrationEngine", () => {
425 commandId: CommandId.make("cmd-auto-settle-after-liveness-cleared"),429 commandId: CommandId.make("cmd-auto-settle-after-liveness-cleared"),
426 threadId: liveThreadId,430 threadId: liveThreadId,
427 snapshotSequence: livenessSnapshotSequence,431 snapshotSequence: livenessSnapshotSequence,
432 settledAt: lastActivityAt,
428 });433 });
429434
430 const freshSnapshotSequence = yield* engine.latestSequence;435 const freshSnapshotSequence = yield* engine.latestSequence;
...@@ -439,15 +444,16 @@ describe("OrchestrationEngine", () => {...@@ -439,15 +444,16 @@ describe("OrchestrationEngine", () => {
439 commandId: CommandId.make("cmd-auto-settle-after-unrelated-update"),444 commandId: CommandId.make("cmd-auto-settle-after-unrelated-update"),
440 threadId: guardedThreadId,445 threadId: guardedThreadId,
441 snapshotSequence: freshSnapshotSequence,446 snapshotSequence: freshSnapshotSequence,
447 settledAt: lastActivityAt,
442 });448 });
443449
444 const settled = yield* snapshots.getSnapshot();450 const settled = yield* snapshots.getSnapshot();
445 expect(451 for (const threadId of [guardedThreadId, liveThreadId]) {
446 settled.threads.find((thread) => thread.id === guardedThreadId)?.settledOverride,452 const thread = settled.threads.find((candidate) => candidate.id === threadId);
447 ).toBe("settled");453 expect(thread?.settledOverride).toBe("settled");
448 expect(settled.threads.find((thread) => thread.id === liveThreadId)?.settledOverride).toBe(454 expect(thread?.settledAt).toBe(lastActivityAt);
449 "settled",455 expect(thread?.updatedAt).toBe(now());
450 );456 }
451 }).pipe(Effect.provide(makeOrchestrationLayer())),457 }).pipe(Effect.provide(makeOrchestrationLayer())),
452 );458 );
453459
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+1
...@@ -3371,6 +3371,7 @@ describe("ProviderCommandReactor", () => {...@@ -3371,6 +3371,7 @@ describe("ProviderCommandReactor", () => {
3371 commandId: CommandId.make("cmd-auto-settle-with-session"),3371 commandId: CommandId.make("cmd-auto-settle-with-session"),
3372 threadId: ThreadId.make("thread-1"),3372 threadId: ThreadId.make("thread-1"),
3373 snapshotSequence: beforeSettlement.snapshotSequence,3373 snapshotSequence: beforeSettlement.snapshotSequence,
3374 settledAt: now,
3374 });3375 });
33753376
3376 yield* Deferred.await(sessionStopped);3377 yield* Deferred.await(sessionStopped);
apps/server/src/orchestration/ThreadSettlementPolicy.test.ts+40-3
...@@ -6,7 +6,7 @@ import {...@@ -6,7 +6,7 @@ import {
6 TurnId,6 TurnId,
7 type OrchestrationThreadShell,7 type OrchestrationThreadShell,
8} from "@t3tools/contracts";8} from "@t3tools/contracts";
9import { shouldAutoSettleThread } from "./ThreadSettlementPolicy.ts";9import { resolveAutoSettlementAt } from "./ThreadSettlementPolicy.ts";
1010
11const NOW = "2026-08-28T12:00:00.000Z";11const NOW = "2026-08-28T12:00:00.000Z";
12const makeThread = (12const makeThread = (
...@@ -39,15 +39,52 @@ const decide = (...@@ -39,15 +39,52 @@ const decide = (
39 pullRequest: { state: "open" | "closed" | "merged"; updatedAt: string | null } | null = null,39 pullRequest: { state: "open" | "closed" | "merged"; updatedAt: string | null } | null = null,
40 settings: { days?: number | null; merge?: boolean } = {},40 settings: { days?: number | null; merge?: boolean } = {},
41) =>41) =>
42 shouldAutoSettleThread({42 resolveAutoSettlementAt({
43 thread,43 thread,
44 pullRequest,44 pullRequest,
45 now: NOW,45 now: NOW,
46 autoSettleAfterDays: settings.days === undefined ? 3 : settings.days,46 autoSettleAfterDays: settings.days === undefined ? 3 : settings.days,
47 autoSettleOnMerge: settings.merge ?? true,47 autoSettleOnMerge: settings.merge ?? true,
48 }) !== null;
49
50describe("resolveAutoSettlementAt", () => {
51 it("returns the last activity time for persisted settlement", () => {
52 expect(
53 resolveAutoSettlementAt({
54 thread: makeThread({
55 latestTurn: {
56 turnId: TurnId.make("turn-terminal"),
57 state: "completed",
58 requestedAt: "2026-08-19T00:00:00.000Z",
59 startedAt: "2026-08-19T00:01:00.000Z",
60 completedAt: "2026-08-21T00:00:00.000Z",
61 assistantMessageId: null,
62 },
63 }),
64 pullRequest: null,
65 now: NOW,
66 autoSettleAfterDays: 3,
67 autoSettleOnMerge: true,
68 }),
69 ).toBe("2026-08-21T00:00:00.000Z");
70 });
71
72 it("uses creation time for PR settlement when the thread has no activity", () => {
73 expect(
74 resolveAutoSettlementAt({
75 thread: makeThread({
76 latestUserMessageAt: null,
77 latestTurn: null,
78 updatedAt: "2026-08-27T00:00:00.000Z",
79 }),
80 pullRequest: { state: "closed", updatedAt: NOW },
81 now: NOW,
82 autoSettleAfterDays: null,
83 autoSettleOnMerge: true,
84 }),
85 ).toBe("2026-08-01T00:00:00.000Z");
48 });86 });
4987
50describe("shouldAutoSettleThread", () => {
51 it("settles inactive threads and leaves never-used threads active", () => {88 it("settles inactive threads and leaves never-used threads active", () => {
52 expect(decide(makeThread())).toBe(true);89 expect(decide(makeThread())).toBe(true);
53 expect(decide(makeThread({ latestUserMessageAt: null }))).toBe(false);90 expect(decide(makeThread({ latestUserMessageAt: null }))).toBe(false);
apps/server/src/orchestration/ThreadSettlementPolicy.ts+13-10
...@@ -62,28 +62,31 @@ function pullRequestSettles(...@@ -62,28 +62,31 @@ function pullRequestSettles(
62 return pullRequestAt >= userAnchorAt;62 return pullRequestAt >= userAnchorAt;
63}63}
6464
65export function shouldAutoSettleThread(input: {65export function resolveAutoSettlementAt(input: {
66 readonly thread: OrchestrationThreadShell;66 readonly thread: OrchestrationThreadShell;
67 readonly pullRequest: SettlementPullRequest | null;67 readonly pullRequest: SettlementPullRequest | null;
68 readonly now: string;68 readonly now: string;
69 readonly autoSettleAfterDays: number | null;69 readonly autoSettleAfterDays: number | null;
70 readonly autoSettleOnMerge: boolean;70 readonly autoSettleOnMerge: boolean;
71}): boolean {71}): string | null {
72 const { thread, pullRequest } = input;72 const { thread, pullRequest } = input;
73 if (!isAutoSettlementCandidate(thread, input.now)) return false;73 if (!isAutoSettlementCandidate(thread, input.now)) return null;
74 if (pullRequest !== null) {
75 if (pullRequestSettles(thread, pullRequest, input.autoSettleOnMerge)) return true;
76 if (pullRequest.state === "open") return false;
77 }
78 if (input.autoSettleAfterDays === null) return false;
79 const activityAt = latestTimestamp([74 const activityAt = latestTimestamp([
80 thread.latestUserMessageAt,75 thread.latestUserMessageAt,
81 thread.latestTurn?.requestedAt,76 thread.latestTurn?.requestedAt,
82 thread.latestTurn?.startedAt,77 thread.latestTurn?.startedAt,
83 thread.latestTurn?.completedAt,78 thread.latestTurn?.completedAt,
84 ]);79 ]);
85 if (activityAt === null) return false;80 if (pullRequest !== null) {
86 return Date.parse(activityAt) < Date.parse(input.now) - input.autoSettleAfterDays * DAY_MS;81 if (pullRequestSettles(thread, pullRequest, input.autoSettleOnMerge)) {
82 return activityAt ?? thread.createdAt;
83 }
84 if (pullRequest.state === "open") return null;
85 }
86 if (input.autoSettleAfterDays === null || activityAt === null) return null;
87 return Date.parse(activityAt) < Date.parse(input.now) - input.autoSettleAfterDays * DAY_MS
88 ? activityAt
89 : null;
87}90}
8891
89/** Cheap checks that run before any source control lookup. */92/** Cheap checks that run before any source control lookup. */
apps/server/src/orchestration/ThreadSettlementReactor.test.ts+7-1
...@@ -301,16 +301,22 @@ describe("ThreadSettlementReactor", () => {...@@ -301,16 +301,22 @@ describe("ThreadSettlementReactor", () => {
301 const commands = yield* Ref.get(fixture.commands);301 const commands = yield* Ref.get(fixture.commands);
302 assert.deepStrictEqual(302 assert.deepStrictEqual(
303 commands303 commands
304 .map(({ threadId, snapshotSequence }) => ({ threadId, snapshotSequence }))304 .map(({ threadId, snapshotSequence, settledAt }) => ({
305 threadId,
306 snapshotSequence,
307 settledAt,
308 }))
305 .sort((left, right) => left.threadId.localeCompare(right.threadId)),309 .sort((left, right) => left.threadId.localeCompare(right.threadId)),
306 [310 [
307 {311 {
308 threadId: ThreadId.make("closed-pr"),312 threadId: ThreadId.make("closed-pr"),
309 snapshotSequence: 1,313 snapshotSequence: 1,
314 settledAt: "2026-08-20T00:00:00.000Z",
310 },315 },
311 {316 {
312 threadId: ThreadId.make("inactive"),317 threadId: ThreadId.make("inactive"),
313 snapshotSequence: 1,318 snapshotSequence: 1,
319 settledAt: "2026-08-20T00:00:00.000Z",
314 },320 },
315 ],321 ],
316 );322 );
apps/server/src/orchestration/ThreadSettlementReactor.ts+10-10
...@@ -18,7 +18,7 @@ import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts";...@@ -18,7 +18,7 @@ import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts";
18import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts";18import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts";
19import {19import {
20 isAutoSettlementCandidate,20 isAutoSettlementCandidate,
21 shouldAutoSettleThread,21 resolveAutoSettlementAt,
22 type SettlementPullRequest,22 type SettlementPullRequest,
23} from "./ThreadSettlementPolicy.ts";23} from "./ThreadSettlementPolicy.ts";
2424
...@@ -101,15 +101,14 @@ export const make = Effect.gen(function* () {...@@ -101,15 +101,14 @@ export const make = Effect.gen(function* () {
101 Effect.gen(function* () {101 Effect.gen(function* () {
102 const settings = yield* settingsService.getSettings;102 const settings = yield* settingsService.getSettings;
103 const decisionNow = DateTime.formatIso(yield* DateTime.now);103 const decisionNow = DateTime.formatIso(yield* DateTime.now);
104 if (104 const settledAt = resolveAutoSettlementAt({
105 !shouldAutoSettleThread({105 thread,
106 thread,106 pullRequest,
107 pullRequest,107 now: decisionNow,
108 now: decisionNow,108 autoSettleAfterDays: settings.sidebarAutoSettleAfterDays,
109 autoSettleAfterDays: settings.sidebarAutoSettleAfterDays,109 autoSettleOnMerge: settings.sidebarAutoSettleOnMerge,
110 autoSettleOnMerge: settings.sidebarAutoSettleOnMerge,110 });
111 })111 if (settledAt === null) {
112 ) {
113 return;112 return;
114 }113 }
115 const uuid = yield* crypto.randomUUIDv4;114 const uuid = yield* crypto.randomUUIDv4;
...@@ -118,6 +117,7 @@ export const make = Effect.gen(function* () {...@@ -118,6 +117,7 @@ export const make = Effect.gen(function* () {
118 commandId: CommandId.make(`server:auto-settle:${thread.id}:${uuid}`),117 commandId: CommandId.make(`server:auto-settle:${thread.id}:${uuid}`),
119 threadId: thread.id,118 threadId: thread.id,
120 snapshotSequence: snapshot.snapshotSequence,119 snapshotSequence: snapshot.snapshotSequence,
120 settledAt,
121 });121 });
122 }).pipe(122 }).pipe(
123 Effect.catchCause((cause) =>123 Effect.catchCause((cause) =>
apps/server/src/orchestration/decider.settled.test.ts+22
...@@ -81,6 +81,27 @@ function makeSession(status: OrchestrationSession["status"]): OrchestrationSessi...@@ -81,6 +81,27 @@ function makeSession(status: OrchestrationSession["status"]): OrchestrationSessi
81}81}
8282
83it.layer(NodeServices.layer)("settled thread decider", (it) => {83it.layer(NodeServices.layer)("settled thread decider", (it) => {
84 it.effect("preserves the activity stamp when automatically settling", () =>
85 Effect.gen(function* () {
86 const result = yield* decideOrchestrationCommand({
87 command: {
88 type: "thread.auto-settle",
89 commandId: CommandId.make("cmd-auto-settle-inactive"),
90 threadId: ThreadId.make("thread-1"),
91 snapshotSequence: 0,
92 settledAt: SETTLED_AT,
93 },
94 readModel: makeReadModel(null),
95 });
96 const events = Array.isArray(result) ? result : [result];
97 const settled = events.find((event) => event.type === "thread.settled");
98 expect(settled?.payload.settledAt).toBe(SETTLED_AT);
99 // updatedAt stays the command time so the row still moves on settle.
100 expect(settled?.payload.updatedAt).toBe(settled?.occurredAt);
101 expect(settled?.payload.updatedAt).not.toBe(SETTLED_AT);
102 }),
103 );
104
84 it.effect("rejects an automatic settle when the thread is pinned active", () =>105 it.effect("rejects an automatic settle when the thread is pinned active", () =>
85 Effect.gen(function* () {106 Effect.gen(function* () {
86 const command = {107 const command = {
...@@ -88,6 +109,7 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => {...@@ -88,6 +109,7 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => {
88 commandId: CommandId.make("cmd-auto-settle"),109 commandId: CommandId.make("cmd-auto-settle"),
89 threadId: ThreadId.make("thread-1"),110 threadId: ThreadId.make("thread-1"),
90 snapshotSequence: 0,111 snapshotSequence: 0,
112 settledAt: SETTLED_AT,
91 };113 };
92 const pinnedActive = yield* decideOrchestrationCommand({114 const pinnedActive = yield* decideOrchestrationCommand({
93 command,115 command,
apps/server/src/orchestration/decider.ts+5-1
...@@ -469,7 +469,11 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -469,7 +469,11 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
469 type: "thread.settled" as const,469 type: "thread.settled" as const,
470 payload: {470 payload: {
471 threadId: command.threadId,471 threadId: command.threadId,
472 settledAt: alreadySettled ? thread.settledAt : occurredAt,472 settledAt: alreadySettled
473 ? thread.settledAt
474 : command.type === "thread.auto-settle"
475 ? command.settledAt
476 : occurredAt,
473 // A re-emission is a projected no-op: keep the existing updatedAt477 // A re-emission is a projected no-op: keep the existing updatedAt
474 // so duplicate settles neither rewind nor churn ordering. A fresh478 // so duplicate settles neither rewind nor churn ordering. A fresh
475 // settle stamps the command time.479 // settle stamps the command time.
apps/server/src/persistence/Migrations.ts+2
...@@ -57,6 +57,7 @@ import Migration0042 from "./Migrations/042_ProjectionThreadLinkedPullRequest.ts...@@ -57,6 +57,7 @@ import Migration0042 from "./Migrations/042_ProjectionThreadLinkedPullRequest.ts
57import Migration0043 from "./Migrations/043_ProjectionThreadsUnsettledAt.ts";57import Migration0043 from "./Migrations/043_ProjectionThreadsUnsettledAt.ts";
58import Migration0044 from "./Migrations/044_ClearAutomaticProjectModelDefaults.ts";58import Migration0044 from "./Migrations/044_ClearAutomaticProjectModelDefaults.ts";
59import Migration0045 from "./Migrations/045_ProjectionProjectsAutoPull.ts";59import Migration0045 from "./Migrations/045_ProjectionProjectsAutoPull.ts";
60import Migration0046 from "./Migrations/046_RepairAutomaticSettlementTimestamps.ts";
6061
61/**62/**
62 * Migration loader with all migrations defined inline.63 * Migration loader with all migrations defined inline.
...@@ -114,6 +115,7 @@ export const migrationEntries = [...@@ -114,6 +115,7 @@ export const migrationEntries = [
114 [43, "ProjectionThreadsUnsettledAt", Migration0043],115 [43, "ProjectionThreadsUnsettledAt", Migration0043],
115 [44, "ClearAutomaticProjectModelDefaults", Migration0044],116 [44, "ClearAutomaticProjectModelDefaults", Migration0044],
116 [45, "ProjectionProjectsAutoPull", Migration0045],117 [45, "ProjectionProjectsAutoPull", Migration0045],
118 [46, "RepairAutomaticSettlementTimestamps", Migration0046],
117] as const;119] as const;
118120
119export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);121export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
apps/server/src/persistence/Migrations/046_RepairAutomaticSettlementTimestamps.test.ts created+218
...@@ -0,0 +1,218 @@
1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as SqlClient from "effect/unstable/sql/SqlClient";
5
6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
8
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
10
11const MODEL_SELECTION = '{"instanceId":"codex","model":"gpt-5.6-sol"}';
12
13layer("046_RepairAutomaticSettlementTimestamps", (it) => {
14 it.effect("repairs automatic stamps and leaves manual settlement alone", () =>
15 Effect.gen(function* () {
16 const sql = yield* SqlClient.SqlClient;
17 yield* runMigrations({ toMigrationInclusive: 45 });
18
19 yield* sql`
20 INSERT INTO projection_threads (
21 thread_id,
22 project_id,
23 title,
24 model_selection_json,
25 latest_turn_id,
26 created_at,
27 updated_at,
28 latest_user_message_at,
29 settled_override,
30 settled_at,
31 deleted_at
32 )
33 VALUES
34 (
35 'thread-auto', 'project-1', 'Automatic', ${MODEL_SELECTION}, 'turn-auto',
36 '2026-05-01T00:00:00.000Z', '2026-09-01T00:00:00.000Z', '2026-06-01T00:00:00.000Z',
37 'settled', '2026-09-01T00:00:00.000Z', NULL
38 ),
39 (
40 'thread-auto-no-activity', 'project-1', 'Automatic without activity', ${MODEL_SELECTION}, NULL,
41 '2026-05-02T00:00:00.000Z', '2026-09-01T00:00:00.000Z', NULL,
42 'settled', '2026-09-01T00:00:00.000Z', NULL
43 ),
44 (
45 'thread-auto-later-activity', 'project-1', 'Automatic then active', ${MODEL_SELECTION}, NULL,
46 '2026-05-03T00:00:00.000Z', '2026-09-03T00:00:00.000Z', '2026-09-03T00:00:00.000Z',
47 'settled', '2026-09-01T00:00:00.000Z', NULL
48 ),
49 (
50 'thread-manual', 'project-1', 'Manual', ${MODEL_SELECTION}, NULL,
51 '2026-05-01T00:00:00.000Z', '2026-08-10T00:00:00.000Z', '2026-06-10T00:00:00.000Z',
52 'settled', '2026-08-10T00:00:00.000Z', NULL
53 ),
54 (
55 'thread-resettled', 'project-1', 'Manually re-settled', ${MODEL_SELECTION}, NULL,
56 '2026-05-01T00:00:00.000Z', '2026-09-02T00:00:00.000Z', '2026-06-05T00:00:00.000Z',
57 'settled', '2026-09-02T00:00:00.000Z', NULL
58 )
59 `;
60
61 yield* sql`
62 INSERT INTO projection_thread_messages (
63 message_id, thread_id, turn_id, role, text, is_streaming, created_at, updated_at
64 )
65 VALUES
66 ('message-auto', 'thread-auto', 'turn-auto', 'user', 'Prompt', 0, '2026-06-01T00:00:00.000Z', '2026-06-01T00:00:00.000Z'),
67 ('message-later-old', 'thread-auto-later-activity', NULL, 'user', 'Prompt', 0, '2026-06-20T00:00:00.000Z', '2026-06-20T00:00:00.000Z'),
68 ('message-later-new', 'thread-auto-later-activity', NULL, 'user', 'Prompt', 0, '2026-09-03T00:00:00.000Z', '2026-09-03T00:00:00.000Z'),
69 ('message-manual', 'thread-manual', NULL, 'user', 'Prompt', 0, '2026-06-10T00:00:00.000Z', '2026-06-10T00:00:00.000Z'),
70 ('message-resettled', 'thread-resettled', NULL, 'user', 'Prompt', 0, '2026-06-05T00:00:00.000Z', '2026-06-05T00:00:00.000Z')
71 `;
72
73 yield* sql`
74 INSERT INTO projection_turns (
75 thread_id, turn_id, state, requested_at, started_at, completed_at, checkpoint_files_json
76 )
77 VALUES (
78 'thread-auto', 'turn-auto', 'completed',
79 '2026-06-02T00:00:00.000Z', '2026-06-02T00:01:00.000Z', '2026-06-03T00:00:00.000Z', '[]'
80 )
81 `;
82
83 const settledEvent = (
84 eventId: string,
85 threadId: string,
86 version: number,
87 occurredAt: string,
88 commandId: string,
89 settledAt: string,
90 ) =>
91 sql`
92 INSERT INTO orchestration_events (
93 event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at,
94 command_id, causation_event_id, correlation_id, actor_kind, payload_json, metadata_json
95 )
96 VALUES (
97 ${eventId}, 'thread', ${threadId}, ${version}, 'thread.settled', ${occurredAt},
98 ${commandId}, NULL, ${commandId},
99 ${commandId.startsWith("server:") ? "server" : "client"},
100 ${JSON.stringify({ threadId, settledAt, updatedAt: occurredAt })}, '{}'
101 )
102 `;
103 const automatic = (threadId: string) => `server:auto-settle:${threadId}:uuid`;
104 const sweptAt = "2026-09-01T00:00:00.000Z";
105
106 yield* settledEvent(
107 "event-auto",
108 "thread-auto",
109 0,
110 sweptAt,
111 automatic("thread-auto"),
112 sweptAt,
113 );
114 // A later manual settle re-emits the bad stamp; only the projection matters.
115 yield* settledEvent(
116 "event-auto-repeat",
117 "thread-auto",
118 1,
119 "2026-09-01T00:00:05.000Z",
120 "command-repeat",
121 sweptAt,
122 );
123 yield* settledEvent(
124 "event-auto-no-activity",
125 "thread-auto-no-activity",
126 0,
127 sweptAt,
128 automatic("thread-auto-no-activity"),
129 sweptAt,
130 );
131 yield* settledEvent(
132 "event-auto-later-activity",
133 "thread-auto-later-activity",
134 0,
135 sweptAt,
136 automatic("thread-auto-later-activity"),
137 sweptAt,
138 );
139 yield* settledEvent(
140 "event-manual",
141 "thread-manual",
142 0,
143 "2026-08-10T00:00:00.000Z",
144 "command-manual",
145 "2026-08-10T00:00:00.000Z",
146 );
147 yield* settledEvent(
148 "event-resettled-auto",
149 "thread-resettled",
150 0,
151 sweptAt,
152 automatic("thread-resettled"),
153 sweptAt,
154 );
155 yield* settledEvent(
156 "event-resettled-manual",
157 "thread-resettled",
158 1,
159 "2026-09-02T00:00:00.000Z",
160 "command-resettled-manual",
161 "2026-09-02T00:00:00.000Z",
162 );
163
164 const eventsBefore =
165 yield* sql`SELECT payload_json FROM orchestration_events ORDER BY event_id`;
166
167 yield* runMigrations({ toMigrationInclusive: 46 });
168
169 const threads = yield* sql<{
170 readonly threadId: string;
171 readonly settledAt: string;
172 readonly updatedAt: string;
173 }>`
174 SELECT
175 thread_id AS "threadId",
176 settled_at AS "settledAt",
177 updated_at AS "updatedAt"
178 FROM projection_threads
179 ORDER BY thread_id
180 `;
181 assert.deepStrictEqual(threads, [
182 // Latest activity at or before the sweep: the turn completion.
183 {
184 threadId: "thread-auto",
185 settledAt: "2026-06-03T00:00:00.000Z",
186 updatedAt: "2026-09-01T00:00:00.000Z",
187 },
188 // Activity after the sweep is ignored; the older message wins.
189 {
190 threadId: "thread-auto-later-activity",
191 settledAt: "2026-06-20T00:00:00.000Z",
192 updatedAt: "2026-09-03T00:00:00.000Z",
193 },
194 // No messages or turns: fall back to creation, matching the reactor.
195 {
196 threadId: "thread-auto-no-activity",
197 settledAt: "2026-05-02T00:00:00.000Z",
198 updatedAt: "2026-09-01T00:00:00.000Z",
199 },
200 {
201 threadId: "thread-manual",
202 settledAt: "2026-08-10T00:00:00.000Z",
203 updatedAt: "2026-08-10T00:00:00.000Z",
204 },
205 // Manually re-settled after the sweep keeps the manual stamp.
206 {
207 threadId: "thread-resettled",
208 settledAt: "2026-09-02T00:00:00.000Z",
209 updatedAt: "2026-09-02T00:00:00.000Z",
210 },
211 ]);
212
213 const eventsAfter =
214 yield* sql`SELECT payload_json FROM orchestration_events ORDER BY event_id`;
215 assert.deepStrictEqual(eventsAfter, eventsBefore);
216 }),
217 );
218});
apps/server/src/persistence/Migrations/046_RepairAutomaticSettlementTimestamps.ts created+70
...@@ -0,0 +1,70 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4// Server auto-settlement used to stamp settledAt with the sweep time instead
5// of the thread's last activity. Repair the projection only: the engine and
6// projectors bootstrap from projection rows and cursors, never a full replay,
7// so the historical event payloads can stay as they were recorded. The
8// decider stamped settledAt and occurred_at from the same clock read, which
9// is how an unrepaired automatic settlement is identified below.
10export default Effect.gen(function* () {
11 const sql = yield* SqlClient.SqlClient;
12
13 yield* sql`
14 WITH activity_timestamps AS (
15 SELECT thread_id, created_at AS activity_at
16 FROM projection_thread_messages
17 WHERE role = 'user'
18 UNION ALL
19 SELECT thread_id, requested_at
20 FROM projection_turns
21 UNION ALL
22 SELECT thread_id, started_at
23 FROM projection_turns
24 WHERE started_at IS NOT NULL
25 UNION ALL
26 SELECT thread_id, completed_at
27 FROM projection_turns
28 WHERE completed_at IS NOT NULL
29 ),
30 automatic_settlements AS (
31 SELECT
32 stream_id AS thread_id,
33 occurred_at,
34 json_extract(payload_json, '$.settledAt') AS settled_at
35 FROM orchestration_events
36 WHERE aggregate_kind = 'thread'
37 AND event_type = 'thread.settled'
38 AND actor_kind = 'server'
39 AND command_id LIKE 'server:auto-settle:%'
40 AND json_type(payload_json, '$.settledAt') = 'text'
41 AND json_extract(payload_json, '$.settledAt') = occurred_at
42 )
43 UPDATE projection_threads AS thread
44 SET settled_at = (
45 SELECT COALESCE(
46 (
47 SELECT activity.activity_at
48 FROM activity_timestamps AS activity
49 WHERE activity.thread_id = thread.thread_id
50 AND julianday(activity.activity_at) IS NOT NULL
51 AND julianday(activity.activity_at) <= julianday(automatic.occurred_at)
52 ORDER BY julianday(activity.activity_at) DESC
53 LIMIT 1
54 ),
55 thread.created_at
56 )
57 FROM automatic_settlements AS automatic
58 WHERE automatic.thread_id = thread.thread_id
59 AND automatic.settled_at = thread.settled_at
60 LIMIT 1
61 )
62 WHERE thread.settled_override = 'settled'
63 AND EXISTS (
64 SELECT 1
65 FROM automatic_settlements AS automatic
66 WHERE automatic.thread_id = thread.thread_id
67 AND automatic.settled_at = thread.settled_at
68 )
69 `;
70});
apps/web/src/components/Sidebar.logic.ts+4-30
...@@ -5,8 +5,10 @@ import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/c...@@ -5,8 +5,10 @@ import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/c
5import {5import {
6 activeThreadAnchorTimestampMs,6 activeThreadAnchorTimestampMs,
7 getThreadSortTimestamp,7 getThreadSortTimestamp,
8 resolveSettledThreadTimestamp,
8 sortThreads,9 sortThreads,
9 toSortableTimestamp,10 toSortableTimestamp,
11 type SettledThreadTimestampInput,
10 type ThreadSortInput,12 type ThreadSortInput,
11} from "../lib/threadSort";13} from "../lib/threadSort";
12import type { SidebarThreadSummary, Thread } from "../types";14import type { SidebarThreadSummary, Thread } from "../types";
...@@ -664,41 +666,13 @@ export function reduceSidebarProjectScopeMenuState(...@@ -664,41 +666,13 @@ export function reduceSidebarProjectScopeMenuState(
664 }666 }
665}667}
666668
667type SettledTimestampInput = Pick<
668 SidebarThreadSummary,
669 "settledAt" | "latestUserMessageAt" | "latestTurn" | "updatedAt"
670>;
671
672/** The timestamp a settled row sorts and labels by: settledAt when stamped,
673 otherwise the latest message or turn stamp. updatedAt is the final net. */
674export function resolveSettledTimestamp(thread: SettledTimestampInput): string | null {
675 const settledAt = firstValidTimestamp(thread.settledAt);
676 if (settledAt !== null) return settledAt;
677 let latest: string | null = null;
678 let latestMs = Number.NEGATIVE_INFINITY;
679 for (const candidate of [
680 thread.latestUserMessageAt,
681 thread.latestTurn?.requestedAt,
682 thread.latestTurn?.startedAt,
683 thread.latestTurn?.completedAt,
684 ]) {
685 if (candidate == null) continue;
686 const parsed = Date.parse(candidate);
687 if (!Number.isNaN(parsed) && parsed > latestMs) {
688 latest = candidate;
689 latestMs = parsed;
690 }
691 }
692 return latest ?? firstValidTimestamp(thread.updatedAt);
693}
694
695// Settled rows are history, so they order by when the work ENDED, not when669// Settled rows are history, so they order by when the work ENDED, not when
696// the thread was created or last touched.670// the thread was created or last touched.
697export function sortSettledThreadsForSidebar<671export function sortSettledThreadsForSidebar<
698 T extends SettledTimestampInput & { readonly id: string },672 T extends SettledThreadTimestampInput & { readonly id: string },
699>(threads: readonly T[]): T[] {673>(threads: readonly T[]): T[] {
700 const timestampMs = (thread: T) => {674 const timestampMs = (thread: T) => {
701 const timestamp = resolveSettledTimestamp(thread);675 const timestamp = resolveSettledThreadTimestamp(thread);
702 return timestamp === null ? 0 : Date.parse(timestamp);676 return timestamp === null ? 0 : Date.parse(timestamp);
703 };677 };
704 return [...threads].toSorted(678 return [...threads].toSorted(
apps/web/src/components/Sidebar.tsx+3-3
...@@ -22,6 +22,7 @@ import {...@@ -22,6 +22,7 @@ import {
22 effectiveSnoozed,22 effectiveSnoozed,
23 threadWokeAt,23 threadWokeAt,
24} from "@t3tools/client-runtime/state/thread-settled";24} from "@t3tools/client-runtime/state/thread-settled";
25import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort";
25import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";26import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
26import {27import {
27 scopeProjectRef,28 scopeProjectRef,
...@@ -134,7 +135,6 @@ import {...@@ -134,7 +135,6 @@ import {
134 planPinnedReorder,135 planPinnedReorder,
135 reduceSidebarProjectScopeMenuState,136 reduceSidebarProjectScopeMenuState,
136 resolveAdjacentThreadId,137 resolveAdjacentThreadId,
137 resolveSettledTimestamp,
138 resolveSidebarThreadStatus,138 resolveSidebarThreadStatus,
139 searchSidebarThreadsByTitle,139 searchSidebarThreadsByTitle,
140 shouldCreateNewThreadInCurrentProject,140 shouldCreateNewThreadInCurrentProject,
...@@ -221,10 +221,10 @@ function threadTimeLabel(thread: SidebarThreadSummary): string {...@@ -221,10 +221,10 @@ function threadTimeLabel(thread: SidebarThreadSummary): string {
221}221}
222222
223// Settled rows read "how long ago did this wrap up", matching their sort223// Settled rows read "how long ago did this wrap up", matching their sort
224// key: both go through resolveSettledTimestamp so label and order can't224// key: both go through resolveSettledThreadTimestamp so label and order can't
225// disagree.225// disagree.
226function settledTimeLabel(thread: SidebarThreadSummary): string {226function settledTimeLabel(thread: SidebarThreadSummary): string {
227 const timestamp = resolveSettledTimestamp(thread);227 const timestamp = resolveSettledThreadTimestamp(thread);
228 return timestamp === null ? "" : compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp));228 return timestamp === null ? "" : compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp));
229}229}
230230
apps/web/src/lib/threadSort.ts+2
...@@ -2,7 +2,9 @@ export {...@@ -2,7 +2,9 @@ export {
2 activeThreadAnchorTimestampMs,2 activeThreadAnchorTimestampMs,
3 getLatestThreadForProject,3 getLatestThreadForProject,
4 getThreadSortTimestamp,4 getThreadSortTimestamp,
5 resolveSettledThreadTimestamp,
5 sortThreads,6 sortThreads,
6 toSortableTimestamp,7 toSortableTimestamp,
8 type SettledThreadTimestampInput,
7 type ThreadSortInput,9 type ThreadSortInput,
8} from "@t3tools/client-runtime/state/thread-sort";10} from "@t3tools/client-runtime/state/thread-sort";
docs/internals/overview.md+2-1
...@@ -97,7 +97,8 @@ when a connected environment drifts....@@ -97,7 +97,8 @@ when a connected environment drifts.
97once per minute, including when no client is connected. It dispatches the guarded internal97once per minute, including when no client is connected. It dispatches the guarded internal
98`thread.auto-settle` command, which uses the existing settlement event lifecycle. Automatic98`thread.auto-settle` command, which uses the existing settlement event lifecycle. Automatic
99settlement excludes live background work and requires a comparable PR timestamp for immediate PR99settlement excludes live background work and requires a comparable PR timestamp for immediate PR
100settlement. The command also rejects any later event for its thread after the reactor's snapshot.100settlement. The command carries the latest activity timestamp and rejects any later event for its
101thread after the reactor's snapshot.
101Clients render the persisted settlement state and do not derive settlement from PR or inactivity102Clients render the persisted settlement state and do not derive settlement from PR or inactivity
102state. A committed `thread.settled` event also lets `ProviderCommandReactor` stop an idle provider103state. A committed `thread.settled` event also lets `ProviderCommandReactor` stop an idle provider
103session.104session.
docs/user/thread-sidebar.md+4
...@@ -18,6 +18,10 @@ live background work keep the thread active. T3 Code settles from a closed or me...@@ -18,6 +18,10 @@ live background work keep the thread active. T3 Code settles from a closed or me
18only when its timestamp is not older than the user's latest activity. If that timestamp is not18only when its timestamp is not older than the user's latest activity. If that timestamp is not
19available, the inactivity rule still applies. A manual un-settle also keeps the thread active.19available, the inactivity rule still applies. A manual un-settle also keeps the thread active.
2020
21**Settled** lists threads by when their work finished, newest first. A thread you settle yourself
22sorts by the moment you settled it. A thread that settled on its own sorts by its last message or
23turn, not by when the server noticed it was inactive.
24
21Change these rules in **Settings > General**. The change is written to every environment you are25Change these rules in **Settings > General**. The change is written to every environment you are
22connected to at that moment. An environment that is offline keeps its old value. When a connected26connected to at that moment. An environment that is offline keeps its old value. When a connected
23environment holds a different value, **Settings > General** shows a warning that names it. Choose27environment holds a different value, **Settings > General** shows a warning that names it. Choose
packages/client-runtime/src/state/threadSort.test.ts+33
...@@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test";...@@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test";
22
3import {3import {
4 planPinnedMove,4 planPinnedMove,
5 resolveSettledThreadTimestamp,
5 sortPinnedThreadsByOrderKey,6 sortPinnedThreadsByOrderKey,
6 sortThreads,7 sortThreads,
7 type ThreadSortInput,8 type ThreadSortInput,
...@@ -20,6 +21,38 @@ function makeThread(overrides: Partial<TestThread> = {}): TestThread {...@@ -20,6 +21,38 @@ function makeThread(overrides: Partial<TestThread> = {}): TestThread {
20 };21 };
21}22}
2223
24describe("resolveSettledThreadTimestamp", () => {
25 it("prefers the persisted settlement stamp over later activity", () => {
26 expect(
27 resolveSettledThreadTimestamp({
28 settledAt: "2026-03-09T10:00:00.000Z",
29 latestUserMessageAt: "2026-03-09T11:00:00.000Z",
30 latestTurn: null,
31 updatedAt: "2026-03-09T12:00:00.000Z",
32 }),
33 ).toBe("2026-03-09T10:00:00.000Z");
34 });
35
36 it("falls back to the latest activity when the stamp is missing or malformed", () => {
37 expect(
38 resolveSettledThreadTimestamp({
39 settledAt: "invalid",
40 latestUserMessageAt: "2026-03-09T11:00:00.000Z",
41 latestTurn: null,
42 updatedAt: "2026-03-09T12:00:00.000Z",
43 }),
44 ).toBe("2026-03-09T11:00:00.000Z");
45 expect(
46 resolveSettledThreadTimestamp({
47 settledAt: null,
48 latestUserMessageAt: null,
49 latestTurn: null,
50 updatedAt: "2026-03-09T12:00:00.000Z",
51 }),
52 ).toBe("2026-03-09T12:00:00.000Z");
53 });
54});
55
23describe("sortThreads", () => {56describe("sortThreads", () => {
24 it("falls back to updatedAt and createdAt when latestUserMessageAt is invalid and there are no messages", () => {57 it("falls back to updatedAt and createdAt when latestUserMessageAt is invalid and there are no messages", () => {
25 const sorted = sortThreads(58 const sorted = sortThreads(
packages/client-runtime/src/state/threadSort.ts+31-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import type { ProjectId } from "@t3tools/contracts";1import type { OrchestrationThreadShell, ProjectId } from "@t3tools/contracts";
2import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";2import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
3import * as Arr from "effect/Array";3import * as Arr from "effect/Array";
4import * as Order from "effect/Order";4import * as Order from "effect/Order";
...@@ -19,6 +19,36 @@ export function toSortableTimestamp(iso: string | undefined): number | null {...@@ -19,6 +19,36 @@ export function toSortableTimestamp(iso: string | undefined): number | null {
19 return Number.isFinite(ms) ? ms : null;19 return Number.isFinite(ms) ? ms : null;
20}20}
2121
22export type SettledThreadTimestampInput = Pick<
23 OrchestrationThreadShell,
24 "settledAt" | "latestUserMessageAt" | "latestTurn" | "updatedAt"
25>;
26
27/** The timestamp a settled row sorts and labels by on every client: settledAt
28 when stamped, otherwise the latest message or turn stamp, then updatedAt. */
29export function resolveSettledThreadTimestamp(thread: SettledThreadTimestampInput): string | null {
30 if (thread.settledAt != null && toSortableTimestamp(thread.settledAt) !== null) {
31 return thread.settledAt;
32 }
33
34 let latest: string | null = null;
35 let latestMs = Number.NEGATIVE_INFINITY;
36 for (const candidate of [
37 thread.latestUserMessageAt,
38 thread.latestTurn?.requestedAt,
39 thread.latestTurn?.startedAt,
40 thread.latestTurn?.completedAt,
41 ]) {
42 const parsed = toSortableTimestamp(candidate ?? undefined);
43 if (candidate != null && parsed !== null && parsed > latestMs) {
44 latest = candidate;
45 latestMs = parsed;
46 }
47 }
48 if (latest !== null) return latest;
49 return toSortableTimestamp(thread.updatedAt) === null ? null : thread.updatedAt;
50}
51
22function getFirstSortableTimestamp(...values: Array<string | null | undefined>): number | null {52function getFirstSortableTimestamp(...values: Array<string | null | undefined>): number | null {
23 for (const value of values) {53 for (const value of values) {
24 const timestamp = toSortableTimestamp(value ?? undefined);54 const timestamp = toSortableTimestamp(value ?? undefined);
packages/contracts/src/orchestration.ts+1
...@@ -784,6 +784,7 @@ const ThreadAutoSettleCommand = Schema.Struct({...@@ -784,6 +784,7 @@ const ThreadAutoSettleCommand = Schema.Struct({
784 commandId: CommandId,784 commandId: CommandId,
785 threadId: ThreadId,785 threadId: ThreadId,
786 snapshotSequence: NonNegativeInt,786 snapshotSequence: NonNegativeInt,
787 settledAt: IsoDateTime,
787});788});
788789
789const ThreadUnsettleCommand = Schema.Struct({790const ThreadUnsettleCommand = Schema.Struct({