From 2dd62794612436e3f0d0d9c10a427ccc12efecda Mon Sep 17 00:00:00 2001 From: Rusiru Sadathana <27785781+RusiruSadathana@users.noreply.github.com> Date: Mon, 20 Jul 2026 06:22:46 +0000 Subject: [PATCH 1/9] perf(orchestration): bound in-memory read model and client per-thread state (Issue #4178) Replays PR #4176 on current main and preserves the connection catch-up path from #4177. Replace the array-backed command model with HashMap/HashSet state, evict deleted thread bodies, prune released VCS and browser caches, and clear per-thread client state. Adds focused coverage for read-model invariants, deletion behavior, client cleanup, and VCS cache eviction after the final subscriber releases. Co-authored-by: codex --- .../Layers/OrchestrationEngine.ts | 34 ++-- .../orchestration/commandInvariants.test.ts | 5 +- .../src/orchestration/commandInvariants.ts | 66 ++++---- .../orchestration/commandReadModel.test.ts | 130 +++++++++++++++ .../src/orchestration/commandReadModel.ts | 119 ++++++++++++++ .../src/orchestration/decider.delete.test.ts | 132 +++++++++++++++ apps/server/src/orchestration/decider.ts | 12 +- .../src/orchestration/projector.test.ts | 37 +++-- apps/server/src/orchestration/projector.ts | 152 ++++++++++-------- .../src/vcs/VcsStatusBroadcaster.test.ts | 15 ++ apps/server/src/vcs/VcsStatusBroadcaster.ts | 20 ++- .../src/browser/browserSurfaceStore.test.ts | 8 +- apps/web/src/browser/browserSurfaceStore.ts | 14 +- apps/web/src/hooks/useThreadActions.ts | 26 +++ apps/web/src/previewStateStore.ts | 13 +- apps/web/src/uiStateStore.test.ts | 22 +++ apps/web/src/uiStateStore.ts | 23 +++ 17 files changed, 679 insertions(+), 149 deletions(-) create mode 100644 apps/server/src/orchestration/commandReadModel.test.ts create mode 100644 apps/server/src/orchestration/commandReadModel.ts diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 19184915ac7..12af4c77b9b 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -1,9 +1,4 @@ -import type { - OrchestrationEvent, - OrchestrationReadModel, - ProjectId, - ThreadId, -} from "@t3tools/contracts"; +import type { OrchestrationEvent, ProjectId, ThreadId } from "@t3tools/contracts"; import { OrchestrationCommand } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; @@ -13,6 +8,7 @@ import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as HashMap from "effect/HashMap"; import * as Layer from "effect/Layer"; import * as Metric from "effect/Metric"; import * as Option from "effect/Option"; @@ -37,8 +33,13 @@ import { type OrchestrationDispatchError, type OrchestrationProjectorDecodeError, } from "../Errors.ts"; +import { + createEmptyCommandReadModel, + fromWireReadModel, + type CommandReadModel, +} from "../commandReadModel.ts"; import { decideOrchestrationCommand } from "../decider.ts"; -import { createEmptyReadModel, projectEvent } from "../projector.ts"; +import { projectEvent } from "../projector.ts"; import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { @@ -85,15 +86,15 @@ const makeOrchestrationEngine = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); - let commandReadModel = createEmptyReadModel(yield* nowIso); + let commandReadModel: CommandReadModel = createEmptyCommandReadModel(yield* nowIso); const commandQueue = yield* Queue.unbounded(); const eventPubSub = yield* PubSub.unbounded(); const projectEventsOntoReadModel = ( - baseReadModel: OrchestrationReadModel, + baseReadModel: CommandReadModel, events: ReadonlyArray, - ): Effect.Effect => + ): Effect.Effect => Effect.gen(function* () { let nextReadModel = baseReadModel; for (const event of events) { @@ -298,12 +299,21 @@ const makeOrchestrationEngine = Effect.gen(function* () { }; yield* projectionPipeline.bootstrap; - commandReadModel = yield* projectionSnapshotQuery.getCommandReadModel(); + // Seed the in-memory command model from the DB projection. Deleted threads + // are dropped so the model starts consistent with the projector's eviction + // policy (see commandReadModel.ts / the `thread.deleted` projector branch). + commandReadModel = fromWireReadModel(yield* projectionSnapshotQuery.getCommandReadModel(), { + dropDeletedThreads: true, + }); const worker = Effect.forever(Queue.take(commandQueue).pipe(Effect.flatMap(processEnvelope))); yield* Effect.forkScoped(worker); yield* Effect.logDebug("orchestration engine started").pipe( - Effect.annotateLogs({ sequence: commandReadModel.snapshotSequence }), + Effect.annotateLogs({ + sequence: commandReadModel.snapshotSequence, + threadCount: HashMap.size(commandReadModel.threads), + projectCount: HashMap.size(commandReadModel.projects), + }), ); const readEvents: OrchestrationEngineShape["readEvents"] = (fromSequenceExclusive, limit) => diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index 9c6c8bd2a18..750e3dbcdb9 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -11,6 +11,7 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import { fromWireReadModel } from "./commandReadModel.ts"; import { findThreadById, listThreadsByProjectId, @@ -21,7 +22,7 @@ import { const now = "2026-01-01T00:00:00.000Z"; -const readModel: OrchestrationReadModel = { +const wireReadModel: OrchestrationReadModel = { snapshotSequence: 2, updatedAt: now, projects: [ @@ -102,6 +103,8 @@ const readModel: OrchestrationReadModel = { ], }; +const readModel = fromWireReadModel(wireReadModel, { dropDeletedThreads: false }); + const messageSendCommand: OrchestrationCommand = { type: "thread.turn.start", commandId: CommandId.make("cmd-1"), diff --git a/apps/server/src/orchestration/commandInvariants.ts b/apps/server/src/orchestration/commandInvariants.ts index b59ded77f4f..e3c532ca1ed 100644 --- a/apps/server/src/orchestration/commandInvariants.ts +++ b/apps/server/src/orchestration/commandInvariants.ts @@ -1,16 +1,25 @@ import type { OrchestrationCommand, OrchestrationProject, - OrchestrationReadModel, OrchestrationThread, ProjectId, ThreadId, } from "@t3tools/contracts"; import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; import * as Effect from "effect/Effect"; +import * as HashMap from "effect/HashMap"; +import { + findProjectById, + findThreadById, + isThreadDeleted, + listThreadsByProjectId, + type CommandReadModel, +} from "./commandReadModel.ts"; import { OrchestrationCommandInvariantError } from "./Errors.ts"; +export { findProjectById, findThreadById, listThreadsByProjectId }; + function invariantError(commandType: string, detail: string): OrchestrationCommandInvariantError { return new OrchestrationCommandInvariantError({ commandType, @@ -18,29 +27,8 @@ function invariantError(commandType: string, detail: string): OrchestrationComma }); } -export function findThreadById( - readModel: OrchestrationReadModel, - threadId: ThreadId, -): OrchestrationThread | undefined { - return readModel.threads.find((thread) => thread.id === threadId); -} - -export function findProjectById( - readModel: OrchestrationReadModel, - projectId: ProjectId, -): OrchestrationProject | undefined { - return readModel.projects.find((project) => project.id === projectId); -} - -export function listThreadsByProjectId( - readModel: OrchestrationReadModel, - projectId: ProjectId, -): ReadonlyArray { - return readModel.threads.filter((thread) => thread.projectId === projectId); -} - export function requireProject(input: { - readonly readModel: OrchestrationReadModel; + readonly readModel: CommandReadModel; readonly command: OrchestrationCommand; readonly projectId: ProjectId; }): Effect.Effect { @@ -57,7 +45,7 @@ export function requireProject(input: { } export function requireProjectAbsent(input: { - readonly readModel: OrchestrationReadModel; + readonly readModel: CommandReadModel; readonly command: OrchestrationCommand; readonly projectId: ProjectId; }): Effect.Effect { @@ -73,18 +61,23 @@ export function requireProjectAbsent(input: { } export function requireActiveProjectWorkspaceRootAbsent(input: { - readonly readModel: OrchestrationReadModel; + readonly readModel: CommandReadModel; readonly command: OrchestrationCommand; readonly workspaceRoot: string; readonly exceptProjectId?: ProjectId; }): Effect.Effect { const normalizedWorkspaceRoot = normalizeProjectPathForComparison(input.workspaceRoot); - const existingProject = input.readModel.projects.find( - (project) => + let existingProject: OrchestrationProject | undefined; + for (const project of HashMap.values(input.readModel.projects)) { + if ( project.deletedAt === null && normalizeProjectPathForComparison(project.workspaceRoot) === normalizedWorkspaceRoot && - project.id !== input.exceptProjectId, - ); + project.id !== input.exceptProjectId + ) { + existingProject = project; + break; + } + } if (existingProject === undefined) { return Effect.void; } @@ -97,7 +90,7 @@ export function requireActiveProjectWorkspaceRootAbsent(input: { } export function requireThread(input: { - readonly readModel: OrchestrationReadModel; + readonly readModel: CommandReadModel; readonly command: OrchestrationCommand; readonly threadId: ThreadId; }): Effect.Effect { @@ -114,7 +107,7 @@ export function requireThread(input: { } export function requireThreadArchived(input: { - readonly readModel: OrchestrationReadModel; + readonly readModel: CommandReadModel; readonly command: OrchestrationCommand; readonly threadId: ThreadId; }): Effect.Effect { @@ -133,7 +126,7 @@ export function requireThreadArchived(input: { } export function requireThreadNotArchived(input: { - readonly readModel: OrchestrationReadModel; + readonly readModel: CommandReadModel; readonly command: OrchestrationCommand; readonly threadId: ThreadId; }): Effect.Effect { @@ -152,11 +145,16 @@ export function requireThreadNotArchived(input: { } export function requireThreadAbsent(input: { - readonly readModel: OrchestrationReadModel; + readonly readModel: CommandReadModel; readonly command: OrchestrationCommand; readonly threadId: ThreadId; }): Effect.Effect { - if (!findThreadById(input.readModel, input.threadId)) { + // A deleted thread is evicted from `threads` but its id is retained in + // `deletedThreadIds`, so reject re-using a live OR previously-deleted id. + if ( + !findThreadById(input.readModel, input.threadId) && + !isThreadDeleted(input.readModel, input.threadId) + ) { return Effect.void; } return Effect.fail( diff --git a/apps/server/src/orchestration/commandReadModel.test.ts b/apps/server/src/orchestration/commandReadModel.test.ts new file mode 100644 index 00000000000..d971e0cabae --- /dev/null +++ b/apps/server/src/orchestration/commandReadModel.test.ts @@ -0,0 +1,130 @@ +import { + DEFAULT_PROVIDER_INTERACTION_MODE, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationReadModel, + type OrchestrationThread, +} from "@t3tools/contracts"; +import * as HashMap from "effect/HashMap"; +import { describe, expect, it } from "vite-plus/test"; + +import { + createEmptyCommandReadModel, + findProjectById, + findThreadById, + fromWireReadModel, + isThreadDeleted, + listThreadsByProjectId, +} from "./commandReadModel.ts"; + +const now = "2026-01-01T00:00:00.000Z"; + +function makeThread( + id: string, + projectId: string, + overrides?: Partial, +): OrchestrationThread { + return { + id: ThreadId.make(id), + projectId: ProjectId.make(projectId), + title: id, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + latestTurn: null, + messages: [], + session: null, + activities: [], + proposedPlans: [], + checkpoints: [], + deletedAt: null, + ...overrides, + }; +} + +const wireReadModel: OrchestrationReadModel = { + snapshotSequence: 5, + updatedAt: now, + projects: [ + { + id: ProjectId.make("project-a"), + title: "Project A", + workspaceRoot: "/tmp/project-a", + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + scripts: [], + createdAt: now, + updatedAt: now, + deletedAt: null, + }, + ], + threads: [ + makeThread("thread-live", "project-a"), + makeThread("thread-archived", "project-a", { archivedAt: now }), + makeThread("thread-deleted", "project-a", { deletedAt: now }), + makeThread("thread-other", "project-b"), + ], +}; + +describe("commandReadModel", () => { + it("creates an empty model", () => { + const model = createEmptyCommandReadModel(now); + expect(model.snapshotSequence).toBe(0); + expect(HashMap.size(model.threads)).toBe(0); + expect(HashMap.size(model.projects)).toBe(0); + expect(model.updatedAt).toBe(now); + }); + + it("seeds from the wire model and drops deleted threads by default", () => { + const model = fromWireReadModel(wireReadModel); + expect(model.snapshotSequence).toBe(5); + // deleted thread is evicted; archived + live + other retained + expect(HashMap.size(model.threads)).toBe(3); + expect(HashMap.has(model.threads, ThreadId.make("thread-deleted"))).toBe(false); + expect(HashMap.has(model.threads, ThreadId.make("thread-archived"))).toBe(true); + expect(HashMap.has(model.threads, ThreadId.make("thread-live"))).toBe(true); + expect(HashMap.size(model.projects)).toBe(1); + // The evicted deleted thread's id is retained so the create-twice invariant + // survives a restart. + expect(isThreadDeleted(model, ThreadId.make("thread-deleted"))).toBe(true); + expect(isThreadDeleted(model, ThreadId.make("thread-live"))).toBe(false); + expect(isThreadDeleted(model, ThreadId.make("thread-archived"))).toBe(false); + }); + + it("retains deleted threads when dropDeletedThreads is false", () => { + const model = fromWireReadModel(wireReadModel, { dropDeletedThreads: false }); + expect(HashMap.size(model.threads)).toBe(4); + expect(HashMap.has(model.threads, ThreadId.make("thread-deleted"))).toBe(true); + }); + + it("finds threads and projects by id with O(1) lookups", () => { + const model = fromWireReadModel(wireReadModel); + expect(findThreadById(model, ThreadId.make("thread-live"))?.projectId).toBe("project-a"); + expect(findThreadById(model, ThreadId.make("thread-deleted"))).toBeUndefined(); + expect(findThreadById(model, ThreadId.make("missing"))).toBeUndefined(); + expect(findProjectById(model, ProjectId.make("project-a"))?.title).toBe("Project A"); + expect(findProjectById(model, ProjectId.make("missing"))).toBeUndefined(); + }); + + it("lists threads by project id", () => { + const model = fromWireReadModel(wireReadModel); + const ids = listThreadsByProjectId(model, ProjectId.make("project-a")) + .map((thread) => thread.id) + .toSorted(); + expect(ids).toEqual([ThreadId.make("thread-archived"), ThreadId.make("thread-live")]); + expect(listThreadsByProjectId(model, ProjectId.make("project-b")).map((t) => t.id)).toEqual([ + ThreadId.make("thread-other"), + ]); + }); +}); diff --git a/apps/server/src/orchestration/commandReadModel.ts b/apps/server/src/orchestration/commandReadModel.ts new file mode 100644 index 00000000000..dce19fc2842 --- /dev/null +++ b/apps/server/src/orchestration/commandReadModel.ts @@ -0,0 +1,119 @@ +import type { + OrchestrationProject, + OrchestrationReadModel, + OrchestrationThread, + ProjectId, + ThreadId, +} from "@t3tools/contracts"; +import * as HashMap from "effect/HashMap"; +import * as HashSet from "effect/HashSet"; +import * as Option from "effect/Option"; + +/** + * Server-internal representation of the orchestration read model. + * + * Unlike the wire {@link OrchestrationReadModel} (which uses arrays and is + * produced by the DB-backed `ProjectionSnapshotQuery`), this model is only ever + * touched by the single serial command-worker fiber in `OrchestrationEngine`. + * It uses persistent `HashMap`s keyed by id so the projector and command + * invariants get O(1)-ish lookups and single-key updates instead of O(N) + * array scans and full-array copies on every event. + * + * The model is never serialized or sent over the wire, so its shape can evolve + * independently of the contract. + */ +export interface CommandReadModel { + readonly snapshotSequence: number; + readonly projects: HashMap.HashMap; + readonly threads: HashMap.HashMap; + /** + * Ids of threads that have been deleted. Deleted threads are evicted from + * `threads` (freeing their message/activity/checkpoint arrays), but their id + * is retained here so `requireThreadAbsent` still rejects re-creating a + * thread with a previously-used id — the "cannot be created twice" invariant + * the DB projection also upholds (its tombstone row is never removed). + */ + readonly deletedThreadIds: HashSet.HashSet; + readonly updatedAt: string; +} + +export function createEmptyCommandReadModel(nowIso: string): CommandReadModel { + return { + snapshotSequence: 0, + projects: HashMap.empty(), + threads: HashMap.empty(), + deletedThreadIds: HashSet.empty(), + updatedAt: nowIso, + }; +} + +/** + * Whether a thread id has been used and deleted. Used by `requireThreadAbsent` + * so an evicted (deleted) thread's id cannot be re-created. + */ +export function isThreadDeleted(model: CommandReadModel, threadId: ThreadId): boolean { + return HashSet.has(model.deletedThreadIds, threadId); +} + +/** + * Seed a {@link CommandReadModel} from the array-based wire read model produced + * by `ProjectionSnapshotQuery.getCommandReadModel()` at engine boot. + * + * Deleted threads are dropped so the in-memory model starts consistent with the + * projector's eviction policy (deleted threads are removed, archived threads are + * retained). The DB projection remains the source of truth for deleted rows. + */ +export function fromWireReadModel( + model: OrchestrationReadModel, + options?: { readonly dropDeletedThreads?: boolean }, +): CommandReadModel { + const dropDeletedThreads = options?.dropDeletedThreads ?? true; + const threads = dropDeletedThreads + ? model.threads.filter((thread) => thread.deletedAt === null) + : model.threads; + // Retain the ids of deleted threads so the create-twice invariant survives + // a restart even though the (evicted) thread bodies are not loaded. + const deletedThreadIds = HashSet.fromIterable( + model.threads.filter((thread) => thread.deletedAt !== null).map((thread) => thread.id), + ); + return { + snapshotSequence: model.snapshotSequence, + updatedAt: model.updatedAt, + projects: HashMap.fromIterable(model.projects.map((project) => [project.id, project] as const)), + threads: HashMap.fromIterable(threads.map((thread) => [thread.id, thread] as const)), + deletedThreadIds, + }; +} + +export function findThreadById( + model: CommandReadModel, + threadId: ThreadId, +): OrchestrationThread | undefined { + return Option.getOrUndefined(HashMap.get(model.threads, threadId)); +} + +export function findProjectById( + model: CommandReadModel, + projectId: ProjectId, +): OrchestrationProject | undefined { + return Option.getOrUndefined(HashMap.get(model.projects, projectId)); +} + +export function listThreadsByProjectId( + model: CommandReadModel, + projectId: ProjectId, +): ReadonlyArray { + const result: OrchestrationThread[] = []; + for (const thread of HashMap.values(model.threads)) { + if (thread.projectId === projectId) { + result.push(thread); + } + } + // HashMap iteration order is not insertion order; sort by creation time (then + // id) so callers observe a stable, deterministic order — matching the prior + // array-backed behavior. Only used by the rare `project.delete` fan-out. + return result.toSorted( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), + ); +} diff --git a/apps/server/src/orchestration/decider.delete.test.ts b/apps/server/src/orchestration/decider.delete.test.ts index fea36b5717f..e9f881d1e78 100644 --- a/apps/server/src/orchestration/decider.delete.test.ts +++ b/apps/server/src/orchestration/decider.delete.test.ts @@ -2,6 +2,7 @@ import { CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, EventId, + MessageId, ProjectId, ThreadId, type OrchestrationCommand, @@ -9,6 +10,7 @@ import { ProviderInstanceId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as HashMap from "effect/HashMap"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; @@ -214,4 +216,134 @@ it.layer(NodeServices.layer)("decider deletion flows", (it) => { expect(normalizeDeleteEvent(forcedResult)).toEqual(normalizeDeleteEvent(sequentialEvents)); }), ); + + it.effect("rejects commands targeting an already-deleted (evicted) thread", () => + Effect.gen(function* () { + const seeded = yield* seedReadModel; + const now = "2026-01-01T00:00:00.000Z"; + + // Delete thread-delete-1; the projector evicts it from the model. + const afterDelete = yield* projectEvent(seeded, { + sequence: 4, + eventId: asEventId("evt-thread-delete-1"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-delete-1"), + type: "thread.deleted", + occurredAt: now, + commandId: asCommandId("cmd-thread-delete-1"), + causationEventId: null, + correlationId: asCommandId("cmd-thread-delete-1"), + metadata: {}, + payload: { + threadId: asThreadId("thread-delete-1"), + deletedAt: now, + }, + }); + + // A follow-up command to the deleted thread now fails cleanly. + const error = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: asCommandId("cmd-turn-after-delete"), + threadId: asThreadId("thread-delete-1"), + message: { + messageId: MessageId.make("msg-after-delete"), + role: "user", + text: "hello", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }, + readModel: afterDelete, + }), + ); + expect(error.message).toContain("does not exist"); + + // Re-creating a thread with the SAME (deleted) id is rejected: the id is + // retained in deletedThreadIds even though the thread body was evicted, so + // the "cannot be created twice" invariant still holds and the durable DB + // row is not silently overwritten. + const recreateSameId = (threadId: ThreadId) => + decideOrchestrationCommand({ + command: { + type: "thread.create", + commandId: asCommandId(`cmd-recreate-${threadId}`), + threadId, + projectId: asProjectId("project-delete"), + title: "Recreate", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: now, + }, + readModel: afterDelete, + }); + + const recreateError = yield* Effect.flip(recreateSameId(asThreadId("thread-delete-1"))); + expect(recreateError.message).toContain("cannot be created twice"); + + // A fresh thread with a NEW, never-used id can still be created. + const created = yield* recreateSameId(asThreadId("thread-delete-3")); + const createdEvents = Array.isArray(created) ? created : [created]; + expect(createdEvents.map((event) => event.type)).toEqual(["thread.created"]); + }), + ); + + it.effect("projector evicts deleted threads but retains archived threads", () => + Effect.gen(function* () { + const seeded = yield* seedReadModel; + const now = "2026-01-01T00:00:00.000Z"; + expect(HashMap.size(seeded.threads)).toBe(2); + + // Archiving keeps the thread resident (unarchive/other commands need it). + const afterArchive = yield* projectEvent(seeded, { + sequence: 4, + eventId: asEventId("evt-thread-archive-1"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-delete-1"), + type: "thread.archived", + occurredAt: now, + commandId: asCommandId("cmd-thread-archive-1"), + causationEventId: null, + correlationId: asCommandId("cmd-thread-archive-1"), + metadata: {}, + payload: { + threadId: asThreadId("thread-delete-1"), + archivedAt: now, + updatedAt: now, + }, + }); + expect(HashMap.size(afterArchive.threads)).toBe(2); + expect(HashMap.has(afterArchive.threads, asThreadId("thread-delete-1"))).toBe(true); + + // Deleting evicts the thread from the in-memory model entirely. + const afterDelete = yield* projectEvent(afterArchive, { + sequence: 5, + eventId: asEventId("evt-thread-delete-2"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-delete-2"), + type: "thread.deleted", + occurredAt: now, + commandId: asCommandId("cmd-thread-delete-2"), + causationEventId: null, + correlationId: asCommandId("cmd-thread-delete-2"), + metadata: {}, + payload: { + threadId: asThreadId("thread-delete-2"), + deletedAt: now, + }, + }); + expect(HashMap.size(afterDelete.threads)).toBe(1); + expect(HashMap.has(afterDelete.threads, asThreadId("thread-delete-2"))).toBe(false); + expect(HashMap.has(afterDelete.threads, asThreadId("thread-delete-1"))).toBe(true); + }), + ); }); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index d71470a3066..746ae92c68d 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -1,15 +1,11 @@ -import { - EventId, - type OrchestrationCommand, - type OrchestrationEvent, - type OrchestrationReadModel, -} from "@t3tools/contracts"; +import { EventId, type OrchestrationCommand, type OrchestrationEvent } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import { resolveThreadContext } from "@t3tools/shared/threadContext"; import type * as PlatformError from "effect/PlatformError"; +import type { CommandReadModel } from "./commandReadModel.ts"; import { OrchestrationCommandInvariantError } from "./Errors.ts"; import { listThreadsByProjectId, @@ -66,7 +62,7 @@ const decideCommandSequence = Effect.fn("decideCommandSequence")(function* ({ readModel, }: { readonly commands: ReadonlyArray; - readonly readModel: OrchestrationReadModel; + readonly readModel: CommandReadModel; }): Effect.fn.Return< ReadonlyArray, OrchestrationCommandInvariantError | PlatformError.PlatformError, @@ -100,7 +96,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" readModel, }: { readonly command: OrchestrationCommand; - readonly readModel: OrchestrationReadModel; + readonly readModel: CommandReadModel; }): Effect.fn.Return< DecideOrchestrationCommandResult, OrchestrationCommandInvariantError | PlatformError.PlatformError, diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index db8e552c0f7..dc51a0659ad 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -5,12 +5,25 @@ import { ProviderDriverKind, ThreadId, type OrchestrationEvent, + type OrchestrationThread, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as HashMap from "effect/HashMap"; import { describe, expect, it } from "vite-plus/test"; +import type { CommandReadModel } from "./commandReadModel.ts"; import { createEmptyReadModel, projectEvent } from "./projector.ts"; +/** Thread list from the map, for assertions that previously used an array. */ +function threadsArray(model: CommandReadModel): ReadonlyArray { + return Array.from(HashMap.values(model.threads)); +} + +/** First thread in the map (insertion-order-independent tests use a single thread). */ +function firstThread(model: CommandReadModel): OrchestrationThread | undefined { + return threadsArray(model)[0]; +} + function makeEvent(input: { sequence: number; type: OrchestrationEvent["type"]; @@ -72,7 +85,7 @@ describe("orchestration projector", () => { ); expect(next.snapshotSequence).toBe(1); - expect(next.threads).toEqual([ + expect(threadsArray(next)).toEqual([ { id: "thread-1", projectId: "project-1", @@ -184,7 +197,7 @@ describe("orchestration projector", () => { }), ), ); - expect(archived.threads[0]?.archivedAt).toBe(later); + expect(firstThread(archived)?.archivedAt).toBe(later); const unarchived = await Effect.runPromise( projectEvent( @@ -203,7 +216,7 @@ describe("orchestration projector", () => { }), ), ); - expect(unarchived.threads[0]?.archivedAt).toBeNull(); + expect(firstThread(unarchived)?.archivedAt).toBeNull(); }); it("keeps projector forward-compatible for unhandled event types", async () => { @@ -232,7 +245,7 @@ describe("orchestration projector", () => { expect(next.snapshotSequence).toBe(7); expect(next.updatedAt).toBe("2026-01-01T00:00:00.000Z"); - expect(next.threads).toEqual([]); + expect(threadsArray(next)).toEqual([]); }); it("tracks latest turn id from session lifecycle events", async () => { @@ -328,13 +341,13 @@ describe("orchestration projector", () => { ), ); - const thread = afterRunning.threads[0]; + const thread = firstThread(afterRunning); expect(thread?.latestTurn?.turnId).toBe("turn-1"); expect(thread?.session?.status).toBe("running"); // Leaving the "running" session status settles the running turn with the // session timestamp as the turn end. - const settledThread = afterReady.threads[0]; + const settledThread = firstThread(afterReady); expect(settledThread?.latestTurn?.turnId).toBe("turn-1"); expect(settledThread?.latestTurn?.state).toBe("completed"); expect(settledThread?.latestTurn?.completedAt).toBe(settledAt); @@ -392,8 +405,8 @@ describe("orchestration projector", () => { ), ); - expect(afterUpdate.threads[0]?.runtimeMode).toBe("approval-required"); - expect(afterUpdate.threads[0]?.updatedAt).toBe(updatedAt); + expect(firstThread(afterUpdate)?.runtimeMode).toBe("approval-required"); + expect(firstThread(afterUpdate)?.updatedAt).toBe(updatedAt); }); it("marks assistant messages completed with non-streaming updates", async () => { @@ -478,7 +491,7 @@ describe("orchestration projector", () => { ), ); - const message = afterComplete.threads[0]?.messages[0]; + const message = firstThread(afterComplete)?.messages[0]; expect(message?.id).toBe("assistant:msg-1"); expect(message?.text).toBe("hello"); expect(message?.streaming).toBe(false); @@ -686,7 +699,7 @@ describe("orchestration projector", () => { Promise.resolve(afterCreate), ); - const thread = afterRevert.threads[0]; + const thread = firstThread(afterRevert); expect(thread?.messages.map((message) => ({ role: message.role, text: message.text }))).toEqual( [ { role: "user", text: "First edit" }, @@ -843,7 +856,7 @@ describe("orchestration projector", () => { Promise.resolve(afterCreate), ); - const thread = afterRevert.threads[0]; + const thread = firstThread(afterRevert); expect( thread?.messages.map((message) => ({ id: message.id, @@ -945,7 +958,7 @@ describe("orchestration projector", () => { Promise.resolve(afterMessages), ); - const thread = finalState.threads[0]; + const thread = firstThread(finalState); expect(thread?.messages).toHaveLength(2_000); expect(thread?.messages[0]?.id).toBe("msg-100"); expect(thread?.messages.at(-1)?.id).toBe("msg-2099"); diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index ebb7e57d250..2a23e8106d9 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -1,4 +1,4 @@ -import type { OrchestrationEvent, OrchestrationReadModel, ThreadId } from "@t3tools/contracts"; +import type { OrchestrationEvent, OrchestrationProject, ThreadId } from "@t3tools/contracts"; import { OrchestrationCheckpointSummary, OrchestrationMessage, @@ -6,8 +6,16 @@ import { OrchestrationThread, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as HashMap from "effect/HashMap"; +import * as HashSet from "effect/HashSet"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import { + createEmptyCommandReadModel, + findThreadById, + type CommandReadModel, +} from "./commandReadModel.ts"; import { toProjectorDecodeError, type OrchestrationProjectorDecodeError } from "./Errors.ts"; import { MessageSentPayloadSchema, @@ -61,12 +69,40 @@ function settledTurnStateForSessionStatus( } } +/** + * Apply a patch to a single thread, keyed by id. No-op if the thread is absent + * (mirrors the previous array-map behavior, which left the collection unchanged + * when no entry matched). + */ function updateThread( - threads: ReadonlyArray, + threads: HashMap.HashMap, threadId: ThreadId, patch: ThreadPatch, -): OrchestrationThread[] { - return threads.map((thread) => (thread.id === threadId ? { ...thread, ...patch } : thread)); +): HashMap.HashMap { + const existing = HashMap.get(threads, threadId); + if (Option.isNone(existing)) { + return threads; + } + return HashMap.set(threads, threadId, { ...existing.value, ...patch }); +} + +/** + * Apply an update function to a single project in the model, keyed by id. No-op + * if the project is absent. + */ +function updateProject( + model: CommandReadModel, + projectId: OrchestrationProject["id"], + update: (project: OrchestrationProject) => OrchestrationProject, +): CommandReadModel { + const existing = HashMap.get(model.projects, projectId); + if (Option.isNone(existing)) { + return model; + } + return { + ...model, + projects: HashMap.set(model.projects, projectId, update(existing.value)), + }; } function decodeForEvent( @@ -178,20 +214,15 @@ function compareThreadActivities( return left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id); } -export function createEmptyReadModel(nowIso: string): OrchestrationReadModel { - return { - snapshotSequence: 0, - projects: [], - threads: [], - updatedAt: nowIso, - }; +export function createEmptyReadModel(nowIso: string): CommandReadModel { + return createEmptyCommandReadModel(nowIso); } export function projectEvent( - model: OrchestrationReadModel, + model: CommandReadModel, event: OrchestrationEvent, -): Effect.Effect { - const nextBase: OrchestrationReadModel = { +): Effect.Effect { + const nextBase: CommandReadModel = { ...model, snapshotSequence: event.sequence, updatedAt: event.occurredAt, @@ -201,8 +232,7 @@ export function projectEvent( case "project.created": return decodeForEvent(ProjectCreatedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => { - const existing = nextBase.projects.find((entry) => entry.id === payload.projectId); - const nextProject = { + const nextProject: OrchestrationProject = { id: payload.projectId, title: payload.title, workspaceRoot: payload.workspaceRoot, @@ -215,52 +245,38 @@ export function projectEvent( return { ...nextBase, - projects: existing - ? nextBase.projects.map((entry) => - entry.id === payload.projectId ? nextProject : entry, - ) - : [...nextBase.projects, nextProject], + projects: HashMap.set(nextBase.projects, payload.projectId, nextProject), }; }), ); case "project.meta-updated": return decodeForEvent(ProjectMetaUpdatedPayload, event.payload, event.type, "payload").pipe( - Effect.map((payload) => ({ - ...nextBase, - projects: nextBase.projects.map((project) => - project.id === payload.projectId - ? { - ...project, - ...(payload.title !== undefined ? { title: payload.title } : {}), - ...(payload.workspaceRoot !== undefined - ? { workspaceRoot: payload.workspaceRoot } - : {}), - ...(payload.defaultModelSelection !== undefined - ? { defaultModelSelection: payload.defaultModelSelection } - : {}), - ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}), - updatedAt: payload.updatedAt, - } - : project, - ), - })), + Effect.map((payload) => + updateProject(nextBase, payload.projectId, (project) => ({ + ...project, + ...(payload.title !== undefined ? { title: payload.title } : {}), + ...(payload.workspaceRoot !== undefined + ? { workspaceRoot: payload.workspaceRoot } + : {}), + ...(payload.defaultModelSelection !== undefined + ? { defaultModelSelection: payload.defaultModelSelection } + : {}), + ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}), + updatedAt: payload.updatedAt, + })), + ), ); case "project.deleted": return decodeForEvent(ProjectDeletedPayload, event.payload, event.type, "payload").pipe( - Effect.map((payload) => ({ - ...nextBase, - projects: nextBase.projects.map((project) => - project.id === payload.projectId - ? { - ...project, - deletedAt: payload.deletedAt, - updatedAt: payload.deletedAt, - } - : project, - ), - })), + Effect.map((payload) => + updateProject(nextBase, payload.projectId, (project) => ({ + ...project, + deletedAt: payload.deletedAt, + updatedAt: payload.deletedAt, + })), + ), ); case "thread.created": @@ -300,23 +316,27 @@ export function projectEvent( event.type, "thread", ); - const existing = nextBase.threads.find((entry) => entry.id === thread.id); return { ...nextBase, - threads: existing - ? nextBase.threads.map((entry) => (entry.id === thread.id ? thread : entry)) - : [...nextBase.threads, thread], + threads: HashMap.set(nextBase.threads, thread.id, thread), }; }); case "thread.deleted": + // Evict deleted threads from the in-memory model entirely rather than + // tombstoning them. The DB projection retains the row (it is the source + // of truth for downstream/HTTP reads and cleanup reactors consume the + // event stream, not this model), and no command legitimately targets an + // already-deleted thread. This is the primary fix for unbounded growth: + // a deleted thread's messages/activities/checkpoints are freed and it no + // longer costs anything on every subsequent event. The id is recorded in + // `deletedThreadIds` so `requireThreadAbsent` still rejects re-creating a + // thread with a previously-used id (the invariant the DB tombstone kept). return decodeForEvent(ThreadDeletedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => ({ ...nextBase, - threads: updateThread(nextBase.threads, payload.threadId, { - deletedAt: payload.deletedAt, - updatedAt: payload.deletedAt, - }), + threads: HashMap.remove(nextBase.threads, payload.threadId), + deletedThreadIds: HashSet.add(nextBase.deletedThreadIds, payload.threadId), })), ); @@ -393,7 +413,7 @@ export function projectEvent( event.type, "payload", ); - const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + const thread = findThreadById(nextBase, payload.threadId); if (!thread) { return nextBase; } @@ -454,7 +474,7 @@ export function projectEvent( event.type, "payload", ); - const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + const thread = findThreadById(nextBase, payload.threadId); if (!thread) { return nextBase; } @@ -517,7 +537,7 @@ export function projectEvent( event.type, "payload", ); - const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + const thread = findThreadById(nextBase, payload.threadId); if (!thread) { return nextBase; } @@ -549,7 +569,7 @@ export function projectEvent( event.type, "payload", ); - const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + const thread = findThreadById(nextBase, payload.threadId); if (!thread) { return nextBase; } @@ -619,7 +639,7 @@ export function projectEvent( case "thread.reverted": return decodeForEvent(ThreadRevertedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => { - const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + const thread = findThreadById(nextBase, payload.threadId); if (!thread) { return nextBase; } @@ -675,7 +695,7 @@ export function projectEvent( "payload", ).pipe( Effect.map((payload) => { - const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + const thread = findThreadById(nextBase, payload.threadId); if (!thread) { return nextBase; } diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 031aff3a283..bfea2a63aee 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -763,6 +763,7 @@ describe("VcsStatusBroadcaster", () => { yield* Deferred.await(remoteStarted); assert.equal(state.remoteStatusCalls, 1); + assert.equal(state.localStatusCalls, 1); yield* Scope.close(firstScope, Exit.void); assert.isTrue(Option.isNone(yield* Deferred.poll(remoteInterrupted))); @@ -770,6 +771,20 @@ describe("VcsStatusBroadcaster", () => { yield* Scope.close(secondScope, Exit.void).pipe(Effect.forkScoped); yield* Deferred.await(remoteInterrupted); assert.isTrue(Option.isSome(yield* Deferred.poll(remoteInterrupted))); + + const nextSnapshot = yield* Deferred.make(); + const nextScope = yield* Scope.make(); + yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => + event._tag === "snapshot" + ? Deferred.succeed(nextSnapshot, event).pipe(Effect.ignore) + : Effect.void, + ).pipe(Effect.forkIn(nextScope)); + yield* Deferred.await(nextSnapshot); + + // Releasing the final poller also evicts its cwd cache entry, so a later + // subscription reloads local status instead of retaining state forever. + assert.equal(state.localStatusCalls, 2); + yield* Scope.close(nextScope, Exit.void); }).pipe(Effect.provide(testLayer)); }); }); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 56b36753b6e..9b311651e62 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -471,10 +471,10 @@ export const make = Effect.gen(function* () { const releaseRemotePoller = Effect.fn("VcsStatusBroadcaster.releaseRemotePoller")(function* ( cwd: string, ) { - const pollerToInterrupt = yield* SynchronizedRef.modify(pollersRef, (activePollers) => { + const pollerToInterrupt = yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { const existing = activePollers.get(cwd); if (!existing) { - return [null, activePollers] as const; + return Effect.succeed([null, activePollers] as const); } if (existing.subscriberCount > 1) { @@ -483,12 +483,24 @@ export const make = Effect.gen(function* () { ...existing, subscriberCount: existing.subscriberCount - 1, }); - return [null, nextPollers] as const; + return Effect.succeed([null, nextPollers] as const); } const nextPollers = new Map(activePollers); nextPollers.delete(cwd); - return [existing.fiber, nextPollers] as const; + // Drop the cached status for this cwd in the same critical section that + // removes the poller, so a concurrent retainRemotePoller (which reloads + // the cache and installs a fresh poller) cannot have its new entry wiped + // by this release. Otherwise the cache grows one entry per cwd for the + // broadcaster's lifetime; a future subscriber re-loads and re-seeds it. + return Ref.update(cacheRef, (cache) => { + if (!cache.has(cwd)) { + return cache; + } + const nextCache = new Map(cache); + nextCache.delete(cwd); + return nextCache; + }).pipe(Effect.as([existing.fiber, nextPollers] as const)); }); if (pollerToInterrupt) { diff --git a/apps/web/src/browser/browserSurfaceStore.test.ts b/apps/web/src/browser/browserSurfaceStore.test.ts index ecfce8cb432..b17467d9bb5 100644 --- a/apps/web/src/browser/browserSurfaceStore.test.ts +++ b/apps/web/src/browser/browserSurfaceStore.test.ts @@ -62,17 +62,15 @@ describe("browserSurfaceStore", () => { }); }); - it("hides a surface when its current lease is released", () => { + it("removes a surface entry when its current lease is released", () => { const tabId = "released-browser-surface"; const lease = acquireBrowserSurface(tabId); lease.present({ x: 10, y: 20, width: 900, height: 640 }, true); lease.release(); + // A stale present() from the released lease must not resurrect the entry. lease.present({ x: 0, y: 0, width: 1, height: 1 }, true); - expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({ - visible: false, - owner: null, - }); + expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toBeUndefined(); }); }); diff --git a/apps/web/src/browser/browserSurfaceStore.ts b/apps/web/src/browser/browserSurfaceStore.ts index 58012a11a30..d023896cd4f 100644 --- a/apps/web/src/browser/browserSurfaceStore.ts +++ b/apps/web/src/browser/browserSurfaceStore.ts @@ -142,12 +142,14 @@ export const useBrowserSurfaceStore = create()((set) = set((state) => { const current = state.byTabId[tabId]; if (current?.owner !== owner) return state; - return { - byTabId: { - ...state.byTabId, - [tabId]: { ...current, visible: false, updatedAt: Date.now(), owner: null }, - }, - }; + // Delete the entry entirely instead of leaving a released tombstone + // ({ visible: false, owner: null }). Readers only consider `visible` + // entries, so removal is behavior-preserving, and it stops byTabId from + // accumulating one dead entry per preview tab ever opened. A later + // re-claim of the same tabId starts fresh, which is correct since release + // means the surface was torn down. + const { [tabId]: _released, ...byTabId } = state.byTabId; + return { byTabId }; }), })); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index e14cff481b3..a6e5e1df0a0 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -1,5 +1,6 @@ import { parseScopedThreadKey, + scopedThreadKey, scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime/environment"; @@ -13,6 +14,10 @@ import { useCallback, useMemo, useRef } from "react"; import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic"; import { useComposerDraftStore } from "../composerDraftStore"; +import { useDiffPanelStore } from "../diffPanelStore"; +import { removePreviewThread } from "../previewStateStore"; +import { useRightPanelStore } from "../rightPanelStore"; +import { useUiStateStore } from "../uiStateStore"; import { terminalEnvironment } from "../state/terminal"; import { threadEnvironment } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; @@ -43,6 +48,23 @@ export class ThreadArchiveBlockedError extends Schema.TaggedErrorClass(EMPTY_THREAD_PREVIEW Atom.withLabel("preview:empty-thread"), ); +// Previously `Atom.keepAlive`, which pinned one atom node per thread key ever +// visited in the registry for the process lifetime — an unbounded leak in a +// long-lived tab. Idle-TTL lets the registry evict a thread's preview atom once +// nothing observes it, matching the pattern used for other per-resource atom +// families (see browser/previewWebviewConfigState.ts). Threads with live +// preview sessions stay resident because the observed aggregate +// `activePreviewSessionsAtom` reads them, keeping them out of the idle state; +// only closed/idle threads are collected. An evicted idle thread re-seeds from +// EMPTY and is re-populated by the next server snapshot, so eviction is safe. +const PREVIEW_STATE_IDLE_TTL_MS = 5 * 60_000; + export const previewStateAtom = Atom.family((threadKey: string) => Atom.make(EMPTY_THREAD_PREVIEW_STATE).pipe( - Atom.keepAlive, + Atom.setIdleTTL(PREVIEW_STATE_IDLE_TTL_MS), Atom.withLabel(`preview:thread:${threadKey}`), ), ); diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index 0fbbd79ec27..2052d24a40a 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -9,6 +9,7 @@ import { PERSISTED_STATE_KEY, type PersistedUiState, persistState, + removeThreadUiState, reorderProjects, resolveProjectExpanded, setDefaultAdvertisedEndpointKey, @@ -39,6 +40,27 @@ describe("uiStateStore pure functions", () => { expect(markThreadVisited(visited, threadId, "not-a-date")).toBe(visited); }); + it("removes all per-thread ui state for a deleted thread", () => { + const threadId = ThreadId.make("thread-1"); + const otherId = ThreadId.make("thread-2"); + const state = makeUiState({ + threadLastVisitedAtById: { + [threadId]: "2026-02-25T12:30:00.000Z", + [otherId]: "2026-02-25T12:31:00.000Z", + }, + threadChangedFilesExpandedById: { + [threadId]: { "turn-1": false }, + [otherId]: { "turn-1": false }, + }, + }); + + const next = removeThreadUiState(state, threadId); + expect(next.threadLastVisitedAtById).toEqual({ [otherId]: "2026-02-25T12:31:00.000Z" }); + expect(next.threadChangedFilesExpandedById).toEqual({ [otherId]: { "turn-1": false } }); + // No-op (same reference) when the thread has no state. + expect(removeThreadUiState(next, threadId)).toBe(next); + }); + it("marks a completed thread unread using the server completion timestamp", () => { const threadId = ThreadId.make("thread-1"); const initialState = makeUiState({ diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 4a97f0542b4..1a2d07b8786 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -274,6 +274,27 @@ export function markThreadUnread( }; } +/** + * Drop all per-thread UI state for a deleted thread. Without this, both + * `threadLastVisitedAtById` and `threadChangedFilesExpandedById` grew one entry + * per thread ever visited and were persisted to localStorage indefinitely. + */ +export function removeThreadUiState(state: UiState, threadId: string): UiState { + const hasVisited = threadId in state.threadLastVisitedAtById; + const hasChangedFiles = threadId in state.threadChangedFilesExpandedById; + if (!hasVisited && !hasChangedFiles) { + return state; + } + const { [threadId]: _visited, ...threadLastVisitedAtById } = state.threadLastVisitedAtById; + const { [threadId]: _changed, ...threadChangedFilesExpandedById } = + state.threadChangedFilesExpandedById; + return { + ...state, + threadLastVisitedAtById, + threadChangedFilesExpandedById, + }; +} + export function setThreadChangedFilesExpanded( state: UiState, threadId: string, @@ -414,6 +435,7 @@ export function reorderProjects( interface UiStateStore extends UiState { markThreadVisited: (threadId: string, visitedAt: string) => void; markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; + removeThread: (threadId: string) => void; setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; setDefaultAdvertisedEndpointKey: (key: string | null) => void; setProjectExpanded: (projectIds: string | readonly string[], expanded: boolean) => void; @@ -430,6 +452,7 @@ export const useUiStateStore = create((set) => ({ set((state) => markThreadVisited(state, threadId, visitedAt)), markThreadUnread: (threadId, latestTurnCompletedAt) => set((state) => markThreadUnread(state, threadId, latestTurnCompletedAt)), + removeThread: (threadId) => set((state) => removeThreadUiState(state, threadId)), setThreadChangedFilesExpanded: (threadId, turnId, expanded) => set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)), setDefaultAdvertisedEndpointKey: (key) => From dc7481a765a38b06fdb368e1dda13a4bff4b2565 Mon Sep 17 00:00:00 2001 From: jln <85513960+jln13x@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:53:05 +0300 Subject: [PATCH 2/9] perf: reduce background Git and port polling (upstream #4187) Integrated from https://github.com/pingdotgg/t3code/pull/4187 at 5b816e5fc. --- apps/server/src/preview/PortScanner.test.ts | 110 ++++++++++++++++++++ apps/server/src/preview/PortScanner.ts | 93 ++++++++++++----- apps/server/src/ws.ts | 9 +- apps/web/src/components/DiffPanel.tsx | 7 +- packages/client-runtime/src/state/vcs.ts | 10 +- 5 files changed, 194 insertions(+), 35 deletions(-) diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 69b5729164d..fb0ac2acbc2 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -4,8 +4,10 @@ import { it as effectIt } from "@effect/vitest"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Net from "@t3tools/shared/Net"; import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; import { expect } from "vite-plus/test"; @@ -155,3 +157,111 @@ effectIt("does not swallow process probe interruption", () => } }), ); + +effectIt("replays snapshots without rescanning unchanged terminal registrations", () => { + let probeCount = 0; + let replayCount = 0; + const layer = makeProbeFailureLayer(() => + Effect.sync(() => { + probeCount += 1; + return { + stdout: "", + stderr: "", + code: null, + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + ); + + return Effect.gen(function* () { + const scanner = yield* PortScanner.PortDiscovery; + yield* scanner.subscribe(() => Effect.void); + yield* scanner.retain; + yield* scanner.registerTerminalProcesses({ + threadId: "thread-1", + terminalId: "terminal-1", + processIds: [42], + }); + yield* scanner.registerTerminalProcesses({ + threadId: "thread-1", + terminalId: "terminal-1", + processIds: [42], + }); + yield* scanner.subscribe(() => + Effect.sync(() => { + replayCount += 1; + }), + ); + + expect(probeCount).toBe(2); + expect(replayCount).toBe(1); + }).pipe(Effect.provide(layer)); +}); + +effectIt("serializes snapshot replay with concurrent broadcasts", () => + Effect.gen(function* () { + const replayStarted = yield* Deferred.make(); + const releaseReplay = yield* Deferred.make(); + const secondProbeCompleted = yield* Deferred.make(); + const secondDeliveryStarted = yield* Deferred.make(); + const deliveries: Array> = []; + let probeCount = 0; + let deliveryCount = 0; + const layer = makeProbeFailureLayer(() => + Effect.gen(function* () { + probeCount += 1; + if (probeCount === 2) { + yield* Deferred.succeed(secondProbeCompleted, undefined).pipe(Effect.ignore); + } + return { + stdout: probeCount === 1 ? "p100\ncnode\nn*:3000\n" : "p101\ncnode\nn*:3001\n", + stderr: "", + code: null, + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + ); + + yield* Effect.gen(function* () { + const scanner = yield* PortScanner.PortDiscovery; + yield* scanner.retain; + + const subscription = yield* scanner + .subscribe((servers) => + Effect.gen(function* () { + deliveryCount += 1; + if (deliveryCount === 1) { + yield* Deferred.succeed(replayStarted, undefined).pipe(Effect.ignore); + yield* Deferred.await(releaseReplay); + } else { + yield* Deferred.succeed(secondDeliveryStarted, undefined).pipe(Effect.ignore); + } + deliveries.push(servers.map((server) => server.port)); + }), + ) + .pipe(Effect.forkScoped); + yield* Deferred.await(replayStarted); + + const registration = yield* scanner + .registerTerminalProcesses({ + threadId: "thread-1", + terminalId: "terminal-1", + processIds: [101], + }) + .pipe(Effect.forkScoped); + yield* Deferred.await(secondProbeCompleted); + yield* Effect.yieldNow; + expect(yield* Deferred.isDone(secondDeliveryStarted)).toBe(false); + + yield* Deferred.succeed(releaseReplay, undefined); + yield* Fiber.join(subscription); + yield* Fiber.join(registration); + + expect(deliveries).toEqual([[3000], [3001]]); + }).pipe(Effect.provide(layer)); + }), +); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index c306fca2b33..a0c3bf3a1ec 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -21,7 +21,7 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; -import * as Schedule from "effect/Schedule"; +import * as Semaphore from "effect/Semaphore"; import * as Scope from "effect/Scope"; import * as ProcessRunner from "../processRunner.ts"; @@ -50,7 +50,8 @@ export const COMMON_DEV_PORTS: ReadonlyArray = Object.freeze([ 3000, 3001, 3333, 4173, 4200, 4321, 5000, 5173, 5174, 5175, 5500, 8000, 8080, 8081, 8888, 9000, ]); -const POLL_INTERVAL = Duration.seconds(3); +const ACTIVE_POLL_INTERVAL = Duration.seconds(10); +const IDLE_POLL_INTERVAL = Duration.seconds(20); const LSOF_TIMEOUT_MS = 5_000; const WINDOWS_LISTENER_TIMEOUT_MS = 5_000; @@ -79,6 +80,9 @@ const terminalOwnerKey = (owner: { readonly terminalId: string; }): string => `${owner.threadId}\u0000${owner.terminalId}`; +const processIdsEqual = (left: ReadonlySet, right: ReadonlySet): boolean => + left.size === right.size && [...left].every((processId) => right.has(processId)); + const parseLsofOutput = ( raw: string, terminalByProcessId: ReadonlyMap = new Map(), @@ -190,6 +194,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const net = yield* Net.NetService; const processRunner = yield* ProcessRunner.ProcessRunner; const hostPlatform = yield* HostProcessPlatform; + const notificationLock = yield* Semaphore.make(1); const stateRef = yield* Ref.make({ lastSnapshot: [], listeners: new Set(), @@ -292,25 +297,49 @@ export const make = Effect.gen(function* PortDiscoveryMake() { yield* Effect.forEach(listeners, (listener) => listener(servers), { discard: true }); }); + const publishSnapshot = Effect.fn("PortDiscovery.publishSnapshot")(function* ( + next: ReadonlyArray, + ) { + yield* notificationLock.withPermit( + Effect.gen(function* () { + const changed = yield* Ref.modify(stateRef, (state) => + serversEqual(state.lastSnapshot, next) + ? [false, state] + : [true, { ...state, lastSnapshot: next }], + ); + if (changed) yield* broadcast(next); + }), + ); + }); + const pollTick = Effect.fn("PortDiscovery.pollTick")( function* () { if ((yield* Ref.get(stateRef)).retainCount <= 0) return; const next = yield* scanOnce(); - const changed = yield* Ref.modify(stateRef, (state) => - serversEqual(state.lastSnapshot, next) - ? [false, state] - : [true, { ...state, lastSnapshot: next }], - ); - if (changed) yield* broadcast(next); + yield* publishSnapshot(next); }, Effect.catchCause((cause: Cause.Cause) => Effect.logWarning("preview port scan failed", Cause.pretty(cause)), ), ); - // Single layer-scoped polling fiber. Ticks are no-ops when no client is - // currently retained, so the cost is one Ref.get every POLL_INTERVAL. - yield* Effect.forkScoped(pollTick().pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL)))); + // Keep broad listener discovery as a fallback, but avoid a system-wide lsof + // process every three seconds while the app is otherwise idle. Terminal PID + // changes trigger immediate scans below; the periodic loop is only the + // safety net for listeners started outside a managed terminal. + yield* Effect.forkScoped( + Effect.gen(function* () { + while (true) { + const state = yield* Ref.get(stateRef); + yield* Effect.sleep( + state.retainCount > 0 && state.lastSnapshot.length > 0 + ? ACTIVE_POLL_INTERVAL + : IDLE_POLL_INTERVAL, + ); + yield* pollTick(); + } + }), + ); const acquireRetention = Effect.fn("PortDiscovery.retain")(function* () { const wasIdle = yield* Ref.modify(stateRef, (state) => [ @@ -334,16 +363,23 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const subscribe: PortDiscovery["Service"]["subscribe"] = Effect.fn("PortDiscovery.subscribe")( (listener) => Effect.acquireRelease( - Ref.update(stateRef, (state) => ({ - ...state, - listeners: new Set([...state.listeners, listener]), - })), + notificationLock.withPermit( + Ref.modify(stateRef, (state) => [ + state.lastSnapshot, + { + ...state, + listeners: new Set([...state.listeners, listener]), + }, + ]).pipe(Effect.tap(listener)), + ), () => - Ref.update(stateRef, (state) => { - const listeners = new Set(state.listeners); - listeners.delete(listener); - return { ...state, listeners }; - }), + notificationLock.withPermit( + Ref.update(stateRef, (state) => { + const listeners = new Set(state.listeners); + listeners.delete(listener); + return { ...state, listeners }; + }), + ), ), ); @@ -356,26 +392,33 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const processIds = new Set( input.processIds.filter((processId) => Number.isInteger(processId) && processId > 0), ); - yield* Ref.update(stateRef, (state) => { + const changed = yield* Ref.modify(stateRef, (state) => { const terminalProcesses = new Map(state.terminalProcesses); const key = terminalOwnerKey(owner); + const existing = terminalProcesses.get(key); + if (existing && processIdsEqual(existing.processIds, processIds)) { + return [false, state] as const; + } if (processIds.size === 0) { + if (!existing) return [false, state] as const; terminalProcesses.delete(key); } else { terminalProcesses.set(key, { owner, processIds }); } - return { ...state, terminalProcesses }; + return [true, { ...state, terminalProcesses }] as const; }); + if (changed) yield* pollTick(); }); const unregisterTerminal: PortDiscovery["Service"]["unregisterTerminal"] = Effect.fn( "PortDiscovery.unregisterTerminal", )(function* (input) { - yield* Ref.update(stateRef, (state) => { + const changed = yield* Ref.modify(stateRef, (state) => { const terminalProcesses = new Map(state.terminalProcesses); - terminalProcesses.delete(terminalOwnerKey(input)); - return { ...state, terminalProcesses }; + const removed = terminalProcesses.delete(terminalOwnerKey(input)); + return [removed, removed ? { ...state, terminalProcesses } : state] as const; }); + if (changed) yield* pollTick(); }); return PortDiscovery.of({ diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 7b2ddc2ac02..0d300442224 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2077,13 +2077,10 @@ const makeWsRpcLayer = ( WS_METHODS.subscribeDiscoveredLocalServers, Stream.callback((queue) => Effect.gen(function* () { + // Retention performs one immediate scan when discovery was + // idle. Subscribe replays that snapshot to every connection, + // including connections that join an already-retained scanner. yield* portDiscovery.retain; - const initial = yield* portDiscovery.scan(); - const initialScannedAt = DateTime.formatIso(yield* DateTime.now); - yield* Queue.offer(queue, { - servers: initial, - scannedAt: initialScannedAt, - }); yield* portDiscovery.subscribe((servers) => Effect.gen(function* () { const scannedAt = DateTime.formatIso(yield* DateTime.now); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 5527549c4c6..ad963255576 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -588,7 +588,12 @@ export default function DiffPanel({ filteredItems={filteredBaseRefItems} value={selectedBaseRef ?? AUTOMATIC_BASE_REF} onOpenChange={(open) => { - if (!open) setBaseRefQuery(""); + if (!open) { + setBaseRefQuery(""); + return; + } + localBranchRefs.refresh(); + remoteBranchRefs.refresh(); }} onValueChange={(value) => { if (!value) return; diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index a2492416541..f8f7a8c5217 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -23,7 +23,8 @@ import { followStreamInEnvironment } from "./runtime.ts"; import { vcsCommandConcurrency, vcsCommandScheduler } from "./vcsCommandScheduler.ts"; const OFFLINE_BRANCH_LIST_LIMIT = 100; -const VCS_REFS_REVALIDATE_INTERVAL = "5 seconds"; +const VCS_REFS_REVALIDATE_INTERVAL = "20 seconds"; +const VCS_REFS_IDLE_TTL_MS = 30_000; function canUseVcsRefsCache(input: VcsListRefsInput): boolean { return ( @@ -107,7 +108,10 @@ export const makeCachedVcsRefsChanges = Effect.fn("CachedVcsRefsState.makeChange Stream.switchMap((generation) => generation === null ? Stream.empty - : Stream.tick(VCS_REFS_REVALIDATE_INTERVAL).pipe( + : (input.cursor === undefined + ? Stream.tick(VCS_REFS_REVALIDATE_INTERVAL) + : Stream.succeed(undefined) + ).pipe( Stream.mapEffect( () => refresh().pipe( @@ -151,7 +155,7 @@ export function createVcsEnvironmentAtoms( return runtime .atom(cachedVcsRefsChanges(environmentId, input)) .pipe( - Atom.setIdleTTL(5 * 60_000), + Atom.setIdleTTL(VCS_REFS_IDLE_TTL_MS), Atom.withLabel(`environment-data:vcs:list-refs:${environmentId}:${inputKey}`), ); }), From e1cbef75b3eff8842f9b3a88fca6936a51534d14 Mon Sep 17 00:00:00 2001 From: jln <85513960+jln13x@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:55:54 +0300 Subject: [PATCH 3/9] feat: paginate remote thread history (upstream #4018) Integrated from https://github.com/pingdotgg/t3code/pull/4018 at de8fd6593, adapted to retain the current draft hero/composer layout. --- .../checkpointing/CheckpointDiffQuery.test.ts | 10 + apps/server/src/cli/project.ts | 4 +- .../Layers/OrchestrationEngine.test.ts | 1 + .../Layers/ProjectionSnapshotQuery.test.ts | 251 ++++++++++++++++++ .../Layers/ProjectionSnapshotQuery.ts | 210 +++++++++++++-- .../Services/ProjectionSnapshotQuery.ts | 12 + apps/server/src/orchestration/http.ts | 6 +- .../project/ProjectSetupScriptRunner.test.ts | 1 + .../Layers/ProviderSessionReaper.test.ts | 1 + apps/server/src/serverRuntimeStartup.test.ts | 4 + apps/server/src/ws.ts | 16 ++ apps/web/src/components/ChatView.tsx | 178 ++++++++++++- apps/web/src/components/Sidebar.tsx | 6 +- .../chat/MessagesTimeline.logic.test.ts | 88 ++++++ .../components/chat/MessagesTimeline.logic.ts | 45 ++++ .../components/chat/MessagesTimeline.test.tsx | 33 +++ .../src/components/chat/MessagesTimeline.tsx | 79 +++++- packages/client-runtime/package.json | 4 + .../client-runtime/src/state/orchestration.ts | 7 +- .../src/state/threadReducer.test.ts | 77 +++++- .../client-runtime/src/state/threadReducer.ts | 39 +++ packages/contracts/src/orchestration.ts | 48 ++++ packages/contracts/src/rpc.ts | 12 + 23 files changed, 1094 insertions(+), 38 deletions(-) diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index 8e0e5fb74d5..6e1328210b0 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -77,6 +77,8 @@ describe("CheckpointDiffQuery.layer", () => { Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { getCommandReadModel: () => Effect.die("CheckpointDiffQuery should not request the command read model"), + getThreadActivitiesPage: () => + Effect.die("CheckpointDiffQuery should not request thread activities"), getSnapshot: () => Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), getShellSnapshot: () => @@ -185,6 +187,8 @@ describe("CheckpointDiffQuery.layer", () => { Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { getCommandReadModel: () => Effect.die("CheckpointDiffQuery should not request the command read model"), + getThreadActivitiesPage: () => + Effect.die("CheckpointDiffQuery should not request thread activities"), getSnapshot: () => Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), getShellSnapshot: () => @@ -268,6 +272,8 @@ describe("CheckpointDiffQuery.layer", () => { Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { getCommandReadModel: () => Effect.die("CheckpointDiffQuery should not request the command read model"), + getThreadActivitiesPage: () => + Effect.die("CheckpointDiffQuery should not request thread activities"), getSnapshot: () => Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), getShellSnapshot: () => @@ -336,6 +342,8 @@ describe("CheckpointDiffQuery.layer", () => { Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { getCommandReadModel: () => Effect.die("CheckpointDiffQuery should not request the command read model"), + getThreadActivitiesPage: () => + Effect.die("CheckpointDiffQuery should not request thread activities"), getSnapshot: () => Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), getShellSnapshot: () => @@ -389,6 +397,8 @@ describe("CheckpointDiffQuery.layer", () => { Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { getCommandReadModel: () => Effect.die("CheckpointDiffQuery should not request the command read model"), + getThreadActivitiesPage: () => + Effect.die("CheckpointDiffQuery should not request thread activities"), getSnapshot: () => Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), getShellSnapshot: () => diff --git a/apps/server/src/cli/project.ts b/apps/server/src/cli/project.ts index 25733a5e35b..8b7acdc3103 100644 --- a/apps/server/src/cli/project.ts +++ b/apps/server/src/cli/project.ts @@ -337,7 +337,9 @@ const dispatchLiveOrchestrationCommand = ( const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () { const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; - return yield* projectionSnapshotQuery.getSnapshot(); + // Project resolution only reads `.projects`; the command read model returns the + // same shape without loading the heavy per-thread activity/message tables. + return yield* projectionSnapshotQuery.getCommandReadModel(); }); const tryResolveLiveProjectExecutionMode = Effect.fn("tryResolveLiveProjectExecutionMode")( diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index ea8c1f575c6..94775fa8f3d 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -201,6 +201,7 @@ describe("OrchestrationEngine", () => { getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadActivitiesPage: () => Effect.die("unused"), }), ), Layer.provide( diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 534716bc63a..9548edba94c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -343,6 +343,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { createdAt: "2026-02-24T00:00:06.000Z", }, ], + hasMoreActivities: false, checkpoints: [ { turnId: asTurnId("turn-1"), @@ -976,6 +977,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { assert.equal(threadDetail._tag, "Some"); if (threadDetail._tag === "Some") { assert.deepEqual(threadDetail.value.activities, snapshot.threads[0]?.activities ?? []); + // Well under the window — nothing older to lazy-load. + assert.equal(threadDetail.value.hasMoreActivities, false); } assert.deepEqual(snapshot.threads[0]?.activities ?? [], [ @@ -1155,6 +1158,254 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { }), ); + it.effect( + "windows thread-detail activities to the most recent 500 and pages older on demand", + () => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_thread_activities`; + yield* sql`DELETE FROM projection_state`; + + yield* sql` + INSERT INTO projection_projects ( + project_id, title, workspace_root, default_model_selection_json, + scripts_json, created_at, updated_at, deleted_at + ) + VALUES ( + 'project-1', 'Project 1', '/tmp/project-1', + '{"provider":"codex","model":"gpt-5-codex"}', '[]', + '2026-04-01T00:00:00.000Z', '2026-04-01T00:00:01.000Z', NULL + ) + `; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, runtime_mode, + interaction_mode, branch, worktree_path, latest_turn_id, + latest_user_message_at, pending_approval_count, pending_user_input_count, + has_actionable_proposed_plan, created_at, updated_at, archived_at, deleted_at + ) + VALUES ( + 'thread-1', 'project-1', 'Thread 1', + '{"provider":"codex","model":"gpt-5-codex"}', 'full-access', 'default', + NULL, NULL, NULL, NULL, 0, 0, 0, + '2026-04-01T00:00:02.000Z', '2026-04-01T00:00:03.000Z', NULL, NULL + ) + `; + + // 600 activities (sequence 1..600); the detail load must return only the + // most recent 500 (sequence 101..600), re-sorted ascending for display. + const total = 600; + yield* Effect.forEach( + Array.from({ length: total }, (_unused, index) => index + 1), + (seq) => + sql` + INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, tone, kind, summary, payload_json, + sequence, created_at + ) + VALUES ( + ${`activity-${String(seq).padStart(4, "0")}`}, 'thread-1', NULL, + 'info', 'runtime.note', ${`act-${seq}`}, '{}', ${seq}, + '2026-04-01T00:01:00.000Z' + ) + `, + { discard: true }, + ); + + const threadDetail = yield* snapshotQuery.getThreadDetailById(ThreadId.make("thread-1")); + assert.equal(threadDetail._tag, "Some"); + if (threadDetail._tag === "Some") { + const activities = threadDetail.value.activities; + assert.equal(activities.length, 500); + assert.equal(activities[0]?.summary, "act-101"); + assert.equal(activities[0]?.sequence, 101); + assert.equal(activities.at(-1)?.summary, "act-600"); + // 600 > window, so the client is told older history can be lazy-loaded. + assert.equal(threadDetail.value.hasMoreActivities, true); + } + + // Lazy-load the page immediately older than the windowed view (cursor = + // oldest loaded sequence, 101): sequences 1..100, ascending, no more left. + const olderPage = yield* snapshotQuery.getThreadActivitiesPage({ + threadId: ThreadId.make("thread-1"), + beforeSequence: 101, + limit: 500, + }); + assert.equal(olderPage.activities.length, 100); + assert.equal(olderPage.activities[0]?.summary, "act-1"); + assert.equal(olderPage.activities.at(-1)?.summary, "act-100"); + assert.equal(olderPage.hasMore, false); + + // A bounded page returns the newest `limit` of the older set and reports + // that more remain (sequences 401..600, with 1..400 still older). + const boundedPage = yield* snapshotQuery.getThreadActivitiesPage({ + threadId: ThreadId.make("thread-1"), + beforeSequence: 601, + limit: 200, + }); + assert.equal(boundedPage.activities.length, 200); + assert.equal(boundedPage.activities[0]?.summary, "act-401"); + assert.equal(boundedPage.activities.at(-1)?.summary, "act-600"); + assert.equal(boundedPage.hasMore, true); + + yield* sql`DELETE FROM projection_thread_activities`; + + // Legacy rows may not have a sequence. They are still windowed in the + // detail load and must remain pageable by the deterministic created/id + // ordering used by the snapshot query. + yield* Effect.forEach( + Array.from({ length: total }, (_unused, index) => index + 1), + (seq) => + sql` + INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, tone, kind, summary, payload_json, + sequence, created_at + ) + VALUES ( + ${`unsequenced-${String(seq).padStart(4, "0")}`}, 'thread-1', NULL, + 'info', 'runtime.note', ${`legacy-act-${seq}`}, '{}', NULL, + '2026-04-01T00:01:00.000Z' + ) + `, + { discard: true }, + ); + + const legacyThreadDetail = yield* snapshotQuery.getThreadDetailById( + ThreadId.make("thread-1"), + ); + assert.equal(legacyThreadDetail._tag, "Some"); + if (legacyThreadDetail._tag === "Some") { + const activities = legacyThreadDetail.value.activities; + assert.equal(activities.length, 500); + assert.equal(activities[0]?.summary, "legacy-act-101"); + assert.equal(activities[0]?.sequence, undefined); + assert.equal(activities.at(-1)?.summary, "legacy-act-600"); + + const legacyOlderPage = yield* snapshotQuery.getThreadActivitiesPage({ + threadId: ThreadId.make("thread-1"), + beforeCreatedAt: activities[0]?.createdAt ?? "2026-04-01T00:01:00.000Z", + beforeActivityId: activities[0]?.id ?? asEventId("unsequenced-0101"), + limit: 500, + }); + assert.equal(legacyOlderPage.activities.length, 100); + assert.equal(legacyOlderPage.activities[0]?.summary, "legacy-act-1"); + assert.equal(legacyOlderPage.activities.at(-1)?.summary, "legacy-act-100"); + assert.equal(legacyOlderPage.hasMore, false); + } + + const legacyBoundedPage = yield* snapshotQuery.getThreadActivitiesPage({ + threadId: ThreadId.make("thread-1"), + beforeCreatedAt: "2026-04-01T00:01:00.000Z", + beforeActivityId: asEventId("unsequenced-0601"), + limit: 200, + }); + assert.equal(legacyBoundedPage.activities.length, 200); + assert.equal(legacyBoundedPage.activities[0]?.summary, "legacy-act-401"); + assert.equal(legacyBoundedPage.activities.at(-1)?.summary, "legacy-act-600"); + assert.equal(legacyBoundedPage.hasMore, true); + }), + ); + + it.effect("unsequenced cursor reaches all older rows without stranding sequenced ones", () => + // Regression for the "unsequenced cursor hides sequenced history" concern: + // sequenced rows always sort newer than NULL-sequence (legacy) rows, so when + // the oldest loaded row is unsequenced every sequenced row is already in the + // window — the `sequence IS NULL` cursor can't strand sequenced rows. + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_thread_activities`; + yield* sql`DELETE FROM projection_state`; + yield* sql` + INSERT INTO projection_projects ( + project_id, title, workspace_root, default_model_selection_json, + scripts_json, created_at, updated_at, deleted_at + ) VALUES ( + 'project-1', 'Project 1', '/tmp/project-1', + '{"provider":"codex","model":"gpt-5-codex"}', '[]', + '2026-04-01T00:00:00.000Z', '2026-04-01T00:00:01.000Z', NULL + ) + `; + yield* sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, runtime_mode, + interaction_mode, branch, worktree_path, latest_turn_id, + latest_user_message_at, pending_approval_count, pending_user_input_count, + has_actionable_proposed_plan, created_at, updated_at, archived_at, deleted_at + ) VALUES ( + 'thread-1', 'project-1', 'Thread 1', + '{"provider":"codex","model":"gpt-5-codex"}', 'full-access', 'default', + NULL, NULL, NULL, NULL, 0, 0, 0, + '2026-04-01T00:00:02.000Z', '2026-04-01T00:00:03.000Z', NULL, NULL + ) + `; + // 600 legacy unsequenced rows (older) + 3 sequenced rows (newer). The + // window keeps the 3 sequenced + the most-recent 497 unsequenced, so the + // oldest loaded row is unsequenced and 103 older unsequenced remain. + yield* Effect.forEach( + Array.from({ length: 600 }, (_u, index) => index + 1), + (n) => + sql` + INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, tone, kind, summary, payload_json, + sequence, created_at + ) VALUES ( + ${`unseq-${String(n).padStart(4, "0")}`}, 'thread-1', NULL, + 'info', 'runtime.note', ${`unseq-${n}`}, '{}', NULL, + ${`2026-04-01T00:00:01.${String(n).padStart(3, "0")}Z`} + ) + `, + { discard: true }, + ); + yield* Effect.forEach( + [1, 2, 3], + (seq) => + sql` + INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, tone, kind, summary, payload_json, + sequence, created_at + ) VALUES ( + ${`seq-${seq}`}, 'thread-1', NULL, 'info', 'runtime.note', + ${`seq-${seq}`}, '{}', ${seq}, ${`2026-04-01T09:00:0${seq}.000Z`} + ) + `, + { discard: true }, + ); + + const detail = yield* snapshotQuery.getThreadDetailById(ThreadId.make("thread-1")); + assert.equal(detail._tag, "Some"); + if (detail._tag !== "Some") return; + const windowed = detail.value.activities; + assert.equal(windowed.length, 500); + // Sequenced rows are the newest (end of the ascending window); the oldest + // loaded row is unsequenced — exactly the case the concern is about. + assert.equal(windowed.at(-1)?.summary, "seq-3"); + assert.equal(windowed[0]?.sequence, undefined); + + // The client pages with the unsequenced cursor of the oldest loaded row. + const oldest = windowed[0]; + assert.ok(oldest); + const olderPage = yield* snapshotQuery.getThreadActivitiesPage({ + threadId: ThreadId.make("thread-1"), + beforeCreatedAt: oldest.createdAt, + beforeActivityId: oldest.id, + limit: 500, + }); + // The 103 older unsequenced rows come back, none are sequenced, and no + // sequenced row was stranded (all 3 are already in the window). + assert.equal(olderPage.activities.length, 103); + assert.equal(olderPage.hasMore, false); + assert.ok(olderPage.activities.every((a) => a.sequence === undefined)); + }), + ); + it.effect("uses projection_threads.latest_turn_id for bulk command and shell snapshots", () => Effect.gen(function* () { const snapshotQuery = yield* ProjectionSnapshotQuery; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 07974429638..598f2a92819 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -1,6 +1,7 @@ import { ChatAttachment, CheckpointRef, + EventId, IsoDateTime, MessageId, NonNegativeInt, @@ -117,6 +118,31 @@ const ProjectIdLookupInput = Schema.Struct({ const ThreadIdLookupInput = Schema.Struct({ threadId: ThreadId, }); + +/** + * Maximum number of most-recent activities loaded into a thread-detail snapshot. + * Bounds peak memory when opening a long-lived thread; older activities are + * fetched on demand (lazy-load, planned) and live ones stream in via events. + */ +const THREAD_DETAIL_ACTIVITY_WINDOW = 500; + +// `beforeSequence`/`limit` are NonNegativeInt (not bare Number) to match the +// contract: the WHERE clause `(sequence < beforeSequence OR sequence IS NULL)` +// is only equivalent to the old `COALESCE(sequence, -1) < beforeSequence` when +// `beforeSequence` is non-negative — a negative cursor would silently match no +// sequenced rows and return only unsequenced ones. Validating here (not just at +// the RPC boundary) keeps any future non-RPC caller honest. +const ThreadActivitiesBeforeSequenceInput = Schema.Struct({ + threadId: ThreadId, + beforeSequence: NonNegativeInt, + limit: NonNegativeInt, +}); +const ThreadActivitiesBeforeActivityInput = Schema.Struct({ + threadId: ThreadId, + beforeCreatedAt: IsoDateTime, + beforeActivityId: EventId, + limit: NonNegativeInt, +}); const ProjectionProjectLookupRowSchema = ProjectionProjectDbRowSchema; const ProjectionThreadIdLookupRowSchema = Schema.Struct({ threadId: ThreadId, @@ -254,6 +280,23 @@ function mapProposedPlanRow( }; } +function mapThreadActivityRow( + row: Schema.Schema.Type, +): OrchestrationThreadActivity { + const activity = { + id: row.activityId, + tone: row.tone, + kind: row.kind, + summary: row.summary, + payload: row.payload, + turnId: row.turnId, + createdAt: row.createdAt, + }; + // `sequence` is the pagination cursor; omit it when the (legacy) row is + // unsequenced so the optional contract field stays absent. + return row.sequence !== null ? Object.assign(activity, { sequence: row.sequence }) : activity; +} + function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { return (cause: unknown): ProjectionRepositoryError => Schema.isSchemaError(cause) @@ -812,6 +855,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + // The thread-detail timeline loads only the most recent window of activities so a + // long-lived thread (tens of thousands of activities, hundreds of MB of payload) + // can't blow the server heap. New activities still stream in live via the event + // subscription; older history will be fetched on demand once lazy-load lands. + // Select the most recent N (sequence DESC) then re-sort ascending for display. const listThreadActivityRowsByThread = SqlSchema.findAll({ Request: ThreadIdLookupInput, Result: ProjectionThreadActivityDbRowSchema, @@ -827,8 +875,17 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { payload_json AS "payload", sequence, created_at AS "createdAt" - FROM projection_thread_activities - WHERE thread_id = ${threadId} + FROM ( + SELECT * + FROM projection_thread_activities + WHERE thread_id = ${threadId} + ORDER BY + sequence DESC, + created_at DESC, + activity_id DESC + -- One extra beyond the window so the caller can report hasMoreActivities. + LIMIT ${THREAD_DETAIL_ACTIVITY_WINDOW + 1} + ) ORDER BY sequence ASC, created_at ASC, @@ -836,6 +893,81 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + // Older-than-cursor page for lazy-load. Returns rows newest-first (DESC) so a + // simple LIMIT yields the page adjacent to the cursor; the caller reverses to + // ascending. `sequence IS NULL` (legacy unsequenced) rows sort last under + // `sequence DESC` (SQLite orders NULLs last in DESC) — the very oldest — so + // paging eventually reaches them. `beforeSequence` is a NonNegativeInt, so + // `(sequence < beforeSequence OR sequence IS NULL)` is equivalent to the old + // `COALESCE(sequence, -1) < beforeSequence` but lets the + // (thread_id, sequence, created_at, activity_id) index satisfy the ORDER BY + // directly instead of forcing a filesort over the whole thread. + const listThreadActivityRowsBeforeSequence = SqlSchema.findAll({ + Request: ThreadActivitiesBeforeSequenceInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ threadId, beforeSequence, limit }) => + sql` + SELECT + activity_id AS "activityId", + thread_id AS "threadId", + turn_id AS "turnId", + tone, + kind, + summary, + payload_json AS "payload", + sequence, + created_at AS "createdAt" + FROM projection_thread_activities + WHERE thread_id = ${threadId} + AND (sequence < ${beforeSequence} OR sequence IS NULL) + ORDER BY + sequence DESC, + created_at DESC, + activity_id DESC + LIMIT ${limit} + `, + }); + + // Legacy unsequenced (sequence NULL) rows are paged by a (created_at, + // activity_id) cursor. created_at is compared lexicographically as TEXT, which + // equals chronological order only because timestamps are canonical ISO-8601 + // (always UTC `Z`, fixed millisecond precision) — the same invariant every + // `ORDER BY created_at` in this layer (including the detail window above) + // already relies on, so the cursor stays consistent with how rows are + // displayed. activity_id breaks created_at ties; its ordering is arbitrary but + // matches the window's `activity_id` tiebreak, so pages never skip or repeat. + const listUnsequencedThreadActivityRowsBeforeActivity = SqlSchema.findAll({ + Request: ThreadActivitiesBeforeActivityInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ threadId, beforeCreatedAt, beforeActivityId, limit }) => + sql` + SELECT + activity_id AS "activityId", + thread_id AS "threadId", + turn_id AS "turnId", + tone, + kind, + summary, + payload_json AS "payload", + sequence, + created_at AS "createdAt" + FROM projection_thread_activities + WHERE thread_id = ${threadId} + AND sequence IS NULL + AND ( + created_at < ${beforeCreatedAt} + OR ( + created_at = ${beforeCreatedAt} + AND activity_id < ${beforeActivityId} + ) + ) + ORDER BY + created_at DESC, + activity_id DESC + LIMIT ${limit} + `, + }); + const getThreadSessionRowByThread = SqlSchema.findOneOption({ Request: ThreadIdLookupInput, Result: ProjectionThreadSessionDbRowSchema, @@ -1080,16 +1212,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { for (const row of activityRows) { updatedAt = maxIso(updatedAt, row.createdAt); const threadActivities = activitiesByThread.get(row.threadId) ?? []; - threadActivities.push({ - id: row.activityId, - tone: row.tone, - kind: row.kind, - summary: row.summary, - payload: row.payload, - turnId: row.turnId, - ...(row.sequence !== null ? { sequence: row.sequence } : {}), - createdAt: row.createdAt, - }); + threadActivities.push(mapThreadActivityRow(row)); activitiesByThread.set(row.threadId, threadActivities); } @@ -1198,6 +1321,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { messages: messagesByThread.get(row.threadId) ?? [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], activities: activitiesByThread.get(row.threadId) ?? [], + // The full snapshot is unwindowed, so there is never more to load. + hasMoreActivities: false, checkpoints: checkpointsByThread.get(row.threadId) ?? [], session: sessionsByThread.get(row.threadId) ?? null, })); @@ -2028,21 +2153,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { return message; }), proposedPlans: proposedPlanRows.map(mapProposedPlanRow), - activities: activityRows.map((row) => { - const activity = { - id: row.activityId, - tone: row.tone, - kind: row.kind, - summary: row.summary, - payload: row.payload, - turnId: row.turnId, - createdAt: row.createdAt, - }; - if (row.sequence !== null) { - return Object.assign(activity, { sequence: row.sequence }); - } - return activity; - }), + // The query fetches WINDOW+1 ascending rows; if it returned the extra + // one, older activities exist beyond the window — drop that oldest row + // and flag it so clients can lazy-load older history. + activities: (activityRows.length > THREAD_DETAIL_ACTIVITY_WINDOW + ? activityRows.slice(activityRows.length - THREAD_DETAIL_ACTIVITY_WINDOW) + : activityRows + ).map(mapThreadActivityRow), + hasMoreActivities: activityRows.length > THREAD_DETAIL_ACTIVITY_WINDOW, checkpoints: checkpointRows.map((row) => ({ turnId: row.turnId, checkpointTurnCount: row.checkpointTurnCount, @@ -2093,6 +2211,43 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ); + const getThreadActivitiesPage: ProjectionSnapshotQueryShape["getThreadActivitiesPage"] = ( + input, + ) => + Effect.gen(function* () { + const limit = Math.min( + Math.max(1, input.limit ?? THREAD_DETAIL_ACTIVITY_WINDOW), + THREAD_DETAIL_ACTIVITY_WINDOW, + ); + // Fetch one extra to detect whether older activities remain. + const rowsEffect = + "beforeSequence" in input + ? listThreadActivityRowsBeforeSequence({ + threadId: input.threadId, + beforeSequence: input.beforeSequence, + limit: limit + 1, + }) + : listUnsequencedThreadActivityRowsBeforeActivity({ + threadId: input.threadId, + beforeCreatedAt: input.beforeCreatedAt, + beforeActivityId: input.beforeActivityId, + limit: limit + 1, + }); + const rows = yield* rowsEffect.pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadActivitiesPage:query", + "ProjectionSnapshotQuery.getThreadActivitiesPage:decodeRows", + ), + ), + ); + const hasMore = rows.length > limit; + // Rows are newest-first; keep the page closest to the cursor, then reverse + // to ascending for display. + const page = (hasMore ? rows.slice(0, limit) : rows).map(mapThreadActivityRow).toReversed(); + return { activities: page, hasMore }; + }); + return { getCommandReadModel, getSnapshot, @@ -2108,6 +2263,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { getThreadShellById, getThreadDetailById, getThreadDetailSnapshot, + getThreadActivitiesPage, } satisfies ProjectionSnapshotQueryShape; }); diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 23b291d8778..d235f460ce6 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -9,6 +9,8 @@ import type { CheckpointRef, OrchestrationCheckpointSummary, + OrchestrationGetThreadActivitiesInput, + OrchestrationGetThreadActivitiesResult, OrchestrationProject, OrchestrationProjectShell, OrchestrationReadModel, @@ -168,6 +170,16 @@ export interface ProjectionSnapshotQueryShape { readonly getThreadDetailSnapshot: ( threadId: ThreadId, ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Cursor-paginated load of a thread's older activities (lazy-load / infinite + * scroll). Returns the page of activities immediately older than the provided + * sequence or unsequenced activity cursor, ascending, plus whether older ones + * remain. + */ + readonly getThreadActivitiesPage: ( + input: OrchestrationGetThreadActivitiesInput, + ) => Effect.Effect; } /** diff --git a/apps/server/src/orchestration/http.ts b/apps/server/src/orchestration/http.ts index 016c3d508ec..f57aa7d5711 100644 --- a/apps/server/src/orchestration/http.ts +++ b/apps/server/src/orchestration/http.ts @@ -31,8 +31,12 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( Effect.fn("environment.orchestration.snapshot")(function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); yield* requireEnvironmentScope(AuthOrchestrationReadScope); + // The only consumer (the `t3` CLI project resolver) reads just + // `.projects`, so use the command read model — it returns the same + // OrchestrationReadModel shape but never materialises the per-thread + // activity/message/checkpoint tables (490MB+ on a busy DB → heap OOM). return yield* projectionSnapshotQuery - .getSnapshot() + .getCommandReadModel() .pipe( Effect.catch((cause) => failEnvironmentInternal("orchestration_snapshot_failed", cause), diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 15612908079..077860a6944 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -27,6 +27,7 @@ const makeProject = (scripts: OrchestrationProject["scripts"]): OrchestrationPro const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { getCommandReadModel: () => Effect.die("unused"), + getThreadActivitiesPage: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 166a88ef17b..06769b45984 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -210,6 +210,7 @@ describe("ProviderSessionReaper", () => { ), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + getThreadActivitiesPage: () => Effect.die("unused"), }), ), Layer.provideMerge(NodeServices.layer), diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index b8102bda9ad..89c371a814e 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -97,6 +97,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadActivitiesPage: () => Effect.die("unused"), }), Effect.provideService(AnalyticsService.AnalyticsService, { record: () => Effect.void, @@ -160,6 +161,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + getThreadActivitiesPage: () => Effect.die("unused"), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, @@ -204,6 +206,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + getThreadActivitiesPage: () => Effect.die("unused"), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, @@ -254,6 +257,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + getThreadActivitiesPage: () => Effect.die("unused"), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 0d300442224..89d7b3cb99f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -35,6 +35,7 @@ import { type OrchestrationThreadStreamItem, OrchestrationGetFullThreadDiffError, OrchestrationGetSnapshotError, + OrchestrationGetThreadActivitiesError, OrchestrationGetTurnDiffError, ORCHESTRATION_WS_METHODS, type ProjectId, @@ -294,6 +295,7 @@ const SHELL_RESUME_MAX_GAP = 1_000; const RPC_REQUIRED_SCOPE = new Map([ [ORCHESTRATION_WS_METHODS.dispatchCommand, AuthOrchestrationOperateScope], [ORCHESTRATION_WS_METHODS.getTurnDiff, AuthOrchestrationReadScope], + [ORCHESTRATION_WS_METHODS.getThreadActivities, AuthOrchestrationReadScope], [ORCHESTRATION_WS_METHODS.getFullThreadDiff, AuthOrchestrationReadScope], [ORCHESTRATION_WS_METHODS.replayEvents, AuthOrchestrationReadScope], [ORCHESTRATION_WS_METHODS.subscribeShell, AuthOrchestrationReadScope], @@ -1213,6 +1215,20 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "orchestration" }, ), + [ORCHESTRATION_WS_METHODS.getThreadActivities]: (input) => + observeRpcEffect( + ORCHESTRATION_WS_METHODS.getThreadActivities, + projectionSnapshotQuery.getThreadActivitiesPage(input).pipe( + Effect.mapError( + (cause) => + new OrchestrationGetThreadActivitiesError({ + message: "Failed to load thread activities page", + cause, + }), + ), + ), + { "rpc.aggregate": "orchestration" }, + ), [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: (input) => observeRpcEffect( ORCHESTRATION_WS_METHODS.getFullThreadDiff, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9b438cdfb0d..b334d4bc250 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -63,6 +63,10 @@ import { squashAtomCommandFailure, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; +import { + liveWindowOldestActivityId, + oldestActivityByChronology, +} from "@t3tools/client-runtime/state/thread-reducer"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import { isElectron } from "../env"; @@ -190,6 +194,7 @@ import { primaryServerKeybindingsAtom, serverEnvironment, } from "../state/server"; +import { orchestrationEnvironment } from "../state/orchestration"; import { terminalEnvironment } from "../state/terminal"; import { threadEnvironment } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; @@ -1866,7 +1871,170 @@ function ChatViewContent(props: ChatViewProps) { ); const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; const phase = derivePhase(activeThread?.session ?? null); - const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; + + // ── Older-history lazy-load ──────────────────────────────────────────────── + // The detail snapshot windows activities to the most recent page (the server + // sets `hasMoreActivities` when older ones exist); older pages are fetched on + // demand (infinite scroll-up) and prepended. Messages aren't windowed + // server-side, so this just back-fills the older tool activity. + const [olderActivities, setOlderActivities] = useState< + ReadonlyArray + >([]); + const [olderLoaded, setOlderLoaded] = useState(false); + const [olderHasMore, setOlderHasMore] = useState(false); + const [loadingOlderActivities, setLoadingOlderActivities] = useState(false); + const [olderHistoryCursorVersion, setOlderHistoryCursorVersion] = useState(0); + const loadThreadActivities = useAtomCommand(orchestrationEnvironment.loadThreadActivities, { + reportFailure: false, + }); + const activeThreadActivityRequestKey = activeThread + ? `${activeThread.environmentId}\u0000${activeThread.id}` + : null; + const liveThreadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; + // Order-independent oldest boundary: `activities[0]` shifts when the reducer + // re-sorts unsequenced rows on the first live append, which would otherwise + // make a plain append look like a window reshape. See helper docs. + const liveOldestActivityId = useMemo( + () => liveWindowOldestActivityId(liveThreadActivities), + [liveThreadActivities], + ); + const liveActivityCount = liveThreadActivities.length; + // Bumps on every lazy-load reset so a late in-flight load can't repopulate the + // freshly-cleared state (the thread key alone doesn't change on a same-thread + // window reshape). + const olderActivitiesGenRef = useRef(0); + // The request key of an in-flight older-history load — coalesces the duplicate + // dispatches a fast scroll-to-top fires before the loading state updates. + const inFlightOlderKeyRef = useRef(null); + // The oldest row we've paged past. Advancing this (not re-deriving from the + // merged set) lets an all-overlap page keep paging when the server still + // reports `hasMore` — without it a page that dedupes to nothing would either + // terminate paging early or re-request the same cursor forever. Reset on reshape. + const olderCursorRef = useRef(null); + // Reset the lazy-loaded older pages when the live window is *reshaped* rather + // than purely appended-to: a different thread or a re-snapshot (reconnect) + // changes its oldest row, and a checkpoint revert removes rows so the count + // shrinks. A pure append (same thread, same oldest, larger count) keeps them. + const olderWindowRef = useRef({ + key: activeThreadActivityRequestKey, + oldest: liveOldestActivityId, + count: liveActivityCount, + }); + // useLayoutEffect (not useEffect) so the cleared state commits before paint: + // otherwise the new thread renders one frame with the previous thread's + // lazy-loaded pages still merged in, flashing stale work-log/approval rows. + useLayoutEffect(() => { + const prev = olderWindowRef.current; + olderWindowRef.current = { + key: activeThreadActivityRequestKey, + oldest: liveOldestActivityId, + count: liveActivityCount, + }; + const reshaped = + activeThreadActivityRequestKey !== prev.key || + liveOldestActivityId !== prev.oldest || + liveActivityCount < prev.count; + if (!reshaped) { + return; + } + olderActivitiesGenRef.current += 1; + inFlightOlderKeyRef.current = null; + olderCursorRef.current = null; + setOlderActivities([]); + setOlderLoaded(false); + setOlderHasMore(false); + setLoadingOlderActivities(false); + setOlderHistoryCursorVersion((version) => version + 1); + }, [activeThreadActivityRequestKey, liveOldestActivityId, liveActivityCount]); + + const threadActivities = useMemo( + () => + olderActivities.length > 0 + ? [...olderActivities, ...liveThreadActivities] + : liveThreadActivities, + [olderActivities, liveThreadActivities], + ); + // Latest merged set, read inside the async load handler so dedup runs against + // the current state, not the snapshot captured when the load was dispatched. + const threadActivitiesRef = useRef(threadActivities); + threadActivitiesRef.current = threadActivities; + // Before any page is loaded, the server tells us whether older history exists + // beyond the windowed snapshot; afterwards the page `hasMore` is authoritative. + const hasMoreOlderActivities = olderLoaded + ? olderHasMore + : (activeThread?.hasMoreActivities ?? false); + const loadOlderActivities = useCallback(() => { + if (!activeThread || !hasMoreOlderActivities) { + return; + } + // Page from the explicit cursor (the oldest row we've already paged past) or, + // before any page, the chronologically-oldest loaded row — not + // `threadActivities[0]`: the reducer sorts unsequenced rows to the end, so + // index 0 can be a newer sequenced row whose `beforeSequence` cursor would + // skip older unsequenced history. This matches the reshape sentinel. + const oldestActivity = olderCursorRef.current ?? oldestActivityByChronology(threadActivities); + if (!oldestActivity || !activeThreadActivityRequestKey) { + return; + } + if (inFlightOlderKeyRef.current === activeThreadActivityRequestKey) { + return; // a load for this thread is already in flight + } + const cursorInput = + oldestActivity.sequence !== undefined + ? { beforeSequence: oldestActivity.sequence } + : { beforeCreatedAt: oldestActivity.createdAt, beforeActivityId: oldestActivity.id }; + const requestKey = activeThreadActivityRequestKey; + const gen = olderActivitiesGenRef.current; + inFlightOlderKeyRef.current = requestKey; + setLoadingOlderActivities(true); + void loadThreadActivities({ + environmentId: activeThread.environmentId, + input: { threadId: activeThread.id, ...cursorInput }, + }) + .then((result) => { + // The window/thread was reset while this was in flight — drop the page + // so it can't repopulate state cleared by the reset. + if (olderActivitiesGenRef.current !== gen) { + return; + } + if (result._tag !== "Success") { + return; + } + const page = result.value; + // Advance the cursor to this page's oldest row (pages are ascending, so + // [0] is oldest) even if every row dedupes away — the server cursor is + // strict, so the cursor strictly decreases and paging can't loop, while + // an all-overlap page no longer terminates paging that the server says + // has more. + const pageOldest = page.activities[0]; + if (pageOldest) { + olderCursorRef.current = pageOldest; + } + // Dedup against the LATEST merged set (via ref) so a live append or a + // prior prepend that settled mid-flight can't leave duplicate ids. + const seen = new Set(threadActivitiesRef.current.map((activity) => activity.id)); + const fresh = page.activities.filter((activity) => !seen.has(activity.id)); + if (fresh.length > 0) { + setOlderActivities((prev) => [...fresh, ...prev]); + } + setOlderLoaded(true); + setOlderHasMore(page.hasMore); + setOlderHistoryCursorVersion((version) => version + 1); + }) + .finally(() => { + if (olderActivitiesGenRef.current === gen) { + inFlightOlderKeyRef.current = null; + setLoadingOlderActivities(false); + } + }); + }, [ + activeThread, + activeThreadActivityRequestKey, + hasMoreOlderActivities, + threadActivities, + loadThreadActivities, + ]); + const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]); const pendingApprovals = useMemo( () => derivePendingApprovals(threadActivities), @@ -5334,6 +5502,10 @@ function ChatViewContent(props: ChatViewProps) { activeTurnStartedAt={activeWorkStartedAt} listRef={legendListRef} timelineEntries={timelineEntries} + hasMoreOlder={hasMoreOlderActivities} + loadingOlder={loadingOlderActivities} + olderHistoryCursorVersion={olderHistoryCursorVersion} + onLoadOlder={loadOlderActivities} latestTurn={activeLatestTurn} runningTurnId={ activeThread.session?.status === "running" @@ -5482,7 +5654,9 @@ function ChatViewContent(props: ChatViewProps) { providerStatuses={providerStatuses as ServerProvider[]} activeProjectDefaultModelSelection={activeProject?.defaultModelSelection} activeThreadModelSelection={activeThread?.modelSelection} - activeThreadActivities={activeThread?.activities} + // Use the merged activity set (older pages plus the + // live window) so context state survives pagination. + activeThreadActivities={threadActivities} resolvedTheme={resolvedTheme} settings={settings} keybindings={keybindings} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 1ef8b536edf..99e095fdc43 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -4479,7 +4479,11 @@ export default function Sidebar() { : EMPTY_THREAD_JUMP_LABELS; const orderedSidebarThreadKeys = visibleSidebarThreadKeys; const prewarmedSidebarThreadKeys = useMemo( - () => getSidebarThreadIdsToPrewarm(visibleSidebarThreadKeys), + // Browser clients can sit behind constrained remote links. Prewarming every + // visible thread hydrates several full detail windows before the user opens + // any of them, so keep the eager cache warm-up desktop-only. The active + // route still subscribes to its selected thread normally in either mode. + () => (isElectron ? getSidebarThreadIdsToPrewarm(visibleSidebarThreadKeys) : []), [visibleSidebarThreadKeys], ); const prewarmedSidebarThreadRefs = useMemo( diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 6d74204bc1c..350278734cb 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -4,9 +4,97 @@ import { computeMessageDurationStart, deriveMessagesTimelineRows, normalizeCompactToolLabel, + resolveOlderHistoryAutoLoad, resolveAssistantMessageCopyState, } from "./MessagesTimeline.logic"; +describe("resolveOlderHistoryAutoLoad", () => { + it("does not retry continuously while a failed request leaves the viewport at the start", () => { + const first = resolveOlderHistoryAutoLoad({ + armed: true, + hasMore: true, + isAtStart: true, + loading: false, + observedProgressVersion: 0, + progressVersion: 0, + }); + expect(first).toEqual({ + armed: false, + observedProgressVersion: 0, + shouldLoad: true, + }); + + const afterFailure = resolveOlderHistoryAutoLoad({ + armed: first.armed, + hasMore: true, + isAtStart: true, + loading: false, + observedProgressVersion: first.observedProgressVersion, + progressVersion: 0, + }); + expect(afterFailure).toEqual({ + armed: false, + observedProgressVersion: 0, + shouldLoad: false, + }); + + const afterLeavingStart = resolveOlderHistoryAutoLoad({ + armed: afterFailure.armed, + hasMore: true, + isAtStart: false, + loading: false, + observedProgressVersion: afterFailure.observedProgressVersion, + progressVersion: 0, + }); + expect(afterLeavingStart).toEqual({ + armed: true, + observedProgressVersion: 0, + shouldLoad: false, + }); + + expect( + resolveOlderHistoryAutoLoad({ + armed: afterLeavingStart.armed, + hasMore: true, + isAtStart: true, + loading: false, + observedProgressVersion: afterLeavingStart.observedProgressVersion, + progressVersion: 0, + }), + ).toEqual({ + armed: false, + observedProgressVersion: 0, + shouldLoad: true, + }); + }); + + it("rearms at the start only after a page successfully advances the cursor", () => { + const afterFirstAttempt = resolveOlderHistoryAutoLoad({ + armed: true, + hasMore: true, + isAtStart: true, + loading: false, + observedProgressVersion: 0, + progressVersion: 0, + }); + + expect( + resolveOlderHistoryAutoLoad({ + armed: afterFirstAttempt.armed, + hasMore: true, + isAtStart: true, + loading: false, + observedProgressVersion: afterFirstAttempt.observedProgressVersion, + progressVersion: 1, + }), + ).toEqual({ + armed: false, + observedProgressVersion: 1, + shouldLoad: true, + }); + }); +}); + describe("computeMessageDurationStart", () => { it("returns message createdAt when there is no preceding user message", () => { const result = computeMessageDurationStart([ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 3227bac2413..69cd380b577 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -21,6 +21,51 @@ export interface TimelineEndState { readonly isNearEnd?: boolean; } +export interface OlderHistoryAutoLoadDecision { + readonly armed: boolean; + readonly observedProgressVersion: number; + readonly shouldLoad: boolean; +} + +/** + * Treat reaching the start as an edge, not a continuously-true condition. + * A failed request leaves the viewport at the start, so level-triggered loading + * would immediately retry on every render. Leaving the start OR observing a + * successfully advanced page cursor rearms one future automatic request. The + * visible header control remains available for explicit retries while the edge + * is disarmed. + */ +export function resolveOlderHistoryAutoLoad(input: { + readonly armed: boolean; + readonly hasMore: boolean; + readonly isAtStart: boolean; + readonly loading: boolean; + readonly observedProgressVersion: number; + readonly progressVersion: number; +}): OlderHistoryAutoLoadDecision { + const progressed = input.progressVersion !== input.observedProgressVersion; + const armed = input.armed || progressed; + if (!input.isAtStart) { + return { + armed: true, + observedProgressVersion: input.progressVersion, + shouldLoad: false, + }; + } + if (!armed || !input.hasMore || input.loading) { + return { + armed, + observedProgressVersion: input.progressVersion, + shouldLoad: false, + }; + } + return { + armed: false, + observedProgressVersion: input.progressVersion, + shouldLoad: true, + }; +} + export function resolveTimelineIsAtEnd(state: TimelineEndState | undefined): boolean | undefined { return state?.isNearEnd ?? state?.isAtEnd; } diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 42278637f92..ae5224a8891 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -697,4 +697,37 @@ describe("MessagesTimeline", () => { expect(markup).toContain("lucide-x"); expect(markup).toContain('aria-label="Tool call failed"'); }); + + it("offers a 'Load older history' control when older activity remains", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain("Load older history"); + }); + + it("shows a loading indicator while older history is being fetched", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain("Loading older history"); + }); + + it("renders no older-history control when none remains", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + , + ); + expect(markup).not.toContain("older history"); + }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 699cddee510..21ac4a3ab0c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -72,6 +72,7 @@ import { computeStableMessagesTimelineRows, deriveMessagesTimelineRows, normalizeCompactToolLabel, + resolveOlderHistoryAutoLoad, resolveAssistantMessageCopyState, resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, @@ -187,6 +188,12 @@ interface MessagesTimelineProps { onIsAtEndChange: (isAtEnd: boolean) => void; onManualNavigation: () => void; hideEmptyPlaceholder?: boolean; + /** Older history beyond the live activity window can be lazy-loaded. */ + hasMoreOlder?: boolean; + loadingOlder?: boolean; + /** Increments after the older-history cursor advances or is reset. */ + olderHistoryCursorVersion?: number; + onLoadOlder?: () => void; } // --------------------------------------------------------------------------- @@ -222,6 +229,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onIsAtEndChange, onManualNavigation, hideEmptyPlaceholder = false, + hasMoreOlder = false, + loadingOlder = false, + olderHistoryCursorVersion = 0, + onLoadOlder, }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); @@ -333,6 +344,19 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ); const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0); + const olderHistoryAutoLoadArmedRef = useRef(true); + const olderHistoryObservedProgressVersionRef = useRef(olderHistoryCursorVersion); + const requestOlderHistory = useCallback(() => { + // Disarm before both automatic and explicit requests. If a request fails, + // prop changes while the viewport remains at the start must not trigger an + // immediate retry loop; the header button still permits a deliberate retry. + olderHistoryAutoLoadArmedRef.current = false; + onLoadOlder?.(); + }, [onLoadOlder]); + useEffect(() => { + olderHistoryAutoLoadArmedRef.current = true; + olderHistoryObservedProgressVersionRef.current = olderHistoryCursorVersion; + }, [routeThreadKey, olderHistoryCursorVersion]); const handleAnchorReady = useCallback( (info: { anchorIndex: number | undefined }) => { if (anchorMessageId !== null && info.anchorIndex !== undefined) { @@ -364,6 +388,21 @@ export const MessagesTimeline = memo(function MessagesTimeline({ if (isAtEnd !== undefined) { onIsAtEndChange(isAtEnd); } + // Reaching the top lazy-loads older history; maintainVisibleContentPosition + // (set on the list) keeps the viewport anchored when rows prepend. + const olderHistoryDecision = resolveOlderHistoryAutoLoad({ + armed: olderHistoryAutoLoadArmedRef.current, + hasMore: hasMoreOlder, + isAtStart: state?.isAtStart ?? false, + loading: loadingOlder, + observedProgressVersion: olderHistoryObservedProgressVersionRef.current, + progressVersion: olderHistoryCursorVersion, + }); + olderHistoryAutoLoadArmedRef.current = olderHistoryDecision.armed; + olderHistoryObservedProgressVersionRef.current = olderHistoryDecision.observedProgressVersion; + if (olderHistoryDecision.shouldLoad) { + requestOlderHistory(); + } if (!state || minimapItems.length === 0) { return; } @@ -386,7 +425,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({ strip.dataset.inView = inView ? "true" : "false"; } - }, [listRef, minimapItems, minimapStripMap, onIsAtEndChange]); + }, [ + listRef, + minimapItems, + minimapStripMap, + onIsAtEndChange, + hasMoreOlder, + loadingOlder, + olderHistoryCursorVersion, + requestOlderHistory, + ]); useEffect(() => { const frame = requestAnimationFrame(handleScroll); @@ -418,6 +466,28 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }; }, [timelineViewportElement, rows.length]); + const listHeader = useMemo(() => { + if (loadingOlder) { + return ( +
+ Loading older history… +
+ ); + } + if (hasMoreOlder) { + return ( + + ); + } + return TIMELINE_LIST_HEADER; + }, [loadingOlder, hasMoreOlder, requestOlderHistory]); + const sharedState = useMemo( () => ({ enableGeneratedImageRendering, @@ -471,7 +541,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ [], ); - if (rows.length === 0 && !isWorking) { + // Only short-circuit to the empty state when there is genuinely nothing to + // fetch: the window can derive zero visible rows while older history still + // exists, so the list must remain mounted for its load control. + if (rows.length === 0 && !isWorking && !hasMoreOlder && !loadingOlder) { if (hideEmptyPlaceholder) { return null; } @@ -516,7 +589,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }} onScroll={handleScroll} className="scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5" - ListHeaderComponent={TIMELINE_LIST_HEADER} + ListHeaderComponent={listHeader} ListFooterComponent={TIMELINE_LIST_FOOTER} /> ( @@ -12,6 +12,11 @@ export function createOrchestrationEnvironmentAtoms( label: "environment-data:orchestration:turn-diff", tag: ORCHESTRATION_WS_METHODS.getTurnDiff, }), + // Imperative lazy-load of older thread activities (infinite scroll-up). + loadThreadActivities: createEnvironmentRpcCommand(runtime, { + label: "environment-data:orchestration:thread-activities", + tag: ORCHESTRATION_WS_METHODS.getThreadActivities, + }), fullThreadDiff: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:orchestration:full-thread-diff", tag: ORCHESTRATION_WS_METHODS.getFullThreadDiff, diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 94eb1c65370..379897870de 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -9,9 +9,25 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; -import type { OrchestrationThread } from "@t3tools/contracts"; +import type { OrchestrationThread, OrchestrationThreadActivity } from "@t3tools/contracts"; -import { applyThreadDetailEvent } from "./threadReducer.ts"; +import { + applyThreadDetailEvent, + liveWindowOldestActivityId, + oldestActivityByChronology, +} from "./threadReducer.ts"; + +const activity = (id: string, createdAt: string, sequence?: number): OrchestrationThreadActivity => + ({ + id, + tone: "tool", + kind: "command", + summary: id, + payload: {}, + turnId: TurnId.make("turn-1"), + createdAt, + ...(sequence !== undefined ? { sequence } : {}), + }) as unknown as OrchestrationThreadActivity; const baseEventFields = { eventId: EventId.make("event-1"), @@ -697,4 +713,61 @@ describe("applyThreadDetailEvent", () => { expect(result.kind).toBe("unchanged"); }); }); + + describe("liveWindowOldestActivityId", () => { + it("returns null for an empty window", () => { + expect(liveWindowOldestActivityId([])).toBeNull(); + }); + + it("returns the chronologically-oldest id regardless of array position", () => { + // Reducer order places the unsequenced legacy row (oldest) LAST while the + // server snapshot would list it first; the helper picks it either way. + const window = [ + activity("seq-1", "2026-04-01T10:00:01.000Z", 1), + activity("seq-2", "2026-04-01T10:00:02.000Z", 2), + activity("legacy", "2026-04-01T09:00:00.000Z"), + ]; + expect(liveWindowOldestActivityId(window)).toBe("legacy"); + }); + + it("breaks createdAt ties by id", () => { + const window = [ + activity("b", "2026-04-01T10:00:00.000Z", 2), + activity("a", "2026-04-01T10:00:00.000Z", 1), + ]; + expect(liveWindowOldestActivityId(window)).toBe("a"); + }); + + it("is stable when a newer activity is appended (no false reshape)", () => { + const before = [ + activity("legacy", "2026-04-01T09:00:00.000Z"), + activity("seq-1", "2026-04-01T10:00:01.000Z", 1), + ]; + // A live append is the newest activity and is unsequenced in the payload; + // it must not change the detected oldest boundary. + const after = [...before, activity("appended", "2026-04-01T11:00:00.000Z")]; + expect(liveWindowOldestActivityId(after)).toBe(liveWindowOldestActivityId(before)); + expect(liveWindowOldestActivityId(after)).toBe("legacy"); + }); + }); + + describe("oldestActivityByChronology", () => { + it("returns null for an empty set", () => { + expect(oldestActivityByChronology([])).toBeNull(); + }); + + it("returns the unsequenced legacy row so the pagination cursor agrees with the sentinel", () => { + // The reducer placed the legacy (unsequenced, oldest) row at the END; paging + // must cursor from it (createdAt cursor), not from index 0's sequenced row. + const merged = [ + activity("seq-5", "2026-04-01T10:00:05.000Z", 5), + activity("seq-6", "2026-04-01T10:00:06.000Z", 6), + activity("legacy", "2026-04-01T09:00:00.000Z"), + ]; + const oldest = oldestActivityByChronology(merged); + expect(oldest?.id).toBe("legacy"); + expect(oldest?.sequence).toBeUndefined(); // → drives the unsequenced cursor + expect(liveWindowOldestActivityId(merged)).toBe(oldest?.id); // sentinel agrees + }); + }); }); diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 670540fee70..37ae19b44d7 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -35,6 +35,45 @@ const activityOrder = O.combineAll([ O.mapInput(O.String, (a) => a.id), ]); +/** + * The oldest activity in a set, by chronology (`createdAt`, then `id`) rather + * than array position. + * + * `activities[0]` is not a stable "oldest": {@link activityOrder} sorts + * unsequenced rows to the end (a missing `sequence` is treated as newest) while + * the server snapshot lists legacy unsequenced rows first, so the first live + * append re-sorts the array and shifts index 0. Both the lazy-load *reshape* + * sentinel ({@link liveWindowOldestActivityId}) and the lazy-load *pagination + * cursor* derive from this so they agree on which row is oldest regardless of + * the reducer's placement of unsequenced rows. Returns `null` when empty. + */ +export function oldestActivityByChronology( + activities: ReadonlyArray, +): OrchestrationThreadActivity | null { + let oldest: OrchestrationThreadActivity | null = null; + for (const activity of activities) { + if ( + oldest === null || + activity.createdAt < oldest.createdAt || + (activity.createdAt === oldest.createdAt && activity.id < oldest.id) + ) { + oldest = activity; + } + } + return oldest; +} + +/** + * The id of {@link oldestActivityByChronology}, used as the lazy-load reshape + * sentinel (a reconnect re-snapshot or checkpoint revert changes it; a plain + * append does not). Returns `null` when empty. + */ +export function liveWindowOldestActivityId( + activities: ReadonlyArray, +): OrchestrationThreadActivity["id"] | null { + return oldestActivityByChronology(activities)?.id ?? null; +} + /** * Apply a single orchestration event to an `OrchestrationThread`, returning * the updated thread, a deletion signal, or an "unchanged" marker when the diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 972c4f00263..26c3b3cb8d7 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -25,6 +25,7 @@ import { ProviderInstanceId } from "./providerInstance.ts"; export const ORCHESTRATION_WS_METHODS = { dispatchCommand: "orchestration.dispatchCommand", getTurnDiff: "orchestration.getTurnDiff", + getThreadActivities: "orchestration.getThreadActivities", getFullThreadDiff: "orchestration.getFullThreadDiff", replayEvents: "orchestration.replayEvents", getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot", @@ -387,6 +388,10 @@ export const OrchestrationThread = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed([])), ), activities: Schema.Array(OrchestrationThreadActivity), + // The detail snapshot windows `activities` to the most recent page; this is + // true when older activities exist beyond the window and can be lazy-loaded + // via the getThreadActivities RPC. Absent on lightweight (shell) threads. + hasMoreActivities: Schema.optional(Schema.Boolean), checkpoints: Schema.Array(OrchestrationCheckpointSummary), session: Schema.NullOr(OrchestrationSession), }); @@ -1267,6 +1272,37 @@ export type OrchestrationGetTurnDiffInput = typeof OrchestrationGetTurnDiffInput export const OrchestrationGetTurnDiffResult = ThreadTurnDiff; export type OrchestrationGetTurnDiffResult = typeof OrchestrationGetTurnDiffResult.Type; +/** + * Cursor-paginated load of a thread's OLDER activities (lazy-load / infinite + * scroll). Sequenced activity uses `beforeSequence`, the `sequence` of the + * oldest activity the client currently holds. Legacy unsequenced activity uses + * the `(beforeCreatedAt, beforeActivityId)` pair from the oldest loaded + * activity. The server returns the page of activities immediately older than the + * cursor (chronological ascending) plus whether any remain beyond that. + */ +export const OrchestrationGetThreadActivitiesInput = Schema.Union([ + Schema.Struct({ + threadId: ThreadId, + beforeSequence: NonNegativeInt, + limit: Schema.optional(NonNegativeInt), + }), + Schema.Struct({ + threadId: ThreadId, + beforeCreatedAt: IsoDateTime, + beforeActivityId: EventId, + limit: Schema.optional(NonNegativeInt), + }), +]); +export type OrchestrationGetThreadActivitiesInput = + typeof OrchestrationGetThreadActivitiesInput.Type; + +export const OrchestrationGetThreadActivitiesResult = Schema.Struct({ + activities: Schema.Array(OrchestrationThreadActivity), + hasMore: Schema.Boolean, +}); +export type OrchestrationGetThreadActivitiesResult = + typeof OrchestrationGetThreadActivitiesResult.Type; + export const OrchestrationGetFullThreadDiffInput = Schema.Struct({ threadId: ThreadId, toTurnCount: NonNegativeInt, @@ -1294,6 +1330,10 @@ export const OrchestrationRpcSchemas = { input: OrchestrationGetTurnDiffInput, output: OrchestrationGetTurnDiffResult, }, + getThreadActivities: { + input: OrchestrationGetThreadActivitiesInput, + output: OrchestrationGetThreadActivitiesResult, + }, getFullThreadDiff: { input: OrchestrationGetFullThreadDiffInput, output: OrchestrationGetFullThreadDiffResult, @@ -1340,6 +1380,14 @@ export class OrchestrationGetTurnDiffError extends Schema.TaggedErrorClass()( + "OrchestrationGetThreadActivitiesError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) {} + export class OrchestrationGetFullThreadDiffError extends Schema.TaggedErrorClass()( "OrchestrationGetFullThreadDiffError", { diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 9717775e644..2c08105261f 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -54,6 +54,8 @@ import { OrchestrationGetFullThreadDiffError, OrchestrationGetFullThreadDiffInput, OrchestrationGetSnapshotError, + OrchestrationGetThreadActivitiesError, + OrchestrationGetThreadActivitiesInput, OrchestrationGetTurnDiffError, OrchestrationGetTurnDiffInput, OrchestrationReplayEventsError, @@ -648,6 +650,15 @@ export const WsOrchestrationGetTurnDiffRpc = Rpc.make(ORCHESTRATION_WS_METHODS.g error: Schema.Union([OrchestrationGetTurnDiffError, EnvironmentAuthorizationError]), }); +export const WsOrchestrationGetThreadActivitiesRpc = Rpc.make( + ORCHESTRATION_WS_METHODS.getThreadActivities, + { + payload: OrchestrationGetThreadActivitiesInput, + success: OrchestrationRpcSchemas.getThreadActivities.output, + error: Schema.Union([OrchestrationGetThreadActivitiesError, EnvironmentAuthorizationError]), + }, +); + export const WsOrchestrationGetFullThreadDiffRpc = Rpc.make( ORCHESTRATION_WS_METHODS.getFullThreadDiff, { @@ -792,6 +803,7 @@ export const WsRpcGroup = RpcGroup.make( WsSubscribeAuthAccessRpc, WsOrchestrationDispatchCommandRpc, WsOrchestrationGetTurnDiffRpc, + WsOrchestrationGetThreadActivitiesRpc, WsOrchestrationGetFullThreadDiffRpc, WsOrchestrationReplayEventsRpc, WsOrchestrationGetArchivedShellSnapshotRpc, From e23adfb6e3b728dd6bc72703c9114a4ccf981b45 Mon Sep 17 00:00:00 2001 From: jln <85513960+jln13x@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:58:17 +0300 Subject: [PATCH 4/9] docs: record upstream PR integrations Retire the diff-workflow flag now covered by upstream defaults and preserve custom Codex launch arguments during project skill discovery. --- .../src/provider/Drivers/CodexDriver.ts | 2 + .../components/settings/SettingsPanels.tsx | 8 -- docs/personal-fork-changes.md | 12 ++- docs/upstream-integrations.md | 80 +++++++++++++++++++ packages/contracts/src/settings.test.ts | 1 - packages/contracts/src/settings.ts | 2 - 6 files changed, 93 insertions(+), 12 deletions(-) create mode 100644 docs/upstream-integrations.md diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 2289086d7af..7f681d9f9a3 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -41,6 +41,7 @@ import { makePendingCodexProvider, probeCodexAppServerProvider, } from "../Layers/CodexProvider.ts"; +import { resolveCodexLaunchArgs } from "../Layers/codexLaunchArgs.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; @@ -173,6 +174,7 @@ export const CodexDriver: ProviderDriver = { return yield* probeCodexAppServerProvider({ binaryPath: effectiveConfig.binaryPath, homePath: effectiveConfig.homePath, + launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, processEnv), cwd, customModels: [], environment: processEnv, diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 1985ec13611..57dc94ffd65 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -122,7 +122,6 @@ type PersonalFeatureFlagName = Extract< | "enableTextFileAttachments" | "enableGeneratedImageRendering" | "enableProjectSearch" - | "enablePersonalDiffWorkflow" >; const PERSONAL_FEATURE_SETTINGS = [ @@ -176,11 +175,6 @@ const PERSONAL_FEATURE_SETTINGS = [ title: "Project search", description: "Enable the project file picker and global content search.", }, - { - key: "enablePersonalDiffWorkflow", - title: "Working-change diff workflow", - description: "Prefer working changes and keep diffs scoped to the active worktree.", - }, ] as const satisfies ReadonlyArray<{ readonly key: PersonalFeatureFlagName; readonly title: string; @@ -543,7 +537,6 @@ export function useSettingsRestore(onRestored?: () => void) { settings.enableTextFileAttachments, settings.enableGeneratedImageRendering, settings.enableProjectSearch, - settings.enablePersonalDiffWorkflow, theme, ], ); @@ -584,7 +577,6 @@ export function useSettingsRestore(onRestored?: () => void) { enableTextFileAttachments: DEFAULT_UNIFIED_SETTINGS.enableTextFileAttachments, enableGeneratedImageRendering: DEFAULT_UNIFIED_SETTINGS.enableGeneratedImageRendering, enableProjectSearch: DEFAULT_UNIFIED_SETTINGS.enableProjectSearch, - enablePersonalDiffWorkflow: DEFAULT_UNIFIED_SETTINGS.enablePersonalDiffWorkflow, }); onRestored?.(); }, [changedSettingLabels, onRestored, setTheme, updateSettings]); diff --git a/docs/personal-fork-changes.md b/docs/personal-fork-changes.md index 085cd1b2571..4b81411989e 100644 --- a/docs/personal-fork-changes.md +++ b/docs/personal-fork-changes.md @@ -14,7 +14,17 @@ Turning a flag off preserves upstream behavior. | Markdown and text file attachments | `enableTextFileAttachments` | On | | Inline generated-image rendering | `enableGeneratedImageRendering` | On | | Project file and content search | `enableProjectSearch` | On | -| Working-change diff workflow | `enablePersonalDiffWorkflow` | On | + +Upstream syncs and selectively integrated pull requests are tracked in +[Upstream Integrations](./upstream-integrations.md). + +## Retired customizations + +- `enablePersonalDiffWorkflow` was retired on 2026-07-20. Upstream now defaults diffs to working + changes and refreshes reopened diff tabs via + [#3974](https://github.com/pingdotgg/t3code/pull/3974) and + [#3973](https://github.com/pingdotgg/t3code/pull/3973). The fork retains its worktree-aware diff + root and generation-based preview invalidation as unconditional behavior. ## Desktop fork identity diff --git a/docs/upstream-integrations.md b/docs/upstream-integrations.md new file mode 100644 index 00000000000..0650db90851 --- /dev/null +++ b/docs/upstream-integrations.md @@ -0,0 +1,80 @@ +# Upstream Integrations + +This is the provenance ledger for changes brought from +[`pingdotgg/t3code`](https://github.com/pingdotgg/t3code) into the personal fork. Record the exact +upstream revision, local adaptation, and verification whenever this worktree integrates an upstream +pull request. + +## Remote-agent deployment profile + +The primary deployment is a T3 server running on a VPS and reached directly over Tailscale. It does +not use Android and does not currently depend on the hosted T3 Connect relay. + +That makes long-lived server memory, reconnect/catch-up behavior, remote history loading, and +background VPS process load the highest-priority upstream areas. T3 Connect and mobile-only changes +remain part of normal upstream syncs, but are not reasons to selectively port an open pull request. + +## 2026-07-20 upstream sync + +- Merged `upstream/main` at `5d34f9ff235115d43a6cb4b4561d10badf218b87` into the fork baseline + `09860ea2b7c377c82e46fdf05bed46abce138d2e`. +- Preserved the fork's desktop identity, native macOS sidebar presentation, checkout-aware thread + creation, worktree grouping, file-drag mentions, and Codex project-skill discovery. +- Adopted upstream's working-change diff defaults and retired the now-redundant + `enablePersonalDiffWorkflow` flag. The fork's worktree-aware diff root and cache invalidation remain + active. +- Relevant remote-agent improvements arriving through this baseline include headless T3 Connect + setup ([#3749](https://github.com/pingdotgg/t3code/pull/3749)), lightweight connection probes + ([#4137](https://github.com/pingdotgg/t3code/pull/4137)), and faster new-chat/offline catch-up + ([#4177](https://github.com/pingdotgg/t3code/pull/4177)). Of these, #4137 and #4177 directly benefit + direct Tailscale connections; #3749 is available but is not required by the current deployment. + +## Selectively integrated open pull requests + +### Priority 1: remote history pagination — #4018 + +- Source: [fix(web): paginate large thread history for remote clients + #4018](https://github.com/pingdotgg/t3code/pull/4018), head + `de8fd65934768173819b93adcd6b92af3e8c7fc3`. +- Why: bounds initial activity reads and lets the web client fetch older conversation history on + demand. This reduces VPS heap pressure and remote reconnect payload size for long-running threads. +- Local adaptation: retained the newer upstream draft-hero/composer layout and fed its context meter + the merged paginated-plus-live activity set. The fork's empty-draft presentation remains intact. +- Verification: projection pagination, reducer merging/deduplication, and timeline auto-load tests. + +### Priority 2: bounded long-lived state — #4176 + +- Source: [perf(orchestration): bound in-memory read model and client per-thread state + #4176](https://github.com/pingdotgg/t3code/pull/4176), head + `56b6615afdfe3804a466e33cbab9056b8981f217`. +- Why: caps orchestration read-model growth and removes browser, preview, VCS broadcaster, and UI + state when threads are deleted. This protects a continuously running VPS from memory growth tied + to lifetime thread count. +- Local adaptation: none; the upstream commit applied cleanly after the main sync. +- Verification: command read-model, deletion cleanup, projector, VCS broadcaster, and client-store + regression tests supplied by the pull request. + +### Priority 3: lower background Git and port polling — #4187 + +- Source: [Reduce background Git ref and port polling + #4187](https://github.com/pingdotgg/t3code/pull/4187), head + `5b816e5fce668d361ec417431535fb9500c51cb1`. +- Why: reduces idle Git-ref refreshes and system-wide port scans on the VPS while preserving immediate + refreshes when selectors open or managed terminal processes change. +- Local adaptation: integrated as a squash because the pull-request branch contains merge history; + omitted its branch-local `BRANCH_DETAILS.md` in favor of this ledger. +- Verification: port-scanner replay, ordering, retention, and redundant-scan regression tests. + +All three pull requests were still open at integration time. On later upstream syncs, compare their +final merged commits against these recorded heads before dropping or resolving duplicate patches. + +## Integration checklist + +1. Fetch `upstream/main` and the candidate pull-request head. +2. Record the pull-request URL, exact head SHA, priority, and deployment rationale here. +3. Prefer the final net diff when a pull-request branch contains merge commits; preserve upstream + commits directly when their history is clean. +4. Resolve against current fork behavior at the narrowest boundary and update + `personal-fork-changes.md` for preserved, replaced, or retired customizations. +5. Run focused regression tests plus `vp check` and `vp run typecheck`. Run native-mobile lint when an + upstream sync changes native mobile code. diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index c080ec6e3cf..4665eac2e9d 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -54,7 +54,6 @@ describe("personal fork feature flags", () => { "enableTextFileAttachments", "enableGeneratedImageRendering", "enableProjectSearch", - "enablePersonalDiffWorkflow", ] as const; it("defaults every personal feature on", () => { diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 41c9448f61d..f85f4cc9a82 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -398,7 +398,6 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed(true)), ), enableProjectSearch: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), - enablePersonalDiffWorkflow: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), automaticGitFetchInterval: Schema.DurationFromMillis.pipe( Schema.withDecodingDefault( Effect.succeed(Duration.toMillis(DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL)), @@ -548,7 +547,6 @@ export const ServerSettingsPatch = Schema.Struct({ enableTextFileAttachments: Schema.optionalKey(Schema.Boolean), enableGeneratedImageRendering: Schema.optionalKey(Schema.Boolean), enableProjectSearch: Schema.optionalKey(Schema.Boolean), - enablePersonalDiffWorkflow: Schema.optionalKey(Schema.Boolean), automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), From 9fd04f51c1495927e2dc5b7da9a98a77894d8a6c Mon Sep 17 00:00:00 2001 From: jln <85513960+jln13x@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:31:18 +0300 Subject: [PATCH 5/9] docs: simplify upstream PR ledger --- docs/personal-fork-changes.md | 6 +++--- docs/upstream-integrations.md | 21 +++++++-------------- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/docs/personal-fork-changes.md b/docs/personal-fork-changes.md index 0fad4053bd3..dc759f569a0 100644 --- a/docs/personal-fork-changes.md +++ b/docs/personal-fork-changes.md @@ -21,9 +21,9 @@ Upstream PRs integrated into the fork are listed in ## Retired customizations -- Working-change diff workflow (`enablePersonalDiffWorkflow`): retired during the July 2026 - upstream sync after upstream adopted working-tree-first diff selection and active-worktree - scoping. The redundant feature flag and settings control were removed. +- Working-change diff workflow (`enablePersonalDiffWorkflow`): retired after upstream adopted + working-tree-first diff selection and active-worktree scoping. The redundant feature flag and + settings control were removed. ## Desktop fork identity diff --git a/docs/upstream-integrations.md b/docs/upstream-integrations.md index f180411c208..e7ca5a3bbd5 100644 --- a/docs/upstream-integrations.md +++ b/docs/upstream-integrations.md @@ -1,15 +1,8 @@ -# Upstream PR Integrations +# Upstream PRs -This file tracks upstream pull requests selectively integrated into the personal fork. Routine -`upstream/main` merges are not listed. - -- **2026-07-20 — [#4018: Paginate large thread history for remote - clients](https://github.com/pingdotgg/t3code/pull/4018):** Bounds initial activity reads and loads - older conversation history on demand, reducing memory use and reconnect payloads for long-running - remote threads. -- **2026-07-20 — [#4176: Bound in-memory read model and client per-thread - state](https://github.com/pingdotgg/t3code/pull/4176):** Caps orchestration state and cleans up - browser, preview, VCS, and UI state when threads are deleted. -- **2026-07-20 — [#4187: Reduce background Git ref and port - polling](https://github.com/pingdotgg/t3code/pull/4187):** Reduces idle Git and port-scanning work - while retaining immediate refreshes after relevant user actions and terminal changes. +- [#4018](https://github.com/pingdotgg/t3code/pull/4018) — Loads older conversation history on + demand. This reduces memory use and reconnect payloads for long-running remote threads. +- [#4176](https://github.com/pingdotgg/t3code/pull/4176) — Caps orchestration state. It also cleans + up browser, preview, VCS, and UI state when threads are deleted. +- [#4187](https://github.com/pingdotgg/t3code/pull/4187) — Reduces idle Git and port-scanning work. + Relevant user actions and terminal changes still refresh immediately. From 33d2c7e35a272b7f0e4811112914089bc01aba44 Mon Sep 17 00:00:00 2001 From: Chamaru Amasara Date: Tue, 21 Jul 2026 00:47:18 +0530 Subject: [PATCH 6/9] fix(server): keep provider sessions alive during background agent work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The provider session reaper stops sessions after 30 min of lastSeenAt inactivity. Background agent work (dynamic workflows, subagents) keeps running after the foreground turn settles, but the adapter session goes "ready" with no active turn and nothing refreshes lastSeenAt — so the reaper tears the session down mid-workflow, and the in-flight background orchestration is lost. Refresh lastSeenAt from runtime activity: ProviderService now bumps the binding's last_seen_at when the adapter emits runtime events (e.g. task.progress from background subagents), throttled per thread so an event burst is at most one lightweight write per window. A new targeted touchLastSeen on the runtime repository / session directory updates only last_seen_at, and only for non-stopped rows so a reaped session is never resurrected. Co-Authored-By: Claude Opus 4.8 --- .../src/persistence/ProviderSessionRuntime.ts | 41 ++++++++++ .../src/provider/Layers/CodexAdapter.test.ts | 1 + .../provider/Layers/OpenCodeAdapter.test.ts | 1 + .../provider/Layers/ProviderService.test.ts | 75 +++++++++++++++++++ .../src/provider/Layers/ProviderService.ts | 39 +++++++++- .../Layers/ProviderSessionDirectory.test.ts | 60 +++++++++++++++ .../Layers/ProviderSessionDirectory.ts | 9 +++ .../Services/ProviderSessionDirectory.ts | 11 +++ 8 files changed, 236 insertions(+), 1 deletion(-) diff --git a/apps/server/src/persistence/ProviderSessionRuntime.ts b/apps/server/src/persistence/ProviderSessionRuntime.ts index 2ccdd862522..c2be2c046f1 100644 --- a/apps/server/src/persistence/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/ProviderSessionRuntime.ts @@ -58,6 +58,12 @@ export type GetProviderSessionRuntimeInput = typeof GetProviderSessionRuntimeInp export const DeleteProviderSessionRuntimeInput = Schema.Struct({ threadId: ThreadId }); export type DeleteProviderSessionRuntimeInput = typeof DeleteProviderSessionRuntimeInput.Type; +export const TouchLastSeenInput = Schema.Struct({ + threadId: ThreadId, + lastSeenAt: IsoDateTime, +}); +export type TouchLastSeenInput = typeof TouchLastSeenInput.Type; + /** * ProviderSessionRuntimeRepository - Service tag for provider runtime persistence. */ @@ -93,6 +99,18 @@ export class ProviderSessionRuntimeRepository extends Context.Service< ProviderSessionRuntimeRepositoryError >; + /** + * Bump only `last_seen_at` for an existing, non-stopped row. + * + * A targeted update used to keep a session's inactivity clock fresh from + * background runtime activity (e.g. a running dynamic workflow) without + * rewriting the full runtime payload. Rows in `stopped` status are left + * untouched so a reaped session is never resurrected. + */ + readonly touchLastSeen: ( + input: TouchLastSeenInput, + ) => Effect.Effect; + /** * Delete provider runtime state by canonical thread id. */ @@ -235,6 +253,17 @@ export const make = Effect.gen(function* () { `, }); + const touchLastSeenRow = SqlSchema.void({ + Request: TouchLastSeenInput, + execute: ({ threadId, lastSeenAt }) => + sql` + UPDATE provider_session_runtime + SET last_seen_at = ${lastSeenAt} + WHERE thread_id = ${threadId} + AND status != 'stopped' + `, + }); + const upsert: ProviderSessionRuntimeRepository["Service"]["upsert"] = (runtime) => upsertRuntimeRow(runtime).pipe( Effect.mapError( @@ -308,6 +337,17 @@ export const make = Effect.gen(function* () { ), ); + const touchLastSeen: ProviderSessionRuntimeRepository["Service"]["touchLastSeen"] = (input) => + touchLastSeenRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProviderSessionRuntimeRepository.touchLastSeen:query", + "ProviderSessionRuntimeRepository.touchLastSeen:encodeRequest", + { threadId: input.threadId }, + ), + ), + ); + const deleteByThreadId: ProviderSessionRuntimeRepository["Service"]["deleteByThreadId"] = ( input, ) => @@ -326,6 +366,7 @@ export const make = Effect.gen(function* () { upsert, getByThreadId, list, + touchLastSeen, deleteByThreadId, } satisfies ProviderSessionRuntimeRepository["Service"]; }); diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 4ae654a5187..4d151ebe058 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -218,6 +218,7 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory getBinding: () => Effect.succeed(Option.none()), listThreadIds: () => Effect.succeed([]), listBindings: () => Effect.succeed([]), + touchLastSeen: () => Effect.void, }); const validationRuntimeFactory = makeRuntimeFactory(); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 1385ccbaabe..6ec56d819b0 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -238,6 +238,7 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory getBinding: () => Effect.succeed(Option.none()), listThreadIds: () => Effect.succeed([]), listBindings: () => Effect.succeed([]), + touchLastSeen: () => Effect.void, }); // The adapter now receives its settings as a plain argument (the old design diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index ccbbce1759f..1baa612d0d5 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -642,6 +642,81 @@ it.effect("ProviderServiceLive writes canonical events to the emitting thread se }).pipe(Effect.provide(NodeServices.layer)), ); +it.effect("ProviderServiceLive touches lastSeenAt on background runtime activity, throttled", () => + Effect.gen(function* () { + const codex = makeFakeCodexAdapter(); + const registry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("codex")]: codex.adapter, + }); + + // Spy directory: records every touchLastSeen call so we assert the wiring + // directly (a runtime event triggers a touch) rather than inferring it from + // DB timestamp noise, and confirms throttling collapses a burst. + const touched: string[] = []; + const spyDirectoryLayer = Layer.succeed(ProviderSessionDirectory.ProviderSessionDirectory, { + upsert: () => Effect.void, + getProvider: () => Effect.die(new Error("getProvider unused in test")), + getBinding: () => Effect.succeed(Option.none()), + listThreadIds: () => Effect.succeed([]), + listBindings: () => Effect.succeed([]), + touchLastSeen: (threadId) => + Effect.sync(() => { + touched.push(threadId); + }), + }); + + const threadId = asThreadId("thread-runtime-activity-touch"); + + const providerLayer = makeProviderServiceLive({ + // Tight throttle window so the test can prove both "touches" and + // "throttles a rapid burst" deterministically with the test clock. + runtimeActivityTouchThrottleMs: 1_000, + }).pipe( + Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)), + Layer.provide(spyDirectoryLayer), + Layer.provide(defaultServerSettingsLayer), + Layer.provide(AnalyticsService.layerTest), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + ); + + const emitTaskProgress = (id: string) => + codex.emit({ + eventId: asEventId(id), + provider: ProviderDriverKind.make("codex"), + threadId, + createdAt: "2026-01-01T00:05:00.000Z", + type: "task.progress", + payload: {}, + }); + + yield* Effect.gen(function* () { + yield* ProviderService.ProviderService; + yield* advanceTestClock(10); + + // First background event → one touch. + emitTaskProgress("evt-task-progress-1"); + yield* advanceTestClock(20); + assert.deepEqual(touched, [threadId]); + + // Rapid second event within the throttle window → collapsed (no new touch). + emitTaskProgress("evt-task-progress-2"); + yield* advanceTestClock(20); + assert.deepEqual(touched, [threadId]); + + // After the throttle window elapses, a further event touches again. + yield* advanceTestClock(1_000); + emitTaskProgress("evt-task-progress-3"); + yield* advanceTestClock(20); + assert.deepEqual(touched, [threadId, threadId]); + }).pipe(Effect.provide(providerLayer)); + }).pipe(Effect.provide(NodeServices.layer)), +); + it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", () => Effect.gen(function* () { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-service-")); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 2eaaeb8ce3c..ecd702d085f 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -25,6 +25,7 @@ import { type ProviderSession, } from "@t3tools/contracts"; import { causeErrorTag } from "@t3tools/shared/observability"; +import * as Clock from "effect/Clock"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -64,6 +65,11 @@ const isModelSelection = Schema.is(ModelSelection); */ export interface ProviderServiceLiveOptions { readonly canonicalEventLogger?: EventNdjsonLogger; + /** + * Minimum interval between `lastSeenAt` refreshes triggered by runtime + * activity, per thread. Defaults to 60s. Exposed for tests. + */ + readonly runtimeActivityTouchThrottleMs?: number; } type ProviderServiceMethod = @@ -213,6 +219,13 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const registry = yield* ProviderAdapterRegistry.ProviderAdapterRegistry; const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; const runtimeEventPubSub = yield* PubSub.unbounded(); + // Throttle `lastSeenAt` refreshes keyed by thread. A running dynamic workflow + // emits many runtime events per second (`task.progress`, token-usage updates, + // …); we only need to bump the inactivity clock often enough that the session + // reaper never mistakes an active background session for an idle one. One + // write per thread per window is plenty and keeps the DB churn negligible. + const lastSeenTouchMs = options?.runtimeActivityTouchThrottleMs ?? 60_000; + const lastSeenTouchByThread = new Map(); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) => McpSessionRegistry.issueActiveMcpCredential({ threadId, providerInstanceId }).pipe( @@ -281,6 +294,27 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }); }); + // Keep a live session's inactivity clock fresh from runtime activity. This + // matters for work that runs *after* the foreground turn settles — a + // background dynamic workflow / subagents keep emitting runtime events (e.g. + // `task.progress`) while the adapter session has already gone `ready` with no + // active turn. Without this, the session reaper sees a stale `lastSeenAt` and + // tears the session down mid-workflow. Throttled per thread so a burst of + // events is at most one lightweight `last_seen_at` write per window. + const refreshLastSeenForActivity = (event: ProviderRuntimeEvent): Effect.Effect => + Effect.gen(function* () { + const threadId = event.threadId; + if (threadId === undefined) return; + const now = yield* Clock.currentTimeMillis; + const previous = lastSeenTouchByThread.get(threadId); + if (previous !== undefined && now - previous < lastSeenTouchMs) return; + lastSeenTouchByThread.set(threadId, now); + // Best-effort: a failed touch must never break event processing. The row + // may be absent/stopped (touch is a no-op) or the write may transiently + // fail; the next event refreshes it. + yield* directory.touchLastSeen(threadId).pipe(Effect.catchCause(() => Effect.void)); + }); + const processRuntimeEvent = ( source: { readonly instanceId: ProviderInstanceId; @@ -293,7 +327,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( increment(providerRuntimeEventsTotal, { provider: canonicalEvent.provider, eventType: canonicalEvent.type, - }).pipe(Effect.andThen(publishRuntimeEvent(canonicalEvent))), + }).pipe( + Effect.andThen(refreshLastSeenForActivity(canonicalEvent)), + Effect.andThen(publishRuntimeEvent(canonicalEvent)), + ), ), ); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index 079b7f10ebf..ca4e4eaa8c4 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -196,6 +196,66 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL ]); })); + it("touchLastSeen bumps last_seen_at for a live binding", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const threadId = ThreadId.make("thread-touch-live"); + + yield* runtimeRepository.upsert({ + threadId, + providerName: "claudeAgent", + providerInstanceId: null, + adapterKey: "claudeAgent", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-01-01T00:00:00.000Z", + resumeCursor: null, + runtimePayload: null, + }); + + yield* directory.touchLastSeen(threadId); + + const runtime = yield* runtimeRepository.getByThreadId({ threadId }); + assert.equal(Option.isSome(runtime), true); + if (Option.isSome(runtime)) { + // A fresh timestamp replaces the stale one; other fields are preserved. + assert.notEqual(runtime.value.lastSeenAt, "2026-01-01T00:00:00.000Z"); + assert.equal(runtime.value.status, "running"); + assert.equal(runtime.value.providerName, "claudeAgent"); + } + })); + + it("touchLastSeen does not resurrect a stopped binding", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const threadId = ThreadId.make("thread-touch-stopped"); + const stoppedAt = "2026-01-01T00:00:00.000Z"; + + yield* runtimeRepository.upsert({ + threadId, + providerName: "claudeAgent", + providerInstanceId: null, + adapterKey: "claudeAgent", + runtimeMode: "full-access", + status: "stopped", + lastSeenAt: stoppedAt, + resumeCursor: null, + runtimePayload: null, + }); + + yield* directory.touchLastSeen(threadId); + + const runtime = yield* runtimeRepository.getByThreadId({ threadId }); + assert.equal(Option.isSome(runtime), true); + if (Option.isSome(runtime)) { + // Stopped rows are left untouched — the reaper already killed them. + assert.equal(runtime.value.lastSeenAt, stoppedAt); + assert.equal(runtime.value.status, "stopped"); + } + })); + it("resets adapterKey to the new provider when provider changes without an explicit adapter key", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 23075bd9a06..9c99263c23d 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -182,12 +182,21 @@ const makeProviderSessionDirectory = Effect.gen(function* () { ), ); + const touchLastSeen: ProviderSessionDirectoryShape["touchLastSeen"] = (threadId) => + Effect.gen(function* () { + const now = DateTime.formatIso(yield* DateTime.now); + yield* repository + .touchLastSeen({ threadId, lastSeenAt: now }) + .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.touchLastSeen"))); + }); + return { upsert, getProvider, getBinding, listThreadIds, listBindings, + touchLastSeen, } satisfies ProviderSessionDirectoryShape; }); diff --git a/apps/server/src/provider/Services/ProviderSessionDirectory.ts b/apps/server/src/provider/Services/ProviderSessionDirectory.ts index f2dd4323f7a..a9be9d04cd3 100644 --- a/apps/server/src/provider/Services/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Services/ProviderSessionDirectory.ts @@ -62,6 +62,17 @@ export interface ProviderSessionDirectoryShape { ReadonlyArray, ProviderSessionDirectoryPersistenceError >; + + /** + * Bump only `last_seen_at` for a live (non-stopped) binding. + * + * Used to keep a session's inactivity clock fresh from background runtime + * activity without rewriting the full binding. No-op if the row is absent + * or already stopped. + */ + readonly touchLastSeen: ( + threadId: ThreadId, + ) => Effect.Effect; } export class ProviderSessionDirectory extends Context.Service< From 93a54fe294404c2c024f5bdbfcbcd2c3dcb1aebb Mon Sep 17 00:00:00 2001 From: Chrrxs Date: Wed, 15 Jul 2026 10:25:03 -0400 Subject: [PATCH 7/9] perf(server): skip shell refresh for streaming deltas Streaming assistant messages still update their message projection and thread timestamp, but no longer rebuild the full thread shell summary for every delta. Add a regression test that fails if the hot path reads activity history. --- .../Layers/ProjectionPipeline.test.ts | 132 ++++++++++++++++++ .../Layers/ProjectionPipeline.ts | 8 +- 2 files changed, 139 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 0999000ed4f..ac43c721e04 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -175,6 +175,138 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { ); }); +it.layer( + Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-streaming-shell-summary-")), +)("OrchestrationProjectionPipeline", (it) => { + it.effect("does not rescan thread shell history for streaming assistant messages", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread-streaming-shell-summary"); + const messageId = MessageId.make("message-streaming-shell-summary"); + const createdAt = "2026-01-01T00:00:00.000Z"; + const deltaAt = "2026-01-01T00:00:01.000Z"; + const appendAndProject = (event: Parameters[0]) => + eventStore + .append(event) + .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); + + yield* appendAndProject({ + type: "project.created", + eventId: EventId.make("evt-streaming-shell-summary-1"), + aggregateKind: "project", + aggregateId: ProjectId.make("project-streaming-shell-summary"), + occurredAt: createdAt, + commandId: CommandId.make("cmd-streaming-shell-summary-1"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-streaming-shell-summary-1"), + metadata: {}, + payload: { + projectId: ProjectId.make("project-streaming-shell-summary"), + title: "Streaming shell summary", + workspaceRoot: "/tmp/project-streaming-shell-summary", + defaultModelSelection: null, + scripts: [], + createdAt, + updatedAt: createdAt, + }, + }); + + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.make("evt-streaming-shell-summary-2"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: createdAt, + commandId: CommandId.make("cmd-streaming-shell-summary-2"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-streaming-shell-summary-2"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-streaming-shell-summary"), + title: "Streaming shell summary", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + + // A streaming assistant delta must not read activity history. This invalid + // sentinel makes any accidental list/decode deterministic and immediately red. + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + ) VALUES ( + 'activity-streaming-shell-summary', + ${threadId}, + NULL, + 'info', + 'test.sentinel', + 'must not be decoded', + '{', + NULL, + ${createdAt} + ) + `; + + yield* appendAndProject({ + type: "thread.message-sent", + eventId: EventId.make("evt-streaming-shell-summary-3"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: deltaAt, + commandId: CommandId.make("cmd-streaming-shell-summary-3"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-streaming-shell-summary-3"), + metadata: {}, + payload: { + threadId, + messageId, + role: "assistant", + text: "delta", + turnId: null, + streaming: true, + createdAt, + updatedAt: deltaAt, + }, + }); + + const messageRows = yield* sql<{ + readonly text: string; + readonly isStreaming: number; + }>` + SELECT text, is_streaming AS "isStreaming" + FROM projection_thread_messages + WHERE message_id = ${messageId} + `; + assert.deepEqual(messageRows, [{ text: "delta", isStreaming: 1 }]); + + const threadRows = yield* sql<{ readonly updatedAt: string }>` + SELECT updated_at AS "updatedAt" + FROM projection_threads + WHERE thread_id = ${threadId} + `; + assert.deepEqual(threadRows, [{ updatedAt: deltaAt }]); + }), + ); +}); + it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-base-")))( "OrchestrationProjectionPipeline", (it) => { diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 9c8b1d19b03..fdbf53469b6 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -729,7 +729,13 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...existingRow.value, updatedAt: event.occurredAt, }); - yield* refreshThreadShellSummary(event.payload.threadId); + if ( + event.type !== "thread.message-sent" || + event.payload.role !== "assistant" || + !event.payload.streaming + ) { + yield* refreshThreadShellSummary(event.payload.threadId); + } return; } From 49b4056218d9c34f5edfa55b1d0c3a3122d54eff Mon Sep 17 00:00:00 2001 From: jln <85513960+jln13x@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:42:14 +0300 Subject: [PATCH 8/9] perf(server): cache repository top-level lookups Port upstream PR #3166 onto the current project resolver layout. --- .../RepositoryIdentityResolver.test.ts | 120 ++++++++++++++++++ .../src/project/RepositoryIdentityResolver.ts | 42 ++++-- 2 files changed, 151 insertions(+), 11 deletions(-) diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index a997459e63d..b99cba6ed76 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -6,6 +6,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import { TestClock } from "effect/testing"; +import { ChildProcessSpawner } from "effect/unstable/process"; import * as ProcessRunner from "../processRunner.ts"; import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts"; @@ -22,6 +23,33 @@ const git = (cwd: string, args: ReadonlyArray) => }); }).pipe(Effect.provide(ProcessRunner.layer)); +const makeCallCountingProcessRunner = ( + state: { revParseCalls: number; remoteCalls: number }, + fixture: { readonly topLevel: string; readonly remotesStdout: string }, +) => + ProcessRunner.ProcessRunner.of({ + run: (input) => { + const succeed = (stdout: string) => + Effect.succeed({ + stdout, + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + } satisfies ProcessRunner.ProcessRunOutput); + if (input.args.includes("rev-parse") && input.args.includes("--show-toplevel")) { + state.revParseCalls += 1; + return succeed(fixture.topLevel); + } + if (input.args.includes("remote") && input.args.includes("-v")) { + state.remoteCalls += 1; + return succeed(fixture.remotesStdout); + } + return succeed(""); + }, + }); + const makeRepositoryIdentityResolverTestLayer = (options: { readonly positiveCacheTtl?: Duration.Input; readonly negativeCacheTtl?: Duration.Input; @@ -232,4 +260,96 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { ), ), ); + + it.effect( + "resolves the git top-level only once across repeated resolves of the same workspace", + () => + Effect.gen(function* () { + const state = { revParseCalls: 0, remoteCalls: 0 }; + const cwd = "/workspace/project"; + const resolverLayer = Layer.effect( + RepositoryIdentityResolver.RepositoryIdentityResolver, + RepositoryIdentityResolver.make({ cacheCapacity: 16 }), + ).pipe( + Layer.provide( + Layer.succeed( + ProcessRunner.ProcessRunner, + makeCallCountingProcessRunner(state, { + topLevel: cwd, + remotesStdout: + "origin\tgit@github.com:T3Tools/t3code.git (fetch)\n" + + "origin\tgit@github.com:T3Tools/t3code.git (push)\n", + }), + ), + ), + ); + + yield* Effect.gen(function* () { + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const first = yield* resolver.resolve(cwd); + const second = yield* resolver.resolve(cwd); + + expect(first?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(second?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(state.revParseCalls).toBe(1); + expect(state.remoteCalls).toBe(1); + }).pipe(Effect.provide(resolverLayer)); + }), + ); + + it.effect( + "retries the top-level lookup after a transient git failure instead of caching the cwd fallback", + () => + Effect.gen(function* () { + let revParseCalls = 0; + const nestedWorkspace = "/workspace/repo/packages/web"; + const topLevel = "/workspace/repo"; + const succeed = (stdout: string) => + Effect.succeed({ + stdout, + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + } satisfies ProcessRunner.ProcessRunOutput); + const processRunner = ProcessRunner.ProcessRunner.of({ + run: (input) => { + if (input.args.includes("rev-parse") && input.args.includes("--show-toplevel")) { + revParseCalls += 1; + return revParseCalls === 1 + ? Effect.succeed({ + stdout: "", + stderr: "", + code: null, + timedOut: true, + stdoutTruncated: false, + stderrTruncated: false, + } satisfies ProcessRunner.ProcessRunOutput) + : succeed(topLevel); + } + if (input.args.includes("remote") && input.args.includes("-v")) { + return succeed( + "origin\tgit@github.com:T3Tools/t3code.git (fetch)\n" + + "origin\tgit@github.com:T3Tools/t3code.git (push)\n", + ); + } + return succeed(""); + }, + }); + const resolverLayer = Layer.effect( + RepositoryIdentityResolver.RepositoryIdentityResolver, + RepositoryIdentityResolver.make({ cacheCapacity: 16 }), + ).pipe(Layer.provide(Layer.succeed(ProcessRunner.ProcessRunner, processRunner))); + + yield* Effect.gen(function* () { + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + yield* resolver.resolve(nestedWorkspace); + const second = yield* resolver.resolve(nestedWorkspace); + + expect(revParseCalls).toBe(2); + expect(second?.rootPath).toBe(topLevel); + }).pipe(Effect.provide(resolverLayer)); + }), + ); }); diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index 50608e7704c..405f41f0fc8 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -22,6 +22,11 @@ export interface RepositoryIdentityResolverOptions { readonly negativeCacheTtl?: Duration.Input; } +interface RepositoryIdentityCacheKey { + readonly cacheKey: string; + readonly resolved: boolean; +} + export class RepositoryIdentityResolver extends Context.Service< RepositoryIdentityResolver, { @@ -88,9 +93,10 @@ function buildRepositoryIdentity(input: { } const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver.resolveCacheKey")( - function* (cwd: string) { + function* ( + cwd: string, + ): Effect.fn.Return { const processRunner = yield* ProcessRunner.ProcessRunner; - let cacheKey = cwd; // git is a real executable on every platform — no cmd.exe shell mode, which // would split paths containing spaces during cmd's re-tokenization. @@ -102,15 +108,13 @@ const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver. }) .pipe(Effect.option); if (topLevelResult._tag === "None" || topLevelResult.value.code !== 0) { - return cacheKey; + return { cacheKey: cwd, resolved: false }; } const candidate = topLevelResult.value.stdout.trim(); - if (candidate.length > 0) { - cacheKey = candidate; - } - - return cacheKey; + return candidate.length > 0 + ? { cacheKey: candidate, resolved: true } + : { cacheKey: cwd, resolved: false }; }, ); @@ -140,6 +144,24 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( ) { const processRunner = yield* ProcessRunner.ProcessRunner; + // Reconnect event replay can resolve the same workspace many times. Cache only + // successful cwd -> git top-level lookups; transient failures must retry so a + // nested workspace cannot be pinned to the wrong repository root. + const repositoryIdentityCacheKeyCache = yield* Cache.makeWith( + (cwd) => + resolveRepositoryIdentityCacheKey(cwd).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, processRunner), + ), + { + capacity: options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY, + timeToLive: Exit.match({ + onSuccess: (value) => + value.resolved ? (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL) : Duration.zero, + onFailure: () => Duration.zero, + }), + }, + ); + const repositoryIdentityCache = yield* Cache.makeWith( (cacheKey) => resolveRepositoryIdentityFromCacheKey(cacheKey).pipe( @@ -160,9 +182,7 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( const resolve: RepositoryIdentityResolver["Service"]["resolve"] = Effect.fn( "RepositoryIdentityResolver.resolve", )(function* (cwd) { - const cacheKey = yield* resolveRepositoryIdentityCacheKey(cwd).pipe( - Effect.provideService(ProcessRunner.ProcessRunner, processRunner), - ); + const { cacheKey } = yield* Cache.get(repositoryIdentityCacheKeyCache, cwd); return yield* Cache.get(repositoryIdentityCache, cacheKey); }); From 7390ab21571701e06d77e6f6c17c5d0a1b578537 Mon Sep 17 00:00:00 2001 From: jln <85513960+jln13x@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:42:20 +0300 Subject: [PATCH 9/9] docs: record remote-agent upstream integrations --- docs/upstream-integrations.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/upstream-integrations.md b/docs/upstream-integrations.md index e7ca5a3bbd5..11f1e7368b9 100644 --- a/docs/upstream-integrations.md +++ b/docs/upstream-integrations.md @@ -6,3 +6,9 @@ up browser, preview, VCS, and UI state when threads are deleted. - [#4187](https://github.com/pingdotgg/t3code/pull/4187) — Reduces idle Git and port-scanning work. Relevant user actions and terminal changes still refresh immediately. +- [#4199](https://github.com/pingdotgg/t3code/pull/4199) — Keeps Codex sessions alive while + background agents are still producing events. This prevents active remote work from being reaped. +- [#4009](https://github.com/pingdotgg/t3code/pull/4009) — Avoids rebuilding thread summaries for + every streaming delta. This reduces server work during long Codex responses. +- [#3166](https://github.com/pingdotgg/t3code/pull/3166) — Caches successful Git top-level lookups. + Reconnect event replay no longer spawns the same Git process for every event.