feat(session): a finished session says why it ended (#412)

Until now every finished run said only `ended` or `failed`. A user stop,
a trial window running out, an idle timeout, a host restart and a game
quitting were indistinguishable afterwards.

**Schema.** Migration `0026_session_end_reason` adds two nullable
columns to `session`:
- `end_reason`: one of `user`, `window`, `hours`, `idle`, `left_queue`,
`game_exited`, `launch_failed`, `host_stopped`, `host_restart`.
- `time_claimed`: when a host took the run, set on `requested →
starting`.

**API.**
- `POST /session/:id/state` takes an optional `endReason`. With `ended`
or `failed` it is stored. With `starting` or `live` it is a 400 and the
run does not move.
- Leaving it out of a terminal report stores null, so host agents that
predate this keep working.
- `endReason` and `timeClaimed` are returned on every session response.
- The API writes the reasons it decides itself: `user` when a run nobody
has claimed is stopped, and `left_queue` when a waiting run is abandoned
(the `Left the queue` message is kept).

**New transition, `starting → ended`.** A run stopped while its game is
still downloading has failed at nothing, and never reached `live`, which
is where billing starts. Until now the only way out of `starting` was
`failed`.

**Release order: this goes first.** The host agent that sends
`endReason` is a separate change. This route's body schema is strict, so
without this deployed the current API answers that agent with a 400.

**Tests.**
- API: 185 pass, 5 of them new.
- Core: 41 session tests pass, 3 of them new.
- The new tests fail when the implementation is broken on purpose.
- No new type errors.
This commit is contained in:
Wanjohi
2026-10-16 00:20:49 +03:00
committed by GitHub
parent cf0606a81a
commit b50dce2cef
10 changed files with 4176 additions and 17 deletions
@@ -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 = {
+78 -11
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})` }
: {}),
@@ -325,10 +370,14 @@ export namespace Session {
* the only mutual exclusion in this design. `ended` and `failed` are
* terminal, so their entries are empty rather than absent — a state with no
* exits is a fact worth writing down.
*
* `starting → ended` is a run stopped before it went live — the person
* stopping it while its game downloads, say. Nothing failed, so `failed`
* would be a lie, and it never reached `live`, so it was never billed.
*/
export const NEXT_STATES: Record<Info['state'], readonly Info['state'][]> = {
requested: ['starting'],
starting: ['live', 'failed'],
starting: ['live', 'ended', 'failed'],
live: ['ended', 'failed'],
ended: [],
failed: []
@@ -541,7 +590,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 +762,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 +811,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 +828,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 +952,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 +997,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 +1149,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
+57
View File
@@ -1157,3 +1157,60 @@ 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');
});
test('a run stopped before it went live ends, with its reason, and was never billed', async () => {
const run = await requestedRun('ses-stopped-starting', 5472);
await report(run, 'starting');
// Stopped while its game downloaded: nothing failed, and it never
// reached `live`, which is where billing starts.
const ended = await report(run, 'ended', 'user');
expect(ended.outcome).toBe('moved');
expect(ended.session?.state).toBe('ended');
expect(ended.session?.endReason).toBe('user');
expect(ended.session?.timeStarted).toBeNull();
});
});