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
+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 () => {