Make Run runtime state authoritative

This commit is contained in:
npc0-hue
2026-08-06 10:07:01 +08:00
parent d8d07914da
commit 0875a1b17e
10 changed files with 324 additions and 12 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-06
@@ -0,0 +1,30 @@
## Context
Platform currently stores `serverInstances.state` as both desired state and observed runtime state. `CompleteRunJob` projects successful lifecycle jobs directly into that field, so a previous `process.start` success can leave a server as `running` even after the actual Run-managed process is gone. A manually started generated Run can register and heartbeat, but platform will not dispatch another start job because it trusts the stale stored state.
Run already has the safer primitive: plugin-declared `process.status` executes inside the generated Run workspace and returns a redacted `processState`. This change uses that existing channel as the observed runtime source.
## Goals / Non-Goals
**Goals:**
- Make generated Run startup reconcile stale platform lifecycle state through a platform-dispatched, Run-executed `process.status` job.
- Project server state from Run `processState` for status/start/stop lifecycle results.
- Preserve platform ownership of authorization, command dispatch, leases, and audit.
**Non-Goals:**
- Add a new live telemetry protocol or raw process list to heartbeat.
- Hardcode SCUM-specific executable names, Steam app IDs, paths, ports, or health checks in platform or Run.
- Change plugin create wizard requirements or distribution-build ownership.
## Decisions
- Use `process.status` rather than adding heartbeat fields. This keeps state reconciliation inside the existing job lease, capability, audit, and plugin-declared action model.
- Queue status reconciliation on generated Run registration when stored state is `running` or `failed`. Those states are the ones most likely to be stale after a manually restarted Run or process crash.
- Skip reconciliation when an active lifecycle job already exists for the server. The active job is already the current control operation and should not be raced by a status probe.
- Project `process.status` into lifecycle state with conservative mapping: `running` => `running`, `stopped/not-started` => `stopped`, unexpected `exited` => `failed`, operator-stopped `exited` => `stopped`.
## Risks / Trade-offs
- A plugin start wrapper can be `running` while still installing dependencies. This change does not solve game readiness; it only prevents platform from preserving stale process state when Run reports no managed process.
- Status reconciliation is asynchronous. A manual Run may briefly appear with stale state until it claims and completes the status job.
- If a server process was started outside the active generated Run workspace, the registering Run will report `not-started` and platform will mark stopped. That is intentional: unmanaged external processes are not authoritative for platform lifecycle.
@@ -0,0 +1,23 @@
## Why
Manual generated Run execution exposed a stale lifecycle design: platform persisted `running` after the previous supervised process was gone, so a newly-started Run worker could register successfully but had no way to correct the visible server state before an operator issued another lifecycle command. The machine-side Run must be the source of observed runtime truth; platform state should converge from Run-reported process facts instead of blocking actions based only on stale stored state.
## What Changes
- Add runtime-state reconciliation for generated Run registration so a Run endpoint can report the actual managed process state for its server after reconnect/startup.
- Project `process.status` results into server lifecycle state using Run-reported `processState` values such as `running`, `stopped`, `not-started`, and `exited`.
- Prevent stale platform `running` from surviving when the active Run reports no managed process for that server.
- Keep lifecycle command authorization and job dispatch platform-owned; only observed runtime/process state becomes Run-authoritative.
## Capabilities
### New Capabilities
- `run-runtime-state-authority`: Defines how platform reconciles server lifecycle state from generated Run process observations.
### Modified Capabilities
## Impact
- Affects `platform/service` lifecycle projection, Run registration handling, and lifecycle tests.
- No new product areas, billing, provider workflow, or plugin-specific hardcoding.
- No new external dependencies.
@@ -0,0 +1,38 @@
## ADDED Requirements
### Requirement: Generated Run registration reconciles observed process state
When a generated Run registers for a bound server instance, the platform SHALL enqueue a scoped `process.status` reconciliation job when the stored server state says the game process is running or failed and no active lifecycle job already covers that server. The reconciliation job SHALL use the plugin-declared status action and the same scoped workspace metadata as normal lifecycle jobs.
#### Scenario: Stale running state is checked after manual Run startup
- **WHEN** a generated Run registers for a server whose stored state is `running`
- **THEN** the platform enqueues one `process.status` job for that server and Run endpoint
#### Scenario: Existing active lifecycle job avoids duplicate status checks
- **WHEN** a generated Run registers while the same server already has an active lifecycle job
- **THEN** the platform does not enqueue an additional status reconciliation job
### Requirement: Run process status is authoritative for observed lifecycle state
The platform SHALL project terminal `process.status` results from Run into the server instance state. A Run-reported `processState` of `running` SHALL mark the server `running`; `stopped` or `not-started` SHALL mark it `stopped`; `exited` SHALL mark it `failed` unless the exit classification is an operator stop such as `requested-stop`, `forced-stop`, or `already-stopped`, in which case it SHALL mark the server `stopped`.
#### Scenario: Run reports no managed process
- **WHEN** a status reconciliation job succeeds with `processState=not-started`
- **THEN** the platform marks the server `stopped` instead of preserving stale `running`
#### Scenario: Run reports an unexpected exit
- **WHEN** a status reconciliation job succeeds with `processState=exited` and `exitClassification=unexpected-exit`
- **THEN** the platform marks the server `failed`
#### Scenario: Run reports a live process
- **WHEN** a status reconciliation job succeeds with `processState=running`
- **THEN** the platform marks the server `running`
### Requirement: Lifecycle command state changes use Run execution facts
For lifecycle start and stop jobs, the platform SHALL use Run execution facts when projecting server state. A successful `process.start` result SHALL mark `running` only when Run reports `processState=running`; a successful `process.stop` result SHALL mark `stopped` when Run reports `stopped`, `not-started`, or an operator-stopped `exited` state.
#### Scenario: Start succeeds without a running process fact
- **WHEN** a `process.start` job succeeds but Run reports `processState=stopped`
- **THEN** the platform does not mark the server `running`
#### Scenario: Stop succeeds from an already-stopped process
- **WHEN** a `process.stop` job succeeds with `processState=not-started`
- **THEN** the platform marks the server `stopped`
@@ -0,0 +1,9 @@
## 1. Runtime State Reconciliation
- [x] 1.1 Queue generated Run status reconciliation on registration when stored server state may be stale
- [x] 1.2 Project `process.status` execution results into server lifecycle state using Run `processState`
## 2. Verification
- [x] 2.1 Add service tests for stale running correction and active-job dedupe
- [x] 2.2 Run targeted Go tests, `openspec validate make-run-runtime-state-authoritative --strict`, and `scripts/check-structure.sh`