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 {
44} from "@t3tools/client-runtime/state/shell";
55import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
66import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
7import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort";
78import type { MenuAction } from "@react-native-menu/menu";
89import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";
910import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native";
......@@ -406,7 +407,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
406407
407408 const status = resolveThreadListV2Status(thread);
408409 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
411418 const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]);
412419 const handleRegenerateTitle = useCallback(
......@@ -892,7 +899,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
892899 >
893900 {snoozedRow && props.snoozeWakeLabelText !== undefined
894901 ? props.snoozeWakeLabelText
895 : relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt)}
902 : timeLabel}
896903 </Text>
897904 </View>
898905 </Pressable>
apps/mobile/src/features/threads/threadListV2.test.ts+27-1
......@@ -641,6 +641,32 @@ describe("buildThreadListV2Items", () => {
641641 expect(items.map((item) => item.thread.id)).toEqual(["newer-created", "older-created"]);
642642 });
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
644670 it("keeps settled threads in the tail and filters by search query", () => {
645671 const { items } = buildThreadListV2Items({
646672 threads: [
......@@ -738,7 +764,7 @@ describe("buildThreadListV2Items settled paging", () => {
738764 id: ThreadId.make(`settled-${index}`),
739765 title: `Settled ${index}`,
740766 settledOverride: "settled",
741 settledAt: NOW,
767 settledAt: `2026-06-01T0${index}:10:00.000Z`,
742768 latestUserMessageAt: `2026-06-01T0${index}:00:00.000Z`,
743769 // A turn adopted the message (same requestedAt): without it the
744770 // 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
1010import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
1111import {
1212 activeThreadAnchorTimestampMs,
13 resolveSettledThreadTimestamp,
1314 sortPinnedThreadsByOrderKey,
1415} from "@t3tools/client-runtime/state/thread-sort";
1516import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
......@@ -149,17 +150,6 @@ function parseTimestampMs(isoDate: string): number {
149150 return Number.isNaN(parsed) ? 0 : parsed;
150151}
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
163153/**
164154 * v2 sort: static order, newest anchor on top. Activity NEVER reorders the
165155 * list — a row holds its position between lifecycle transitions. The anchor
......@@ -410,8 +400,8 @@ export function buildThreadListV2Items(input: {
410400 );
411401 const orderedSettled = [...settled].sort(
412402 (left, right) =>
413 firstValidTimestampMs(right.latestUserMessageAt, right.updatedAt) -
414 firstValidTimestampMs(left.latestUserMessageAt, left.updatedAt),
403 parseTimestampMs(resolveSettledThreadTimestamp(right) ?? "") -
404 parseTimestampMs(resolveSettledThreadTimestamp(left) ?? ""),
415405 );
416406 const settledLimit = input.settledLimit ?? Number.POSITIVE_INFINITY;
417407 const pagedSettled =
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+12-6
......@@ -380,12 +380,15 @@ describe("OrchestrationEngine", () => {
380380 originalUpdatedAt,
381381 );
382382
383 // Automatic settlement stamps the last activity, never the sweep time.
384 const lastActivityAt = "2025-12-20T00:00:00.000Z";
383385 const staleError = yield* engine
384386 .dispatch({
385387 type: "thread.auto-settle",
386388 commandId: CommandId.make("cmd-auto-settle-stale-snapshot"),
387389 threadId: guardedThreadId,
388390 snapshotSequence,
391 settledAt: lastActivityAt,
389392 })
390393 .pipe(Effect.flip);
391394 expect(staleError._tag).toBe("OrchestrationCommandInvariantError");
......@@ -413,6 +416,7 @@ describe("OrchestrationEngine", () => {
413416 commandId: CommandId.make(`cmd-auto-settle-${expectedLiveness}`),
414417 threadId: liveThreadId,
415418 snapshotSequence: livenessSnapshotSequence,
419 settledAt: lastActivityAt,
416420 })
417421 .pipe(Effect.flip);
418422 expect(livenessError._tag).toBe("OrchestrationCommandInvariantError");
......@@ -425,6 +429,7 @@ describe("OrchestrationEngine", () => {
425429 commandId: CommandId.make("cmd-auto-settle-after-liveness-cleared"),
426430 threadId: liveThreadId,
427431 snapshotSequence: livenessSnapshotSequence,
432 settledAt: lastActivityAt,
428433 });
429434
430435 const freshSnapshotSequence = yield* engine.latestSequence;
......@@ -439,15 +444,16 @@ describe("OrchestrationEngine", () => {
439444 commandId: CommandId.make("cmd-auto-settle-after-unrelated-update"),
440445 threadId: guardedThreadId,
441446 snapshotSequence: freshSnapshotSequence,
447 settledAt: lastActivityAt,
442448 });
443449
444450 const settled = yield* snapshots.getSnapshot();
445 expect(
446 settled.threads.find((thread) => thread.id === guardedThreadId)?.settledOverride,
447 ).toBe("settled");
448 expect(settled.threads.find((thread) => thread.id === liveThreadId)?.settledOverride).toBe(
449 "settled",
450 );
451 for (const threadId of [guardedThreadId, liveThreadId]) {
452 const thread = settled.threads.find((candidate) => candidate.id === threadId);
453 expect(thread?.settledOverride).toBe("settled");
454 expect(thread?.settledAt).toBe(lastActivityAt);
455 expect(thread?.updatedAt).toBe(now());
456 }
451457 }).pipe(Effect.provide(makeOrchestrationLayer())),
452458 );
453459
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+1
......@@ -3371,6 +3371,7 @@ describe("ProviderCommandReactor", () => {
33713371 commandId: CommandId.make("cmd-auto-settle-with-session"),
33723372 threadId: ThreadId.make("thread-1"),
33733373 snapshotSequence: beforeSettlement.snapshotSequence,
3374 settledAt: now,
33743375 });
33753376
33763377 yield* Deferred.await(sessionStopped);
apps/server/src/orchestration/ThreadSettlementPolicy.test.ts+40-3
......@@ -6,7 +6,7 @@ import {
66 TurnId,
77 type OrchestrationThreadShell,
88} from "@t3tools/contracts";
9import { shouldAutoSettleThread } from "./ThreadSettlementPolicy.ts";
9import { resolveAutoSettlementAt } from "./ThreadSettlementPolicy.ts";
1010
1111const NOW = "2026-08-28T12:00:00.000Z";
1212const makeThread = (
......@@ -39,15 +39,52 @@ const decide = (
3939 pullRequest: { state: "open" | "closed" | "merged"; updatedAt: string | null } | null = null,
4040 settings: { days?: number | null; merge?: boolean } = {},
4141) =>
42 shouldAutoSettleThread({
42 resolveAutoSettlementAt({
4343 thread,
4444 pullRequest,
4545 now: NOW,
4646 autoSettleAfterDays: settings.days === undefined ? 3 : settings.days,
4747 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");
4886 });
4987
50describe("shouldAutoSettleThread", () => {
5188 it("settles inactive threads and leaves never-used threads active", () => {
5289 expect(decide(makeThread())).toBe(true);
5390 expect(decide(makeThread({ latestUserMessageAt: null }))).toBe(false);
apps/server/src/orchestration/ThreadSettlementPolicy.ts+13-10
......@@ -62,28 +62,31 @@ function pullRequestSettles(
6262 return pullRequestAt >= userAnchorAt;
6363}
6464
65export function shouldAutoSettleThread(input: {
65export function resolveAutoSettlementAt(input: {
6666 readonly thread: OrchestrationThreadShell;
6767 readonly pullRequest: SettlementPullRequest | null;
6868 readonly now: string;
6969 readonly autoSettleAfterDays: number | null;
7070 readonly autoSettleOnMerge: boolean;
71}): boolean {
71}): string | null {
7272 const { thread, pullRequest } = input;
73 if (!isAutoSettlementCandidate(thread, input.now)) return false;
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;
73 if (!isAutoSettlementCandidate(thread, input.now)) return null;
7974 const activityAt = latestTimestamp([
8075 thread.latestUserMessageAt,
8176 thread.latestTurn?.requestedAt,
8277 thread.latestTurn?.startedAt,
8378 thread.latestTurn?.completedAt,
8479 ]);
85 if (activityAt === null) return false;
86 return Date.parse(activityAt) < Date.parse(input.now) - input.autoSettleAfterDays * DAY_MS;
80 if (pullRequest !== null) {
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;
8790}
8891
8992/** Cheap checks that run before any source control lookup. */
apps/server/src/orchestration/ThreadSettlementReactor.test.ts+7-1
......@@ -301,16 +301,22 @@ describe("ThreadSettlementReactor", () => {
301301 const commands = yield* Ref.get(fixture.commands);
302302 assert.deepStrictEqual(
303303 commands
304 .map(({ threadId, snapshotSequence }) => ({ threadId, snapshotSequence }))
304 .map(({ threadId, snapshotSequence, settledAt }) => ({
305 threadId,
306 snapshotSequence,
307 settledAt,
308 }))
305309 .sort((left, right) => left.threadId.localeCompare(right.threadId)),
306310 [
307311 {
308312 threadId: ThreadId.make("closed-pr"),
309313 snapshotSequence: 1,
314 settledAt: "2026-08-20T00:00:00.000Z",
310315 },
311316 {
312317 threadId: ThreadId.make("inactive"),
313318 snapshotSequence: 1,
319 settledAt: "2026-08-20T00:00:00.000Z",
314320 },
315321 ],
316322 );
apps/server/src/orchestration/ThreadSettlementReactor.ts+10-10
......@@ -18,7 +18,7 @@ import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts";
1818import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts";
1919import {
2020 isAutoSettlementCandidate,
21 shouldAutoSettleThread,
21 resolveAutoSettlementAt,
2222 type SettlementPullRequest,
2323} from "./ThreadSettlementPolicy.ts";
2424
......@@ -101,15 +101,14 @@ export const make = Effect.gen(function* () {
101101 Effect.gen(function* () {
102102 const settings = yield* settingsService.getSettings;
103103 const decisionNow = DateTime.formatIso(yield* DateTime.now);
104 if (
105 !shouldAutoSettleThread({
106 thread,
107 pullRequest,
108 now: decisionNow,
109 autoSettleAfterDays: settings.sidebarAutoSettleAfterDays,
110 autoSettleOnMerge: settings.sidebarAutoSettleOnMerge,
111 })
112 ) {
104 const settledAt = resolveAutoSettlementAt({
105 thread,
106 pullRequest,
107 now: decisionNow,
108 autoSettleAfterDays: settings.sidebarAutoSettleAfterDays,
109 autoSettleOnMerge: settings.sidebarAutoSettleOnMerge,
110 });
111 if (settledAt === null) {
113112 return;
114113 }
115114 const uuid = yield* crypto.randomUUIDv4;
......@@ -118,6 +117,7 @@ export const make = Effect.gen(function* () {
118117 commandId: CommandId.make(`server:auto-settle:${thread.id}:${uuid}`),
119118 threadId: thread.id,
120119 snapshotSequence: snapshot.snapshotSequence,
120 settledAt,
121121 });
122122 }).pipe(
123123 Effect.catchCause((cause) =>
apps/server/src/orchestration/decider.settled.test.ts+22
......@@ -81,6 +81,27 @@ function makeSession(status: OrchestrationSession["status"]): OrchestrationSessi
8181}
8282
8383it.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
84105 it.effect("rejects an automatic settle when the thread is pinned active", () =>
85106 Effect.gen(function* () {
86107 const command = {
......@@ -88,6 +109,7 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => {
88109 commandId: CommandId.make("cmd-auto-settle"),
89110 threadId: ThreadId.make("thread-1"),
90111 snapshotSequence: 0,
112 settledAt: SETTLED_AT,
91113 };
92114 const pinnedActive = yield* decideOrchestrationCommand({
93115 command,
apps/server/src/orchestration/decider.ts+5-1
......@@ -469,7 +469,11 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
469469 type: "thread.settled" as const,
470470 payload: {
471471 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,
473477 // A re-emission is a projected no-op: keep the existing updatedAt
474478 // so duplicate settles neither rewind nor churn ordering. A fresh
475479 // settle stamps the command time.
apps/server/src/persistence/Migrations.ts+2
......@@ -57,6 +57,7 @@ import Migration0042 from "./Migrations/042_ProjectionThreadLinkedPullRequest.ts
5757import Migration0043 from "./Migrations/043_ProjectionThreadsUnsettledAt.ts";
5858import Migration0044 from "./Migrations/044_ClearAutomaticProjectModelDefaults.ts";
5959import Migration0045 from "./Migrations/045_ProjectionProjectsAutoPull.ts";
60import Migration0046 from "./Migrations/046_RepairAutomaticSettlementTimestamps.ts";
6061
6162/**
6263 * Migration loader with all migrations defined inline.
......@@ -114,6 +115,7 @@ export const migrationEntries = [
114115 [43, "ProjectionThreadsUnsettledAt", Migration0043],
115116 [44, "ClearAutomaticProjectModelDefaults", Migration0044],
116117 [45, "ProjectionProjectsAutoPull", Migration0045],
118 [46, "RepairAutomaticSettlementTimestamps", Migration0046],
117119] as const;
118120
119121export 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
55import {
66 activeThreadAnchorTimestampMs,
77 getThreadSortTimestamp,
8 resolveSettledThreadTimestamp,
89 sortThreads,
910 toSortableTimestamp,
11 type SettledThreadTimestampInput,
1012 type ThreadSortInput,
1113} from "../lib/threadSort";
1214import type { SidebarThreadSummary, Thread } from "../types";
......@@ -664,41 +666,13 @@ export function reduceSidebarProjectScopeMenuState(
664666 }
665667}
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
695669// Settled rows are history, so they order by when the work ENDED, not when
696670// the thread was created or last touched.
697671export function sortSettledThreadsForSidebar<
698 T extends SettledTimestampInput & { readonly id: string },
672 T extends SettledThreadTimestampInput & { readonly id: string },
699673>(threads: readonly T[]): T[] {
700674 const timestampMs = (thread: T) => {
701 const timestamp = resolveSettledTimestamp(thread);
675 const timestamp = resolveSettledThreadTimestamp(thread);
702676 return timestamp === null ? 0 : Date.parse(timestamp);
703677 };
704678 return [...threads].toSorted(
apps/web/src/components/Sidebar.tsx+3-3
......@@ -22,6 +22,7 @@ import {
2222 effectiveSnoozed,
2323 threadWokeAt,
2424} from "@t3tools/client-runtime/state/thread-settled";
25import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort";
2526import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
2627import {
2728 scopeProjectRef,
......@@ -134,7 +135,6 @@ import {
134135 planPinnedReorder,
135136 reduceSidebarProjectScopeMenuState,
136137 resolveAdjacentThreadId,
137 resolveSettledTimestamp,
138138 resolveSidebarThreadStatus,
139139 searchSidebarThreadsByTitle,
140140 shouldCreateNewThreadInCurrentProject,
......@@ -221,10 +221,10 @@ function threadTimeLabel(thread: SidebarThreadSummary): string {
221221}
222222
223223// Settled rows read "how long ago did this wrap up", matching their sort
224// key: both go through resolveSettledTimestamp so label and order can't
224// key: both go through resolveSettledThreadTimestamp so label and order can't
225225// disagree.
226226function settledTimeLabel(thread: SidebarThreadSummary): string {
227 const timestamp = resolveSettledTimestamp(thread);
227 const timestamp = resolveSettledThreadTimestamp(thread);
228228 return timestamp === null ? "" : compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp));
229229}
230230
apps/web/src/lib/threadSort.ts+2
......@@ -2,7 +2,9 @@ export {
22 activeThreadAnchorTimestampMs,
33 getLatestThreadForProject,
44 getThreadSortTimestamp,
5 resolveSettledThreadTimestamp,
56 sortThreads,
67 toSortableTimestamp,
8 type SettledThreadTimestampInput,
79 type ThreadSortInput,
810} from "@t3tools/client-runtime/state/thread-sort";
docs/internals/overview.md+2-1
......@@ -97,7 +97,8 @@ when a connected environment drifts.
9797once per minute, including when no client is connected. It dispatches the guarded internal
9898`thread.auto-settle` command, which uses the existing settlement event lifecycle. Automatic
9999settlement 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.
101102Clients render the persisted settlement state and do not derive settlement from PR or inactivity
102103state. A committed `thread.settled` event also lets `ProviderCommandReactor` stop an idle provider
103104session.
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
1818only when its timestamp is not older than the user's latest activity. If that timestamp is not
1919available, 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
2125Change these rules in **Settings > General**. The change is written to every environment you are
2226connected to at that moment. An environment that is offline keeps its old value. When a connected
2327environment 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";
22
33import {
44 planPinnedMove,
5 resolveSettledThreadTimestamp,
56 sortPinnedThreadsByOrderKey,
67 sortThreads,
78 type ThreadSortInput,
......@@ -20,6 +21,38 @@ function makeThread(overrides: Partial<TestThread> = {}): TestThread {
2021 };
2122}
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
2356describe("sortThreads", () => {
2457 it("falls back to updatedAt and createdAt when latestUserMessageAt is invalid and there are no messages", () => {
2558 const sorted = sortThreads(
packages/client-runtime/src/state/threadSort.ts+31-1
......@@ -1,4 +1,4 @@
1import type { ProjectId } from "@t3tools/contracts";
1import type { OrchestrationThreadShell, ProjectId } from "@t3tools/contracts";
22import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
33import * as Arr from "effect/Array";
44import * as Order from "effect/Order";
......@@ -19,6 +19,36 @@ export function toSortableTimestamp(iso: string | undefined): number | null {
1919 return Number.isFinite(ms) ? ms : null;
2020}
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
2252function getFirstSortableTimestamp(...values: Array<string | null | undefined>): number | null {
2353 for (const value of values) {
2454 const timestamp = toSortableTimestamp(value ?? undefined);
packages/contracts/src/orchestration.ts+1
......@@ -784,6 +784,7 @@ const ThreadAutoSettleCommand = Schema.Struct({
784784 commandId: CommandId,
785785 threadId: ThreadId,
786786 snapshotSequence: NonNegativeInt,
787 settledAt: IsoDateTime,
787788});
788789
789790const ThreadUnsettleCommand = Schema.Struct({