diff --git a/AGENTS.md b/AGENTS.md index 92184fc..4dd0d2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -246,7 +246,7 @@ This file defines how coding agents should work in this repository. outcome lists whose `errors` keys exactly match `failed`, and `list_gpus` GPU records with visible ordinal metadata. Service-returned job IDs must satisfy the shared URL-path-safe job-id contract. Known nested `status.params` fields - must match the public session contract while extra fields remain + must be present and match the public session contract while extra fields remain forward-compatible. Session `state` values in status records must be one of `active`, `starting`, `stopping`, `runtime_failed`, or `stop_failed`. - Targeted CLI service commands must reject success payloads for the wrong diff --git a/docs/guides/mcp.md b/docs/guides/mcp.md index 669f621..212a314 100644 --- a/docs/guides/mcp.md +++ b/docs/guides/mcp.md @@ -188,7 +188,8 @@ is still in progress. That includes both `status(job_id)` and the all-session `status()` list, so agents do not mistake an in-progress start for no session. Returned session `params` are snapshots; local embedding callers can inspect or modify the returned object without mutating service state. Known fields in -`params` follow the same public session contract as `start_keep`. +`params` are always present and follow the same public session contract as +`start_keep`. If an already-started worker later reports a terminal runtime or allocation failure, the retained session is refreshed to `state="runtime_failed"` with `last_error`. It remains visible and stoppable. This is distinct from normal diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 870e6ea..873264e 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -209,6 +209,9 @@ legacy local scripts. Omitted direct-call `params` are treated as `{}` for compatibility, but explicit `params: null` or any other non-object `params` value returns `-32602 Invalid params` before method side effects. +Status records include a complete `params` snapshot with `gpu_ids`, `vram`, +`interval`, and `busy_threshold`; omitted `gpu_ids` is represented as `null`, +not by omitting the key. | Endpoint | Method | Purpose | | --- | --- | --- | diff --git a/src/keep_gpu/cli.py b/src/keep_gpu/cli.py index ada1b4b..6f5241d 100644 --- a/src/keep_gpu/cli.py +++ b/src/keep_gpu/cli.py @@ -846,7 +846,7 @@ def _validate_status_params(params: Dict[str, Any], method: str, prefix: str) -> } for field, validator in validators.items(): if field not in params: - continue + raise _malformed_method_result(method, f"{prefix}.{field} is required") try: validator(params[field]) except (TypeError, ValueError, OverflowError) as exc: diff --git a/tests/test_cli_service_commands.py b/tests/test_cli_service_commands.py index 8e0096d..bafa201 100644 --- a/tests/test_cli_service_commands.py +++ b/tests/test_cli_service_commands.py @@ -997,14 +997,7 @@ def fake_rpc(method, params, host, port): assert params == {} return { "active": True, - "active_jobs": [ - { - "job_id": "job-1", - "params": {"gpu_ids": [0]}, - "state": "active", - "last_error": None, - } - ], + "active_jobs": [_status_session_record()], } monkeypatch.setattr(cli, "_rpc_call", fake_rpc) @@ -1078,13 +1071,7 @@ def test_status_job_outputs_single_decoded_json_object(monkeypatch): def fake_rpc(method, params, host, port): assert method == "status" assert params == {"job_id": "job-1"} - return { - "active": True, - "job_id": "job-1", - "params": {"gpu_ids": [0]}, - "state": "active", - "last_error": None, - } + return {"active": True, **_status_session_record()} monkeypatch.setattr(cli, "_rpc_call", fake_rpc) @@ -1263,6 +1250,30 @@ def fake_rpc(method, params, host, port): assert "Traceback" not in result.output +@pytest.mark.parametrize( + "missing_field", + ["gpu_ids", "vram", "interval", "busy_threshold"], +) +def test_status_rejects_missing_session_params(monkeypatch, missing_field): + session = _status_session_record() + del session["params"][missing_field] + + def fake_rpc(method, params, host, port): + assert method == "status" + assert params == {} + return {"active_jobs": [session]} + + monkeypatch.setattr(cli, "_rpc_call", fake_rpc) + + result = runner.invoke(cli.app, ["status"]) + + assert result.exit_code == 1 + decoded = _single_decoded_json_object(result.output) + assert "Malformed status response" in decoded["error"] + assert f"active_jobs[0].params.{missing_field} is required" in decoded["error"] + assert "Traceback" not in result.output + + @pytest.mark.parametrize( "payload", [ @@ -1358,10 +1369,8 @@ def fake_rpc(method, params, host, port): {"active": False, "job_id": "other-job"}, { "active": True, + **_status_session_record(), "job_id": "other-job", - "params": {}, - "state": "active", - "last_error": None, }, ], ) @@ -2914,16 +2923,7 @@ def fake_rpc(method, params, host, port, timeout=8.0): calls["status"] += 1 if calls["status"] == 1: return {"active_jobs": []} - return { - "active_jobs": [ - { - "job_id": "late-job", - "params": {}, - "state": "active", - "last_error": None, - } - ] - } + return {"active_jobs": [{**_status_session_record(), "job_id": "late-job"}]} if method == "stop_keep": return { "stopped": [], @@ -3036,16 +3036,7 @@ def test_service_stop_refuses_active_sessions_without_force(monkeypatch): cli, "_rpc_call", lambda method, params, host, port, timeout=8.0: ( - { - "active_jobs": [ - { - "job_id": "j1", - "params": {}, - "state": "active", - "last_error": None, - } - ] - } + {"active_jobs": [{**_status_session_record(), "job_id": "j1"}]} if method == "status" else {"stopped": []} ),