feat: orchestrate lesson sessions from stemctl
This commit is contained in:
@@ -59,6 +59,21 @@ pozostają pod `~/dev/workspace/stem`. Kontener dostaje wybraną kartę jako
|
|||||||
Pierwsze wywołanie danego profilu może potrwać, ponieważ buduje go ze
|
Pierwsze wywołanie danego profilu może potrwać, ponieważ buduje go ze
|
||||||
źródłowego Dockerfile. Kolejne korzystają z lokalnych warstw cache.
|
źródłowego Dockerfile. Kolejne korzystają z lokalnych warstw cache.
|
||||||
|
|
||||||
|
Pełną sesję lekcji można wybrać i kontrolować numerami z CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./stemctl session choices --series inf --card 7 --task 4
|
||||||
|
./stemctl session reset hazard3-sim --series inf --card 7 --task 4 --step 12
|
||||||
|
./stemctl session refresh hazard3-sim --series inf --card 7 --task 4
|
||||||
|
./stemctl session attach hazard3-sim --series inf --card 7 --task 4
|
||||||
|
./stemctl session detach hazard3-sim --series inf --card 7 --task 4
|
||||||
|
```
|
||||||
|
|
||||||
|
`current:0` jest domyślnym pane: pane `0` okna tmuxa, w którym działa
|
||||||
|
wywołujący Codex. `session stage`/`session checkpoint` ustawia dowolny etap UML
|
||||||
|
online w Termdebug albo tylko na stronie z `--offline`. Szczegóły i wszystkie
|
||||||
|
selektory opisuje [dokumentacja sesji](doc/session.md).
|
||||||
|
|
||||||
Wymuszenie samego przygotowania środowiska:
|
Wymuszenie samego przygotowania środowiska:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -203,6 +218,7 @@ Nowe materiały powinny używać nazw kanonicznych.
|
|||||||
- [plan architektury](doc/architecture-plan.md)
|
- [plan architektury](doc/architecture-plan.md)
|
||||||
- [kontenery i interfejs](doc/containers.md)
|
- [kontenery i interfejs](doc/containers.md)
|
||||||
- [wytyczne dokumentowania debugowania](doc/debug-documentation-guidelines.md)
|
- [wytyczne dokumentowania debugowania](doc/debug-documentation-guidelines.md)
|
||||||
|
- [sesja karty, UML, MCP i tmux z CLI](doc/session.md)
|
||||||
- [migracja nazw i workspace](doc/migration-stem-launcher.md)
|
- [migracja nazw i workspace](doc/migration-stem-launcher.md)
|
||||||
- [serie i karty](doc/series.md)
|
- [serie i karty](doc/series.md)
|
||||||
- [tokeny Gitea](doc/tokens.md)
|
- [tokeny Gitea](doc/tokens.md)
|
||||||
|
|||||||
+113
@@ -0,0 +1,113 @@
|
|||||||
|
# Sesja karty z wiersza poleceń
|
||||||
|
|
||||||
|
`stemctl session` łączy pięć selektorów w jeden kontrakt:
|
||||||
|
|
||||||
|
```text
|
||||||
|
seria → karta → Task → blok/faza/krok/snapshot UML → instancja kontenera
|
||||||
|
```
|
||||||
|
|
||||||
|
Każdy selektor przyjmuje stabilne ID. Seria, karta, Task, blok, faza, krok i
|
||||||
|
snapshot przyjmują także numery pokazywane przez `choices`. Numer karty jest
|
||||||
|
jednobazową pozycją w manifeście serii, więc nie zależy od tego, czy repo karty
|
||||||
|
zostało już pobrane. W przypadku kroków najpierw sprawdzany jest globalny numer
|
||||||
|
strzałki z diagramu, a przy jawnie wybranej fazie także lokalny numer kroku.
|
||||||
|
|
||||||
|
## Lista wyboru
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./stemctl session choices --series inf --card 7 --task 4
|
||||||
|
./stemctl session choices --series inf --card pointers --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Tabele zawierają kolumnę `status`:
|
||||||
|
|
||||||
|
- `opracowane` — materiał UML ma komplet recept replay/checkpoint;
|
||||||
|
- `robocze` — istnieje źródło albo częściowe metadane;
|
||||||
|
- `brak` — karta lub materiał nie znajduje się jeszcze w workspace.
|
||||||
|
|
||||||
|
## Sterowanie środowiskiem
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Utwórz debugger Task04 w pane 0 okna, z którego uruchomiono Codexa.
|
||||||
|
./stemctl session start hazard3-sim --series inf --card 7 --task 4
|
||||||
|
|
||||||
|
# Usuń i odtwórz kontener, po czym zatrzymaj maszynę na strzałce 12.
|
||||||
|
./stemctl session reset hazard3-sim \
|
||||||
|
--series inf --card 7 --task 4 --step 12
|
||||||
|
|
||||||
|
# Zachowaj kontener i ponownie podepnij istniejący debugger.
|
||||||
|
./stemctl session refresh hazard3-sim --series inf --card 7 --task 4
|
||||||
|
|
||||||
|
# Podepnij/odepnij tylko klienta w zewnętrznym tmuxie. Serwer debuggera żyje dalej.
|
||||||
|
./stemctl session attach hazard3-sim --series inf --card 7 --task 4
|
||||||
|
./stemctl session detach hazard3-sim --series inf --card 7 --task 4
|
||||||
|
```
|
||||||
|
|
||||||
|
Domyślny cel `--pane current:0` oznacza pane `0` tego samego okna tmuxa, w
|
||||||
|
którym działa wywołujący Codex. Można podać jawne `%ID`, `sesja:okno.pane` albo
|
||||||
|
`--pane none`. Launcher odmawia zastąpienia pane, z którego sam został
|
||||||
|
uruchomiony, aby nie zakończyć Codexa. Pane Codexa jest dodatkowo oznaczone
|
||||||
|
PID-em pane w opcjach tmuxa; jawne `%ID` podane z innego terminala również
|
||||||
|
zostanie odrzucone. Świadome obejście tej drugiej ochrony wymaga
|
||||||
|
`--force-pane`; nie da się nim zastąpić pane wykonującego bieżącą komendę.
|
||||||
|
|
||||||
|
`detach` odłącza wyłącznie zewnętrznego klienta tmuxa. Nie zatrzymuje
|
||||||
|
kontenera, symulatora, GDB, Neovima ani wewnętrznej sesji tmuxa.
|
||||||
|
|
||||||
|
## Wybór stanu UML
|
||||||
|
|
||||||
|
Równoważne przykłady:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./stemctl session stage hazard3-sim --series inf --card 7 --task 4 --step 12
|
||||||
|
./stemctl session checkpoint hazard3-sim --series inf --card pointers --task 4 \
|
||||||
|
--snapshot task04.alloc5.commit
|
||||||
|
./stemctl session stage hazard3-sim --series inf --card 7 --task 4 --stage alloc-5/7
|
||||||
|
./stemctl session stage hazard3-sim --series inf --card 7 --task 4 \
|
||||||
|
--stage task04/allocator-flow/alloc-5/alloc5-commit
|
||||||
|
./stemctl session stage hazard3-sim --series inf --card 7 --task 4 \
|
||||||
|
--phase 2 --step 3
|
||||||
|
./stemctl session stage hazard3-sim --series inf --card 7 --task 4 --first
|
||||||
|
```
|
||||||
|
|
||||||
|
W ostatnim przykładzie `3` oznacza trzeci krok wewnątrz drugiej fazy, jeżeli
|
||||||
|
w tej fazie nie istnieje globalna strzałka numer 3.
|
||||||
|
|
||||||
|
Tryb online najpierw sprawdza pełną tożsamość karty (`id`, UUID, wersję i hash
|
||||||
|
źródłowego JSON-a), receptę checkpointu, profil, target oraz bind mount
|
||||||
|
`/workspace`. Następnie wybiera sockety MCP i przekazuje kontrolerowi
|
||||||
|
oczekiwane `instance/container_id/profile/target`. Replay zostaje odrzucony,
|
||||||
|
jeżeli globalny wybór MCP zmieni się przed aktywacją albo podczas niej. Dopiero
|
||||||
|
po udanym zatrzymaniu GDB publikowane są pozycja strony i `SYNC ON`.
|
||||||
|
|
||||||
|
`--offline` zmienia wyłącznie stan strony i wyłącza SYNC/sterowanie Neovimem.
|
||||||
|
`--control` dodatkowo uzbraja sterowanie klawiaturą Neovima; samo SYNC nie robi
|
||||||
|
tego automatycznie. `stage/checkpoint` wymaga jawnego selektora; pierwszy etap
|
||||||
|
wybiera się świadomie przez `--first`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./stemctl session stage hazard3-sim --series inf --card 7 --task 4 \
|
||||||
|
--step 12 --offline
|
||||||
|
./stemctl session status hazard3-sim --series inf --card 7 --task 4
|
||||||
|
./stemctl session status hazard3-sim --series inf --card 7 --task 4 \
|
||||||
|
--json --strict
|
||||||
|
```
|
||||||
|
|
||||||
|
Adres serwera karty można zmienić przez `--card-url` albo `STEM_CARD_URL`.
|
||||||
|
Limit oczekiwania na Neovima/GDB/MCP ustawia `--timeout`.
|
||||||
|
|
||||||
|
## Przyszłe strategie i adnotacje
|
||||||
|
|
||||||
|
Kluczem rozszerzeń pozostaje pełna pozycja
|
||||||
|
`task/block/phase/step/snapshot`. Pod tym kluczem będzie można później
|
||||||
|
przechowywać wiele strategii debugowania i adnotacji bez zmiany obecnego CLI.
|
||||||
|
Planowany kontrakt rozdziela treść od widoczności:
|
||||||
|
|
||||||
|
```text
|
||||||
|
strategy: id, label, commands, expected_observations
|
||||||
|
annotation: id, strategy_id, target, geometry, style, text, visible
|
||||||
|
```
|
||||||
|
|
||||||
|
`target` może wskazywać diagram, bufor/wiersz Neovima, rejestr, ramkę stosu
|
||||||
|
albo zakres pamięci. Operacje `show/hide/toggle` mają zmieniać widoczność bez
|
||||||
|
usuwania adnotacji; `add/remove` będą osobnymi, audytowalnymi operacjami.
|
||||||
+316
-2
@@ -7,7 +7,7 @@ import socket
|
|||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from contextlib import redirect_stdout
|
from contextlib import redirect_stderr, redirect_stdout
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
@@ -33,7 +33,7 @@ class StemctlContractTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_new_command_help_never_crashes(self) -> None:
|
def test_new_command_help_never_crashes(self) -> None:
|
||||||
script = Path(__file__).resolve().parents[1] / "rvctl.py"
|
script = Path(__file__).resolve().parents[1] / "rvctl.py"
|
||||||
commands = ["env", "build", "test", "run", "debug", "deploy", "shell", "start", "status", "attach", "stop", "rm", "probe", "mcp"]
|
commands = ["env", "build", "test", "run", "debug", "deploy", "shell", "start", "status", "attach", "stop", "rm", "probe", "mcp", "session"]
|
||||||
for command in commands:
|
for command in commands:
|
||||||
with self.subTest(command=command):
|
with self.subTest(command=command):
|
||||||
completed = subprocess.run(
|
completed = subprocess.run(
|
||||||
@@ -342,6 +342,320 @@ class StemctlContractTests(unittest.TestCase):
|
|||||||
nvim_server.close()
|
nvim_server.close()
|
||||||
tmux_server.close()
|
tmux_server.close()
|
||||||
|
|
||||||
|
def test_session_parser_exposes_numbered_target_and_stage_controls(self) -> None:
|
||||||
|
parser = rvctl.build_parser()
|
||||||
|
args = parser.parse_args(
|
||||||
|
[
|
||||||
|
"session",
|
||||||
|
"reset",
|
||||||
|
"hazard3-sim",
|
||||||
|
"--series",
|
||||||
|
"1",
|
||||||
|
"--card",
|
||||||
|
"7",
|
||||||
|
"--task",
|
||||||
|
"4",
|
||||||
|
"--phase",
|
||||||
|
"2",
|
||||||
|
"--step",
|
||||||
|
"3",
|
||||||
|
"--pane",
|
||||||
|
"current:0",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.assertEqual(args.session_command, "reset")
|
||||||
|
self.assertEqual(args.card, "7")
|
||||||
|
self.assertEqual(args.task, "4")
|
||||||
|
self.assertEqual(args.phase, "2")
|
||||||
|
self.assertEqual(args.step, "3")
|
||||||
|
self.assertEqual(args.pane, "current:0")
|
||||||
|
|
||||||
|
def test_session_phase_local_step_falls_back_after_global_number(self) -> None:
|
||||||
|
entries = []
|
||||||
|
for phase_index, numbers in enumerate(((1, 2), (6, 7, 8))):
|
||||||
|
for step_index, number in enumerate(numbers):
|
||||||
|
entries.append(
|
||||||
|
{
|
||||||
|
"task": {"id": "task04", "label": "Task04", "index": 0},
|
||||||
|
"block": {"id": "flow", "label": "Flow", "index": 0},
|
||||||
|
"phase": {
|
||||||
|
"id": f"phase-{phase_index + 1}",
|
||||||
|
"label": f"Phase {phase_index + 1}",
|
||||||
|
"index": phase_index,
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"id": f"step-{number}",
|
||||||
|
"label": f"Step {number}",
|
||||||
|
"number": number,
|
||||||
|
"index": step_index,
|
||||||
|
"global_index": len(entries),
|
||||||
|
},
|
||||||
|
"snapshot": {"ref": f"task04.step-{number}", "index": step_index},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
args = SimpleNamespace(
|
||||||
|
block=None,
|
||||||
|
phase="2",
|
||||||
|
step="3",
|
||||||
|
snapshot=None,
|
||||||
|
stage=None,
|
||||||
|
)
|
||||||
|
selected = rvctl.resolve_navigation_entry({"entries": entries}, "task04_address_arithmetic_alloc", args)
|
||||||
|
self.assertEqual(selected["phase"]["id"], "phase-2")
|
||||||
|
self.assertEqual(selected["step"]["number"], 8)
|
||||||
|
|
||||||
|
def test_session_material_status_has_three_polish_values(self) -> None:
|
||||||
|
entry = {
|
||||||
|
"step": {"id": "one"},
|
||||||
|
"snapshot": {"ref": "task04.one"},
|
||||||
|
}
|
||||||
|
self.assertEqual(
|
||||||
|
rvctl.navigation_entry_status(
|
||||||
|
entry,
|
||||||
|
{
|
||||||
|
"task04.one": {
|
||||||
|
"stop": {"symbol": "main", "offset": 0},
|
||||||
|
"verify": {"expressions": []},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"opracowane",
|
||||||
|
)
|
||||||
|
self.assertEqual(rvctl.navigation_entry_status(entry, {}), "robocze")
|
||||||
|
self.assertEqual(rvctl.development_status([]), "brak")
|
||||||
|
|
||||||
|
def test_session_card_number_uses_manifest_order(self) -> None:
|
||||||
|
cards = [
|
||||||
|
rvctl.CardInfo("inf", "first", "first", "main", "", "a", "b", "r1", "r1a", "main"),
|
||||||
|
rvctl.CardInfo("inf", "second", "second", "main", "", "a", "b", "r1", "r1a", "main"),
|
||||||
|
]
|
||||||
|
config = SimpleNamespace(defaults={})
|
||||||
|
with mock.patch.object(rvctl, "source_card_infos", return_value=cards):
|
||||||
|
self.assertEqual(rvctl.resolve_session_card(config, "inf", "2"), "second")
|
||||||
|
|
||||||
|
def test_session_rejects_destructive_online_reset_without_pane_in_preflight(self) -> None:
|
||||||
|
parser = rvctl.build_parser()
|
||||||
|
args = parser.parse_args(
|
||||||
|
[
|
||||||
|
"session",
|
||||||
|
"reset",
|
||||||
|
"hazard3-sim",
|
||||||
|
"--series",
|
||||||
|
"inf",
|
||||||
|
"--card",
|
||||||
|
"7",
|
||||||
|
"--task",
|
||||||
|
"4",
|
||||||
|
"--pane",
|
||||||
|
"none",
|
||||||
|
"--step",
|
||||||
|
"12",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(SystemExit, "requires a debugger pane"):
|
||||||
|
rvctl.validate_session_command_args(args)
|
||||||
|
|
||||||
|
def test_session_rejects_profile_target_pair_before_mutation(self) -> None:
|
||||||
|
args = SimpleNamespace(
|
||||||
|
series="inf",
|
||||||
|
card="7",
|
||||||
|
task="4",
|
||||||
|
profile="hazard3-sim",
|
||||||
|
target="rp2350-rv",
|
||||||
|
instance=None,
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
mock.patch.object(rvctl, "resolve_session_series", return_value="inf"),
|
||||||
|
mock.patch.object(rvctl, "resolve_session_card", return_value="pointers"),
|
||||||
|
mock.patch.object(rvctl, "resolve_workspace_card_path", return_value=Path("/tmp/card")),
|
||||||
|
mock.patch.object(rvctl, "resolve_submission_task", return_value="task04"),
|
||||||
|
self.assertRaisesRegex(SystemExit, "does not belong to profile hazard3-sim"),
|
||||||
|
):
|
||||||
|
rvctl.resolve_session_target(SimpleNamespace(), args)
|
||||||
|
|
||||||
|
def test_session_validates_pane_before_container_commands(self) -> None:
|
||||||
|
parser = rvctl.build_parser()
|
||||||
|
args = parser.parse_args(
|
||||||
|
["session", "reset", "hazard3-sim", "--series", "inf", "--card", "7", "--task", "4"]
|
||||||
|
)
|
||||||
|
target = rvctl.SessionTarget(
|
||||||
|
"inf", "pointers", Path("/tmp/card"), "task04_example", "hazard3-sim",
|
||||||
|
"hazard3-baremetal", "hazard3-sim-inf-pointers-t4"
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
mock.patch.object(rvctl, "resolve_session_target", return_value=target),
|
||||||
|
mock.patch.object(rvctl, "resolve_tmux_session_pane", side_effect=SystemExit("protected")),
|
||||||
|
mock.patch.object(subprocess, "run") as run,
|
||||||
|
self.assertRaisesRegex(SystemExit, "protected"),
|
||||||
|
):
|
||||||
|
rvctl.run_session(SimpleNamespace(), args)
|
||||||
|
run.assert_not_called()
|
||||||
|
|
||||||
|
def test_session_start_pane_none_applies_offline_stage(self) -> None:
|
||||||
|
parser = rvctl.build_parser()
|
||||||
|
args = parser.parse_args(
|
||||||
|
[
|
||||||
|
"session", "start", "hazard3-sim", "--series", "inf", "--card", "7",
|
||||||
|
"--task", "4", "--pane", "none", "--step", "12", "--offline"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
target = rvctl.SessionTarget(
|
||||||
|
"inf", "pointers", Path("/tmp/card"), "task04_example", "hazard3-sim",
|
||||||
|
"hazard3-baremetal", "hazard3-sim-inf-pointers-t4"
|
||||||
|
)
|
||||||
|
prepared = {
|
||||||
|
"task": {"id": "task04"},
|
||||||
|
"block": {"id": "flow"},
|
||||||
|
"phase": {"id": "alloc-5"},
|
||||||
|
"step": {"id": "commit", "number": 12},
|
||||||
|
"snapshot": {"ref": "task04.alloc5.commit"},
|
||||||
|
}
|
||||||
|
with (
|
||||||
|
mock.patch.object(rvctl, "resolve_session_target", return_value=target),
|
||||||
|
mock.patch.object(rvctl, "prepare_session_stage", return_value=prepared),
|
||||||
|
mock.patch.object(rvctl, "session_env_command", return_value=("true", {"command": "true"})),
|
||||||
|
mock.patch.object(rvctl, "select_session_stage") as select_stage,
|
||||||
|
mock.patch.object(subprocess, "run"),
|
||||||
|
redirect_stdout(io.StringIO()),
|
||||||
|
):
|
||||||
|
rvctl.run_session(SimpleNamespace(), args)
|
||||||
|
select_stage.assert_called_once_with(
|
||||||
|
mock.ANY, target, args, online=False, prepared_entry=prepared
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_session_stage_requires_explicit_selector_and_finite_timeout(self) -> None:
|
||||||
|
parser = rvctl.build_parser()
|
||||||
|
args = parser.parse_args(
|
||||||
|
["session", "stage", "hazard3-sim", "--series", "inf", "--card", "7", "--task", "4"]
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(SystemExit, "requires a UML selector"):
|
||||||
|
rvctl.validate_session_command_args(args)
|
||||||
|
with redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
|
||||||
|
parser.parse_args(
|
||||||
|
[
|
||||||
|
"session", "stage", "hazard3-sim", "--series", "inf", "--card", "7",
|
||||||
|
"--task", "4", "--first", "--timeout", "nan"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_session_protects_codex_owned_pane_by_pid_marker(self) -> None:
|
||||||
|
completed = subprocess.CompletedProcess(
|
||||||
|
[], 0, stdout="1:0.0\t%3\t/tmp\t999\t999\tthread-1\n", stderr=""
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
mock.patch.object(rvctl, "mark_invoking_codex_pane"),
|
||||||
|
mock.patch.object(subprocess, "run", return_value=completed),
|
||||||
|
mock.patch.dict(os.environ, {"TMUX_PANE": "%33"}),
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(SystemExit, "owned by Codex"):
|
||||||
|
rvctl.resolve_tmux_session_pane("%3")
|
||||||
|
self.assertEqual(rvctl.resolve_tmux_session_pane("%3", force=True)[1], "%3")
|
||||||
|
|
||||||
|
def test_session_identity_is_fail_closed_on_uuid_or_hash_mismatch(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
card_path = Path(temporary)
|
||||||
|
(card_path / "json").mkdir()
|
||||||
|
source = {
|
||||||
|
"card": {
|
||||||
|
"id": "card-1",
|
||||||
|
"uuid": "uuid-1",
|
||||||
|
"version": "v1",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(card_path / "json/card_source.json").write_text(json.dumps(source), encoding="utf-8")
|
||||||
|
target = rvctl.SessionTarget(
|
||||||
|
"inf", "card", card_path, "task04", "hazard3-sim",
|
||||||
|
"hazard3-baremetal", "instance"
|
||||||
|
)
|
||||||
|
served = {
|
||||||
|
"id": "card-1",
|
||||||
|
"uuid": "different",
|
||||||
|
"version": "v1",
|
||||||
|
"source_sha256": "different",
|
||||||
|
}
|
||||||
|
with mock.patch.object(rvctl, "card_api_json", return_value=served):
|
||||||
|
with self.assertRaisesRegex(SystemExit, "uuid, source_sha256"):
|
||||||
|
rvctl.verify_card_api_identity("http://127.0.0.1:8080", target, 1)
|
||||||
|
|
||||||
|
def test_session_cancelled_checkpoint_is_not_published(self) -> None:
|
||||||
|
target = rvctl.SessionTarget(
|
||||||
|
"inf", "pointers", Path("/tmp/card"), "task04", "hazard3-sim",
|
||||||
|
"hazard3-baremetal", "hazard3-sim-inf-pointers-t4"
|
||||||
|
)
|
||||||
|
args = SimpleNamespace(
|
||||||
|
card_url="http://127.0.0.1:8080",
|
||||||
|
timeout=1,
|
||||||
|
focus="step",
|
||||||
|
control=False,
|
||||||
|
)
|
||||||
|
entry = {
|
||||||
|
"task": {"id": "task04"},
|
||||||
|
"block": {"id": "allocator-flow"},
|
||||||
|
"phase": {"id": "alloc-5"},
|
||||||
|
"step": {"id": "alloc5-commit", "number": 12},
|
||||||
|
"snapshot": {"ref": "task04.alloc5.commit"},
|
||||||
|
}
|
||||||
|
record = {
|
||||||
|
"container_id": "abc123",
|
||||||
|
"instance": target.instance,
|
||||||
|
"profile": target.profile,
|
||||||
|
"target": target.target,
|
||||||
|
}
|
||||||
|
api = mock.Mock(return_value={"status": "cancelled", "message": "newer request"})
|
||||||
|
with (
|
||||||
|
mock.patch.object(rvctl, "resolve_mcp_record", return_value=record),
|
||||||
|
mock.patch.object(rvctl, "validated_mcp_record"),
|
||||||
|
mock.patch.object(rvctl, "validate_mcp_card_mount"),
|
||||||
|
mock.patch.object(rvctl, "select_mcp_instance"),
|
||||||
|
mock.patch.object(
|
||||||
|
rvctl,
|
||||||
|
"verify_card_api_identity",
|
||||||
|
return_value={
|
||||||
|
"id": "card-1",
|
||||||
|
"uuid": "uuid-1",
|
||||||
|
"version": "v1",
|
||||||
|
"source_sha256": "abc123",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
mock.patch.object(rvctl, "card_api_json", api),
|
||||||
|
self.assertRaisesRegex(SystemExit, "did not reach ready: cancelled"),
|
||||||
|
):
|
||||||
|
rvctl.select_session_stage(SimpleNamespace(), target, args, True, entry)
|
||||||
|
self.assertEqual(api.call_count, 1)
|
||||||
|
payload = api.call_args.kwargs["payload"]
|
||||||
|
self.assertEqual(payload["expected_identity"]["source_sha256"], "abc123")
|
||||||
|
|
||||||
|
def test_mcp_registry_profile_and_target_must_match_container_labels(self) -> None:
|
||||||
|
record = {
|
||||||
|
"runtime": "podman",
|
||||||
|
"container_name": "container",
|
||||||
|
"container_id": "abc123",
|
||||||
|
"instance_key": "instance-key",
|
||||||
|
"profile": "hazard3-sim",
|
||||||
|
"target": "hazard3-baremetal",
|
||||||
|
"socket_dir": "/tmp/not-used",
|
||||||
|
}
|
||||||
|
inspection = [{
|
||||||
|
"Id": "abc123",
|
||||||
|
"Config": {"Labels": {
|
||||||
|
"edu.stem.instance-key": "instance-key",
|
||||||
|
"edu.stem.profile": "hazard3-sim",
|
||||||
|
"edu.stem.target": "rp2350-rv",
|
||||||
|
}},
|
||||||
|
}]
|
||||||
|
completed = subprocess.CompletedProcess([], 0, stdout=json.dumps(inspection), stderr="")
|
||||||
|
with (
|
||||||
|
mock.patch.object(rvctl.shutil, "which", return_value="/usr/bin/podman"),
|
||||||
|
mock.patch.object(subprocess, "run", return_value=completed),
|
||||||
|
self.assertRaisesRegex(SystemExit, "Stale MCP registry"),
|
||||||
|
):
|
||||||
|
rvctl.validated_mcp_record(record)
|
||||||
|
|
||||||
|
def test_session_task_matching_is_anchored(self) -> None:
|
||||||
|
self.assertTrue(rvctl.same_task("task04", "task04_address_arithmetic_alloc"))
|
||||||
|
self.assertFalse(rvctl.same_task("notask04", "task04_address_arithmetic_alloc"))
|
||||||
|
self.assertFalse(rvctl.same_task("figure2026", "task04_address_arithmetic_alloc"))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user