feat: orchestrate lesson sessions from stemctl

This commit is contained in:
user
2026-07-17 13:31:36 +02:00
parent 8d1379ec66
commit 319e8b84f4
4 changed files with 1822 additions and 5 deletions
+316 -2
View File
@@ -7,7 +7,7 @@ import socket
import subprocess
import tempfile
import unittest
from contextlib import redirect_stdout
from contextlib import redirect_stderr, redirect_stdout
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
@@ -33,7 +33,7 @@ class StemctlContractTests(unittest.TestCase):
def test_new_command_help_never_crashes(self) -> None:
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:
with self.subTest(command=command):
completed = subprocess.run(
@@ -342,6 +342,320 @@ class StemctlContractTests(unittest.TestCase):
nvim_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__":
unittest.main()