From 477bba429a3a9f81ca69f870af5476fd8b7f3ec8 Mon Sep 17 00:00:00 2001 From: Matteo Prandi Date: Mon, 30 Mar 2026 20:59:15 +0200 Subject: [PATCH 1/8] fix(agent-launcher): isolate scheduler state by run --- services/agent-launcher/app/scheduler.py | 46 +++++++++++++------ .../unit/test_scheduler_context_forwarding.py | 26 ++++++++++- 2 files changed, 56 insertions(+), 16 deletions(-) diff --git a/services/agent-launcher/app/scheduler.py b/services/agent-launcher/app/scheduler.py index f38368a..474f03e 100644 --- a/services/agent-launcher/app/scheduler.py +++ b/services/agent-launcher/app/scheduler.py @@ -79,6 +79,7 @@ class HeartbeatResult: """Result of a heartbeat operation.""" agent_id: str success: bool + run_id: str = "" actions_executed: int = 0 error: Optional[str] = None timestamp: datetime = field(default_factory=datetime.utcnow) @@ -624,6 +625,7 @@ async def _run_single_heartbeat( if self._state != SchedulerState.RUNNING: return HeartbeatResult( agent_id=agent_id, + run_id=run_id, success=True, actions_executed=0, metadata={"skipped": "scheduler_not_running"}, @@ -647,6 +649,7 @@ async def _run_single_heartbeat( if self._state != SchedulerState.RUNNING: return HeartbeatResult( agent_id=agent_id, + run_id=run_id, success=True, actions_executed=0, metadata={"skipped": "scheduler_not_running"}, @@ -658,6 +661,7 @@ async def _run_single_heartbeat( if self._state != SchedulerState.RUNNING: result = HeartbeatResult( agent_id=agent_id, + run_id=run_id, success=True, actions_executed=0, metadata={"skipped": "scheduler_not_running"}, @@ -676,6 +680,7 @@ async def _run_single_heartbeat( except asyncio.CancelledError: result = HeartbeatResult( agent_id=agent_id, + run_id=run_id, success=True, actions_executed=0, metadata={"skipped": "cancelled"}, @@ -697,6 +702,7 @@ async def _run_single_heartbeat( result = HeartbeatResult( agent_id=agent_id, + run_id=run_id, success=False, error=error_msg ) @@ -715,6 +721,7 @@ async def _run_single_heartbeat( except asyncio.CancelledError: return HeartbeatResult( agent_id=agent_id, + run_id=run_id, success=True, actions_executed=0, metadata={"skipped": "cancelled"}, @@ -793,6 +800,7 @@ async def _call_heartbeat_endpoint( return HeartbeatResult( agent_id=agent_id, + run_id=run_id, # Paused/skipped are intentional control states, not failures. success=heartbeat_status in {"completed", "paused", "skipped"}, actions_executed=data.get("actions_executed", 0), @@ -1043,21 +1051,29 @@ def _discover_agents(self) -> List[tuple]: def _update_agent_state(self, result: HeartbeatResult): """Update agent state based on heartbeat result.""" - # Find agent state by agent_id - for state in self._agent_states.values(): - if state.agent_id == result.agent_id: - if result.success: - state.status = "active" - state.consecutive_failures = 0 - state.total_actions += result.actions_executed - else: - state.consecutive_failures += 1 - state.last_error = result.error - if state.consecutive_failures >= 3: - state.status = "failed" - - state.last_heartbeat = result.timestamp - break + state: Optional[AgentState] = None + if result.run_id: + state = self._agent_states.get(f"{result.run_id}/{result.agent_id}") + + # Backward-compatible fallback for older call sites/tests that omit run_id. + if state is None: + for candidate in self._agent_states.values(): + if candidate.agent_id == result.agent_id: + state = candidate + break + + if state is not None: + if result.success: + state.status = "active" + state.consecutive_failures = 0 + state.total_actions += result.actions_executed + else: + state.consecutive_failures += 1 + state.last_error = result.error + if state.consecutive_failures >= 3: + state.status = "failed" + + state.last_heartbeat = result.timestamp self._maybe_stop_when_all_agents_terminal() def _parse_interval(self, interval_str: str) -> float: diff --git a/services/agent-launcher/tests/unit/test_scheduler_context_forwarding.py b/services/agent-launcher/tests/unit/test_scheduler_context_forwarding.py index 3346af4..b1f2a08 100644 --- a/services/agent-launcher/tests/unit/test_scheduler_context_forwarding.py +++ b/services/agent-launcher/tests/unit/test_scheduler_context_forwarding.py @@ -48,7 +48,7 @@ def __init__(self, *args, **kwargs): sys.modules["apscheduler.triggers.date"] = apscheduler_date_module from app import scheduler as scheduler_module -from app.scheduler import HeartbeatScheduler +from app.scheduler import AgentState, HeartbeatScheduler class _FakeResponse: @@ -473,3 +473,27 @@ def test_scheduler_progress_summarizes_agent_state() -> None: "total_agents": 3, "all_agents_terminal": False, } + + +@pytest.mark.unit +def test_update_agent_state_isolated_by_run_id() -> None: + scheduler = HeartbeatScheduler() + scheduler.initialize(interval="5s", timeout="2s") + scheduler._agent_states = { + "run-a/agent-1": AgentState(agent_id="agent-1", run_id="run-a", heartbeat_index=0, status="running"), + "run-b/agent-1": AgentState(agent_id="agent-1", run_id="run-b", heartbeat_index=0, status="running"), + } + + scheduler._update_agent_state( + scheduler_module.HeartbeatResult( + agent_id="agent-1", + run_id="run-b", + success=True, + actions_executed=2, + ) + ) + + assert scheduler._agent_states["run-a/agent-1"].status == "running" + assert scheduler._agent_states["run-a/agent-1"].total_actions == 0 + assert scheduler._agent_states["run-b/agent-1"].status == "active" + assert scheduler._agent_states["run-b/agent-1"].total_actions == 2 From 9af4e2114e2d4c0a78ed64aa9cf1db7c91b1849a Mon Sep 17 00:00:00 2001 From: Matteo Prandi Date: Mon, 30 Mar 2026 21:04:31 +0200 Subject: [PATCH 2/8] docs(contrib): clarify dev pull request flow --- CONTRIBUTING.md | 20 ++++++++++++++++++++ README.md | 5 +++++ 2 files changed, 25 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c00e42e..07a82d8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,6 +26,24 @@ bash scripts/up_stack.sh bash scripts/verify_platform.sh ``` +## Branch Strategy + +- `main` is the stable release branch. +- `dev` is the integration branch for active development. +- Open pull requests against `dev`, not `main`. +- Do not push feature work directly to `main`. + +Recommended flow: + +```bash +git fetch origin +git switch dev +git pull --rebase origin dev +git switch -c feat/ +``` + +If you are contributing from a fork, open your PR from `feat/` into `icaro-lab/MASE:dev`. + ## Main Contribution Paths ### New Environment @@ -75,9 +93,11 @@ bash scripts/verify_platform.sh ## Pull Requests - Keep PRs scoped. +- Base PRs on `dev`. - Describe the user-facing change clearly. - Include the commands you ran. - Call out any deferred work explicitly. +- Rebase or merge from `origin/dev` before marking the PR ready. ## Design Rules diff --git a/README.md b/README.md index 3793d2d..3b6ed8b 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,11 @@ This repo now includes: - [SECURITY.md](SECURITY.md) - GitHub CI under `.github/workflows/ci.yml` +Contribution flow: + +- `main` stays stable +- contributions should branch from `dev` and open PRs back into `dev` + ## Repository Shape ```text From 7a9347a78155e830c7195f5d86a67455740a6005 Mon Sep 17 00:00:00 2001 From: Matteo Prandi Date: Mon, 30 Mar 2026 21:43:29 +0200 Subject: [PATCH 3/8] fix(controller): reserve unique frontend ports per run --- services/controller/app/run_compat.py | 33 +++++ services/controller/app/run_frontend_ports.py | 103 ++++++++++++++++ services/controller/app/run_launcher.py | 57 +++++++-- services/controller/app/run_read_model.py | 25 +++- .../tests/test_run_frontend_ports.py | 113 ++++++++++++++++++ 5 files changed, 319 insertions(+), 12 deletions(-) create mode 100644 services/controller/app/run_frontend_ports.py create mode 100644 services/controller/tests/test_run_frontend_ports.py diff --git a/services/controller/app/run_compat.py b/services/controller/app/run_compat.py index 0c64d33..1b4f452 100644 --- a/services/controller/app/run_compat.py +++ b/services/controller/app/run_compat.py @@ -35,6 +35,7 @@ resolve_run_max_ticks, resolve_run_runtime_limit, ) +from app.run_frontend_ports import apply_frontend_port, reserve_frontend_port from app.run_launcher import RunLaunchConfig, run_launcher from app import run_public_ops from app.run_read_model import build_run_response @@ -101,6 +102,37 @@ async def create_bound_run( ) db.add(db_run) db.flush() + try: + launch_manifest = run_launcher.resolve_environment_launch( + str(normalized_env_config.get("environment_id") or "").strip() or environment_id + ) + frontend_port = reserve_frontend_port( + db, + run_id=db_run.run_id, + environment_has_frontend=bool(launch_manifest.get("frontend_service")), + ) + apply_frontend_port( + run_id=db_run.run_id, + environment_config=normalized_env_config, + snapshot=snapshot, + frontend_port=frontend_port, + ) + except Exception as port_error: + db.rollback() + db_run.status = RunStatusEnum.FAILED.value + db_run.ended_at = datetime.utcnow() + emit_run_terminal_event( + db=db, + run=db_run, + terminal_status=RunStatusEnum.FAILED.value, + terminal_reason=f"Failed to reserve run frontend port: {port_error}", + terminal_source="create_run_frontend_port", + ) + db.commit() + raise HTTPException( + status_code=500, + detail=f"Failed to reserve run frontend port: {port_error}", + ) from port_error run_binding.upsert_run_binding( db, run=db_run, @@ -161,6 +193,7 @@ async def create_bound_run( seed=request.seed, resolved_bundle_hash=resolved_bundle_hash, api_key=effective_api_key, + frontend_port=frontend_port, ) async def mark_run_failed_and_stop( diff --git a/services/controller/app/run_frontend_ports.py b/services/controller/app/run_frontend_ports.py new file mode 100644 index 0000000..3ea5754 --- /dev/null +++ b/services/controller/app/run_frontend_ports.py @@ -0,0 +1,103 @@ +"""Helpers for reserving per-run environment frontend ports.""" + +from __future__ import annotations + +from typing import Any + +from sqlalchemy.orm import Session + +from app.database import RunBinding as RunBindingDB +from app.run_launcher import RunLauncher + + +def _coerce_port(value: Any) -> int | None: + try: + if value is None: + return None + port = int(value) + except (TypeError, ValueError): + return None + if port <= 0: + return None + return port + + +def extract_frontend_port(*payloads: Any) -> int | None: + """Read a reserved frontend port from run binding payloads.""" + for payload in payloads: + if not isinstance(payload, dict): + continue + launch = payload.get("launch") if isinstance(payload.get("launch"), dict) else {} + for key in ("frontend_port", "environment_frontend_port"): + port = _coerce_port(launch.get(key)) + if port is not None: + return port + frontend_url = str(launch.get("frontend_url") or launch.get("environment_frontend_url") or "").strip() + if frontend_url.startswith("http://localhost:"): + port = _coerce_port(frontend_url.rsplit(":", 1)[-1]) + if port is not None: + return port + return None + + +def has_reserved_frontend(environment_config: dict[str, Any] | None, snapshot: dict[str, Any] | None = None) -> bool: + """Return True when a run binding already carries a frontend reservation.""" + return extract_frontend_port(environment_config or {}, snapshot or {}) is not None + + +def reserve_frontend_port( + db: Session, + *, + run_id: str, + environment_has_frontend: bool, +) -> int | None: + """Reserve a stable frontend port for a run. + + Ports are reserved across all bound runs so stopped runs can still be + restarted later without colliding with newer runs. + """ + if not environment_has_frontend: + return None + + reserved_ports: set[int] = set() + for binding in db.query(RunBindingDB).all(): + if str(binding.run_id) == str(run_id): + continue + port = extract_frontend_port( + binding.environment_config if isinstance(binding.environment_config, dict) else {}, + binding.snapshot if isinstance(binding.snapshot, dict) else {}, + ) + if port is not None: + reserved_ports.add(port) + + preferred_port = RunLauncher.get_environment_frontend_port(run_id) + base = RunLauncher.FRONTEND_PORT_BASE + span = RunLauncher.FRONTEND_PORT_SPAN + start_offset = preferred_port - base + + for step in range(span): + candidate = base + ((start_offset + step) % span) + if candidate not in reserved_ports: + return candidate + + raise ValueError("No available reserved frontend ports remain in the configured span") + + +def apply_frontend_port( + *, + run_id: str, + environment_config: dict[str, Any], + snapshot: dict[str, Any], + frontend_port: int | None, +) -> None: + """Persist a resolved frontend port into environment config and snapshot.""" + if frontend_port is None: + return + + frontend_url = RunLauncher.get_environment_frontend_url(run_id, frontend_port=frontend_port) + + for payload in (environment_config, snapshot): + launch = payload.get("launch") if isinstance(payload.get("launch"), dict) else {} + launch["frontend_port"] = int(frontend_port) + launch["frontend_url"] = frontend_url + payload["launch"] = launch diff --git a/services/controller/app/run_launcher.py b/services/controller/app/run_launcher.py index d1afdcf..6dfb7e5 100644 --- a/services/controller/app/run_launcher.py +++ b/services/controller/app/run_launcher.py @@ -28,6 +28,7 @@ class RunLaunchConfig(BaseModel): seed: Optional[int] = Field(None, description="Random seed") resolved_bundle_hash: Optional[str] = Field(None, description="Bundle hash") api_key: Optional[str] = Field(None, description="LLM API key for agents") + frontend_port: Optional[int] = Field(None, description="Reserved host port for environment frontend") class RunLauncher: @@ -124,9 +125,18 @@ def get_environment_frontend_port(cls, run_id: str, namespace: Optional[str] = N return cls.FRONTEND_PORT_BASE + offset @classmethod - def get_environment_frontend_url(cls, run_id: str, namespace: Optional[str] = None) -> str: + def get_environment_frontend_url( + cls, + run_id: str, + namespace: Optional[str] = None, + frontend_port: Optional[int] = None, + ) -> str: """Get browser-facing frontend URL for a run.""" - return f"http://localhost:{cls.get_environment_frontend_port(run_id, namespace=namespace)}" + resolved_port = int(frontend_port) if frontend_port is not None else cls.get_environment_frontend_port( + run_id, + namespace=namespace, + ) + return f"http://localhost:{resolved_port}" @classmethod def default_environment_image_repository(cls, environment_id: str, asset_kind: str) -> str: @@ -378,6 +388,7 @@ def get_service_urls( self, run_id: str, environment_id: Optional[str] = None, + frontend_port: Optional[int] = None, ) -> Dict[str, str]: """Get service URLs for a run. @@ -406,7 +417,10 @@ def get_service_urls( urls["environment_frontend_internal"] = ( f"http://{frontend_service['container_prefix']}-{run_id}:{frontend_service['port']}" ) - urls["environment_frontend"] = self.get_environment_frontend_url(run_id) + urls["environment_frontend"] = self.get_environment_frontend_url( + run_id, + frontend_port=frontend_port, + ) return urls @@ -431,7 +445,11 @@ def generate_run_override(self, config: RunLaunchConfig, oracle_bundle_paths: Op environment_service = launch_config["environment_service"] agent_service = launch_config["agent_worker_service"] frontend_service = launch_config.get("frontend_service") - frontend_port = self.get_environment_frontend_port(run_id) + frontend_port = ( + int(config.frontend_port) + if config.frontend_port is not None + else self.get_environment_frontend_port(run_id) + ) resolved_llm_provider = ( "dummy" if str(config.api_key or "").strip().lower() == "dummy" @@ -450,7 +468,10 @@ def generate_run_override(self, config: RunLaunchConfig, oracle_bundle_paths: Op "ENV_STATE_KEY": state_key, "ENV_STATE_PATH": state_path, "ENV_FRONTEND_PORT": str(frontend_port), - "ENV_FRONTEND_URL": self.get_environment_frontend_url(config.run_id), + "ENV_FRONTEND_URL": self.get_environment_frontend_url( + config.run_id, + frontend_port=frontend_port, + ), "DATABASE_URL": database_url, "SEED": str(config.seed) if config.seed else "", "RESOLVED_BUNDLE_HASH": config.resolved_bundle_hash or "", @@ -475,7 +496,10 @@ def generate_run_override(self, config: RunLaunchConfig, oracle_bundle_paths: Op state_key=state_key, state_path=state_path, frontend_port=frontend_port, - frontend_url=self.get_environment_frontend_url(config.run_id), + frontend_url=self.get_environment_frontend_url( + config.run_id, + frontend_port=frontend_port, + ), ) ) @@ -690,7 +714,11 @@ def launch_run(self, config: RunLaunchConfig) -> Dict: state_key = f"{config.environment_id}:{config.run_id}" state_path = f"/app/data/state/{config.run_id}" database_url = f"sqlite:////app/data/state/{config.run_id}/environment.db" - frontend_port = self.get_environment_frontend_port(config.run_id) + frontend_port = ( + int(config.frontend_port) + if config.frontend_port is not None + else self.get_environment_frontend_port(config.run_id) + ) resolved_llm_provider = ( "dummy" if str(config.api_key or "").strip().lower() == "dummy" @@ -705,10 +733,16 @@ def launch_run(self, config: RunLaunchConfig) -> Dict: "ENV_STATE_PATH": state_path, "DATABASE_URL": database_url, "ENV_FRONTEND_PORT": str(frontend_port), - "ENV_FRONTEND_URL": self.get_environment_frontend_url(config.run_id), + "ENV_FRONTEND_URL": self.get_environment_frontend_url( + config.run_id, + frontend_port=frontend_port, + ), "ENV_CONTAINER_PREFIX": config.environment_id, "ENVIRONMENT_FRONTEND_PORT": str(frontend_port), - "ENVIRONMENT_FRONTEND_URL": self.get_environment_frontend_url(config.run_id), + "ENVIRONMENT_FRONTEND_URL": self.get_environment_frontend_url( + config.run_id, + frontend_port=frontend_port, + ), "ENVIRONMENT_NAME": config.environment_id, "SEED": str(config.seed) if config.seed else "", "RESOLVED_BUNDLE_HASH": config.resolved_bundle_hash or "", @@ -723,7 +757,10 @@ def launch_run(self, config: RunLaunchConfig) -> Dict: state_key=state_key, state_path=state_path, frontend_port=frontend_port, - frontend_url=self.get_environment_frontend_url(config.run_id), + frontend_url=self.get_environment_frontend_url( + config.run_id, + frontend_port=frontend_port, + ), ) ) diff --git a/services/controller/app/run_read_model.py b/services/controller/app/run_read_model.py index 2b5f5a6..c00f6e1 100644 --- a/services/controller/app/run_read_model.py +++ b/services/controller/app/run_read_model.py @@ -16,6 +16,7 @@ from app.models import Run, RunStatus from app.run_binding import build_run_context from app.run_config import resolve_run_runtime_limit +from app.run_frontend_ports import extract_frontend_port from app.run_launcher import RunLaunchConfig, run_launcher @@ -102,10 +103,15 @@ def resolve_run_service_urls(run: RunDB, db: Session) -> dict[str, str]: context = build_run_context(run, db) environment_id = resolve_environment_id_for_run(run, db) has_frontend = _context_has_frontend(context) + frontend_port = extract_frontend_port( + context.get("environment_config") if isinstance(context.get("environment_config"), dict) else {}, + context.get("snapshot") if isinstance(context.get("snapshot"), dict) else {}, + ) try: return run_launcher.get_service_urls( run_id=run.run_id, environment_id=environment_id, + frontend_port=frontend_port, ) except Exception as exc: logger.warning( @@ -119,7 +125,10 @@ def resolve_run_service_urls(run: RunDB, db: Session) -> dict[str, str]: "agent_worker": settings.get_agent_worker_url(run.run_id, environment_id=environment_id), } if has_frontend: - fallback_urls["environment_frontend"] = run_launcher.get_environment_frontend_url(run.run_id) + fallback_urls["environment_frontend"] = run_launcher.get_environment_frontend_url( + run.run_id, + frontend_port=frontend_port, + ) return fallback_urls @@ -127,6 +136,10 @@ def build_restart_launch_config(run: RunDB, db: Session) -> RunLaunchConfig: context = build_run_context(run, db) environment_id = resolve_environment_id_for_run(run, db) effective_api_key = settings.openrouter_api_key or None + frontend_port = extract_frontend_port( + context.get("environment_config") if isinstance(context.get("environment_config"), dict) else {}, + context.get("snapshot") if isinstance(context.get("snapshot"), dict) else {}, + ) return RunLaunchConfig( run_id=run.run_id, @@ -137,6 +150,7 @@ def build_restart_launch_config(run: RunDB, db: Session) -> RunLaunchConfig: or None ), api_key=effective_api_key, + frontend_port=frontend_port, ) @@ -150,6 +164,10 @@ def build_run_response( context = build_run_context(run, db) runtime_limit_minutes, _ = resolve_run_runtime_limit(context.get("environment_config")) has_frontend = _context_has_frontend(context) + frontend_port = extract_frontend_port( + context.get("environment_config") if isinstance(context.get("environment_config"), dict) else {}, + context.get("snapshot") if isinstance(context.get("snapshot"), dict) else {}, + ) service_urls = resolve_run_service_urls(run, db) environment_id = resolve_environment_id_for_run(run, db) @@ -159,7 +177,10 @@ def build_run_response( ) frontend_url = None if has_frontend: - frontend_url = service_urls.get("environment_frontend") or run_launcher.get_environment_frontend_url(run.run_id) + frontend_url = service_urls.get("environment_frontend") or run_launcher.get_environment_frontend_url( + run.run_id, + frontend_port=frontend_port, + ) return Run( run_id=run.run_id, resolved_bundle_hash=run.resolved_bundle_hash, diff --git a/services/controller/tests/test_run_frontend_ports.py b/services/controller/tests/test_run_frontend_ports.py new file mode 100644 index 0000000..3fe9023 --- /dev/null +++ b/services/controller/tests/test_run_frontend_ports.py @@ -0,0 +1,113 @@ +"""Unit tests for reserved per-run frontend ports.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from app.database import Base, Run as RunDB, RunBinding as RunBindingDB +from app.run_frontend_ports import apply_frontend_port, reserve_frontend_port +from app.run_launcher import RunLauncher +from app.run_read_model import build_restart_launch_config + + +@pytest.fixture +def db_session(): + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(engine) + session = sessionmaker(bind=engine, autocommit=False, autoflush=False)() + try: + yield session + finally: + session.close() + + +@pytest.mark.unit +def test_reserve_frontend_port_skips_reserved_binding_ports( + db_session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + db_session.add_all( + [ + RunBindingDB( + run_id="run-a", + environment_id="hello-world", + environment_config={"launch": {"frontend_port": 18100}}, + snapshot={"launch": {"frontend_port": 18100}}, + ), + RunBindingDB( + run_id="run-b", + environment_id="hello-world", + environment_config={"launch": {"frontend_port": 18101}}, + snapshot={"launch": {"frontend_port": 18101}}, + ), + ] + ) + db_session.commit() + + monkeypatch.setattr(RunLauncher, "get_environment_frontend_port", classmethod(lambda cls, run_id, namespace=None: 18100)) + + reserved = reserve_frontend_port( + db_session, + run_id="run-c", + environment_has_frontend=True, + ) + + assert reserved == 18102 + + +@pytest.mark.unit +def test_apply_frontend_port_persists_launch_metadata() -> None: + environment_config = {"launch": {"environment_id": "hello-world"}} + snapshot = {"launch": {"environment_id": "hello-world"}} + + apply_frontend_port( + run_id="run-123", + environment_config=environment_config, + snapshot=snapshot, + frontend_port=18234, + ) + + assert environment_config["launch"]["frontend_port"] == 18234 + assert snapshot["launch"]["frontend_port"] == 18234 + assert environment_config["launch"]["frontend_url"] == "http://localhost:18234" + assert snapshot["launch"]["frontend_url"] == "http://localhost:18234" + + +@pytest.mark.unit +def test_build_restart_launch_config_uses_reserved_frontend_port(db_session) -> None: + run = RunDB( + run_id="run-front", + resolved_bundle_hash="sha256:bundle", + status="completed", + started_at=datetime(2026, 3, 30, 12, 0, 0, tzinfo=timezone.utc), + ) + db_session.add(run) + db_session.flush() + db_session.add( + RunBindingDB( + run_id=run.run_id, + environment_id="hello-world", + runtime_id="openclaw", + environment_ref="environment/hello-world", + environment_config={ + "environment_id": "hello-world", + "runtime_id": "openclaw", + "launch": {"frontend_port": 18234}, + }, + snapshot={"environment_id": "hello-world", "runtime_id": "openclaw", "launch": {"frontend_port": 18234}}, + ) + ) + db_session.commit() + + config = build_restart_launch_config(run, db_session) + + assert config.frontend_port == 18234 From 6df80c70e66a5f5f5ce4d6bca6a14a95d5f279d5 Mon Sep 17 00:00:00 2001 From: Matteo Prandi Date: Tue, 31 Mar 2026 18:29:21 +0200 Subject: [PATCH 4/8] fix(scripts): resolve root env in worktrees --- scripts/runtime_env.sh | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/scripts/runtime_env.sh b/scripts/runtime_env.sh index 600f38f..7488dd0 100755 --- a/scripts/runtime_env.sh +++ b/scripts/runtime_env.sh @@ -64,6 +64,32 @@ PY printf '%s' "$value" } +resolve_dotenv_path() { + local root="$1" + local candidate="$root/.env" + local common_dir="" + local shared_root="" + + if [[ -f "$candidate" ]]; then + printf '%s' "$candidate" + return 0 + fi + + common_dir="$(git -C "$root" rev-parse --git-common-dir 2>/dev/null || true)" + if [[ -n "$common_dir" ]]; then + if [[ "$common_dir" != /* ]]; then + common_dir="$(cd "$root" && cd "$common_dir" && pwd -P)" + fi + shared_root="$(cd "$common_dir/.." 2>/dev/null && pwd -P || true)" + if [[ -n "$shared_root" && -f "$shared_root/.env" ]]; then + printf '%s' "$shared_root/.env" + return 0 + fi + fi + + printf '%s' "$candidate" +} + emit "COMPOSE_PROJECT_NAME" "$namespace" emit "HOST_PROJECT_ROOT" "$repo_root" emit "MASE_NETWORK_NAME" "${namespace}-network" @@ -79,7 +105,7 @@ emit "GRAFANA_PORT" "${GRAFANA_PORT:-3001}" emit "AGENT_WORKER_IMAGE" "${AGENT_WORKER_IMAGE:-mase-agent-launcher:${namespace}}" emit "MASE_IMAGE_NAMESPACE" "${MASE_IMAGE_NAMESPACE:-$namespace}" -dotenv_path="$repo_root/.env" +dotenv_path="$(resolve_dotenv_path "$repo_root")" openrouter_api_key="$( resolve_optional_from_dotenv "OPENROUTER_API_KEY" "$dotenv_path" \ || resolve_optional_from_dotenv "AGENT_LAUNCHER_OPENROUTER_API_KEY" "$dotenv_path" \ From 28737df14c41fca72e416f7a86e97fbe158e2ff4 Mon Sep 17 00:00:00 2001 From: Matteo Prandi Date: Tue, 7 Apr 2026 18:38:04 +0200 Subject: [PATCH 5/8] feat(telemetry): add bounded HTTP response snapshots --- .../202604071545__telemetry-http-snapshots.md | 24 +++ README.md | 1 + docs/run-and-inspect.md | 10 + docs/telemetry.md | 58 ++++++ services/agent-launcher/app/config.py | 6 + services/agent-launcher/app/executor.py | 14 ++ services/agent-launcher/app/http_snapshot.py | 172 ++++++++++++++++++ .../test_heartbeat_endpoint_multiround.py | 33 ++++ .../tests/unit/test_http_snapshot.py | 53 ++++++ 9 files changed, 371 insertions(+) create mode 100644 .project/logs/202604071545__telemetry-http-snapshots.md create mode 100644 docs/telemetry.md create mode 100644 services/agent-launcher/app/http_snapshot.py create mode 100644 services/agent-launcher/tests/unit/test_http_snapshot.py diff --git a/.project/logs/202604071545__telemetry-http-snapshots.md b/.project/logs/202604071545__telemetry-http-snapshots.md new file mode 100644 index 0000000..86621cd --- /dev/null +++ b/.project/logs/202604071545__telemetry-http-snapshots.md @@ -0,0 +1,24 @@ +# Telemetry HTTP Snapshots + +## Scope + +- add bounded structured HTTP response snapshots to action telemetry +- keep the feature generic in public MASE +- document the persisted telemetry surfaces and snapshot limits + +## Changed Surfaces + +- `services/agent-launcher/app/http_snapshot.py` +- `services/agent-launcher/app/config.py` +- `services/agent-launcher/app/executor.py` +- `services/agent-launcher/tests/unit/test_http_snapshot.py` +- `services/agent-launcher/tests/integration/test_heartbeat_endpoint_multiround.py` +- `docs/telemetry.md` +- `docs/run-and-inspect.md` +- `README.md` + +## Notes + +- snapshots are bounded and redacted, not raw body dumps +- controller storage/export path already carries payload JSON, so no controller schema change was needed +- this is intended to support later environment-specific projections such as feed score/rank analysis diff --git a/README.md b/README.md index 3b6ed8b..b505413 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,7 @@ Public-facing docs for the extractable OSS surface now live under `docs/`: - [`docs/concepts.md`](docs/concepts.md) - [`docs/quickstart.md`](docs/quickstart.md) - [`docs/openrouter.md`](docs/openrouter.md) +- [`docs/telemetry.md`](docs/telemetry.md) - [`docs/runtime-contract.md`](docs/runtime-contract.md) - [`docs/environment-contract.md`](docs/environment-contract.md) - [`docs/create-environment.md`](docs/create-environment.md) diff --git a/docs/run-and-inspect.md b/docs/run-and-inspect.md index 55d82e2..52377e4 100644 --- a/docs/run-and-inspect.md +++ b/docs/run-and-inspect.md @@ -35,6 +35,16 @@ Useful API endpoints: - `GET /api/v1/runs/{run_id}/events` - `GET /api/v1/runs/{run_id}/metrics` - `GET /api/v1/runs/{run_id}/scheduler/status` +- `GET /api/v1/telemetry/events/{run_id}` +- `GET /api/v1/telemetry/metrics/{run_id}` + +`action_attempt` telemetry rows now persist two response surfaces for HTTP actions: + +- `response_preview`: short redacted string preview +- `response_snapshot`: bounded structured JSON snapshot for JSON-like bodies + +The snapshot is generic and size-limited. Environments can later derive domain-specific analyses +from it without requiring raw full-body dumps in telemetry. ## Operate diff --git a/docs/telemetry.md b/docs/telemetry.md new file mode 100644 index 0000000..0241814 --- /dev/null +++ b/docs/telemetry.md @@ -0,0 +1,58 @@ +# Telemetry + +MASE persists run-scoped telemetry at two levels: + +- `events`: general run/system events +- `agent_action_events`: per-action rows emitted by the runtime + +The most useful starting point is `action_attempt` telemetry. Those rows already include: + +- action identity (`action_type`, `action_name`, `action_key`) +- request metadata (`method`, `path`, `status_code`, `request_id`) +- timing and success/failure fields +- a bounded `response_preview` + +For JSON-like HTTP responses, MASE now also stores: + +- `response_snapshot` +- `response_snapshot_meta` + +The snapshot is intentionally bounded rather than a raw body dump: + +- nested depth is capped +- dict keys and list items are capped +- long strings are truncated +- sensitive-looking values are redacted + +This makes telemetry usable for downstream analysis without turning the controller database into a +full packet capture. + +## Querying + +Useful endpoints: + +- `GET /api/v1/telemetry/events/{run_id}` +- `GET /api/v1/telemetry/metrics/{run_id}` +- `GET /api/v1/runs/{run_id}/events` + +## Tuning + +Agent-launcher snapshot limits are configurable via environment variables: + +- `AGENT_LAUNCHER_HTTP_RESPONSE_SNAPSHOT_ENABLED` +- `AGENT_LAUNCHER_HTTP_RESPONSE_SNAPSHOT_MAX_DEPTH` +- `AGENT_LAUNCHER_HTTP_RESPONSE_SNAPSHOT_MAX_DICT_KEYS` +- `AGENT_LAUNCHER_HTTP_RESPONSE_SNAPSHOT_MAX_LIST_ITEMS` +- `AGENT_LAUNCHER_HTTP_RESPONSE_SNAPSHOT_MAX_STRING_CHARS` +- `AGENT_LAUNCHER_HTTP_RESPONSE_SNAPSHOT_MAX_TOTAL_NODES` + +## Extension Pattern + +The generic snapshot layer should remain environment-agnostic. + +If an environment needs domain-specific analysis, the recommended pattern is: + +1. capture a bounded generic `response_snapshot` in MASE +2. derive environment-specific projections in the environment repo or downstream analysis pipeline + +That keeps core telemetry reusable while still supporting richer experiment-specific metrics. diff --git a/services/agent-launcher/app/config.py b/services/agent-launcher/app/config.py index 325045a..a7f8e86 100644 --- a/services/agent-launcher/app/config.py +++ b/services/agent-launcher/app/config.py @@ -33,6 +33,12 @@ class Settings(BaseSettings): # Execution Limits max_actions_per_heartbeat: int = 10 http_timeout: int = 30 + http_response_snapshot_enabled: bool = True + http_response_snapshot_max_depth: int = 4 + http_response_snapshot_max_dict_keys: int = 24 + http_response_snapshot_max_list_items: int = 64 + http_response_snapshot_max_string_chars: int = 120 + http_response_snapshot_max_total_nodes: int = 2048 # Service Configuration environment_url: Optional[str] = None diff --git a/services/agent-launcher/app/executor.py b/services/agent-launcher/app/executor.py index 9bd3484..48697f4 100644 --- a/services/agent-launcher/app/executor.py +++ b/services/agent-launcher/app/executor.py @@ -11,6 +11,7 @@ from .action_parser import Action, ActionType, HTTPMethod from .agent_fs import AgentFilesystem from .config import settings +from .http_snapshot import build_response_snapshot from .telemetry_client import ActionCategory, record_action from .scheduler import heartbeat_scheduler @@ -1138,6 +1139,8 @@ async def _record_action_telemetry( response_preview = None response_body_chars = None + response_snapshot = None + response_snapshot_meta = None response_payload = result.get("response") if isinstance(response_payload, dict): body = response_payload.get("body") @@ -1150,6 +1153,15 @@ async def _record_action_telemetry( response_body_chars = len(response_preview) if len(response_preview) > 700: response_preview = f"{response_preview[:697]}..." + response_snapshot, response_snapshot_meta = build_response_snapshot( + body, + enabled=settings.http_response_snapshot_enabled, + max_depth=settings.http_response_snapshot_max_depth, + max_dict_keys=settings.http_response_snapshot_max_dict_keys, + max_list_items=settings.http_response_snapshot_max_list_items, + max_string_chars=settings.http_response_snapshot_max_string_chars, + max_total_nodes=settings.http_response_snapshot_max_total_nodes, + ) action_url = None if hasattr(action, "url"): @@ -1174,6 +1186,8 @@ async def _record_action_telemetry( "error_code": error_code if not success else None, "response_preview": response_preview, "response_body_chars": response_body_chars, + "response_snapshot": response_snapshot, + "response_snapshot_meta": response_snapshot_meta, } await record_action( diff --git a/services/agent-launcher/app/http_snapshot.py b/services/agent-launcher/app/http_snapshot.py new file mode 100644 index 0000000..91671ff --- /dev/null +++ b/services/agent-launcher/app/http_snapshot.py @@ -0,0 +1,172 @@ +"""Bounded structured snapshots for HTTP response telemetry.""" + +from __future__ import annotations + +import re +from typing import Any, Dict, Optional, Tuple + + +SENSITIVE_KEY_PATTERN = re.compile( + r"(api[_-]?key|token|authorization|secret|password|cookie|session|credential|bearer)", + flags=re.IGNORECASE, +) +SENSITIVE_TEXT_PATTERNS = ( + re.compile(r"Bearer\s+[A-Za-z0-9\-._~+/]+=*", flags=re.IGNORECASE), + re.compile( + r"(?i)(api[_-]?key|token|authorization|secret|password|cookie|session|credential)\s*[:=]\s*[^\s,;]+" + ), +) +SNAPSHOT_SCHEMA = "mase.http_response_snapshot.v1" +TRUNCATED_MARKER = "__mase_truncated__" +SUMMARY_MARKER = "__mase_summary__" + + +def _sanitize_text(text: str) -> Tuple[str, bool]: + value = str(text or "") + redacted = False + for pattern in SENSITIVE_TEXT_PATTERNS: + updated = pattern.sub(lambda _match: "***", value) + if updated != value: + redacted = True + value = updated + return value, redacted + + +def _summarize_container(value: Any, reason: str) -> Dict[str, Any]: + size = len(value) if isinstance(value, (dict, list)) else None + kind = "object" if isinstance(value, dict) else "array" if isinstance(value, list) else type(value).__name__ + summary: Dict[str, Any] = {"type": kind, "reason": reason} + if size is not None: + summary["size"] = size + return {SUMMARY_MARKER: summary} + + +def _snapshot_value( + value: Any, + *, + depth: int, + key_hint: Optional[str], + stats: Dict[str, Any], + limits: Dict[str, int], +) -> Any: + stats["nodes"] += 1 + if stats["nodes"] > limits["max_total_nodes"]: + stats["truncated"] = True + return _summarize_container(value, "max_total_nodes") + + if key_hint and SENSITIVE_KEY_PATTERN.search(key_hint): + stats["redacted"] = True + return "***" + + if isinstance(value, str): + text, redacted = _sanitize_text(value) + if redacted: + stats["redacted"] = True + if len(text) > limits["max_string_chars"]: + stats["truncated"] = True + return text[: limits["max_string_chars"]] + "..." + return text + + if isinstance(value, (int, float, bool)) or value is None: + return value + + if depth >= limits["max_depth"]: + stats["truncated"] = True + return _summarize_container(value, "max_depth") + + if isinstance(value, dict): + snapshot: Dict[str, Any] = {} + items = list(value.items()) + for index, (raw_key, raw_value) in enumerate(items): + if index >= limits["max_dict_keys"]: + stats["truncated"] = True + snapshot[TRUNCATED_MARKER] = { + "reason": "max_dict_keys", + "remaining": len(items) - limits["max_dict_keys"], + } + break + key = str(raw_key) + snapshot[key] = _snapshot_value( + raw_value, + depth=depth + 1, + key_hint=key, + stats=stats, + limits=limits, + ) + return snapshot + + if isinstance(value, list): + snapshot = [] + for index, item in enumerate(value): + if index >= limits["max_list_items"]: + stats["truncated"] = True + snapshot.append( + { + TRUNCATED_MARKER: { + "reason": "max_list_items", + "remaining": len(value) - limits["max_list_items"], + } + } + ) + break + snapshot.append( + _snapshot_value( + item, + depth=depth + 1, + key_hint=None, + stats=stats, + limits=limits, + ) + ) + return snapshot + + text, redacted = _sanitize_text(str(value)) + if redacted: + stats["redacted"] = True + if len(text) > limits["max_string_chars"]: + stats["truncated"] = True + return text[: limits["max_string_chars"]] + "..." + return text + + +def build_response_snapshot( + body: Any, + *, + enabled: bool, + max_depth: int, + max_dict_keys: int, + max_list_items: int, + max_string_chars: int, + max_total_nodes: int, +) -> Tuple[Optional[Any], Optional[Dict[str, Any]]]: + """Return a bounded structured snapshot for JSON-like HTTP bodies.""" + if not enabled or not isinstance(body, (dict, list)): + return None, None + + limits = { + "max_depth": max(1, int(max_depth)), + "max_dict_keys": max(1, int(max_dict_keys)), + "max_list_items": max(1, int(max_list_items)), + "max_string_chars": max(16, int(max_string_chars)), + "max_total_nodes": max(32, int(max_total_nodes)), + } + stats: Dict[str, Any] = {"nodes": 0, "truncated": False, "redacted": False} + snapshot = _snapshot_value( + body, + depth=0, + key_hint=None, + stats=stats, + limits=limits, + ) + + meta = { + "schema": SNAPSHOT_SCHEMA, + "body_type": "object" if isinstance(body, dict) else "array", + "truncated": bool(stats["truncated"]), + "redacted": bool(stats["redacted"]), + "nodes_captured": int(stats["nodes"]), + "limits": limits, + "top_level_items_original": len(body), + "top_level_items_captured": len(snapshot) if isinstance(snapshot, (dict, list)) else None, + } + return snapshot, meta diff --git a/services/agent-launcher/tests/integration/test_heartbeat_endpoint_multiround.py b/services/agent-launcher/tests/integration/test_heartbeat_endpoint_multiround.py index 334e80e..d1ff44e 100644 --- a/services/agent-launcher/tests/integration/test_heartbeat_endpoint_multiround.py +++ b/services/agent-launcher/tests/integration/test_heartbeat_endpoint_multiround.py @@ -487,6 +487,7 @@ async def test_heartbeat_endpoint_emits_prompt_llm_and_action_telemetry( ) -> None: emitted_action_types = [] emitted_event_types = [] + action_attempt_payloads = [] async def _capture_record_action(*args, **kwargs): action_type = kwargs.get("action_type") @@ -496,6 +497,8 @@ async def _capture_record_action(*args, **kwargs): event_type = payload.get("event_type") if event_type: emitted_event_types.append(str(event_type)) + if event_type == "action_attempt": + action_attempt_payloads.append(payload) monkeypatch.setattr(launcher_module, "record_action", _capture_record_action) monkeypatch.setattr(executor_module, "record_action", _capture_record_action) @@ -521,6 +524,30 @@ async def _local_dummy_messages(self, messages, tools=None): _local_dummy_messages, ) + async def _fake_execute_direct_http(self, action, method, headers, body): + return { + "action": "http", + "success": True, + "action_type": "http_get", + "action_name": "local contract read", + "action_key": "http_get:local contract read", + "method": method.value, + "path": action.url, + "status_code": 200, + "request_id": "req-test", + "response": { + "status_code": 200, + "headers": {"content-type": "application/json"}, + "body": { + "contract": "ok", + "items": [{"id": "p1", "score": 3}, {"id": "p2", "score": 5}], + }, + }, + "timestamp": "2026-01-01T00:00:00Z", + } + + monkeypatch.setattr(executor_module.ActionExecutor, "_execute_direct_http", _fake_execute_direct_http) + transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: create = await client.post( @@ -550,6 +577,12 @@ async def _local_dummy_messages(self, messages, tools=None): assert "prompt_part" in emitted_action_types assert "llm_io" in emitted_action_types assert "action_attempt" in emitted_event_types + assert action_attempt_payloads + assert any(item.get("response_snapshot") is not None for item in action_attempt_payloads) + assert any( + (item.get("response_snapshot_meta") or {}).get("schema") == "mase.http_response_snapshot.v1" + for item in action_attempt_payloads + ) class LegacyHeartbeatResponse(BaseModel): diff --git a/services/agent-launcher/tests/unit/test_http_snapshot.py b/services/agent-launcher/tests/unit/test_http_snapshot.py new file mode 100644 index 0000000..f709593 --- /dev/null +++ b/services/agent-launcher/tests/unit/test_http_snapshot.py @@ -0,0 +1,53 @@ +"""Unit tests for bounded HTTP response telemetry snapshots.""" + +from app.http_snapshot import SNAPSHOT_SCHEMA, TRUNCATED_MARKER, build_response_snapshot + + +def test_build_response_snapshot_redacts_sensitive_keys_and_truncates_strings() -> None: + snapshot, meta = build_response_snapshot( + { + "posts": [ + { + "id": "p1", + "score": 7, + "title": "A" * 80, + "api_token": "TOPSECRET", + } + ] + }, + enabled=True, + max_depth=4, + max_dict_keys=8, + max_list_items=8, + max_string_chars=24, + max_total_nodes=128, + ) + + assert meta is not None + assert meta["schema"] == SNAPSHOT_SCHEMA + assert meta["redacted"] is True + assert meta["truncated"] is True + assert snapshot["posts"][0]["id"] == "p1" + assert snapshot["posts"][0]["score"] == 7 + assert snapshot["posts"][0]["api_token"] == "***" + assert snapshot["posts"][0]["title"].endswith("...") + assert len(snapshot["posts"][0]["title"]) == 27 + + +def test_build_response_snapshot_limits_large_lists() -> None: + snapshot, meta = build_response_snapshot( + {"items": [{"id": f"p{i}", "score": i} for i in range(5)]}, + enabled=True, + max_depth=4, + max_dict_keys=8, + max_list_items=3, + max_string_chars=40, + max_total_nodes=128, + ) + + assert meta is not None + assert meta["truncated"] is True + items = snapshot["items"] + assert len(items) == 4 + assert items[-1][TRUNCATED_MARKER]["reason"] == "max_list_items" + assert items[-1][TRUNCATED_MARKER]["remaining"] == 2 From 7be56b074d4cc063b4806c5d5c0bce28085dd442 Mon Sep 17 00:00:00 2001 From: Matteo Prandi Date: Thu, 9 Apr 2026 17:03:00 +0200 Subject: [PATCH 6/8] docs(citation): add software citation metadata --- .zenodo.json | 21 +++++++++++++++++++++ CITATION.cff | 21 +++++++++++++++++++++ README.md | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+) create mode 100644 .zenodo.json create mode 100644 CITATION.cff diff --git a/.zenodo.json b/.zenodo.json new file mode 100644 index 0000000..b636581 --- /dev/null +++ b/.zenodo.json @@ -0,0 +1,21 @@ +{ + "title": "MASE: Multi-Agent Simulation Environment", + "upload_type": "software", + "access_right": "open", + "license": "Apache-2.0", + "creators": [ + { + "name": "Prandi, Matteo", + "affiliation": "Icaro Lab", + "orcid": "0009-0002-9258-5589" + } + ], + "description": "MASE is experimentation infrastructure for studying AI agent interaction at scale.", + "keywords": [ + "multi-agent systems", + "simulation", + "AI agents", + "research infrastructure" + ], + "language": "eng" +} diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000..1cebe70 --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,21 @@ +cff-version: 1.2.0 +message: >- + If you use MASE in academic work, please cite the specific software release + you used. If you used unreleased code, cite the repository together with the + commit hash. +title: "MASE: Multi-Agent Simulation Environment" +type: software +authors: + - family-names: "Prandi" + given-names: "Matteo" + affiliation: "Icaro Lab" + orcid: "https://orcid.org/0009-0002-9258-5589" +repository-code: "https://github.com/icaro-lab/MASE" +url: "https://github.com/icaro-lab/MASE" +license: "Apache-2.0" +abstract: "Experimentation infrastructure for studying AI agent interaction at scale." +keywords: + - "multi-agent systems" + - "simulation" + - "AI agents" + - "research infrastructure" diff --git a/README.md b/README.md index b505413..586a5ba 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,46 @@ Public-facing docs for the extractable OSS surface now live under `docs/`: - [`docs/run-and-inspect.md`](docs/run-and-inspect.md) - [`docs/troubleshooting.md`](docs/troubleshooting.md) +## Citation + +MASE now includes both [`CITATION.cff`](CITATION.cff) for GitHub's built-in +repository citation support and [`.zenodo.json`](.zenodo.json) for release +archiving metadata on Zenodo. + +If you use MASE in research: + +- cite the specific software release you used +- if you used unreleased code, cite the repository together with the commit hash + +Until the first DOI-backed release is published, the repo itself can be cited as +software: + +```bibtex +@software{prandi_mase, + author = {Prandi, Matteo}, + title = {MASE: Multi-Agent Simulation Environment}, + year = {2026}, + institution = {Icaro Lab}, + url = {https://github.com/icaro-lab/MASE}, + note = {GitHub repository} +} +``` + +After the first Zenodo-backed release, prefer citing the archived release DOI +instead of the repository URL. + +### Maintainer Release Setup + +The repository is prepared for DOI-backed releases, but the GitHub-to-Zenodo +link must be enabled once in the Zenodo UI: + +1. Log into Zenodo with GitHub or ORCID and enable the `icaro-lab/MASE` + repository under GitHub integration settings. +2. Create a GitHub release with a version tag such as `v0.1.0`. +3. Zenodo will archive that release and mint a DOI for it. +4. Add the minted DOI badge and DOI citation example to this README after the + first archived release exists. + ## Project Status MASE is currently in alpha. From 9f981751e25510ac4e0feff9140d4a37f64942f6 Mon Sep 17 00:00:00 2001 From: Matteo Prandi Date: Thu, 9 Apr 2026 17:19:06 +0200 Subject: [PATCH 7/8] docs(citation): add release DOI badge --- CITATION.cff | 11 +++++++++++ README.md | 34 ++++++++++++++++++---------------- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index 1cebe70..4f0d0d9 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -19,3 +19,14 @@ keywords: - "simulation" - "AI agents" - "research infrastructure" +preferred-citation: + type: software + authors: + - family-names: "Prandi" + given-names: "Matteo" + affiliation: "Icaro Lab" + orcid: "https://orcid.org/0009-0002-9258-5589" + title: "MASE: Multi-Agent Simulation Environment" + version: "v0.1.0" + doi: "10.5281/zenodo.19485472" + url: "https://doi.org/10.5281/zenodo.19485472" diff --git a/README.md b/README.md index 586a5ba..9af80a8 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@

CI License + DOI Status

@@ -165,34 +166,35 @@ If you use MASE in research: - cite the specific software release you used - if you used unreleased code, cite the repository together with the commit hash -Until the first DOI-backed release is published, the repo itself can be cited as -software: +The current DOI-backed software release is: + +- `v0.1.0` +- DOI: [`10.5281/zenodo.19485472`](https://doi.org/10.5281/zenodo.19485472) + +Preferred citation for the first archived release: ```bibtex -@software{prandi_mase, +@software{prandi_mase_v010, author = {Prandi, Matteo}, title = {MASE: Multi-Agent Simulation Environment}, + version = {v0.1.0}, year = {2026}, - institution = {Icaro Lab}, - url = {https://github.com/icaro-lab/MASE}, - note = {GitHub repository} + doi = {10.5281/zenodo.19485472}, + url = {https://doi.org/10.5281/zenodo.19485472} } ``` -After the first Zenodo-backed release, prefer citing the archived release DOI -instead of the repository URL. +For unreleased work on top of `main` or `dev`, cite the repository together with +the commit hash instead of the `v0.1.0` DOI. ### Maintainer Release Setup -The repository is prepared for DOI-backed releases, but the GitHub-to-Zenodo -link must be enabled once in the Zenodo UI: +The repository is set up for DOI-backed releases through GitHub + Zenodo: -1. Log into Zenodo with GitHub or ORCID and enable the `icaro-lab/MASE` - repository under GitHub integration settings. -2. Create a GitHub release with a version tag such as `v0.1.0`. -3. Zenodo will archive that release and mint a DOI for it. -4. Add the minted DOI badge and DOI citation example to this README after the - first archived release exists. +1. Create a GitHub release with a new version tag. +2. Zenodo will archive that release and mint a new DOI for it. +3. Update [`CITATION.cff`](CITATION.cff) and this README so the preferred + citation points at the latest archived release DOI. ## Project Status From 026834f83e862e91c3bf9aa9c02fb484d79628ad Mon Sep 17 00:00:00 2001 From: Matteo Prandi Date: Thu, 9 Apr 2026 17:27:40 +0200 Subject: [PATCH 8/8] docs(readme): clarify branch model --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9af80a8..8ef8c4e 100644 --- a/README.md +++ b/README.md @@ -221,8 +221,11 @@ This repo now includes: Contribution flow: -- `main` stays stable -- contributions should branch from `dev` and open PRs back into `dev` +- `main` is the stable public default branch +- GitHub releases and release tags should be cut from `main` +- `dev` is the integration branch for ongoing work +- feature branches should usually branch from `dev` and open PRs back into `dev` +- promote `dev` into `main` when the integrated state is ready to become the new public baseline ## Repository Shape