feat(session): a terminal session says why it ended

`ended` and `failed` say how a run finished but not why: a stop its owner
asked for, a free weekend running out, an idle box and a game quitting on
its own all left the same row behind. A session now carries `end_reason`,
one word from a fixed list, written by whoever ends the run, and
`time_claimed`, stamped when a host takes the run, so bring-up can be
measured from the claim rather than from the request.

The control plane writes the two endings it decides itself: `user` when
the owner stops a run nobody had claimed yet, and `left_queue` when a
waiting run is abandoned. The write keeps a reason only on a terminal
state, the same way an error message is kept only for a failure, so no
row can say it ended while it is still going. Both fields are returned
wherever a session is.

The column is text rather than a database enum so a reason added later
is a code change and not a migration.
This commit is contained in:
Wanjohi
2026-10-15 23:25:44 +03:00
parent d565e6d445
commit c467dc082d
9 changed files with 4036 additions and 14 deletions
+5 -1
View File
@@ -146,7 +146,11 @@ describe('the line for a full host', () => {
await Session.setState({ id: playing.run.id, state: 'ended' });
expect((await h.jobs()).map((j) => j.sessionId)).toEqual([here.run.id]);
const left = await Session.fromID(gone.run.id);
expect(left).toMatchObject({ state: 'ended', errorMessage: 'Left the queue' });
expect(left).toMatchObject({
state: 'ended',
errorMessage: 'Left the queue',
endReason: 'left_queue'
});
});
test('a run with a slot is not in line', async () => {
+13 -2
View File
@@ -160,11 +160,13 @@ describe('POST /session', () => {
state: 'requested',
tier: null,
ticket: null,
timeClaimed: null,
timeStarted: null,
timeStopped: null,
timeStopRequested: null,
trial: false,
errorMessage: null
errorMessage: null,
endReason: null
});
expect(body.data.id.startsWith('ses_')).toBe(true);
});
@@ -478,7 +480,11 @@ describe('POST /session/:id/state', () => {
body: JSON.stringify({ state: 'starting', claimToken: HOLDER })
});
expect(res.status).toBe(200);
expect(((await res.json()) as any).data.state).toBe('starting');
const claimed = ((await res.json()) as any).data;
expect(claimed.state).toBe('starting');
// The time it was taken is what bring-up is measured from.
expect(body.data.timeClaimed).toBeNull();
expect(claimed.timeClaimed).not.toBeNull();
const jobs = await app.request('/machine/jobs', { headers: s.host });
expect(((await jobs.json()) as any).data).toEqual([]);
@@ -721,6 +727,7 @@ describe('POST /session/:id/ticket', () => {
const data = ((await read.json()) as any).data;
expect(Object.keys(data).sort()).toEqual([
'boxId',
'endReason',
'errorMessage',
'gameId',
'id',
@@ -729,6 +736,7 @@ describe('POST /session/:id/ticket', () => {
'state',
'ticket',
'tier',
'timeClaimed',
'timeStarted',
'timeStopRequested',
'timeStopped',
@@ -901,6 +909,9 @@ describe('POST /session/:id/stop', () => {
const out = (await res.json()) as any;
expect(out.data.state).toBe('ended');
expect(out.data.timeStopped).not.toBeNull();
// Ended here rather than by a host, so the reason is written here too.
expect(out.data.endReason).toBe('user');
expect(out.data.timeClaimed).toBeNull();
const res2 = await app.request('/machine/jobs', { headers: s.host });
expect(((await res2.json()) as any).data).toEqual([]);
});
@@ -0,0 +1,2 @@
ALTER TABLE "session" ADD COLUMN "end_reason" text;--> statement-breakpoint
ALTER TABLE "session" ADD COLUMN "time_claimed" timestamp with time zone;
File diff suppressed because it is too large Load Diff
@@ -183,6 +183,13 @@
"when": 1791715304549,
"tag": "0025_run_queue",
"breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1792095717042,
"tag": "0026_session_end_reason",
"breakpoints": true
}
]
}
+3 -1
View File
@@ -177,9 +177,11 @@ export namespace Examples {
// Shaped like the real thing so the docs do not teach a shorter one,
// and obviously not one.
claimToken: '00000000000000000000000000000000',
timeClaimed: '2026-07-28T11:59:30.000Z',
timeStarted: '2026-07-28T12:00:00.000Z',
timeStopped: null,
errorMessage: null
errorMessage: null,
endReason: null
};
export const GameDownload = {
+73 -10
View File
@@ -56,6 +56,36 @@ export namespace Session {
*/
export const ABANDONED_AFTER_SECONDS = 60;
/**
* Why a run is over.
*
* `ended` and `failed` say how a run finished, not why, and a stop its
* owner asked for, a free weekend running out and a game quitting on its
* own all read `ended` without this. Whoever ends the run writes it: the
* control plane for the two endings it decides itself, `user` on a run
* nobody had claimed and `left_queue`, and the host agent for everything
* else, in its terminal report. ref(d-0083)
*/
export const EndReason = z
.enum([
'user',
'window',
'hours',
'idle',
'left_queue',
'game_exited',
'launch_failed',
'host_stopped',
'host_restart'
])
.meta({
description:
'Why a run is over. `user`: its owner asked. `window`: the free weekend closed. `hours`: this weekend’s free hours are used. `idle`: nobody was connected to it. `left_queue`: it waited for a slot and nobody asked after it. `game_exited`: the game quit on its own. `launch_failed`: it never got as far as playing. `host_stopped`: its host ended it for a reason of its own. `host_restart`: its host restarted and the run did not survive it',
example: 'user'
});
export type EndReason = z.infer<typeof EndReason>;
export const Info = z
.object({
id: z.string().meta({
@@ -87,6 +117,11 @@ export namespace Session {
description: 'The size this run was asked for. Null runs at the size of its box',
example: 'md'
}),
timeClaimed: z.string().nullable().optional().meta({
description:
'When its host took the run on. Null while it waits for one, and for a run that never had one',
example: Examples.Session.timeClaimed
}),
timeStarted: z.string().nullable().optional().meta({
description: 'When the box actually started, not when the row appeared',
example: Examples.Session.timeStarted
@@ -109,6 +144,11 @@ export namespace Session {
description: 'Why it failed, when it did',
example: Examples.Session.errorMessage
}),
endReason: EndReason.nullable().optional().meta({
description:
'Why this run is over, once it is. Null while it runs, and for a run whose host did not say',
example: Examples.Session.endReason
}),
queue: Queue.nullable().optional().meta({
description:
'Where this run is in line for its host, while every slot there is taken. Null once it is not waiting'
@@ -261,10 +301,11 @@ export namespace Session {
*
* `live` stamps `timeStarted` and `ended`/`failed` stamp `timeStopped`, both
* only if unset — so a duplicate report does not extend a session someone is
* billed for, and metering can trust the pair.
* billed for, and metering can trust the pair. A reason is kept only for a
* run that is over, as an error message is kept only for one that failed.
*/
export const setState = fn(
Info.pick({ id: true, state: true, errorMessage: true }),
Info.pick({ id: true, state: true, errorMessage: true, endReason: true }),
async (input) => {
const now = sql`now()`;
return Database.use(async (tx) => {
@@ -273,6 +314,10 @@ export namespace Session {
.set({
state: input.state,
errorMessage: input.state === 'failed' ? (input.errorMessage ?? null) : null,
endReason:
input.state === 'ended' || input.state === 'failed'
? (input.endReason ?? null)
: null,
...(input.state === 'live'
? { timeStarted: sql`coalesce(${SessionTable.timeStarted}, ${now})` }
: {}),
@@ -541,7 +586,8 @@ export namespace Session {
.set({
state: 'ended',
timeStopped: sql`now()`,
errorMessage: 'Left the queue'
errorMessage: 'Left the queue',
endReason: 'left_queue' satisfies EndReason
})
.where(
and(
@@ -712,7 +758,12 @@ export namespace Session {
// the same moment wins or loses cleanly, never both.
const [cancelled] = await tx
.update(SessionTable)
.set({ state: 'ended', timeStopRequested: sql`now()`, timeStopped: sql`now()` })
.set({
state: 'ended',
timeStopRequested: sql`now()`,
timeStopped: sql`now()`,
endReason: 'user' satisfies EndReason
})
.where(and(scope, eq(SessionTable.state, 'requested')))
.returning();
if (cancelled) return serialize(cancelled);
@@ -756,7 +807,8 @@ export namespace Session {
from: z.enum(SessionState.enumValues),
to: z.enum(SessionState.enumValues),
claimToken: ClaimToken,
errorMessage: Info.shape.errorMessage
errorMessage: Info.shape.errorMessage,
endReason: Info.shape.endReason
}),
async (input) => {
const now = sql`now()`;
@@ -772,9 +824,16 @@ export namespace Session {
state: input.to,
// Taken on the claim and never written again: a terminal row
// still records which attempt ran it, and a holder that goes
// back to null lets a settled claim be replayed.
...(isClaim ? { claimToken: input.claimToken } : {}),
// back to null lets a settled claim be replayed. The time is
// taken with it, so a run's bring-up can be measured from
// the moment a host had it rather than from when it asked.
...(isClaim ? { claimToken: input.claimToken, timeClaimed: now } : {}),
errorMessage: input.to === 'failed' ? (input.errorMessage ?? null) : null,
// Only a run that is over has a reason to be over. Null when
// the report carries none, which is every report from a
// host agent that predates the field.
endReason:
input.to === 'ended' || input.to === 'failed' ? (input.endReason ?? null) : null,
...(input.to === 'live'
? { timeStarted: sql`coalesce(${SessionTable.timeStarted}, ${now})` }
: {}),
@@ -889,7 +948,8 @@ export namespace Session {
machineId: z.string(),
state: z.enum(SessionState.enumValues),
claimToken: ClaimToken,
errorMessage: Info.shape.errorMessage
errorMessage: Info.shape.errorMessage,
endReason: Info.shape.endReason
}),
async (input): Promise<TransitionResult> => {
// One transaction, because "this run is live" and "the box under it
@@ -933,7 +993,8 @@ export namespace Session {
from: current.state,
to: input.state,
claimToken: input.claimToken,
errorMessage: input.errorMessage
errorMessage: input.errorMessage,
endReason: input.endReason
});
// The state read above is not the state written below, and the
// gap is where two agents race. Nothing moved means somebody
@@ -1084,11 +1145,13 @@ export namespace Session {
state: input.state as Info['state'],
tier: input.tier as Info['tier'],
ticket: input.ticket,
timeClaimed: input.timeClaimed?.toISOString() ?? null,
timeStarted: input.timeStarted?.toISOString() ?? null,
timeStopped: input.timeStopped?.toISOString() ?? null,
timeStopRequested: input.timeStopRequested?.toISOString() ?? null,
trial: input.trial,
errorMessage: input.errorMessage
errorMessage: input.errorMessage,
endReason: input.endReason as Info['endReason']
};
}
}
+12
View File
@@ -79,6 +79,18 @@ export const SessionTable = pgTable(
trial: boolean('trial').notNull().default(false),
/** Why it ended badly, when it did. */
errorMessage: text('error_message'),
/**
* Why the run is over, in one word from `Session.EndReason`, written by
* whoever ended it. Null while it runs, and null for a run ended by a
* host agent too old to say. Text rather than a database enum, so a
* reason added later is a code change and not a migration.
*/
endReason: text('end_reason'),
/**
* When a host agent claimed this run, or null while it waits. The gap
* between this and `timeStarted` is the time spent bringing it up.
*/
timeClaimed: utc('time_claimed'),
/**
* Which attempt holds this run. Null until an agent claims it, and never
* cleared afterwards — a terminal row still records who ran it, and a
+44
View File
@@ -1157,3 +1157,47 @@ describe('Session tickets need a claim first', () => {
expect(closed.outcome).toBe('closed');
});
});
describe('Session end reasons', () => {
const report = (
run: { machineId: string; session: { id: string } },
state: Session.ReportableState,
endReason: Session.EndReason | null = null
) =>
Session.transition({
claimToken: HOLDER,
id: run.session.id,
machineId: run.machineId,
state,
errorMessage: null,
endReason
});
test('the claim stamps when a host took the run, and nothing before it does', async () => {
const run = await requestedRun('ses-claimed-at', 5470);
expect(run.session.timeClaimed).toBeNull();
const claimed = await report(run, 'starting');
expect(claimed.session?.timeClaimed).not.toBeNull();
// Going live is not a second claim, so the time a host took the run
// stays the time it took it.
const live = await report(run, 'live');
expect(live.session?.timeClaimed).toBe(claimed.session!.timeClaimed!);
});
test('a reason is kept only for a run that is over', async () => {
const run = await requestedRun('ses-reason-terminal', 5471);
await report(run, 'starting');
// The write itself refuses to give a running run a reason, whoever
// calls it, so no row can say it ended while it is still going.
const live = await report(run, 'live', 'idle');
expect(live.outcome).toBe('moved');
expect(live.session?.endReason).toBeNull();
const ended = await report(run, 'ended', 'idle');
expect(ended.session?.endReason).toBe('idle');
expect((await Session.fromID(run.session.id))?.endReason).toBe('idle');
});
});