Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .server-changes/sessions-idle-status.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---

The Sessions list no longer shows an abandoned session as Active with a duration that climbs forever. A session whose run has finished now shows as Idle with a duration frozen at when it stopped, and only sessions with a run still executing show as Active.
34 changes: 26 additions & 8 deletions apps/webapp/app/components/sessions/v1/SessionStatus.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,34 @@
import { CheckCircleIcon, ClockIcon } from "@heroicons/react/20/solid";
import assertNever from "assert-never";
import { type SessionStatus } from "~/services/sessionsRepository/sessionsRepository.server";
import {
type SessionDisplayStatus,
type SessionStatus,
} from "~/services/sessionsRepository/sessionsRepository.server";
import { cn } from "~/utils/cn";

// Filterable statuses only — `IDLE` is display-only and derived from run
// liveness, so it never appears in the filter surface.
export const allSessionStatuses = ["ACTIVE", "CLOSED", "EXPIRED"] as const satisfies Readonly<
Array<SessionStatus>
>;

const descriptions: Record<SessionStatus, string> = {
const descriptions: Record<SessionDisplayStatus, string> = {
ACTIVE: "The session is open and can receive input or schedule new runs.",
IDLE: "The session is open but has no run currently executing.",
CLOSED: "The session was closed; no further input or runs can be triggered against it.",
EXPIRED: "The session passed its expiry time without being closed explicitly.",
};
Comment thread
D-K-P marked this conversation as resolved.
Comment thread
D-K-P marked this conversation as resolved.

export function descriptionForSessionStatus(status: SessionStatus): string {
export function descriptionForSessionStatus(status: SessionDisplayStatus): string {
return descriptions[status];
}

export function sessionStatusTitle(status: SessionStatus): string {
export function sessionStatusTitle(status: SessionDisplayStatus): string {
switch (status) {
case "ACTIVE":
return "Active";
case "IDLE":
return "Idle";
case "CLOSED":
return "Closed";
case "EXPIRED":
Expand All @@ -30,10 +38,12 @@ export function sessionStatusTitle(status: SessionStatus): string {
}
}

export function sessionStatusColor(status: SessionStatus): string {
export function sessionStatusColor(status: SessionDisplayStatus): string {
switch (status) {
case "ACTIVE":
return "text-pending";
case "IDLE":
return "text-text-dimmed";
case "CLOSED":
return "text-success";
case "EXPIRED":
Expand All @@ -48,7 +58,7 @@ export function SessionStatusIcon({
className,
pulse = true,
}: {
status: SessionStatus;
status: SessionDisplayStatus;
className: string;
pulse?: boolean;
}) {
Expand All @@ -64,6 +74,14 @@ export function SessionStatusIcon({
</span>
</span>
);
case "IDLE":
// Open but not live: a static, dimmed dot (no pulse) — distinct from
// ACTIVE's pulsing dot and EXPIRED's clock.
return (
<span className={cn("inline-flex items-center justify-center", className)}>
<span className="size-2 rounded-full bg-text-dimmed" />
</span>
);
case "CLOSED":
return <CheckCircleIcon className={cn(sessionStatusColor(status), className)} />;
case "EXPIRED":
Expand All @@ -73,7 +91,7 @@ export function SessionStatusIcon({
}
}

export function SessionStatusLabel({ status }: { status: SessionStatus }) {
export function SessionStatusLabel({ status }: { status: SessionDisplayStatus }) {
// system-mono-label: System themes uncolor the label (see tailwind.css)
return (
<span className={cn("system-mono-label", sessionStatusColor(status))}>
Expand All @@ -88,7 +106,7 @@ export function SessionStatusCombo({
iconClassName,
pulse = true,
}: {
status: SessionStatus;
status: SessionDisplayStatus;
className?: string;
iconClassName?: string;
pulse?: boolean;
Expand Down
16 changes: 11 additions & 5 deletions apps/webapp/app/components/sessions/v1/SessionsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -195,23 +195,29 @@ 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).
// Only a genuinely live session ticks. Everything else freezes at the moment
// it stopped being live: closedAt for explicit closes, expiresAt when the TTL
// ran out, or the current run's completedAt for an idle (open, not-running)
// session — so an abandoned session doesn't count up forever.
if (session.status === "ACTIVE") {
return <LiveTimer startTime={new Date(session.createdAt)} />;
}

const endedAt =
session.status === "CLOSED"
? session.closedAt
: session.status === "EXPIRED"
? session.expiresAt
: undefined;
: session.currentRunCompletedAt;

if (endedAt) {
return (
<>{formatDuration(new Date(session.createdAt), new Date(endedAt), { style: "short" })}</>
);
}

return <LiveTimer startTime={new Date(session.createdAt)} />;
// Idle session that never ran — nothing to measure.
return <span className="text-text-dimmed">–</span>;
}

function SessionActionsCell({ runPath, allRunsPath }: { runPath?: string; allRunsPath: string }) {
Expand Down
26 changes: 18 additions & 8 deletions apps/webapp/app/presenters/v3/SessionListPresenter.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
LEGACY_PLAYGROUND_TAG,
} from "~/services/sessionsRepository/sessionsRepository.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { deriveSessionStatus } from "./deriveSessionStatus";
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
import { runStore } from "~/v3/runStore.server";
import { startActiveSpan } from "~/v3/tracer.server";
Expand Down Expand Up @@ -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
)
Expand All @@ -209,15 +210,19 @@ export class SessionListPresenter {

return {
sessions: sessions.map((session) => {
const status: SessionStatus =
session.closedAt != null
? "CLOSED"
: session.expiresAt != null && session.expiresAt.getTime() < now
? "EXPIRED"
: "ACTIVE";

const currentRun = session.currentRunId ? runById.get(session.currentRunId) : undefined;

// A session is only ACTIVE while its current run is genuinely live.
// Open sessions whose run has terminated (or that have no run) read
// IDLE rather than ticking ACTIVE forever.
const status = deriveSessionStatus({
closedAt: session.closedAt,
expiresAt: session.expiresAt,
hasCurrentRun: session.currentRunId != null,
currentRunStatus: currentRun?.status,
now,
});
Comment thread
D-K-P marked this conversation as resolved.

return {
id: session.id,
friendlyId: session.friendlyId,
Expand All @@ -239,6 +244,11 @@ export class SessionListPresenter {
updatedAt: session.updatedAt.toISOString(),
environment: displayableEnvironment,
currentRunFriendlyId: currentRun?.friendlyId,
// Freeze point for an IDLE session's duration — when its current run
// finished. Undefined when the session never ran (renders as a dash).
currentRunCompletedAt: currentRun?.completedAt
? currentRun.completedAt.toISOString()
: undefined,
};
}),
pagination: {
Expand Down
92 changes: 92 additions & 0 deletions apps/webapp/app/presenters/v3/deriveSessionStatus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import { deriveSessionStatus } from "./deriveSessionStatus";

const NOW = new Date("2026-08-06T12:00:00.000Z").getTime();
const PAST = new Date("2026-08-01T00:00:00.000Z");
const FUTURE = new Date("2026-08-10T00:00:00.000Z");

describe("deriveSessionStatus", () => {
it("returns CLOSED when closedAt is set, even with a live run", () => {
expect(
deriveSessionStatus({
closedAt: PAST,
expiresAt: null,
hasCurrentRun: true,
currentRunStatus: "EXECUTING",
now: NOW,
})
).toBe("CLOSED");
});

it("prefers CLOSED over an elapsed expiresAt", () => {
expect(
deriveSessionStatus({
closedAt: PAST,
expiresAt: PAST,
hasCurrentRun: false,
currentRunStatus: undefined,
now: NOW,
})
).toBe("CLOSED");
});

it("returns EXPIRED when expiresAt is in the past", () => {
expect(
deriveSessionStatus({
closedAt: null,
expiresAt: PAST,
hasCurrentRun: true,
currentRunStatus: "EXECUTING",
now: NOW,
})
).toBe("EXPIRED");
});

it("returns ACTIVE when the current run is non-final", () => {
expect(
deriveSessionStatus({
closedAt: null,
expiresAt: FUTURE,
hasCurrentRun: true,
currentRunStatus: "EXECUTING",
now: NOW,
})
).toBe("ACTIVE");
});

it("returns IDLE when the current run has reached a terminal state", () => {
expect(
deriveSessionStatus({
closedAt: null,
expiresAt: null,
hasCurrentRun: true,
currentRunStatus: "EXPIRED",
now: NOW,
})
).toBe("IDLE");
});

it("returns IDLE when there is no current run", () => {
expect(
deriveSessionStatus({
closedAt: null,
expiresAt: null,
hasCurrentRun: false,
currentRunStatus: undefined,
now: NOW,
})
).toBe("IDLE");
});

it("returns IDLE when the current run pointer can't be resolved (status unknown)", () => {
expect(
deriveSessionStatus({
closedAt: null,
expiresAt: null,
hasCurrentRun: true,
currentRunStatus: undefined,
now: NOW,
})
).toBe("IDLE");
});
});
46 changes: 46 additions & 0 deletions apps/webapp/app/presenters/v3/deriveSessionStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { type TaskRunStatus } from "@trigger.dev/database";
import { type SessionDisplayStatus } from "~/services/sessionsRepository/sessionsRepository.server";
import { isFinalRunStatus } from "~/v3/taskStatus";

export type DeriveSessionStatusInput = {
/** `Session.closedAt` — set once when the session is explicitly closed. */
closedAt: Date | null;
/** `Session.expiresAt` — retention deadline, if any. */
expiresAt: Date | null;
/** 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;
/** `Date.now()` at the time of derivation. */
now: number;
};

/**
* Derives the display status of a session from its terminal markers and the
* liveness of its current run.
*
* Precedence: an explicit close wins, then an elapsed retention deadline. Only
* then do we ask whether the session is genuinely live: it's `ACTIVE` when its
* current run exists and is non-final, otherwise `IDLE` (open but nothing
* running). This is what stops an abandoned session whose run terminated long
* ago from reading `ACTIVE` forever.
*/
export function deriveSessionStatus(input: DeriveSessionStatusInput): SessionDisplayStatus {
if (input.closedAt != null) {
return "CLOSED";
}

if (input.expiresAt != null && input.expiresAt.getTime() < input.now) {
return "EXPIRED";
}

const hasLiveRun =
input.hasCurrentRun &&
input.currentRunStatus !== undefined &&
!isFinalRunStatus(input.currentRunStatus);

return hasLiveRun ? "ACTIVE" : "IDLE";
}
Comment on lines +31 to +46

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Run detail page still derives session status the old way, so it can disagree with the new Idle label

The run/span page renders a session badge from run.session.status, which apps/webapp/app/presenters/v3/SpanPresenter.server.ts:373-379 still derives from closedAt/expiresAt only. After this PR, an open session whose current run has terminated reads IDLE on the sessions list and the session detail page, but still reads ACTIVE on the run's span panel. Same for the agent activity chart legend (apps/webapp/app/presenters/v3/AgentDetailPresenter.server.ts:34). Not strictly wrong (those surfaces are also fed by the three-value model), but the PR's stated goal of list/detail agreement leaves this third surface behind.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good flag, splitting it in two:

  • Agent activity chart (AgentDetailPresenter): this is a ClickHouse aggregation over closedAt/expiresAt, i.e. the same aggregation class as the status filter. Idle is display-only and intentionally not represented in aggregation/filter surfaces, so this one deliberately stays ACTIVE/CLOSED/EXPIRED.
  • Run/span panel session badge (SpanPresenter:373): agreed this is a display badge like the list and detail page, so for full consistency it should reuse deriveSessionStatus. It needs the session's current-run status available there (an extra field to load). That is beyond this ticket's list+detail scope, so leaving this open for the maintainer to decide whether to fold in here or track as a follow-up.

Loading
Loading