| author | |
| committer | |
| log | 2971ec3209d7ef1b00d7ded70fe6342816b1539f |
| tree | b279a4496a9e2cd20384ee63272c2f9e334b21f3 |
| parent | 15fea6c5f40331c8325f33bd08b2dcbf924e935a |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 4 | } from "@t3tools/client-runtime/state/shell"; |
| 5 | 5 | import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; |
| 6 | 6 | import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; |
| 7 | import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort"; | |
| 7 | 8 | import type { MenuAction } from "@react-native-menu/menu"; |
| 8 | 9 | import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react"; |
| 9 | 10 | import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native"; |
| ... | ... | @@ -406,7 +407,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 406 | 407 | |
| 407 | 408 | const status = resolveThreadListV2Status(thread); |
| 408 | 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); | |
| 410 | 417 | |
| 411 | 418 | const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); |
| 412 | 419 | const handleRegenerateTitle = useCallback( |
| ... | ... | @@ -892,7 +899,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 892 | 899 | > |
| 893 | 900 | {snoozedRow && props.snoozeWakeLabelText !== undefined |
| 894 | 901 | ? props.snoozeWakeLabelText |
| 895 | : relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt)} | |
| 902 | : timeLabel} | |
| 896 | 903 | </Text> |
| 897 | 904 | </View> |
| 898 | 905 | </Pressable> |
apps/mobile/src/features/threads/threadListV2.test.ts+27-1| ... | ... | @@ -641,6 +641,32 @@ describe("buildThreadListV2Items", () => { |
| 641 | 641 | expect(items.map((item) => item.thread.id)).toEqual(["newer-created", "older-created"]); |
| 642 | 642 | }); |
| 643 | 643 | |
| 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 | 670 | it("keeps settled threads in the tail and filters by search query", () => { |
| 645 | 671 | const { items } = buildThreadListV2Items({ |
| 646 | 672 | threads: [ |
| ... | ... | @@ -738,7 +764,7 @@ describe("buildThreadListV2Items settled paging", () => { |
| 738 | 764 | id: ThreadId.make(`settled-${index}`), |
| 739 | 765 | title: `Settled ${index}`, |
| 740 | 766 | settledOverride: "settled", |
| 741 | settledAt: NOW, | |
| 767 | settledAt: `2026-06-01T0${index}:10:00.000Z`, | |
| 742 | 768 | latestUserMessageAt: `2026-06-01T0${index}:00:00.000Z`, |
| 743 | 769 | // A turn adopted the message (same requestedAt): without it the |
| 744 | 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 | 10 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; |
| 11 | 11 | import { |
| 12 | 12 | activeThreadAnchorTimestampMs, |
| 13 | resolveSettledThreadTimestamp, | |
| 13 | 14 | sortPinnedThreadsByOrderKey, |
| 14 | 15 | } from "@t3tools/client-runtime/state/thread-sort"; |
| 15 | 16 | import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; |
| ... | ... | @@ -149,17 +150,6 @@ function parseTimestampMs(isoDate: string): number { |
| 149 | 150 | return Number.isNaN(parsed) ? 0 : parsed; |
| 150 | 151 | } |
| 151 | 152 | |
| 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. */ | |
| 154 | function 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 | 154 | * v2 sort: static order, newest anchor on top. Activity NEVER reorders the |
| 165 | 155 | * list — a row holds its position between lifecycle transitions. The anchor |
| ... | ... | @@ -410,8 +400,8 @@ export function buildThreadListV2Items(input: { |
| 410 | 400 | ); |
| 411 | 401 | const orderedSettled = [...settled].sort( |
| 412 | 402 | (left, right) => |
| 413 | firstValidTimestampMs(right.latestUserMessageAt, right.updatedAt) - | |
| 414 | firstValidTimestampMs(left.latestUserMessageAt, left.updatedAt), | |
| 403 | parseTimestampMs(resolveSettledThreadTimestamp(right) ?? "") - | |
| 404 | parseTimestampMs(resolveSettledThreadTimestamp(left) ?? ""), | |
| 415 | 405 | ); |
| 416 | 406 | const settledLimit = input.settledLimit ?? Number.POSITIVE_INFINITY; |
| 417 | 407 | const pagedSettled = |
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+12-6| ... | ... | @@ -380,12 +380,15 @@ describe("OrchestrationEngine", () => { |
| 380 | 380 | originalUpdatedAt, |
| 381 | 381 | ); |
| 382 | 382 | |
| 383 | // Automatic settlement stamps the last activity, never the sweep time. | |
| 384 | const lastActivityAt = "2025-12-20T00:00:00.000Z"; | |
| 383 | 385 | const staleError = yield* engine |
| 384 | 386 | .dispatch({ |
| 385 | 387 | type: "thread.auto-settle", |
| 386 | 388 | commandId: CommandId.make("cmd-auto-settle-stale-snapshot"), |
| 387 | 389 | threadId: guardedThreadId, |
| 388 | 390 | snapshotSequence, |
| 391 | settledAt: lastActivityAt, | |
| 389 | 392 | }) |
| 390 | 393 | .pipe(Effect.flip); |
| 391 | 394 | expect(staleError._tag).toBe("OrchestrationCommandInvariantError"); |
| ... | ... | @@ -413,6 +416,7 @@ describe("OrchestrationEngine", () => { |
| 413 | 416 | commandId: CommandId.make(`cmd-auto-settle-${expectedLiveness}`), |
| 414 | 417 | threadId: liveThreadId, |
| 415 | 418 | snapshotSequence: livenessSnapshotSequence, |
| 419 | settledAt: lastActivityAt, | |
| 416 | 420 | }) |
| 417 | 421 | .pipe(Effect.flip); |
| 418 | 422 | expect(livenessError._tag).toBe("OrchestrationCommandInvariantError"); |
| ... | ... | @@ -425,6 +429,7 @@ describe("OrchestrationEngine", () => { |
| 425 | 429 | commandId: CommandId.make("cmd-auto-settle-after-liveness-cleared"), |
| 426 | 430 | threadId: liveThreadId, |
| 427 | 431 | snapshotSequence: livenessSnapshotSequence, |
| 432 | settledAt: lastActivityAt, | |
| 428 | 433 | }); |
| 429 | 434 | |
| 430 | 435 | const freshSnapshotSequence = yield* engine.latestSequence; |
| ... | ... | @@ -439,15 +444,16 @@ describe("OrchestrationEngine", () => { |
| 439 | 444 | commandId: CommandId.make("cmd-auto-settle-after-unrelated-update"), |
| 440 | 445 | threadId: guardedThreadId, |
| 441 | 446 | snapshotSequence: freshSnapshotSequence, |
| 447 | settledAt: lastActivityAt, | |
| 442 | 448 | }); |
| 443 | 449 | |
| 444 | 450 | 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 | } | |
| 451 | 457 | }).pipe(Effect.provide(makeOrchestrationLayer())), |
| 452 | 458 | ); |
| 453 | 459 |
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+1| ... | ... | @@ -3371,6 +3371,7 @@ describe("ProviderCommandReactor", () => { |
| 3371 | 3371 | commandId: CommandId.make("cmd-auto-settle-with-session"), |
| 3372 | 3372 | threadId: ThreadId.make("thread-1"), |
| 3373 | 3373 | snapshotSequence: beforeSettlement.snapshotSequence, |
| 3374 | settledAt: now, | |
| 3374 | 3375 | }); |
| 3375 | 3376 | |
| 3376 | 3377 | yield* Deferred.await(sessionStopped); |
apps/server/src/orchestration/ThreadSettlementPolicy.test.ts+40-3| ... | ... | @@ -6,7 +6,7 @@ import { |
| 6 | 6 | TurnId, |
| 7 | 7 | type OrchestrationThreadShell, |
| 8 | 8 | } from "@t3tools/contracts"; |
| 9 | import { shouldAutoSettleThread } from "./ThreadSettlementPolicy.ts"; | |
| 9 | import { resolveAutoSettlementAt } from "./ThreadSettlementPolicy.ts"; | |
| 10 | 10 | |
| 11 | 11 | const NOW = "2026-08-28T12:00:00.000Z"; |
| 12 | 12 | const makeThread = ( |
| ... | ... | @@ -39,15 +39,52 @@ const decide = ( |
| 39 | 39 | pullRequest: { state: "open" | "closed" | "merged"; updatedAt: string | null } | null = null, |
| 40 | 40 | settings: { days?: number | null; merge?: boolean } = {}, |
| 41 | 41 | ) => |
| 42 | shouldAutoSettleThread({ | |
| 42 | resolveAutoSettlementAt({ | |
| 43 | 43 | thread, |
| 44 | 44 | pullRequest, |
| 45 | 45 | now: NOW, |
| 46 | 46 | autoSettleAfterDays: settings.days === undefined ? 3 : settings.days, |
| 47 | 47 | autoSettleOnMerge: settings.merge ?? true, |
| 48 | }) !== null; | |
| 49 | ||
| 50 | describe("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 | }); |
| 49 | 87 | |
| 50 | describe("shouldAutoSettleThread", () => { | |
| 51 | 88 | it("settles inactive threads and leaves never-used threads active", () => { |
| 52 | 89 | expect(decide(makeThread())).toBe(true); |
| 53 | 90 | expect(decide(makeThread({ latestUserMessageAt: null }))).toBe(false); |
apps/server/src/orchestration/ThreadSettlementPolicy.ts+13-10| ... | ... | @@ -62,28 +62,31 @@ function pullRequestSettles( |
| 62 | 62 | return pullRequestAt >= userAnchorAt; |
| 63 | 63 | } |
| 64 | 64 | |
| 65 | export function shouldAutoSettleThread(input: { | |
| 65 | export function resolveAutoSettlementAt(input: { | |
| 66 | 66 | readonly thread: OrchestrationThreadShell; |
| 67 | 67 | readonly pullRequest: SettlementPullRequest | null; |
| 68 | 68 | readonly now: string; |
| 69 | 69 | readonly autoSettleAfterDays: number | null; |
| 70 | 70 | readonly autoSettleOnMerge: boolean; |
| 71 | }): boolean { | |
| 71 | }): string | null { | |
| 72 | 72 | 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; | |
| 79 | 74 | const activityAt = latestTimestamp([ |
| 80 | 75 | thread.latestUserMessageAt, |
| 81 | 76 | thread.latestTurn?.requestedAt, |
| 82 | 77 | thread.latestTurn?.startedAt, |
| 83 | 78 | thread.latestTurn?.completedAt, |
| 84 | 79 | ]); |
| 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; | |
| 87 | 90 | } |
| 88 | 91 | |
| 89 | 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 | 301 | const commands = yield* Ref.get(fixture.commands); |
| 302 | 302 | assert.deepStrictEqual( |
| 303 | 303 | commands |
| 304 | .map(({ threadId, snapshotSequence }) => ({ threadId, snapshotSequence })) | |
| 304 | .map(({ threadId, snapshotSequence, settledAt }) => ({ | |
| 305 | threadId, | |
| 306 | snapshotSequence, | |
| 307 | settledAt, | |
| 308 | })) | |
| 305 | 309 | .sort((left, right) => left.threadId.localeCompare(right.threadId)), |
| 306 | 310 | [ |
| 307 | 311 | { |
| 308 | 312 | threadId: ThreadId.make("closed-pr"), |
| 309 | 313 | snapshotSequence: 1, |
| 314 | settledAt: "2026-08-20T00:00:00.000Z", | |
| 310 | 315 | }, |
| 311 | 316 | { |
| 312 | 317 | threadId: ThreadId.make("inactive"), |
| 313 | 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 | 18 | import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts"; |
| 19 | 19 | import { |
| 20 | 20 | isAutoSettlementCandidate, |
| 21 | shouldAutoSettleThread, | |
| 21 | resolveAutoSettlementAt, | |
| 22 | 22 | type SettlementPullRequest, |
| 23 | 23 | } from "./ThreadSettlementPolicy.ts"; |
| 24 | 24 | |
| ... | ... | @@ -101,15 +101,14 @@ export const make = Effect.gen(function* () { |
| 101 | 101 | Effect.gen(function* () { |
| 102 | 102 | const settings = yield* settingsService.getSettings; |
| 103 | 103 | 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) { | |
| 113 | 112 | return; |
| 114 | 113 | } |
| 115 | 114 | const uuid = yield* crypto.randomUUIDv4; |
| ... | ... | @@ -118,6 +117,7 @@ export const make = Effect.gen(function* () { |
| 118 | 117 | commandId: CommandId.make(`server:auto-settle:${thread.id}:${uuid}`), |
| 119 | 118 | threadId: thread.id, |
| 120 | 119 | snapshotSequence: snapshot.snapshotSequence, |
| 120 | settledAt, | |
| 121 | 121 | }); |
| 122 | 122 | }).pipe( |
| 123 | 123 | Effect.catchCause((cause) => |
apps/server/src/orchestration/decider.settled.test.ts+22| ... | ... | @@ -81,6 +81,27 @@ function makeSession(status: OrchestrationSession["status"]): OrchestrationSessi |
| 81 | 81 | } |
| 82 | 82 | |
| 83 | 83 | it.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 | 105 | it.effect("rejects an automatic settle when the thread is pinned active", () => |
| 85 | 106 | Effect.gen(function* () { |
| 86 | 107 | const command = { |
| ... | ... | @@ -88,6 +109,7 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { |
| 88 | 109 | commandId: CommandId.make("cmd-auto-settle"), |
| 89 | 110 | threadId: ThreadId.make("thread-1"), |
| 90 | 111 | snapshotSequence: 0, |
| 112 | settledAt: SETTLED_AT, | |
| 91 | 113 | }; |
| 92 | 114 | const pinnedActive = yield* decideOrchestrationCommand({ |
| 93 | 115 | command, |
apps/server/src/orchestration/decider.ts+5-1| ... | ... | @@ -469,7 +469,11 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 469 | 469 | type: "thread.settled" as const, |
| 470 | 470 | payload: { |
| 471 | 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 | 477 | // A re-emission is a projected no-op: keep the existing updatedAt |
| 474 | 478 | // so duplicate settles neither rewind nor churn ordering. A fresh |
| 475 | 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 | 57 | import Migration0043 from "./Migrations/043_ProjectionThreadsUnsettledAt.ts"; |
| 58 | 58 | import Migration0044 from "./Migrations/044_ClearAutomaticProjectModelDefaults.ts"; |
| 59 | 59 | import Migration0045 from "./Migrations/045_ProjectionProjectsAutoPull.ts"; |
| 60 | import Migration0046 from "./Migrations/046_RepairAutomaticSettlementTimestamps.ts"; | |
| 60 | 61 | |
| 61 | 62 | /** |
| 62 | 63 | * Migration loader with all migrations defined inline. |
| ... | ... | @@ -114,6 +115,7 @@ export const migrationEntries = [ |
| 114 | 115 | [43, "ProjectionThreadsUnsettledAt", Migration0043], |
| 115 | 116 | [44, "ClearAutomaticProjectModelDefaults", Migration0044], |
| 116 | 117 | [45, "ProjectionProjectsAutoPull", Migration0045], |
| 118 | [46, "RepairAutomaticSettlementTimestamps", Migration0046], | |
| 117 | 119 | ] as const; |
| 118 | 120 | |
| 119 | 121 | export 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 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 5 | ||
| 6 | import { runMigrations } from "../Migrations.ts"; | |
| 7 | import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; | |
| 8 | ||
| 9 | const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); | |
| 10 | ||
| 11 | const MODEL_SELECTION = '{"instanceId":"codex","model":"gpt-5.6-sol"}'; | |
| 12 | ||
| 13 | layer("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 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * 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. | |
| 10 | export 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 | 5 | import { |
| 6 | 6 | activeThreadAnchorTimestampMs, |
| 7 | 7 | getThreadSortTimestamp, |
| 8 | resolveSettledThreadTimestamp, | |
| 8 | 9 | sortThreads, |
| 9 | 10 | toSortableTimestamp, |
| 11 | type SettledThreadTimestampInput, | |
| 10 | 12 | type ThreadSortInput, |
| 11 | 13 | } from "../lib/threadSort"; |
| 12 | 14 | import type { SidebarThreadSummary, Thread } from "../types"; |
| ... | ... | @@ -664,41 +666,13 @@ export function reduceSidebarProjectScopeMenuState( |
| 664 | 666 | } |
| 665 | 667 | } |
| 666 | 668 | |
| 667 | type 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. */ | |
| 674 | export 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 | 669 | // Settled rows are history, so they order by when the work ENDED, not when |
| 696 | 670 | // the thread was created or last touched. |
| 697 | 671 | export function sortSettledThreadsForSidebar< |
| 698 | T extends SettledTimestampInput & { readonly id: string }, | |
| 672 | T extends SettledThreadTimestampInput & { readonly id: string }, | |
| 699 | 673 | >(threads: readonly T[]): T[] { |
| 700 | 674 | const timestampMs = (thread: T) => { |
| 701 | const timestamp = resolveSettledTimestamp(thread); | |
| 675 | const timestamp = resolveSettledThreadTimestamp(thread); | |
| 702 | 676 | return timestamp === null ? 0 : Date.parse(timestamp); |
| 703 | 677 | }; |
| 704 | 678 | return [...threads].toSorted( |
apps/web/src/components/Sidebar.tsx+3-3| ... | ... | @@ -22,6 +22,7 @@ import { |
| 22 | 22 | effectiveSnoozed, |
| 23 | 23 | threadWokeAt, |
| 24 | 24 | } from "@t3tools/client-runtime/state/thread-settled"; |
| 25 | import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort"; | |
| 25 | 26 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; |
| 26 | 27 | import { |
| 27 | 28 | scopeProjectRef, |
| ... | ... | @@ -134,7 +135,6 @@ import { |
| 134 | 135 | planPinnedReorder, |
| 135 | 136 | reduceSidebarProjectScopeMenuState, |
| 136 | 137 | resolveAdjacentThreadId, |
| 137 | resolveSettledTimestamp, | |
| 138 | 138 | resolveSidebarThreadStatus, |
| 139 | 139 | searchSidebarThreadsByTitle, |
| 140 | 140 | shouldCreateNewThreadInCurrentProject, |
| ... | ... | @@ -221,10 +221,10 @@ function threadTimeLabel(thread: SidebarThreadSummary): string { |
| 221 | 221 | } |
| 222 | 222 | |
| 223 | 223 | // 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 | |
| 225 | 225 | // disagree. |
| 226 | 226 | function settledTimeLabel(thread: SidebarThreadSummary): string { |
| 227 | const timestamp = resolveSettledTimestamp(thread); | |
| 227 | const timestamp = resolveSettledThreadTimestamp(thread); | |
| 228 | 228 | return timestamp === null ? "" : compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp)); |
| 229 | 229 | } |
| 230 | 230 |
apps/web/src/lib/threadSort.ts+2| ... | ... | @@ -2,7 +2,9 @@ export { |
| 2 | 2 | activeThreadAnchorTimestampMs, |
| 3 | 3 | getLatestThreadForProject, |
| 4 | 4 | getThreadSortTimestamp, |
| 5 | resolveSettledThreadTimestamp, | |
| 5 | 6 | sortThreads, |
| 6 | 7 | toSortableTimestamp, |
| 8 | type SettledThreadTimestampInput, | |
| 7 | 9 | type ThreadSortInput, |
| 8 | 10 | } from "@t3tools/client-runtime/state/thread-sort"; |
docs/internals/overview.md+2-1| ... | ... | @@ -97,7 +97,8 @@ when a connected environment drifts. |
| 97 | 97 | once per minute, including when no client is connected. It dispatches the guarded internal |
| 98 | 98 | `thread.auto-settle` command, which uses the existing settlement event lifecycle. Automatic |
| 99 | 99 | settlement excludes live background work and requires a comparable PR timestamp for immediate PR |
| 100 | settlement. The command also rejects any later event for its thread after the reactor's snapshot. | |
| 100 | settlement. The command carries the latest activity timestamp and rejects any later event for its | |
| 101 | thread after the reactor's snapshot. | |
| 101 | 102 | Clients render the persisted settlement state and do not derive settlement from PR or inactivity |
| 102 | 103 | state. A committed `thread.settled` event also lets `ProviderCommandReactor` stop an idle provider |
| 103 | 104 | session. |
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 | 18 | only when its timestamp is not older than the user's latest activity. If that timestamp is not |
| 19 | 19 | available, the inactivity rule still applies. A manual un-settle also keeps the thread active. |
| 20 | 20 | |
| 21 | **Settled** lists threads by when their work finished, newest first. A thread you settle yourself | |
| 22 | sorts by the moment you settled it. A thread that settled on its own sorts by its last message or | |
| 23 | turn, not by when the server noticed it was inactive. | |
| 24 | ||
| 21 | 25 | Change these rules in **Settings > General**. The change is written to every environment you are |
| 22 | 26 | connected to at that moment. An environment that is offline keeps its old value. When a connected |
| 23 | 27 | environment 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 | 2 | |
| 3 | 3 | import { |
| 4 | 4 | planPinnedMove, |
| 5 | resolveSettledThreadTimestamp, | |
| 5 | 6 | sortPinnedThreadsByOrderKey, |
| 6 | 7 | sortThreads, |
| 7 | 8 | type ThreadSortInput, |
| ... | ... | @@ -20,6 +21,38 @@ function makeThread(overrides: Partial<TestThread> = {}): TestThread { |
| 20 | 21 | }; |
| 21 | 22 | } |
| 22 | 23 | |
| 24 | describe("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 | ||
| 23 | 56 | describe("sortThreads", () => { |
| 24 | 57 | it("falls back to updatedAt and createdAt when latestUserMessageAt is invalid and there are no messages", () => { |
| 25 | 58 | const sorted = sortThreads( |
packages/client-runtime/src/state/threadSort.ts+31-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { ProjectId } from "@t3tools/contracts"; | |
| 1 | import type { OrchestrationThreadShell, ProjectId } from "@t3tools/contracts"; | |
| 2 | 2 | import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; |
| 3 | 3 | import * as Arr from "effect/Array"; |
| 4 | 4 | import * as Order from "effect/Order"; |
| ... | ... | @@ -19,6 +19,36 @@ export function toSortableTimestamp(iso: string | undefined): number | null { |
| 19 | 19 | return Number.isFinite(ms) ? ms : null; |
| 20 | 20 | } |
| 21 | 21 | |
| 22 | export 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. */ | |
| 29 | export 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 | ||
| 22 | 52 | function getFirstSortableTimestamp(...values: Array<string | null | undefined>): number | null { |
| 23 | 53 | for (const value of values) { |
| 24 | 54 | const timestamp = toSortableTimestamp(value ?? undefined); |
packages/contracts/src/orchestration.ts+1| ... | ... | @@ -784,6 +784,7 @@ const ThreadAutoSettleCommand = Schema.Struct({ |
| 784 | 784 | commandId: CommandId, |
| 785 | 785 | threadId: ThreadId, |
| 786 | 786 | snapshotSequence: NonNegativeInt, |
| 787 | settledAt: IsoDateTime, | |
| 787 | 788 | }); |
| 788 | 789 | |
| 789 | 790 | const ThreadUnsettleCommand = Schema.Struct({ |