Skip to content

Commit 3dfca81

Browse files
committed
fix(webapp): stop session durations climbing forever when no run is live
Sessions kept an ever-growing wall-clock duration because the cell ticked for any open session, even when its run had finished long ago. The duration now ticks only while a run is genuinely executing; otherwise it freezes at the last run's completion (or shows a dash if it never ran). Session status stays the existing filterable ACTIVE/CLOSED/EXPIRED set, so there is nothing new to filter. This drops the earlier display-only IDLE status, which was not filterable.
1 parent 200d501 commit 3dfca81

12 files changed

Lines changed: 129 additions & 260 deletions

File tree

.server-changes/sessions-idle-status.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ area: webapp
33
type: fix
44
---
55

6-
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.
6+
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.

apps/webapp/app/components/sessions/v1/SessionStatus.tsx

Lines changed: 8 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,26 @@
11
import { CheckCircleIcon, ClockIcon } from "@heroicons/react/20/solid";
22
import assertNever from "assert-never";
3-
import {
4-
type SessionDisplayStatus,
5-
type SessionStatus,
6-
} from "~/services/sessionsRepository/sessionsRepository.server";
3+
import { type SessionStatus } from "~/services/sessionsRepository/sessionsRepository.server";
74
import { cn } from "~/utils/cn";
85

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

15-
const descriptions: Record<SessionDisplayStatus, string> = {
10+
const descriptions: Record<SessionStatus, string> = {
1611
ACTIVE: "The session is open and can receive input or schedule new runs.",
17-
IDLE: "The session is open but has no run currently executing.",
1812
CLOSED: "The session was closed; no further input or runs can be triggered against it.",
1913
EXPIRED: "The session passed its expiry time without being closed explicitly.",
2014
};
2115

22-
export function descriptionForSessionStatus(status: SessionDisplayStatus): string {
16+
export function descriptionForSessionStatus(status: SessionStatus): string {
2317
return descriptions[status];
2418
}
2519

26-
export function sessionStatusTitle(status: SessionDisplayStatus): string {
20+
export function sessionStatusTitle(status: SessionStatus): string {
2721
switch (status) {
2822
case "ACTIVE":
2923
return "Active";
30-
case "IDLE":
31-
return "Idle";
3224
case "CLOSED":
3325
return "Closed";
3426
case "EXPIRED":
@@ -38,12 +30,10 @@ export function sessionStatusTitle(status: SessionDisplayStatus): string {
3830
}
3931
}
4032

41-
export function sessionStatusColor(status: SessionDisplayStatus): string {
33+
export function sessionStatusColor(status: SessionStatus): string {
4234
switch (status) {
4335
case "ACTIVE":
4436
return "text-pending";
45-
case "IDLE":
46-
return "text-text-dimmed";
4737
case "CLOSED":
4838
return "text-success";
4939
case "EXPIRED":
@@ -58,7 +48,7 @@ export function SessionStatusIcon({
5848
className,
5949
pulse = true,
6050
}: {
61-
status: SessionDisplayStatus;
51+
status: SessionStatus;
6252
className: string;
6353
pulse?: boolean;
6454
}) {
@@ -74,14 +64,6 @@ export function SessionStatusIcon({
7464
</span>
7565
</span>
7666
);
77-
case "IDLE":
78-
// Open but not live: a static, dimmed dot (no pulse) — distinct from
79-
// ACTIVE's pulsing dot and EXPIRED's clock.
80-
return (
81-
<span className={cn("inline-flex items-center justify-center", className)}>
82-
<span className="size-2 rounded-full bg-text-dimmed" />
83-
</span>
84-
);
8567
case "CLOSED":
8668
return <CheckCircleIcon className={cn(sessionStatusColor(status), className)} />;
8769
case "EXPIRED":
@@ -91,7 +73,7 @@ export function SessionStatusIcon({
9173
}
9274
}
9375

94-
export function SessionStatusLabel({ status }: { status: SessionDisplayStatus }) {
76+
export function SessionStatusLabel({ status }: { status: SessionStatus }) {
9577
// system-mono-label: System themes uncolor the label (see tailwind.css)
9678
return (
9779
<span className={cn("system-mono-label", sessionStatusColor(status))}>
@@ -106,7 +88,7 @@ export function SessionStatusCombo({
10688
iconClassName,
10789
pulse = true,
10890
}: {
109-
status: SessionDisplayStatus;
91+
status: SessionStatus;
11092
className?: string;
11193
iconClassName?: string;
11294
pulse?: boolean;

apps/webapp/app/components/sessions/v1/SessionsTable.tsx

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -195,28 +195,37 @@ export function SessionsTable({
195195
}
196196

197197
function SessionDuration({ session }: { session: SessionListItem }) {
198-
// Only a genuinely live session ticks. Everything else freezes at the moment
199-
// it stopped being live: closedAt for explicit closes, expiresAt when the TTL
200-
// ran out, or the current run's completedAt for an idle (open, not-running)
201-
// session — so an abandoned session doesn't count up forever.
202-
if (session.status === "ACTIVE") {
203-
return <LiveTimer startTime={new Date(session.createdAt)} />;
204-
}
205-
206-
const endedAt =
198+
// Closed and expired sessions freeze at the moment they ended.
199+
const terminalEnd =
207200
session.status === "CLOSED"
208201
? session.closedAt
209202
: session.status === "EXPIRED"
210203
? session.expiresAt
211-
: session.currentRunCompletedAt;
204+
: undefined;
205+
206+
if (terminalEnd) {
207+
return (
208+
<>{formatDuration(new Date(session.createdAt), new Date(terminalEnd), { style: "short" })}</>
209+
);
210+
}
211+
212+
// An open session ticks only while a run is genuinely executing; otherwise it
213+
// freezes at the last run's completion so the duration doesn't climb forever.
214+
if (session.hasLiveRun) {
215+
return <LiveTimer startTime={new Date(session.createdAt)} />;
216+
}
212217

213-
if (endedAt) {
218+
if (session.currentRunCompletedAt) {
214219
return (
215-
<>{formatDuration(new Date(session.createdAt), new Date(endedAt), { style: "short" })}</>
220+
<>
221+
{formatDuration(new Date(session.createdAt), new Date(session.currentRunCompletedAt), {
222+
style: "short",
223+
})}
224+
</>
216225
);
217226
}
218227

219-
// Idle session that never ran — nothing to measure.
228+
// Open session that never ran — nothing to measure.
220229
return <span className="text-text-dimmed"></span>;
221230
}
222231

apps/webapp/app/presenters/v3/SessionListPresenter.server.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
LEGACY_PLAYGROUND_TAG,
1515
} from "~/services/sessionsRepository/sessionsRepository.server";
1616
import { ServiceValidationError } from "~/v3/services/baseService.server";
17-
import { deriveSessionStatus } from "./deriveSessionStatus";
17+
import { isSessionLive } from "./isSessionLive";
1818
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
1919
import { runStore } from "~/v3/runStore.server";
2020
import { startActiveSpan } from "~/v3/tracer.server";
@@ -212,15 +212,18 @@ export class SessionListPresenter {
212212
sessions: sessions.map((session) => {
213213
const currentRun = session.currentRunId ? runById.get(session.currentRunId) : undefined;
214214

215-
// A session is only ACTIVE while its current run is genuinely live.
216-
// Open sessions whose run has terminated (or that have no run) read
217-
// IDLE rather than ticking ACTIVE forever.
218-
const status = deriveSessionStatus({
219-
closedAt: session.closedAt,
220-
expiresAt: session.expiresAt,
215+
const status: SessionStatus =
216+
session.closedAt != null
217+
? "CLOSED"
218+
: session.expiresAt != null && session.expiresAt.getTime() < now
219+
? "EXPIRED"
220+
: "ACTIVE";
221+
222+
// Whether a run is genuinely executing right now. Drives the duration
223+
// cell (tick vs freeze); it does NOT affect the filterable status.
224+
const hasLiveRun = isSessionLive({
221225
hasCurrentRun: session.currentRunId != null,
222226
currentRunStatus: currentRun?.status,
223-
now,
224227
});
225228

226229
return {
@@ -244,8 +247,9 @@ export class SessionListPresenter {
244247
updatedAt: session.updatedAt.toISOString(),
245248
environment: displayableEnvironment,
246249
currentRunFriendlyId: currentRun?.friendlyId,
247-
// Freeze point for an IDLE session's duration — when its current run
248-
// finished. Undefined when the session never ran (renders as a dash).
250+
hasLiveRun,
251+
// Freeze point for the duration when the session isn't live: when its
252+
// current run finished. Undefined when it never ran (renders a dash).
249253
currentRunCompletedAt: currentRun?.completedAt
250254
? currentRun.completedAt.toISOString()
251255
: undefined,

apps/webapp/app/presenters/v3/SpanPresenter.server.ts

Lines changed: 7 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ import { runStore } from "~/v3/runStore.server";
3636
import { getTaskEventStoreTableForRun, type TaskEventStoreTable } from "~/v3/taskEventStore.server";
3737
import { isFailedRunStatus, isFinalRunStatus } from "~/v3/taskStatus";
3838
import { BasePresenter } from "./basePresenter.server";
39-
import { deriveSessionStatus } from "./deriveSessionStatus";
4039
import { WaitpointPresenter } from "./WaitpointPresenter.server";
4140
import {
4241
controlPlaneResolver,
@@ -359,41 +358,25 @@ export class SpanPresenter extends BasePresenter {
359358
taskIdentifier: true,
360359
closedAt: true,
361360
expiresAt: true,
362-
currentRunId: true,
363361
},
364362
},
365363
},
366364
})
367365
: null;
368366

369-
// Resolve the session's current run so the badge reflects run liveness
370-
// (the same IDLE-vs-ACTIVE distinction as the sessions list/detail), not
371-
// just closedAt/expiresAt. Env-scoped, matching the run reads here.
372-
const sessionCurrentRun =
373-
sessionRun && sessionRun.session.currentRunId
374-
? await runStore.findRun(
375-
{
376-
id: sessionRun.session.currentRunId,
377-
runtimeEnvironmentId: run.runtimeEnvironmentId,
378-
},
379-
{ select: { status: true } },
380-
this._replica
381-
)
382-
: null;
383-
384367
const session = sessionRun
385368
? {
386369
friendlyId: sessionRun.session.friendlyId,
387370
externalId: sessionRun.session.externalId,
388371
type: sessionRun.session.type,
389372
taskIdentifier: sessionRun.session.taskIdentifier,
390-
status: deriveSessionStatus({
391-
closedAt: sessionRun.session.closedAt,
392-
expiresAt: sessionRun.session.expiresAt,
393-
hasCurrentRun: sessionRun.session.currentRunId != null,
394-
currentRunStatus: sessionCurrentRun?.status,
395-
now: Date.now(),
396-
}),
373+
status:
374+
sessionRun.session.closedAt != null
375+
? ("CLOSED" as const)
376+
: sessionRun.session.expiresAt != null &&
377+
sessionRun.session.expiresAt.getTime() < Date.now()
378+
? ("EXPIRED" as const)
379+
: ("ACTIVE" as const),
397380
reason: sessionRun.reason,
398381
triggeredAt: sessionRun.triggeredAt,
399382
}

apps/webapp/app/presenters/v3/deriveSessionStatus.test.ts

Lines changed: 0 additions & 92 deletions
This file was deleted.

apps/webapp/app/presenters/v3/deriveSessionStatus.ts

Lines changed: 0 additions & 46 deletions
This file was deleted.

0 commit comments

Comments
 (0)