diff --git a/.server-changes/sessions-idle-status.md b/.server-changes/sessions-idle-status.md
new file mode 100644
index 0000000000..8b15a97221
--- /dev/null
+++ b/.server-changes/sessions-idle-status.md
@@ -0,0 +1,6 @@
+---
+area: webapp
+type: fix
+---
+
+The Sessions list no longer shows an ever-growing duration for a session whose run finished long ago. The duration now stops at the last run's activity, and only sessions with a run still executing keep counting up.
diff --git a/apps/webapp/app/components/sessions/v1/SessionsTable.tsx b/apps/webapp/app/components/sessions/v1/SessionsTable.tsx
index 4340a97681..5e26dece86 100644
--- a/apps/webapp/app/components/sessions/v1/SessionsTable.tsx
+++ b/apps/webapp/app/components/sessions/v1/SessionsTable.tsx
@@ -195,23 +195,38 @@ export function SessionsTable({
}
function SessionDuration({ session }: { session: SessionListItem }) {
- // Active sessions tick live; closed/expired sessions freeze at the
- // moment they ended (closedAt for explicit closes, expiresAt when the
- // TTL ran out without a close call).
- const endedAt =
+ // Closed and expired sessions freeze at the moment they ended.
+ const terminalEnd =
session.status === "CLOSED"
? session.closedAt
: session.status === "EXPIRED"
? session.expiresAt
: undefined;
- if (endedAt) {
+ if (terminalEnd) {
return (
- <>{formatDuration(new Date(session.createdAt), new Date(endedAt), { style: "short" })}>
+ <>{formatDuration(new Date(session.createdAt), new Date(terminalEnd), { style: "short" })}>
);
}
- return ;
+ // An open session ticks only while a run is genuinely executing; otherwise it
+ // freezes at the last run's completion so the duration doesn't climb forever.
+ if (session.hasLiveRun) {
+ return ;
+ }
+
+ if (session.currentRunCompletedAt) {
+ return (
+ <>
+ {formatDuration(new Date(session.createdAt), new Date(session.currentRunCompletedAt), {
+ style: "short",
+ })}
+ >
+ );
+ }
+
+ // Open session that never ran — nothing to measure.
+ return –;
}
function SessionActionsCell({ runPath, allRunsPath }: { runPath?: string; allRunsPath: string }) {
diff --git a/apps/webapp/app/presenters/v3/SessionListPresenter.server.ts b/apps/webapp/app/presenters/v3/SessionListPresenter.server.ts
index 1e6d1fa239..5144599c8c 100644
--- a/apps/webapp/app/presenters/v3/SessionListPresenter.server.ts
+++ b/apps/webapp/app/presenters/v3/SessionListPresenter.server.ts
@@ -14,6 +14,7 @@ import {
LEGACY_PLAYGROUND_TAG,
} from "~/services/sessionsRepository/sessionsRepository.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
+import { isSessionLive } from "./isSessionLive";
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
import { runStore } from "~/v3/runStore.server";
import { startActiveSpan } from "~/v3/tracer.server";
@@ -196,7 +197,7 @@ export class SessionListPresenter {
projectId,
runtimeEnvironmentId: environmentId,
},
- select: { id: true, friendlyId: true },
+ select: { id: true, friendlyId: true, status: true, completedAt: true },
},
this.replica
)
@@ -209,6 +210,8 @@ export class SessionListPresenter {
return {
sessions: sessions.map((session) => {
+ const currentRun = session.currentRunId ? runById.get(session.currentRunId) : undefined;
+
const status: SessionStatus =
session.closedAt != null
? "CLOSED"
@@ -216,7 +219,12 @@ export class SessionListPresenter {
? "EXPIRED"
: "ACTIVE";
- const currentRun = session.currentRunId ? runById.get(session.currentRunId) : undefined;
+ // Whether a run is genuinely executing right now. Drives the duration
+ // cell (tick vs freeze); it does NOT affect the filterable status.
+ const hasLiveRun = isSessionLive({
+ hasCurrentRun: session.currentRunId != null,
+ currentRunStatus: currentRun?.status,
+ });
return {
id: session.id,
@@ -239,6 +247,12 @@ export class SessionListPresenter {
updatedAt: session.updatedAt.toISOString(),
environment: displayableEnvironment,
currentRunFriendlyId: currentRun?.friendlyId,
+ hasLiveRun,
+ // Freeze point for the duration when the session isn't live: when its
+ // current run finished. Undefined when it never ran (renders a dash).
+ currentRunCompletedAt: currentRun?.completedAt
+ ? currentRun.completedAt.toISOString()
+ : undefined,
};
}),
pagination: {
diff --git a/apps/webapp/app/presenters/v3/isSessionLive.test.ts b/apps/webapp/app/presenters/v3/isSessionLive.test.ts
new file mode 100644
index 0000000000..3471094e17
--- /dev/null
+++ b/apps/webapp/app/presenters/v3/isSessionLive.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from "vitest";
+import { isSessionLive } from "./isSessionLive";
+
+describe("isSessionLive", () => {
+ it("is live when the current run is executing", () => {
+ expect(isSessionLive({ hasCurrentRun: true, currentRunStatus: "EXECUTING" })).toBe(true);
+ });
+
+ it("treats any non-final run status as live", () => {
+ expect(isSessionLive({ hasCurrentRun: true, currentRunStatus: "PENDING" })).toBe(true);
+ });
+
+ it("is not live when the current run has reached a terminal state", () => {
+ expect(isSessionLive({ hasCurrentRun: true, currentRunStatus: "EXPIRED" })).toBe(false);
+ });
+
+ it("is not live when there is no current run", () => {
+ expect(isSessionLive({ hasCurrentRun: false, currentRunStatus: undefined })).toBe(false);
+ });
+
+ it("is not live when the current run pointer can't be resolved (status unknown)", () => {
+ expect(isSessionLive({ hasCurrentRun: true, currentRunStatus: undefined })).toBe(false);
+ });
+});
diff --git a/apps/webapp/app/presenters/v3/isSessionLive.ts b/apps/webapp/app/presenters/v3/isSessionLive.ts
new file mode 100644
index 0000000000..894c27d2da
--- /dev/null
+++ b/apps/webapp/app/presenters/v3/isSessionLive.ts
@@ -0,0 +1,22 @@
+import { type TaskRunStatus } from "@trigger.dev/database";
+import { isFinalRunStatus } from "~/v3/taskStatus";
+
+export type IsSessionLiveInput = {
+ /** Whether the session points at a current run at all. */
+ hasCurrentRun: boolean;
+ /**
+ * Status of the current run. `undefined` when there is no current run, or the
+ * pointer couldn't be resolved (stale / cross-env).
+ */
+ currentRunStatus: TaskRunStatus | undefined;
+};
+
+/**
+ * A session is "live" when its current run is still executing (a non-final run
+ * status). This drives whether the session's duration ticks or freezes; it does
+ * NOT change the session's status, which stays the filterable
+ * `ACTIVE`/`CLOSED`/`EXPIRED` set derived from `closedAt`/`expiresAt`.
+ */
+export function isSessionLive({ hasCurrentRun, currentRunStatus }: IsSessionLiveInput): boolean {
+ return hasCurrentRun && currentRunStatus !== undefined && !isFinalRunStatus(currentRunStatus);
+}
diff --git a/apps/webapp/test/sessionListPresenterStatus.test.ts b/apps/webapp/test/sessionListPresenterStatus.test.ts
new file mode 100644
index 0000000000..557d318e6b
--- /dev/null
+++ b/apps/webapp/test/sessionListPresenterStatus.test.ts
@@ -0,0 +1,283 @@
+// Integration guard for SessionListPresenter status + duration derivation.
+//
+// Drives the REAL SessionListPresenter.call() against a real Postgres (heteroPostgresTest).
+// The ClickHouse session index is stubbed (orthogonal — it only orders ids) so each stub
+// session's `currentRunId` points at a REAL run we seed in Postgres with a known status.
+// The presenter's `findRuns` read + liveness check therefore run for real end-to-end, which
+// is the wiring the pure unit test can't cover: does the presenter derive the filterable
+// status correctly, compute `hasLiveRun` from the current run, and pass the freeze timestamp
+// (`currentRunCompletedAt`) through?
+
+import { heteroPostgresTest } from "@internal/testcontainers";
+import type { PrismaClient, TaskRunStatus } from "@trigger.dev/database";
+import { describe, expect, vi } from "vitest";
+
+vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 });
+
+// ~/db.server: lazy proxies forwarding to per-test real-container clients (never mocks the DB
+// itself). Run-ops split handles left undefined => runStore builds the single-DB passthrough store.
+const primaryHolder = vi.hoisted(() => ({ client: undefined as any }));
+const replicaHolder = vi.hoisted(() => ({ client: undefined as any }));
+
+vi.mock("~/db.server", async () => {
+ const { Prisma } = await import("@trigger.dev/database");
+ const lazyProxy = (holder: { client: any }, label: string) =>
+ new Proxy(
+ {},
+ {
+ get(_t, prop) {
+ if (!holder.client) throw new Error(`${label} not set for this test`);
+ const value = holder.client[prop];
+ if (value !== null && typeof value === "object") {
+ return new Proxy(value, { get: (_d, method) => holder.client[prop][method] });
+ }
+ return value;
+ },
+ }
+ );
+ return {
+ prisma: lazyProxy(primaryHolder, "primaryHolder.client"),
+ $replica: lazyProxy(replicaHolder, "replicaHolder.client"),
+ runOpsNewPrismaClient: undefined,
+ runOpsNewReplicaClient: undefined,
+ runOpsLegacyPrisma: undefined,
+ runOpsLegacyReplica: undefined,
+ sqlDatabaseSchema: Prisma.sql([`public`]),
+ };
+});
+
+// Orthogonal peripherals.
+const STUB_ENV = {
+ id: "env_stub",
+ type: "DEVELOPMENT" as const,
+ slug: "dev",
+ organizationId: "org_stub",
+ projectId: "proj_stub",
+ userId: undefined,
+ branchName: null,
+ git: null,
+};
+
+vi.mock("~/models/runtimeEnvironment.server", () => ({
+ findDisplayableEnvironment: async () => STUB_ENV,
+}));
+
+vi.mock("~/v3/models/workerDeployment.server", () => ({
+ findCurrentWorkerFromEnvironment: async () => null,
+}));
+
+// The session list comes from ClickHouse via SessionsRepository — orthogonal to the run read.
+// The stub returns controlled session rows whose currentRunId points at runs we seed for real.
+const sessionListHolder = vi.hoisted(() => ({ sessions: [] as any[] }));
+vi.mock("~/services/sessionsRepository/sessionsRepository.server", () => ({
+ LEGACY_PLAYGROUND_TAG: "__playground__",
+ SessionsRepository: class {
+ constructor(_deps: any) {}
+ async listSessions() {
+ return {
+ sessions: sessionListHolder.sessions,
+ pagination: { nextCursor: null, previousCursor: null },
+ };
+ }
+ },
+}));
+
+import { PostgresRunStore } from "@internal/run-store";
+import type { CreateRunInput } from "@internal/run-store";
+import { SessionListPresenter } from "~/presenters/v3/SessionListPresenter.server";
+
+let seq = 0;
+
+async function seedTenant(prisma: PrismaClient, suffix: string) {
+ const organization = await prisma.organization.create({
+ data: { title: `Org ${suffix}`, slug: `org-${suffix}` },
+ });
+ const project = await prisma.project.create({
+ data: {
+ name: `Project ${suffix}`,
+ slug: `project-${suffix}`,
+ externalRef: `proj_${suffix}`,
+ organizationId: organization.id,
+ },
+ });
+ const environment = await prisma.runtimeEnvironment.create({
+ data: {
+ type: "DEVELOPMENT",
+ slug: "dev",
+ projectId: project.id,
+ organizationId: organization.id,
+ apiKey: `tr_dev_${suffix}`,
+ pkApiKey: `pk_dev_${suffix}`,
+ shortcode: `short_${suffix}`,
+ },
+ });
+ return { organization, project, environment };
+}
+
+function buildCreateRunInput(p: {
+ runId: string;
+ friendlyId: string;
+ organizationId: string;
+ projectId: string;
+ runtimeEnvironmentId: string;
+}): CreateRunInput {
+ return {
+ data: {
+ id: p.runId,
+ engine: "V2",
+ status: "EXECUTING",
+ friendlyId: p.friendlyId,
+ runtimeEnvironmentId: p.runtimeEnvironmentId,
+ environmentType: "DEVELOPMENT",
+ organizationId: p.organizationId,
+ projectId: p.projectId,
+ taskIdentifier: "my-agent",
+ payload: '{"hello":"world"}',
+ payloadType: "application/json",
+ context: { foo: "bar" },
+ traceContext: { trace: "ctx" },
+ traceId: `trace_${p.runId}`,
+ spanId: `span_${p.runId}`,
+ runTags: [],
+ queue: "task/my-agent",
+ isTest: false,
+ taskEventStore: "taskEvent",
+ depth: 0,
+ createdAt: new Date("2024-01-01T00:00:00.000Z"),
+ },
+ snapshot: {
+ engine: "V2",
+ executionStatus: "EXECUTING",
+ description: "Run is executing",
+ runStatus: "EXECUTING",
+ environmentId: p.runtimeEnvironmentId,
+ environmentType: "DEVELOPMENT",
+ projectId: p.projectId,
+ organizationId: p.organizationId,
+ },
+ };
+}
+
+// Seed a run, then mutate it to the target terminal/live status + completedAt for the test shape.
+async function seedRun(
+ prisma: PrismaClient,
+ seed: { organization: { id: string }; project: { id: string }; environment: { id: string } },
+ p: { suffix: string; status: TaskRunStatus; completedAt: Date | null }
+) {
+ const runId = `run_${p.suffix}`;
+ const writerStore = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
+ await writerStore.createRun(
+ buildCreateRunInput({
+ runId,
+ friendlyId: `run_f_${p.suffix}`,
+ organizationId: seed.organization.id,
+ projectId: seed.project.id,
+ runtimeEnvironmentId: seed.environment.id,
+ })
+ );
+ await prisma.taskRun.update({
+ where: { id: runId },
+ data: { status: p.status, completedAt: p.completedAt },
+ });
+ return runId;
+}
+
+function stubSession(p: {
+ suffix: string;
+ currentRunId: string | null;
+ closedAt?: Date | null;
+ expiresAt?: Date | null;
+}) {
+ return {
+ id: `sess_${p.suffix}`,
+ friendlyId: `session_${p.suffix}`,
+ externalId: null,
+ type: "chat.agent",
+ taskIdentifier: "my-agent",
+ isTest: false,
+ tags: [],
+ closedAt: p.closedAt ?? null,
+ closedReason: null,
+ expiresAt: p.expiresAt ?? null,
+ createdAt: new Date("2024-01-01T00:00:00.000Z"),
+ updatedAt: new Date("2024-01-01T00:00:00.000Z"),
+ currentRunId: p.currentRunId,
+ };
+}
+
+describe("SessionListPresenter status + duration derivation", () => {
+ heteroPostgresTest(
+ "keeps the filterable status and drives duration off run liveness",
+ async ({ prisma14 }) => {
+ const prisma = prisma14 as unknown as PrismaClient;
+ const suffix = `status_${seq++}`;
+ const seed = await seedTenant(prisma, suffix);
+
+ const RUN_COMPLETED_AT = new Date("2024-01-01T00:10:00.000Z");
+ const PAST = new Date("2020-01-01T00:00:00.000Z");
+
+ // An open session whose only run has terminated — the reported bug. Status
+ // stays ACTIVE (it is open), but it is not live so its duration freezes.
+ const idleRunId = await seedRun(prisma, seed, {
+ suffix: `idle_${suffix}`,
+ status: "EXPIRED",
+ completedAt: RUN_COMPLETED_AT,
+ });
+ // An open session with a genuinely live run.
+ const activeRunId = await seedRun(prisma, seed, {
+ suffix: `active_${suffix}`,
+ status: "EXECUTING",
+ completedAt: null,
+ });
+ // A closed session (still points at a live run — closed must win).
+ const closedRunId = await seedRun(prisma, seed, {
+ suffix: `closed_${suffix}`,
+ status: "EXECUTING",
+ completedAt: null,
+ });
+
+ sessionListHolder.sessions = [
+ stubSession({ suffix: `idle_${suffix}`, currentRunId: idleRunId }),
+ stubSession({ suffix: `active_${suffix}`, currentRunId: activeRunId }),
+ stubSession({
+ suffix: `closed_${suffix}`,
+ currentRunId: closedRunId,
+ closedAt: new Date("2024-01-02T00:00:00.000Z"),
+ }),
+ stubSession({ suffix: `expired_${suffix}`, currentRunId: null, expiresAt: PAST }),
+ stubSession({ suffix: `neverran_${suffix}`, currentRunId: null }),
+ ];
+
+ primaryHolder.client = prisma;
+ replicaHolder.client = prisma;
+
+ const presenter = new SessionListPresenter(prisma as any, {} as any);
+ const result = await presenter.call(seed.organization.id, seed.environment.id, {
+ projectId: seed.project.id,
+ });
+
+ const byId = new Map(result.sessions.map((s) => [s.id, s] as const));
+
+ // Open but not live: still ACTIVE (filterable), not live, and the duration
+ // freezes at the terminated run's completion instead of climbing forever.
+ const idle = byId.get(`sess_idle_${suffix}`)!;
+ expect(idle.status).toBe("ACTIVE");
+ expect(idle.hasLiveRun).toBe(false);
+ expect(idle.currentRunCompletedAt).toBe(RUN_COMPLETED_AT.toISOString());
+
+ // Open with a live run: ACTIVE and ticking.
+ const active = byId.get(`sess_active_${suffix}`)!;
+ expect(active.status).toBe("ACTIVE");
+ expect(active.hasLiveRun).toBe(true);
+
+ expect(byId.get(`sess_closed_${suffix}`)!.status).toBe("CLOSED");
+ expect(byId.get(`sess_expired_${suffix}`)!.status).toBe("EXPIRED");
+
+ // Open session that never ran: ACTIVE, not live, no freeze point (dash).
+ const neverRan = byId.get(`sess_neverran_${suffix}`)!;
+ expect(neverRan.status).toBe("ACTIVE");
+ expect(neverRan.hasLiveRun).toBe(false);
+ expect(neverRan.currentRunCompletedAt).toBeUndefined();
+ }
+ );
+});
diff --git a/apps/webapp/vitest.config.ts b/apps/webapp/vitest.config.ts
index 80ce4c0a27..ccdc4db627 100644
--- a/apps/webapp/vitest.config.ts
+++ b/apps/webapp/vitest.config.ts
@@ -22,7 +22,7 @@ export default defineConfig({
"app/components/dashboard-agent/**/*.test.ts",
"app/components/queues/**/*.test.ts",
"app/routes/storybook.agent-ui/*.test.ts",
- "app/presenters/v3/reports/**/*.test.ts",
+ "app/presenters/**/*.test.ts",
],
// *.e2e.test.ts: smoke matrix, run via vitest.e2e.config.ts.
// *.e2e.full.test.ts: full auth suite, runs via vitest.e2e.full.config.ts
diff --git a/docs/ai-chat/sessions.mdx b/docs/ai-chat/sessions.mdx
index 041fd3d99e..bb2a2d84c1 100644
--- a/docs/ai-chat/sessions.mdx
+++ b/docs/ai-chat/sessions.mdx
@@ -99,7 +99,10 @@ const { id, runId, publicAccessToken, isCached } = await sessions.start({
type: "chat.agent",
externalId: chatId,
taskIdentifier: "my-chat",
+ // Top-level tags live on the Session row and are what `sessions.list({ tag })` filters on.
+ tags: [`chat:${chatId}`],
triggerConfig: {
+ // triggerConfig.tags tag each run the session schedules, not the session row.
tags: [`chat:${chatId}`],
basePayload: { /* whatever your task's payload shape is */ },
},
@@ -153,7 +156,7 @@ Cursor-paginated list of Sessions in the current environment. Returns a `CursorP
```ts
for await (const s of sessions.list({
type: "chat.agent",
- tag: `user:${userId}`,
+ tag: `chat:${chatId}`,
status: "ACTIVE",
limit: 50,
})) {
@@ -164,7 +167,7 @@ for await (const s of sessions.list({
| Filter | Type | Notes |
|---|---|---|
| `type` | `string \| string[]` | e.g. `"chat.agent"` |
-| `tag` | `string \| string[]` | Matches `triggerConfig.tags` |
+| `tag` | `string \| string[]` | Matches the session's own `tags` (the top-level `tags` on `sessions.start`), not `triggerConfig.tags` |
| `taskIdentifier` | `string \| string[]` | Filter by task |
| `externalId` | `string` | Exact match |
| `status` | `"ACTIVE" \| "CLOSED" \| "EXPIRED"` | Lifecycle state |