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
+22 -2
View File
@@ -88,10 +88,18 @@ export namespace SessionApi {
errorMessage: z.string().max(1024).nullable().optional().meta({
description: 'Why it failed. Kept only for a run that did',
example: Examples.Session.errorMessage
}),
endReason: Session.EndReason.nullable().optional().meta({
description:
'Why the run is over. Only with `ended` or `failed`; leave it out and the run records no reason',
example: 'game_exited'
})
})
.strict();
/** The states a run is over in, and so the only ones a reason goes with. */
const TERMINAL: readonly Session.ReportableState[] = ['ended', 'failed'];
export const route = new Hono()
.get(
'/',
@@ -385,7 +393,7 @@ export namespace SessionApi {
tags: ['Session'],
summary: 'Report where a run has got to',
description:
'For the host the run’s box is placed on, and no other. Moving a run out of `requested` is the claim, and it is a compare-and-set: exactly one caller can take a given run, and one that loses gets 409. Re-reporting a state already reported is fine and changes nothing, including the timestamps a run is billed on. A transition that does not exist is 409 and the run does not move.',
'For the host the run’s box is placed on, and no other. Moving a run out of `requested` is the claim, and it is a compare-and-set: exactly one caller can take a given run, and one that loses gets 409. Re-reporting a state already reported is fine and changes nothing, including the timestamps a run is billed on. A transition that does not exist is 409 and the run does not move. Say why a run ended with `endReason` on the report that ends it; sending one with any other state is 400.',
responses: {
200: {
content: { 'application/json': { schema: Result(Session.Info) } },
@@ -400,12 +408,24 @@ export namespace SessionApi {
validator('json', StateReport),
async (c) => {
const body = c.req.valid('json');
// Refused rather than dropped. A reason on a run that is still
// going is a host agent that has its states and its reasons out
// of step, and storing nothing would hide that from it.
if (body.endReason && !TERMINAL.includes(body.state)) {
throw new VisibleError(
'validation',
ErrorCodes.Validation.INVALID_PARAMETER,
`A run that is ${body.state} is not over, so it has no reason to have ended`,
'endReason'
);
}
const result = await Session.transition({
id: c.req.valid('param').id,
machineId: Actor.machineID,
state: body.state,
claimToken: body.claimToken,
errorMessage: body.errorMessage ?? null
errorMessage: body.errorMessage ?? null,
endReason: body.endReason ?? null
});
switch (result.outcome) {
+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 () => {
+113 -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([]);
@@ -638,6 +644,106 @@ describe('POST /session/:id/state', () => {
expect(res.status).toBe(400);
});
test('the report that ends a run says why, and the owner reads it', async () => {
const s = await scene('route-reason-ended', 5570);
const { body } = await requestSession(s);
const report = (state: string, endReason?: string) =>
app.request(`/session/${body.data.id}/state`, {
method: 'POST',
headers: s.host,
body: JSON.stringify({ state, claimToken: HOLDER, endReason })
});
await report('starting');
await report('live');
const ended = await report('ended', 'game_exited');
expect(ended.status).toBe(200);
expect(((await ended.json()) as any).data.endReason).toBe('game_exited');
const read = await app.request(`/session/${body.data.id}`, { headers: s.user });
expect(((await read.json()) as any).data.endReason).toBe('game_exited');
});
test('a run that failed says why it is over, beside what went wrong', async () => {
const s = await scene('route-reason-failed', 5571);
const { body } = await requestSession(s);
const report = (payload: Record<string, unknown>) =>
app.request(`/session/${body.data.id}/state`, {
method: 'POST',
headers: s.host,
body: JSON.stringify({ claimToken: HOLDER, ...payload })
});
await report({ state: 'starting' });
const failed = await report({
state: 'failed',
errorMessage: 'the download could not finish',
endReason: 'launch_failed'
});
expect(failed.status).toBe(200);
expect(((await failed.json()) as any).data).toMatchObject({
state: 'failed',
errorMessage: 'the download could not finish',
endReason: 'launch_failed'
});
});
test('a reason with a run that is not over is refused, and the run stays put', async () => {
const s = await scene('route-reason-early', 5572);
const { body } = await requestSession(s);
await app.request(`/session/${body.data.id}/state`, {
method: 'POST',
headers: s.host,
body: JSON.stringify({ state: 'starting', claimToken: HOLDER })
});
const res = await app.request(`/session/${body.data.id}/state`, {
method: 'POST',
headers: s.host,
body: JSON.stringify({ state: 'live', claimToken: HOLDER, endReason: 'idle' })
});
expect(res.status).toBe(400);
expect(((await res.json()) as any).param).toBe('endReason');
const run = await Session.fromID(body.data.id);
expect(run?.state).toBe('starting');
expect(run?.endReason).toBeNull();
});
test('a reason nobody defined is a validation error', async () => {
const s = await scene('route-reason-bogus', 5573);
const { body } = await requestSession(s);
await app.request(`/session/${body.data.id}/state`, {
method: 'POST',
headers: s.host,
body: JSON.stringify({ state: 'starting', claimToken: HOLDER })
});
const res = await app.request(`/session/${body.data.id}/state`, {
method: 'POST',
headers: s.host,
body: JSON.stringify({ state: 'failed', claimToken: HOLDER, endReason: 'bored' })
});
expect(res.status).toBe(400);
expect((await Session.fromID(body.data.id))?.state).toBe('starting');
});
test('a host agent that sends no reason still ends the run, with none recorded', async () => {
const s = await scene('route-reason-absent', 5574);
const { body } = await requestSession(s);
const report = (state: string) =>
app.request(`/session/${body.data.id}/state`, {
method: 'POST',
headers: s.host,
body: JSON.stringify({ state, claimToken: HOLDER })
});
await report('starting');
await report('live');
// The report every host agent already in the field sends.
const ended = await report('ended');
expect(ended.status).toBe(200);
expect(((await ended.json()) as any).data).toMatchObject({ state: 'ended', endReason: null });
});
test('a person cannot report a state on their own session', async () => {
const s = await scene('route-claim-person', 5538);
const { body } = await requestSession(s);
@@ -721,6 +827,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 +836,7 @@ describe('POST /session/:id/ticket', () => {
'state',
'ticket',
'tier',
'timeClaimed',
'timeStarted',
'timeStopRequested',
'timeStopped',
@@ -901,6 +1009,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([]);
});