From 6c4350711c7e5c7c950f9380aa6b02b409d99ccf Mon Sep 17 00:00:00 2001 From: luhehb <278922717@qq.com> Date: Tue, 11 Aug 2026 16:15:40 +0800 Subject: [PATCH 01/11] highlight the job id when start up --- launcher.py | 1 + 1 file changed, 1 insertion(+) diff --git a/launcher.py b/launcher.py index fe7ef24..f35bae4 100644 --- a/launcher.py +++ b/launcher.py @@ -28,6 +28,7 @@ async def main(argv: Sequence[str] | None = None) -> int: log.debug("log run directory: %s", log_session.run_dir) cfg = load_simulation_run_config(args) + log.info("JOB INITIALIZED | job_id=\033[1;96m%s\033[0m", cfg.job_id) flow = SimulationFlow(cfg) stop_event = asyncio.Event() _install_signal_handlers(stop_event) From cb4859366e5015ea77111bab8b9988a892ba1753 Mon Sep 17 00:00:00 2001 From: luhehb <278922717@qq.com> Date: Wed, 12 Aug 2026 15:30:40 +0800 Subject: [PATCH 02/11] add resume parameter to continue the previous job when use the same job id --- args.py | 1 + core/data_manager/manager.py | 4 + core/data_manager/strategy/base_strategy.py | 5 + .../strategy/cloud_strategy_impl.py | 23 ++- .../strategy/sqlite_strategy_impl.py | 15 ++ core/data_manager/yaml_aggregator.py | 150 +++++++++++++++++- docs/guides/data-manager.md | 2 +- docs/guides/data-manager_CN.md | 2 +- docs/reference/configuration.md | 3 +- docs/reference/configuration_CN.md | 3 +- manager/db_loader.py | 15 +- manager/repository.py | 4 +- manager/simulation_config.py | 10 +- manager/simulation_flow.py | 6 +- manager/simulation_worker.py | 17 +- manager/types.py | 1 + 16 files changed, 232 insertions(+), 29 deletions(-) diff --git a/args.py b/args.py index fadf344..d6c1a7a 100644 --- a/args.py +++ b/args.py @@ -38,6 +38,7 @@ def parse_simulation_args(argv: Sequence[str] | None = None) -> argparse.Namespa help="SQLite storage DB URI. Cloud storage ignores this and uses wt-data-gateway defaults.", ) parser.add_argument("--rebuild-table", action=argparse.BooleanOptionalAction, default=False) + parser.add_argument("--resume", action="store_true", help="Continue a job and skip finished environments") parser.add_argument("--disable-buffer", dest="enable_buffer", action="store_false", default=True) parser.add_argument("--buffer-size", type=int, default=100) parser.add_argument("--flush-interval", type=float, default=5.0) diff --git a/core/data_manager/manager.py b/core/data_manager/manager.py index 632acbc..ec8d22d 100644 --- a/core/data_manager/manager.py +++ b/core/data_manager/manager.py @@ -83,6 +83,10 @@ async def get_environment_by_env_id(self, env_id: str) -> Optional[Dict]: """Retrieve one environment config by env_id.""" return await self.strategy.get_environment_by_env_id(env_id) + async def mark_environment_finished(self, env_id: str) -> int: + """Mark one environment completed for this job.""" + return await self.strategy.mark_environment_finished(env_id) + def create_session( self, env_id: str, diff --git a/core/data_manager/strategy/base_strategy.py b/core/data_manager/strategy/base_strategy.py index fec2d86..0bbdaee 100644 --- a/core/data_manager/strategy/base_strategy.py +++ b/core/data_manager/strategy/base_strategy.py @@ -92,6 +92,11 @@ async def get_environment_by_env_id(self, env_id: str) -> Optional[Dict[str, Any continue return env return None + + @abstractmethod + async def mark_environment_finished(self, env_id: str) -> int: + """Mark one environment completed after its full workflow succeeds.""" + pass @abstractmethod async def create_session( diff --git a/core/data_manager/strategy/cloud_strategy_impl.py b/core/data_manager/strategy/cloud_strategy_impl.py index d9a0d33..6b1d13d 100644 --- a/core/data_manager/strategy/cloud_strategy_impl.py +++ b/core/data_manager/strategy/cloud_strategy_impl.py @@ -532,6 +532,24 @@ async def get_environment_by_env_id(self, env_id: str) -> Optional[Dict[str, Any self._env_configs[str(config["env_id"])] = config return dict(config) + async def mark_environment_finished(self, env_id: str) -> int: + """Mark one cloud environment for the current job as finished.""" + await self.init() + config = await self.get_environment_by_env_id(env_id) + if config is None or str(config.get("job_id") or "") != str(self.job_id): + raise RuntimeError( + f"env config does not belong to job_id={self.job_id!r}: env_id={env_id!r}" + ) + updated = await asyncio.to_thread( + self.env_manager.update_config, + env_id, + {"finished": True}, + ) + if not updated: + raise RuntimeError(f"failed to mark cloud env config finished: env_id={env_id}") + self._env_configs[env_id]["finished"] = True + return 1 + def get_env_configs( self, limit: Optional[int] = None, @@ -539,7 +557,10 @@ def get_env_configs( job_id: Optional[str] = None, ) -> List[Dict]: """Synchronous scheduler reader for cached cloud environment configs.""" - configs = self._list_env_configs(job_id=job_id) + configs = [ + row for row in self._list_env_configs(job_id=job_id) + if not _truthy_bool(row.get("finished", False)) + ] start = max(0, int(offset or 0)) if limit is None: return configs[start:] diff --git a/core/data_manager/strategy/sqlite_strategy_impl.py b/core/data_manager/strategy/sqlite_strategy_impl.py index 2377a51..8770b11 100644 --- a/core/data_manager/strategy/sqlite_strategy_impl.py +++ b/core/data_manager/strategy/sqlite_strategy_impl.py @@ -278,6 +278,21 @@ async def get_environment_by_env_id(self, env_id: str) -> Optional[Dict[str, Any trace.emit_summary(status="failed", error_type=type(exc).__name__, error=str(exc)) raise + async def mark_environment_finished(self, env_id: str) -> int: + """Mark one active environment for the current job as finished.""" + await self.init() + updated = await JobEnvironment.filter( + job_id=self.job_id, + env_id=env_id, + is_deleted=False, + ).update(finished=True) + if updated != 1: + raise RuntimeError( + f"expected one env config for job_id={self.job_id!r} env_id={env_id!r}, " + f"updated={updated}" + ) + return updated + async def create_session( self, env_id: str, diff --git a/core/data_manager/yaml_aggregator.py b/core/data_manager/yaml_aggregator.py index 8d9902e..1ba57b8 100644 --- a/core/data_manager/yaml_aggregator.py +++ b/core/data_manager/yaml_aggregator.py @@ -12,7 +12,7 @@ from tortoise.transactions import in_transaction from .load_yaml import load_yaml_configs -from core.data_manager.models import JobEnvironment +from core.data_manager.models import JobEnvironment, SessionStep log = logging.getLogger("yaml_aggregator") @@ -201,12 +201,15 @@ async def sync_configs_to_db( storage_type: str, startup_submit_count: int = 100, followup_submit_batch: int = 100, + *, + rebuild_table: bool = False, + resume: bool = False, ) -> Any: """ Sync YAML configurations to the database. - For SQLite: Uses the JobEnvironment table; removed envs are soft-deleted. - For Cloud: Appends configs to S3 via EnvConfigManager. + Existing jobs must explicitly choose either rebuild or resume. Rebuild + deletes only the current job; resume keeps existing configs and skips sync. Returns: SQLite: sqlite3.Connection for manager usage @@ -217,6 +220,39 @@ async def sync_configs_to_db( set_job_db_processing_done(job_id, False) try: + if rebuild_table and resume: + raise ValueError("--rebuild-table and --resume cannot be used together") + + existing_cloud_configs: List[Dict] = [] + if storage_type == "sqlite": + job_exists = await JobEnvironment.filter(job_id=job_id).exists() + elif storage_type == "cloud": + existing_cloud_configs = await _get_cloud_job_configs(data_manager) + job_exists = bool(existing_cloud_configs) + else: + raise ValueError(f"Unknown storage type: {storage_type}") + + if job_exists and resume: + await _delete_unfinished_session_steps( + data_manager, + storage_type, + existing_cloud_configs, + ) + if storage_type == "cloud": + _restore_cloud_env_cache(data_manager, existing_cloud_configs) + set_job_db_processing_done(job_id, True) + log.info("Resuming existing job_id=%s; finished environments will be skipped", job_id) + return data_manager.get_sync_connection() if storage_type == "sqlite" else data_manager.strategy.env_manager + if job_exists and not rebuild_table: + raise RuntimeError( + f"job_id={job_id!r} already exists; use --resume to continue it " + "or --rebuild-table to start it over" + ) + + if job_exists: + await _delete_job_data(data_manager, storage_type, existing_cloud_configs) + log.info("Deleted existing data for job_id=%s before rebuild", job_id) + if storage_type == "sqlite": return await _sync_sqlite( data_manager, @@ -231,12 +267,118 @@ async def sync_configs_to_db( startup_submit_count, followup_submit_batch, ) - raise ValueError(f"Unknown storage type: {storage_type}") except Exception: set_job_db_processing_done(job_id, True) raise +def _escape_sql_literal(value: str) -> str: + return str(value).replace("'", "''") + + +async def _get_cloud_job_configs(data_manager) -> List[Dict]: + env_manager = data_manager.strategy.env_manager + query = f"job_id = '{_escape_sql_literal(data_manager.job_id)}'" + rows: List[Dict] = [] + offset = 0 + page_size = 1000 + while True: + page = await asyncio.to_thread( + env_manager.get_env_configs, + limit=page_size, + offset=offset, + filter_query=query, + ) + if not page: + break + rows.extend(dict(row) for row in page) + if len(page) < page_size: + break + offset += len(page) + return rows + + +def _restore_cloud_env_cache(data_manager, configs: List[Dict]) -> None: + for config in configs: + row = data_manager.strategy._normalize_env_config(config) + env_id = str(row.get("env_id") or "") + if env_id: + data_manager.strategy._env_configs[env_id] = row + + +async def _delete_unfinished_session_steps( + data_manager, + storage_type: str, + cloud_configs: List[Dict], +) -> None: + job_id = data_manager.job_id + if storage_type == "sqlite": + unfinished_env_ids = await JobEnvironment.filter( + job_id=job_id, + finished=False, + ).values_list("env_id", flat=True) + if not unfinished_env_ids: + return + session_ids = await SessionStep.filter( + job_id=job_id, + session_id__in=unfinished_env_ids, + ).distinct().values_list("session_id", flat=True) + if session_ids: + await SessionStep.filter( + job_id=job_id, + session_id__in=session_ids, + ).delete() + return + + client = data_manager.strategy.client + session_ids = [] + for config in cloud_configs: + if config.get("finished", False): + continue + env_id = str(config.get("env_id") or "") + if not env_id: + continue + query = ( + f"job_id = '{_escape_sql_literal(job_id)}' AND " + f"session_id = '{_escape_sql_literal(env_id)}'" + ) + rows = await asyncio.to_thread( + client.query_data, + filter_query=query, + limit=1, + columns=["session_id"], + partition=job_id, + checkout_latest=True, + ) + if rows: + session_ids.append(str(rows[0].get("session_id") or env_id)) + + for session_id in session_ids: + query = ( + f"job_id = '{_escape_sql_literal(job_id)}' AND " + f"session_id = '{_escape_sql_literal(session_id)}'" + ) + await asyncio.to_thread(client.delete_landing, query) + + +async def _delete_job_data(data_manager, storage_type: str, cloud_configs: List[Dict]) -> None: + job_id = data_manager.job_id + if storage_type == "sqlite": + async with in_transaction() as connection: + await SessionStep.filter(job_id=job_id).using_db(connection).delete() + await JobEnvironment.filter(job_id=job_id).using_db(connection).delete() + return + + strategy = data_manager.strategy + query = f"job_id = '{_escape_sql_literal(job_id)}'" + await asyncio.to_thread(strategy.client.delete_landing, query) + for config in cloud_configs: + env_id = str(config.get("env_id") or "") + if env_id and not await asyncio.to_thread(strategy.env_manager.delete_config, env_id): + raise RuntimeError(f"failed to delete cloud env config env_id={env_id}") + strategy._env_configs.pop(env_id, None) + + async def _sync_sqlite( data_manager, yaml_configs: List[Dict], diff --git a/docs/guides/data-manager.md b/docs/guides/data-manager.md index be65658..8d43ab9 100644 --- a/docs/guides/data-manager.md +++ b/docs/guides/data-manager.md @@ -185,4 +185,4 @@ SQLite strategy creates runtime indexes: - `idx_job_environments_job_deleted_id` on `(job_id, is_deleted, id)`. - `idx_session_steps_job_trainable_id` on `(job_id, is_trainable, id)`. -Use `--rebuild-table` only for disposable local runs; it deletes the SQLite DB file before loading configs. +An existing `job_id` requires either `--resume` or `--rebuild-table`. Resume skips completed environments; rebuild deletes only the current job's configs and trajectories. The options are mutually exclusive. diff --git a/docs/guides/data-manager_CN.md b/docs/guides/data-manager_CN.md index a7ae1f7..9639559 100644 --- a/docs/guides/data-manager_CN.md +++ b/docs/guides/data-manager_CN.md @@ -185,4 +185,4 @@ SQLite strategy 会创建运行时索引: - `idx_job_environments_job_deleted_id` on `(job_id, is_deleted, id)`。 - `idx_session_steps_job_trainable_id` on `(job_id, is_trainable, id)`。 -`--rebuild-table` 只建议用于可丢弃的本地运行;它会在加载配置前删除 SQLite DB 文件。 +已有 `job_id` 必须显式选择 `--resume` 或 `--rebuild-table`。前者跳过已完成环境,后者只删除当前任务的配置和轨迹;两个参数不能同时使用。 diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index aa10acc..5dc8eae 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -65,7 +65,8 @@ For the first smoke test, use `env/geo3k/datasets/geo3k_sample.jsonl` in a local | Category | Flag | Default | Description | |----------|------|---------|-------------| -| Storage | `--rebuild-table` / `--no-rebuild-table` | `false` | For SQLite, delete the DB file before loading configs. | +| Storage | `--rebuild-table` / `--no-rebuild-table` | `false` | Delete configs and trajectories for the current `job_id`, then start over. Mutually exclusive with `--resume`. | +| Storage | `--resume` | `false` | Resume an existing `job_id` and skip environments with `finished=true`. | | Storage | `--disable-buffer` | buffer enabled | Disable buffered writes. | | Storage | `--buffer-size` | `100` | Write buffer capacity. | | Storage | `--flush-interval` | `5.0` | Write buffer flush interval in seconds. | diff --git a/docs/reference/configuration_CN.md b/docs/reference/configuration_CN.md index 1910fbb..91f408b 100644 --- a/docs/reference/configuration_CN.md +++ b/docs/reference/configuration_CN.md @@ -65,7 +65,8 @@ python launcher.py \ | 类别 | 参数 | 默认值 | 说明 | |------|------|--------|------| -| Storage | `--rebuild-table` / `--no-rebuild-table` | `false` | SQLite 下,加载配置前删除 DB 文件。 | +| Storage | `--rebuild-table` / `--no-rebuild-table` | `false` | 删除当前 `job_id` 的配置和轨迹后重头运行。不能与 `--resume` 同时使用。 | +| Storage | `--resume` | `false` | 续跑已有 `job_id`,跳过 `finished=true` 的环境。 | | Storage | `--disable-buffer` | buffer 启用 | 禁用缓冲写入。 | | Storage | `--buffer-size` | `100` | 写入缓冲区容量。 | | Storage | `--flush-interval` | `5.0` | 写入缓冲刷新间隔,单位秒。 | diff --git a/manager/db_loader.py b/manager/db_loader.py index 47b7406..e409ac1 100644 --- a/manager/db_loader.py +++ b/manager/db_loader.py @@ -148,6 +148,12 @@ def _normalize_rows(rows: Any) -> List[Dict[str, Any]]: return normalized +def _is_finished(value: Any) -> bool: + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) + + def _rows_from_mapping_cache(env_configs: Mapping[str, Any], job_id: Optional[str] = None) -> List[Dict[str, Any]]: rows: List[Dict[str, Any]] = [] for index, value in enumerate(env_configs.values(), start=1): @@ -159,6 +165,8 @@ def _rows_from_mapping_cache(env_configs: Mapping[str, Any], job_id: Optional[st row = _normalize_remote_row(value, index) if job_id and str(row.get("job_id") or "") != job_id: continue + if _is_finished(row.get("finished", False)): + continue rows.append(row) return rows @@ -272,7 +280,7 @@ def get_active_data( ) -> List[Dict[str, Any]]: """Return a paginated slice of active agent rows from the legacy table.""" if isinstance(conn, sqlite3.Connection): - filters = ["is_deleted = 0"] + filters = ["is_deleted = 0", "finished = 0"] params: List[Any] = [] if job_id: filters.append("job_id = ?") @@ -303,7 +311,7 @@ def get_active_data_after_id( ) -> List[Dict[str, Any]]: """Return active agent rows whose primary key is greater than ``after_id``.""" if isinstance(conn, sqlite3.Connection): - filters = ["is_deleted = 0", "id > ?"] + filters = ["is_deleted = 0", "finished = 0", "id > ?"] params: List[Any] = [after_id] if job_id: filters.append("job_id = ?") @@ -340,7 +348,7 @@ def get_active_data_after_id( def get_env_image_map(conn: Any, job_id: Optional[str] = None) -> Dict[str, Any]: """Return a mapping of legacy env_name -> image for all active agents.""" if isinstance(conn, sqlite3.Connection): - filters = ["is_deleted = 0"] + filters = ["is_deleted = 0", "finished = 0"] params: List[Any] = [] if job_id: filters.append("job_id = ?") @@ -377,6 +385,7 @@ def get_all_image(conn: Any, job_id: Optional[str] = None) -> Dict[str, str]: if isinstance(conn, sqlite3.Connection): filters = [ "is_deleted = 0", + "finished = 0", "image IS NOT NULL AND TRIM(image) != ''", "env_name IS NOT NULL", ] diff --git a/manager/repository.py b/manager/repository.py index e5104f8..b6cc60c 100644 --- a/manager/repository.py +++ b/manager/repository.py @@ -41,7 +41,9 @@ def __init__( self._job_id = str(job_id or "").strip() or None self._db_processing_done_checker = db_processing_done_checker - self._cursor_reads_enabled = isinstance(conn, sqlite3.Connection) + self._cursor_reads_enabled = isinstance(conn, sqlite3.Connection) or callable( + getattr(conn, "get_env_configs", None) + ) self._last_seen_id: int = 0 self._fallback_offset: int = 0 self._row_buffer: Deque[Dict[str, Any]] = deque() diff --git a/manager/simulation_config.py b/manager/simulation_config.py index 9c35f71..d7f80c9 100644 --- a/manager/simulation_config.py +++ b/manager/simulation_config.py @@ -316,6 +316,7 @@ def load_simulation_run_config(args: Any) -> SimulationRunConfig: cleanup_stale_docker_containers=bool(getattr(args, "cleanup_stale_docker_containers", True)), max_workers=max_workers, rebuild_table=bool(args.rebuild_table), + resume=bool(getattr(args, "resume", False)), enable_buffer=bool(args.enable_buffer), buffer_size=int(args.buffer_size), flush_interval=float(args.flush_interval), @@ -922,15 +923,6 @@ def set_nested(cfg: Dict[str, Any], path: List[str], value: Any) -> None: cur[path[-1]] = value -def rebuild_sqlite_db(db_url: str) -> None: - if not db_url.startswith("sqlite://"): - return - file_path = db_url[len("sqlite://") :].split("?", 1)[0] - if file_path and os.path.exists(file_path): - os.remove(file_path) - log.info("Removed existing SQLite DB for rebuild: %s", file_path) - - def _validate_gateway_route_key(model: str, *, arg_name: str = "--llm-model") -> None: normalized_model = str(model or "").strip() placeholder_models = { diff --git a/manager/simulation_flow.py b/manager/simulation_flow.py index 84ecde4..ba1ea5d 100644 --- a/manager/simulation_flow.py +++ b/manager/simulation_flow.py @@ -30,7 +30,6 @@ build_manager_runtime_config, expand_rl_epoch, expand_rl_group_size, - rebuild_sqlite_db, ) from .simulation_lease_pool import SimulationLeasePool from .simulation_worker import SimulationWorkerGroup @@ -93,9 +92,6 @@ async def run(self) -> SimulationRunSummary: raise async def prepare_storage(self) -> None: - if self.cfg.rebuild_table and self.cfg.storage_type == "sqlite": - rebuild_sqlite_db(self.cfg.db_url) - storage_config: Dict[str, Any] = { "enable_buffer": self.cfg.enable_buffer, "buffer_size": self.cfg.buffer_size, @@ -120,6 +116,8 @@ async def prepare_storage(self) -> None: self.cfg.storage_type, self.cfg.startup_submit_count, self.cfg.followup_submit_batch, + rebuild_table=self.cfg.rebuild_table, + resume=self.cfg.resume, ) self.manager_cfg = build_manager_runtime_config(self.cfg) log.info( diff --git a/manager/simulation_worker.py b/manager/simulation_worker.py index bb099e5..856cd7a 100644 --- a/manager/simulation_worker.py +++ b/manager/simulation_worker.py @@ -237,6 +237,7 @@ async def _worker_loop(self, worker_id: int) -> None: result: SimulationStartResult | None = None release_reusable: bool | None = None cancelled = False + gateway_finalized = self.gateway_client is None try: log.debug( "worker=%d acquired agent=%s runtime=%s resource=%s reuse=%s", @@ -268,7 +269,7 @@ async def _worker_loop(self, worker_id: int) -> None: trace.update_context(gateway_completion_mode=completion_mode) if self.gateway_client is not None: with trace.span("gateway_finalize"): - await self._finalize_gateway_session( + gateway_finalized = await self._finalize_gateway_session( result, completion_mode=completion_mode, worker_id=worker_id, @@ -276,6 +277,14 @@ async def _worker_loop(self, worker_id: int) -> None: trace=trace, ) + if ( + (result.status == "succeeded" or result.truncated) + and completion_mode == "complete" + and gateway_finalized + ): + with trace.span("mark_environment_finished"): + await self.data_manager.mark_environment_finished(lease.agent_id) + if completion_mode == "abort": result.total_reward = 0.0 release_reusable = False @@ -464,9 +473,9 @@ async def _finalize_gateway_session( worker_id: int, agent_key: str, trace: PerfTrace | None = None, - ) -> None: + ) -> bool: if self.gateway_client is None: - return + return True reason = "rollout_finished" if completion_mode == "complete" else "system_error" try: if trace is None: @@ -485,6 +494,7 @@ async def _finalize_gateway_session( ) with trace.span("gateway_wait_telemetry_flush"): await self.gateway_client.wait_telemetry_flush(result.session_id) + return True except httpx.HTTPError as exc: log.warning( "worker=%d agent=%s gateway session finalization failed; preserving rollout status=%s " @@ -495,6 +505,7 @@ async def _finalize_gateway_session( result.session_id, exc, ) + return False @staticmethod def _gateway_completion_mode(result: SimulationStartResult) -> str: diff --git a/manager/types.py b/manager/types.py index 8a7d20f..7208096 100644 --- a/manager/types.py +++ b/manager/types.py @@ -80,6 +80,7 @@ class SimulationRunConfig: cleanup_stale_docker_containers: bool = True max_workers: Optional[int] = None rebuild_table: bool = False + resume: bool = False enable_buffer: bool = True buffer_size: int = 100 flush_interval: float = 5.0 From 0ca98f837218d4f75e976a247e62c46ec1262fa6 Mon Sep 17 00:00:00 2001 From: luhehb <278922717@qq.com> Date: Wed, 12 Aug 2026 17:38:40 +0800 Subject: [PATCH 03/11] write dataset into all the steps --- core/data_manager/strategy/base_strategy.py | 2 +- gateway/storage.py | 23 ++------------------- 2 files changed, 3 insertions(+), 22 deletions(-) diff --git a/core/data_manager/strategy/base_strategy.py b/core/data_manager/strategy/base_strategy.py index 0bbdaee..633e32b 100644 --- a/core/data_manager/strategy/base_strategy.py +++ b/core/data_manager/strategy/base_strategy.py @@ -152,7 +152,7 @@ async def record_step( terminated: Whether this is a terminal step truncated: Whether episode was truncated is_trainable: Whether this step is eligible for training - dataset: Optional task dataset stored on the first gateway step + dataset: Optional task dataset stored with the current step """ pass diff --git a/gateway/storage.py b/gateway/storage.py index 21fafa8..370ee7a 100644 --- a/gateway/storage.py +++ b/gateway/storage.py @@ -44,8 +44,6 @@ def __init__(self, cfg: GatewayConfig, data_manager: DataManager): self._sessions: dict[tuple[str, str], _CachedSession] = {} self._environments: dict[str, _SessionEnvironment] = {} self._patched_environment_sessions: set[str] = set() - self._dataset_pending_sessions: set[str] = set() - self._dataset_written_sessions: set[str] = set() self._latest_record_ids: dict[tuple[str, str], str] = {} self._lock = asyncio.Lock() @@ -330,7 +328,6 @@ async def record_inference_steps_batch( ) -> None: if not batch: return - claimed_dataset_sessions: set[str] = set() started = time.perf_counter() trace = PerfTrace( "gateway.storage.record_inference_steps_batch", @@ -352,16 +349,6 @@ async def record_inference_steps_batch( session = await self.get_or_create_session(binding, record.requested_model) environment = await self._resolve_session_environment(record.session_id) dataset = environment.dataset if environment is not None else None - attach_dataset = False - if dataset is not None and record.seq_id == 1: - async with self._lock: - if ( - record.session_id not in self._dataset_pending_sessions - and record.session_id not in self._dataset_written_sessions - ): - self._dataset_pending_sessions.add(record.session_id) - claimed_dataset_sessions.add(record.session_id) - attach_dataset = True stored_messages: Any = _trajectory_messages(record) stored_response: Any = record.response @@ -404,17 +391,15 @@ async def record_inference_steps_batch( "truncated": record.is_truncated, "is_trainable": False, } - if attach_dataset: - step["dataset"] = dataset if provider_meta is not None: step["provider_meta"] = provider_meta + if dataset is not None: + step["dataset"] = dataset steps.append(step) with trace.span("storage.record_steps_batch", table="session_steps"): record_ids = await self.data_manager.record_steps_batch(steps) async with self._lock: - self._dataset_pending_sessions.difference_update(claimed_dataset_sessions) - self._dataset_written_sessions.update(claimed_dataset_sessions) for (_, record), record_id in zip(batch, record_ids): if record_id: self._latest_record_ids[(record.session_id, record.requested_model)] = record_id @@ -426,13 +411,9 @@ async def record_inference_steps_batch( ) trace.emit_summary(status="success", elapsed_ms=elapsed_ms) except asyncio.CancelledError: - async with self._lock: - self._dataset_pending_sessions.difference_update(claimed_dataset_sessions) trace.emit_summary(status="cancelled", error_type="CancelledError") raise except Exception as exc: - async with self._lock: - self._dataset_pending_sessions.difference_update(claimed_dataset_sessions) trace.emit_summary(status="failed", error_type=type(exc).__name__, error=str(exc)) raise From f22ecbcc3b4909ac9406f0838842308223c5abcc Mon Sep 17 00:00:00 2001 From: luhehb <278922717@qq.com> Date: Thu, 13 Aug 2026 16:26:29 +0800 Subject: [PATCH 04/11] set the is_trainable always as false for the safactory workflow --- core/data_manager/manager.py | 2 +- core/data_manager/models.py | 4 ++-- core/data_manager/strategy/base_strategy.py | 2 +- .../strategy/cloud_strategy_impl.py | 10 ++++++---- .../strategy/sqlite_strategy_impl.py | 6 ++++-- evaluator/reward_committer.py | 18 ++---------------- 6 files changed, 16 insertions(+), 26 deletions(-) diff --git a/core/data_manager/manager.py b/core/data_manager/manager.py index ec8d22d..f931102 100644 --- a/core/data_manager/manager.py +++ b/core/data_manager/manager.py @@ -118,7 +118,7 @@ async def record_step( env_state: Optional[str] = None, terminated: bool = False, truncated: bool = False, - is_trainable: bool = True, + is_trainable: bool = False, dataset: Optional[Any] = None, ) -> None: """ diff --git a/core/data_manager/models.py b/core/data_manager/models.py index 6f99b5f..b5b3e73 100644 --- a/core/data_manager/models.py +++ b/core/data_manager/models.py @@ -82,11 +82,11 @@ class SessionStep(Model): is_terminal = fields.BooleanField(default=False, description="Whether this step is terminal") is_truncated = fields.BooleanField(default=False, description="Whether this step is truncated") is_session_completed = fields.BooleanField(default=False, description="Whether the session is completed (final record)") - is_trainable = fields.BooleanField(default=True, description="Whether this step is used for training") + is_trainable = fields.BooleanField(default=False, description="Whether this step is used for training") # Timestamps created_at = fields.DatetimeField(auto_now_add=True) class Meta: table = "session_steps" - unique_together = ("session_id", "step_id", "created_at") \ No newline at end of file + unique_together = ("session_id", "step_id", "created_at") diff --git a/core/data_manager/strategy/base_strategy.py b/core/data_manager/strategy/base_strategy.py index 633e32b..fb11638 100644 --- a/core/data_manager/strategy/base_strategy.py +++ b/core/data_manager/strategy/base_strategy.py @@ -128,7 +128,7 @@ async def record_step( env_state: Optional[str] = None, terminated: bool = False, truncated: bool = False, - is_trainable: bool = True, + is_trainable: bool = False, dataset: Optional[Any] = None, ) -> None: """ diff --git a/core/data_manager/strategy/cloud_strategy_impl.py b/core/data_manager/strategy/cloud_strategy_impl.py index 6b1d13d..4a481b8 100644 --- a/core/data_manager/strategy/cloud_strategy_impl.py +++ b/core/data_manager/strategy/cloud_strategy_impl.py @@ -626,7 +626,7 @@ async def record_step( env_state: Optional[str] = None, terminated: bool = False, truncated: bool = False, - is_trainable: bool = True, + is_trainable: bool = False, dataset: Optional[Any] = None, ): """ @@ -783,7 +783,7 @@ async def _build_step_record( env_state: Optional[str] = None, terminated: bool = False, truncated: bool = False, - is_trainable: bool = True, + is_trainable: bool = False, dataset: Optional[Any] = None, provider_meta: Optional[Dict[str, Any]] = None, ) -> tuple[Any, str]: @@ -855,7 +855,9 @@ async def _build_step_record( is_terminal=terminated or truncated, is_truncated=truncated, is_session_completed=terminated or truncated, - is_trainable=is_trainable, + # Training eligibility is assigned by a later, explicit workflow. + # Every newly recorded trajectory step starts non-trainable. + is_trainable=False, meta_json=json.dumps(meta_json, ensure_ascii=False, default=str) ) @@ -984,7 +986,7 @@ async def list_session_steps( if "dataset" in meta: row["dataset"] = meta["dataset"] if row.get("is_trainable") is None: - row["is_trainable"] = meta.get("is_trainable", True) + row["is_trainable"] = meta.get("is_trainable", False) rows.append(row) rows.sort( diff --git a/core/data_manager/strategy/sqlite_strategy_impl.py b/core/data_manager/strategy/sqlite_strategy_impl.py index 8770b11..e3c4d9d 100644 --- a/core/data_manager/strategy/sqlite_strategy_impl.py +++ b/core/data_manager/strategy/sqlite_strategy_impl.py @@ -329,7 +329,7 @@ async def record_step( env_state: Optional[str] = None, terminated: bool = False, truncated: bool = False, - is_trainable: bool = True, + is_trainable: bool = False, dataset: Optional[Any] = None, ) -> None: """ @@ -385,7 +385,9 @@ async def record_step( is_terminal=terminated or truncated, is_truncated=truncated, is_session_completed=terminated or truncated, - is_trainable=is_trainable, + # Training eligibility is assigned by a later, explicit workflow. + # Every newly recorded trajectory step starts non-trainable. + is_trainable=False, ) # Use buffer or direct save diff --git a/evaluator/reward_committer.py b/evaluator/reward_committer.py index 746c9b4..648c0f0 100644 --- a/evaluator/reward_committer.py +++ b/evaluator/reward_committer.py @@ -131,7 +131,6 @@ async def _commit_cloud( ), "is_terminal": True, "is_session_completed": True, - "is_trainable": False, }, ) if recorded <= 0: @@ -221,7 +220,7 @@ def _commit_sqlite( """ UPDATE session_steps SET step_reward = ?, reward = ?, env_state = ?, - is_terminal = 1, is_session_completed = 1, is_trainable = 0 + is_terminal = 1, is_session_completed = 1 WHERE id = ? """, (eval_result.normalized_score_10, eval_result.normalized_score_10, env_state, summary["id"]), @@ -234,12 +233,11 @@ def _commit_sqlite( """ UPDATE session_steps SET step_reward = ?, reward = ?, env_state = ?, - is_terminal = 1, is_session_completed = 1, is_trainable = 1 + is_terminal = 1, is_session_completed = 1 WHERE id = ? """, (eval_result.normalized_score_10, eval_result.normalized_score_10, env_state, terminal["id"]), ) - _mark_trainable(conn, trainable_ids) conn.commit() def _build_reward_metadata(self, *, session_id: str, eval_result: EvalResult) -> str: @@ -342,15 +340,3 @@ def _has_messages(value: Any) -> bool: except Exception: return bool(value) return bool(parsed) - - -def _mark_trainable(conn: sqlite3.Connection, ids: list[int]) -> None: - for offset in range(0, len(ids), 500): - chunk = ids[offset : offset + 500] - if not chunk: - continue - placeholders = ",".join("?" for _ in chunk) - conn.execute( - f"UPDATE session_steps SET is_trainable = 1 WHERE id IN ({placeholders})", - tuple(chunk), - ) From 027884f5939f01aacec6f373f99218b9dd9959af Mon Sep 17 00:00:00 2001 From: luhehb <278922717@qq.com> Date: Fri, 14 Aug 2026 12:57:34 +0800 Subject: [PATCH 05/11] distinguish the truncated status from failed --- core/data_manager/manager.py | 7 ++ core/data_manager/models.py | 6 +- core/data_manager/strategy/base_strategy.py | 6 +- .../strategy/cloud_strategy_impl.py | 34 ++++-- .../strategy/sqlite_strategy_impl.py | 80 ++++++++++--- evaluator/eval_types.py | 1 + evaluator/reward_committer.py | 43 ++++++- evaluator/trajectory_reader.py | 1 + gateway/app.py | 7 +- gateway/storage.py | 6 + manager/docker_episode_runner.py | 6 +- manager/episode_common.py | 8 +- manager/rjob_episode_runner.py | 12 +- manager/sandbox_episode_runner.py | 6 +- manager/simulation_flow.py | 11 +- manager/simulation_worker.py | 107 +++++++++++++----- manager/types.py | 5 +- 17 files changed, 262 insertions(+), 84 deletions(-) diff --git a/core/data_manager/manager.py b/core/data_manager/manager.py index f931102..7d4d64a 100644 --- a/core/data_manager/manager.py +++ b/core/data_manager/manager.py @@ -120,6 +120,7 @@ async def record_step( truncated: bool = False, is_trainable: bool = False, dataset: Optional[Any] = None, + reward: Optional[float] = None, ) -> None: """ Record a single interaction step with full conversation history. @@ -133,6 +134,7 @@ async def record_step( messages=messages, response=response, step_reward=step_reward, + reward=reward, request=request, env_state=env_state, dataset=dataset, @@ -167,6 +169,7 @@ async def record_evaluation_summary( step_id: int, reward: float, env_state: str, + truncated: bool = False, ) -> int: """Persist a non-trainable evaluation summary row.""" return await self.strategy.record_evaluation_summary( @@ -174,6 +177,7 @@ async def record_evaluation_summary( step_id=step_id, reward=reward, env_state=env_state, + truncated=truncated, ) async def update_session_step( @@ -215,10 +219,12 @@ async def mark_latest_session_completed( llm_model: Optional[str] = None, *, is_session_completed: bool = True, + is_terminal: Optional[bool] = None, ) -> int: """ Set the completion state of the latest persisted trajectory row. When llm_model is provided, only rows for that model are considered. + is_terminal can seal a row before evaluator completion. Returns the number of updated records. """ @@ -226,6 +232,7 @@ async def mark_latest_session_completed( session_id=session_id, llm_model=llm_model, is_session_completed=is_session_completed, + is_terminal=is_terminal, ) async def close(self) -> None: diff --git a/core/data_manager/models.py b/core/data_manager/models.py index b5b3e73..b111299 100644 --- a/core/data_manager/models.py +++ b/core/data_manager/models.py @@ -75,7 +75,11 @@ class SessionStep(Model): # Rewards step_reward = fields.FloatField(default=0.0, description="Reward for this step") - reward = fields.FloatField(default=0.0, description="Cumulative reward up to this step") + reward = fields.FloatField( + null=True, + default=None, + description="Final or cumulative reward; null until evaluated", + ) # State tracking env_state = fields.TextField(null=True, description="JSON: Environment state") diff --git a/core/data_manager/strategy/base_strategy.py b/core/data_manager/strategy/base_strategy.py index fb11638..9f2b49b 100644 --- a/core/data_manager/strategy/base_strategy.py +++ b/core/data_manager/strategy/base_strategy.py @@ -34,7 +34,7 @@ class StorageStrategy(ABC): Key design principles: - session_id equals env_id for compatibility - Each step record contains full conversation history up to that point - - Last record of a session contains total_reward and is_session_completed=True + - Final reward remains null until the evaluator completes the session """ @abstractmethod @@ -130,6 +130,7 @@ async def record_step( truncated: bool = False, is_trainable: bool = False, dataset: Optional[Any] = None, + reward: Optional[float] = None, ) -> None: """ Record a single interaction step with full conversation history. @@ -194,6 +195,7 @@ async def record_evaluation_summary( step_id: int, reward: float, env_state: str, + truncated: bool = False, ) -> int: """Persist a non-trainable evaluation result when no trajectory row exists.""" return 0 @@ -236,10 +238,12 @@ async def mark_latest_session_completed( llm_model: Optional[str] = None, *, is_session_completed: bool = True, + is_terminal: Optional[bool] = None, ) -> int: """ Set the completion state of the latest persisted trajectory row. When llm_model is provided, only rows for that model are considered. + is_terminal can seal a row before evaluator completion. Returns: Number of updated records. diff --git a/core/data_manager/strategy/cloud_strategy_impl.py b/core/data_manager/strategy/cloud_strategy_impl.py index 4a481b8..8ec7d3d 100644 --- a/core/data_manager/strategy/cloud_strategy_impl.py +++ b/core/data_manager/strategy/cloud_strategy_impl.py @@ -628,6 +628,7 @@ async def record_step( truncated: bool = False, is_trainable: bool = False, dataset: Optional[Any] = None, + reward: Optional[float] = None, ): """ Record step to cloud LandingTable. @@ -641,6 +642,7 @@ async def record_step( messages=messages, response=response, step_reward=step_reward, + reward=reward, request=request, env_state=env_state, dataset=dataset, @@ -779,6 +781,7 @@ async def _build_step_record( messages: Any, response: Any, step_reward: float, + reward: Optional[float] = None, request: Optional[str] = None, env_state: Optional[str] = None, terminated: bool = False, @@ -845,7 +848,7 @@ async def _build_step_record( job_id=session.job_id, created_at=int(time.time()), step_reward=step_reward, - reward=session.total_reward, + reward=reward, messages=self._messages_to_landing_value(full_messages), response=self._response_to_landing_value(response), ground_truth_answer=None, @@ -854,7 +857,7 @@ async def _build_step_record( env_name=session.env_name, is_terminal=terminated or truncated, is_truncated=truncated, - is_session_completed=terminated or truncated, + is_session_completed=terminated, # Training eligibility is assigned by a later, explicit workflow. # Every newly recorded trajectory step starts non-trainable. is_trainable=False, @@ -1004,6 +1007,7 @@ async def record_evaluation_summary( step_id: int, reward: float, env_state: str, + truncated: bool = False, ) -> int: """Persist an evaluation-only row when a session has no trainable step.""" await self.init() @@ -1035,9 +1039,10 @@ async def record_evaluation_summary( messages=[], response="", step_reward=reward, + reward=reward, env_state=env_state, terminated=True, - truncated=False, + truncated=truncated, is_trainable=False, ) return 1 @@ -1048,10 +1053,12 @@ async def mark_latest_session_completed( llm_model: Optional[str] = None, *, is_session_completed: bool = True, + is_terminal: Optional[bool] = None, ) -> int: """Set the completion state of the latest cloud-backed trajectory row.""" await self.init() completed = bool(is_session_completed) + terminal = completed if is_terminal is None else bool(is_terminal) if self._enable_buffer: await self._flush_records() @@ -1078,7 +1085,13 @@ async def mark_latest_session_completed( self.client.query_data, filter_query=query, limit=1000, - columns=["step_id", "is_session_completed", "meta_json", "agent_model"], + columns=[ + "step_id", + "is_terminal", + "is_session_completed", + "meta_json", + "agent_model", + ], partition=job_id or None, checkout_latest=True, deserialize_json=True, @@ -1087,12 +1100,13 @@ async def mark_latest_session_completed( if not rows: return 0 - candidates: List[tuple[int, bool, Any]] = [] + candidates: List[tuple[int, bool, bool, Any]] = [] for row in rows: try: candidates.append( ( int(row["step_id"]), + _truthy_bool(row.get("is_terminal")), _truthy_bool(row.get("is_session_completed")), row.get("meta_json"), ) @@ -1103,13 +1117,13 @@ async def mark_latest_session_completed( return 0 trajectory_candidates = [ - item for item in candidates if _is_trajectory_meta_json(item[2]) + item for item in candidates if _is_trajectory_meta_json(item[3]) ] - latest_step_id, latest_completed, _latest_meta_json = max( + latest_step_id, latest_terminal, latest_completed, _latest_meta_json = max( trajectory_candidates or candidates, key=lambda item: item[0], ) - if completed and latest_completed: + if latest_completed == completed and latest_terminal == terminal: return 0 update_query = self._build_session_step_filter( @@ -1123,8 +1137,8 @@ async def mark_latest_session_completed( update_query, { "is_session_completed": completed, - "is_terminal": completed, - **({"step_reward": 0.0, "reward": 0.0} if not completed else {}), + "is_terminal": terminal, + **({"step_reward": 0.0, "reward": None} if not completed else {}), }, partition=job_id or None, trace_context={ diff --git a/core/data_manager/strategy/sqlite_strategy_impl.py b/core/data_manager/strategy/sqlite_strategy_impl.py index e3c4d9d..9107280 100644 --- a/core/data_manager/strategy/sqlite_strategy_impl.py +++ b/core/data_manager/strategy/sqlite_strategy_impl.py @@ -109,20 +109,70 @@ async def _ensure_runtime_schema(self) -> None: file_path = self.db_url[9:].split("?", 1)[0] - def add_missing_columns() -> None: + def ensure_schema() -> None: conn = sqlite3.connect(file_path) try: - columns = { - str(row[1]) - for row in conn.execute("PRAGMA table_info(session_steps)") - } + conn.execute("PRAGMA busy_timeout=30000") + table_info = list(conn.execute("PRAGMA table_info(session_steps)")) + columns = {str(row[1]) for row in table_info} + reward_column = next( + (row for row in table_info if str(row[1]) == "reward"), + None, + ) + if reward_column and (bool(reward_column[3]) or reward_column[4] is not None): + conn.execute("BEGIN IMMEDIATE") + conn.execute("DROP TABLE IF EXISTS session_steps_reward_migration") + conn.execute( + """ + CREATE TABLE session_steps_reward_migration ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + session_id VARCHAR(36) NOT NULL, + step_id INT NOT NULL, + env_name VARCHAR(100) NOT NULL, + llm_model VARCHAR(150) NOT NULL, + group_id VARCHAR(150), + job_id VARCHAR(64), + messages TEXT NOT NULL, + request TEXT, + response TEXT NOT NULL, + step_reward REAL NOT NULL DEFAULT 0, + reward REAL, + env_state TEXT, + is_terminal INT NOT NULL DEFAULT 0, + is_truncated INT NOT NULL DEFAULT 0, + is_session_completed INT NOT NULL DEFAULT 0, + is_trainable INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (session_id, step_id, created_at) + ) + """ + ) + target_columns = ( + "id", "session_id", "step_id", "env_name", "llm_model", + "group_id", "job_id", "messages", "request", "response", + "step_reward", "reward", "env_state", "is_terminal", + "is_truncated", "is_session_completed", "is_trainable", + "created_at", + ) + copied_columns = [column for column in target_columns if column in columns] + column_list = ", ".join(f'"{column}"' for column in copied_columns) + conn.execute( + f"INSERT INTO session_steps_reward_migration ({column_list}) " + f"SELECT {column_list} FROM session_steps" + ) + conn.execute("DROP TABLE session_steps") + conn.execute( + "ALTER TABLE session_steps_reward_migration RENAME TO session_steps" + ) + conn.commit() + return if "request" not in columns: conn.execute("ALTER TABLE session_steps ADD COLUMN request TEXT") conn.commit() finally: conn.close() - await asyncio.to_thread(add_missing_columns) + await asyncio.to_thread(ensure_schema) async def _ensure_runtime_indexes(self) -> None: if not self.db_url.startswith("sqlite://"): @@ -331,6 +381,7 @@ async def record_step( truncated: bool = False, is_trainable: bool = False, dataset: Optional[Any] = None, + reward: Optional[float] = None, ) -> None: """ Record a single interaction step. @@ -380,11 +431,11 @@ async def record_step( request=request, response=response, step_reward=step_reward, - reward=session.total_reward, + reward=reward, env_state=env_state, is_terminal=terminated or truncated, is_truncated=truncated, - is_session_completed=terminated or truncated, + is_session_completed=terminated, # Training eligibility is assigned by a later, explicit workflow. # Every newly recorded trajectory step starts non-trainable. is_trainable=False, @@ -409,8 +460,8 @@ async def record_step( raise log.debug( - "Recorded step %d for session %s: reward=%.4f total=%.4f", - step_id, session.session_id, step_reward, session.total_reward + "Recorded step %d for session %s: step_reward=%.4f reward=%s", + step_id, session.session_id, step_reward, reward, ) async def update_session_step( @@ -504,10 +555,12 @@ async def mark_latest_session_completed( llm_model: Optional[str] = None, *, is_session_completed: bool = True, + is_terminal: Optional[bool] = None, ) -> int: """Set the completion state of the latest trajectory row for a session.""" await self.init() completed = bool(is_session_completed) + terminal = completed if is_terminal is None else bool(is_terminal) trace = PerfTrace( "sqlite_strategy.mark_latest_session_completed", @@ -518,6 +571,7 @@ async def mark_latest_session_completed( "session_id": session_id, "model": llm_model, "is_session_completed": completed, + "is_terminal": terminal, "buffered": bool(self._write_buffer), }, ) @@ -541,7 +595,7 @@ async def mark_latest_session_completed( (step for step in candidates if _is_trajectory_env_state(step.env_state)), candidates[0], ) - if completed and latest.is_session_completed and latest.is_terminal: + if latest.is_session_completed == completed and latest.is_terminal == terminal: trace.emit_summary( status="skipped", candidate_count=len(candidates), @@ -553,10 +607,10 @@ async def mark_latest_session_completed( updates: Dict[str, Any] = { "is_session_completed": completed, - "is_terminal": completed, + "is_terminal": terminal, } if not completed: - updates.update(step_reward=0.0, reward=0.0) + updates.update(step_reward=0.0, reward=None) with trace.span("db_write.mark_session_completed", row_id=latest.id, step_id=latest.step_id): updated = await SessionStep.filter(id=latest.id).update(**updates) trace.emit_summary( diff --git a/evaluator/eval_types.py b/evaluator/eval_types.py index e49f533..4d7f5cb 100644 --- a/evaluator/eval_types.py +++ b/evaluator/eval_types.py @@ -13,6 +13,7 @@ class EvalStatus(str, Enum): SUCCEEDED = "succeeded" FAILED = "failed" TIMEOUT = "timeout" + TRUNCATED = "truncated" @dataclass diff --git a/evaluator/reward_committer.py b/evaluator/reward_committer.py index 648c0f0..89ba0f3 100644 --- a/evaluator/reward_committer.py +++ b/evaluator/reward_committer.py @@ -7,7 +7,7 @@ from typing import Any from core.perf_trace import PerfTrace -from evaluator.eval_types import EvalResult, to_jsonable +from evaluator.eval_types import EvalResult, EvalStatus, to_jsonable from evaluator.trajectory_reader import _sqlite_path log = logging.getLogger("evaluator.reward_committer") @@ -38,6 +38,15 @@ async def commit( session_id: str, eval_result: EvalResult, ) -> None: + if eval_result.status not in { + EvalStatus.SUCCEEDED, + EvalStatus.SUCCEEDED.value, + EvalStatus.TRUNCATED, + EvalStatus.TRUNCATED.value, + }: + raise ValueError( + f"cannot commit reward for evaluation status {eval_result.status!r}" + ) trace = PerfTrace( "evaluator.reward_commit", logger=log, @@ -90,6 +99,10 @@ async def _commit_cloud( session_id: str, eval_result: EvalResult, ) -> None: + truncated = eval_result.status in { + EvalStatus.TRUNCATED, + EvalStatus.TRUNCATED.value, + } rows = await self.data_manager.list_session_steps( session_id, checkout_latest=True, @@ -117,6 +130,7 @@ async def _commit_cloud( step_id=_next_step_id(rows), reward=eval_result.normalized_score_10, env_state=summary_metadata, + truncated=truncated, ) else: recorded = await self.data_manager.update_session_step( @@ -130,6 +144,7 @@ async def _commit_cloud( summary_metadata, ), "is_terminal": True, + **({"is_truncated": True} if truncated else {}), "is_session_completed": True, }, ) @@ -158,6 +173,7 @@ async def _commit_cloud( "reward": eval_result.normalized_score_10, "env_state": env_state, "is_terminal": True, + **({"is_truncated": True} if truncated else {}), "is_session_completed": True, }, ) @@ -172,6 +188,10 @@ def _commit_sqlite( session_id: str, eval_result: EvalResult, ) -> None: + truncated = eval_result.status in { + EvalStatus.TRUNCATED, + EvalStatus.TRUNCATED.value, + } metadata = self._build_reward_metadata(session_id=session_id, eval_result=eval_result) with sqlite3.connect(self.db_path, timeout=30.0) as conn: conn.execute("PRAGMA busy_timeout = 30000") @@ -204,7 +224,7 @@ def _commit_sqlite( (session_id, step_id, env_name, llm_model, group_id, job_id, messages, response, step_reward, reward, env_state, is_terminal, is_truncated, is_session_completed, is_trainable) - VALUES (?, ?, 'gateway', '', '', '', '[]', '', ?, ?, ?, 1, 0, 1, 0) + VALUES (?, ?, 'gateway', '', '', '', '[]', '', ?, ?, ?, 1, ?, 1, 0) """, ( session_id, @@ -212,6 +232,7 @@ def _commit_sqlite( eval_result.normalized_score_10, eval_result.normalized_score_10, summary_metadata, + int(truncated), ), ) else: @@ -220,10 +241,17 @@ def _commit_sqlite( """ UPDATE session_steps SET step_reward = ?, reward = ?, env_state = ?, + is_truncated = CASE WHEN ? THEN 1 ELSE is_truncated END, is_terminal = 1, is_session_completed = 1 WHERE id = ? """, - (eval_result.normalized_score_10, eval_result.normalized_score_10, env_state, summary["id"]), + ( + eval_result.normalized_score_10, + eval_result.normalized_score_10, + env_state, + int(truncated), + summary["id"], + ), ) conn.commit() return @@ -233,10 +261,17 @@ def _commit_sqlite( """ UPDATE session_steps SET step_reward = ?, reward = ?, env_state = ?, + is_truncated = CASE WHEN ? THEN 1 ELSE is_truncated END, is_terminal = 1, is_session_completed = 1 WHERE id = ? """, - (eval_result.normalized_score_10, eval_result.normalized_score_10, env_state, terminal["id"]), + ( + eval_result.normalized_score_10, + eval_result.normalized_score_10, + env_state, + int(truncated), + terminal["id"], + ), ) conn.commit() diff --git a/evaluator/trajectory_reader.py b/evaluator/trajectory_reader.py index ca966bb..943699a 100644 --- a/evaluator/trajectory_reader.py +++ b/evaluator/trajectory_reader.py @@ -269,6 +269,7 @@ def _is_session_sealing_event(step: dict[str, Any]) -> bool: event_type = env_state.get("event_type") return bool( step.get("is_session_completed") + or step.get("is_terminal") or env_state.get("is_session_completed") or event_type in _NON_TRAJECTORY_EVENT_TYPES ) diff --git a/gateway/app.py b/gateway/app.py index ccfca06..9e7baf8 100644 --- a/gateway/app.py +++ b/gateway/app.py @@ -690,8 +690,11 @@ async def close_session(session_id: str, request: Request) -> dict[str, Any]: completion_mode = str(body.get("completion_mode") or completion_mode).strip().lower() except Exception: pass - if completion_mode not in {"complete", "abort"}: - raise HTTPException(status_code=400, detail="completion_mode must be 'complete' or 'abort'") + if completion_mode not in {"complete", "seal", "abort"}: + raise HTTPException( + status_code=400, + detail="completion_mode must be 'complete', 'seal', or 'abort'", + ) binding = await resolver.close_session(session_id, reason=reason) log.info( "Gateway session close requested: session_id=%s reason=%s completion_mode=%s", diff --git a/gateway/storage.py b/gateway/storage.py index 370ee7a..8973f08 100644 --- a/gateway/storage.py +++ b/gateway/storage.py @@ -386,6 +386,7 @@ async def record_inference_steps_batch( "request": record.request, "response": stored_response, "step_reward": 0.0, + "reward": None, "env_state": json.dumps(self._metadata(record), ensure_ascii=False, default=str), "terminated": False, "truncated": record.is_truncated, @@ -433,6 +434,7 @@ async def record_session_close( }, ) try: + terminal = record.is_session_completed or binding.close_reason == "rollout_sealed" if self.cfg.storage_type == "cloud" and record.is_session_completed: async with self._lock: record_ids = [ @@ -484,6 +486,7 @@ async def record_session_close( await self.data_manager.mark_latest_session_completed( session_id=binding.session_id, is_session_completed=record.is_session_completed, + is_terminal=terminal, ) elapsed_ms = (time.perf_counter() - started) * 1000 trace.emit_summary(status="success", elapsed_ms=elapsed_ms, updated_without_model=True) @@ -508,6 +511,7 @@ async def record_session_close( session_id=binding.session_id, llm_model=model, is_session_completed=record.is_session_completed, + is_terminal=terminal, ) for model in models ) @@ -525,6 +529,7 @@ async def record_session_close( session_id=binding.session_id, llm_model=model, is_session_completed=record.is_session_completed, + is_terminal=terminal, ) if updated_count == 0: @@ -536,6 +541,7 @@ async def record_session_close( await self.data_manager.mark_latest_session_completed( session_id=binding.session_id, is_session_completed=record.is_session_completed, + is_terminal=terminal, ) elapsed_ms = (time.perf_counter() - started) * 1000 log.info( diff --git a/manager/docker_episode_runner.py b/manager/docker_episode_runner.py index c011980..3668d2f 100644 --- a/manager/docker_episode_runner.py +++ b/manager/docker_episode_runner.py @@ -83,7 +83,7 @@ async def start( return SimulationStartResult( session_id=str(request.session_id), status="succeeded", - total_reward=0.0, + total_reward=None, step_count=0, terminated=True, truncated=False, @@ -189,8 +189,8 @@ def _timeout_result( timeout_s = float(exc.timeout or self.timeout_s) return SimulationStartResult( session_id=str(request.session_id), - status="failed", - total_reward=0.0, + status="truncated", + total_reward=None, step_count=0, terminated=True, truncated=True, diff --git a/manager/episode_common.py b/manager/episode_common.py index b579c73..cd49d21 100644 --- a/manager/episode_common.py +++ b/manager/episode_common.py @@ -205,13 +205,15 @@ def normalize_result(result: Any, *, session_id: str) -> SimulationStartResult: metrics = body.get("metrics") if not isinstance(metrics, dict): metrics = {} + truncated = bool(body.get("truncated", False)) + reward = body.get("total_reward") return SimulationStartResult( session_id=str(session_id), - status=str(body.get("status") or "succeeded"), - total_reward=float(body.get("total_reward", 0.0) or 0.0), + status="truncated" if truncated else str(body.get("status") or "succeeded"), + total_reward=None if reward is None else float(reward), step_count=int(body.get("step_count", 0) or 0), terminated=bool(body.get("terminated", False)), - truncated=bool(body.get("truncated", False)), + truncated=truncated, error_text=None if body.get("error_text") is None else str(body.get("error_text")), metrics=metrics, ) diff --git a/manager/rjob_episode_runner.py b/manager/rjob_episode_runner.py index 0c90ed5..1c2bd7c 100644 --- a/manager/rjob_episode_runner.py +++ b/manager/rjob_episode_runner.py @@ -147,7 +147,7 @@ async def start( result = SimulationStartResult( session_id=request.session_id, status="succeeded", - total_reward=0.0, + total_reward=None, step_count=0, terminated=True, truncated=False, @@ -214,11 +214,11 @@ async def start( timings_ms["rjob_fetch_timeout_logs_ms"] = _elapsed_ms(started) result = SimulationStartResult( session_id=request.session_id, - status="failed", - total_reward=0.0, + status="truncated", + total_reward=None, step_count=0, terminated=True, - truncated=False, + truncated=True, error_text=str(exc), metrics={ "runtime": "rjob", @@ -299,7 +299,7 @@ def _result_from_terminal_status( return SimulationStartResult( session_id=request.session_id, status="succeeded", - total_reward=0.0, + total_reward=None, step_count=0, terminated=True, truncated=False, @@ -338,7 +338,7 @@ def _result_from_terminal_status( return SimulationStartResult( session_id=request.session_id, status="failed", - total_reward=0.0, + total_reward=None, step_count=0, terminated=True, truncated=False, diff --git a/manager/sandbox_episode_runner.py b/manager/sandbox_episode_runner.py index a831fc1..b4e21d0 100644 --- a/manager/sandbox_episode_runner.py +++ b/manager/sandbox_episode_runner.py @@ -64,8 +64,8 @@ async def start( except asyncio.TimeoutError: return SimulationStartResult( session_id=request.session_id, - status="failed", - total_reward=0.0, + status="truncated", + total_reward=None, step_count=0, terminated=True, truncated=True, @@ -92,7 +92,7 @@ async def start( return SimulationStartResult( session_id=request.session_id, status="succeeded", - total_reward=0.0, + total_reward=None, step_count=0, terminated=True, truncated=False, diff --git a/manager/simulation_flow.py b/manager/simulation_flow.py index ba1ea5d..6498684 100644 --- a/manager/simulation_flow.py +++ b/manager/simulation_flow.py @@ -80,6 +80,7 @@ async def run(self) -> SimulationRunSummary: summary_status=summary.status, total_episodes=summary.total_episodes, succeeded_episodes=summary.succeeded_episodes, + truncated_episodes=summary.truncated_episodes, failed_episodes=summary.failed_episodes, ) trace.emit_summary(status=summary.status) @@ -209,6 +210,11 @@ async def run_workers(self) -> SimulationRunSummary: close_retries=self.cfg.gateway_close_retries, retry_backoff_s=self.cfg.gateway_close_retry_backoff_s, ) + self.reward_committer = RewardCommitter( + db_url=self.cfg.db_url, + storage_type=self.cfg.storage_type, + data_manager=self.data_manager, + ) evaluation_service = None if self.cfg.evaluation_enabled: log.info("EVAL FLOW enabled: rule evaluator only") @@ -221,11 +227,6 @@ async def run_workers(self) -> SimulationRunSummary: max_concurrency=self.cfg.max_workers or self.cfg.warm_pool_size, ) evaluation_service = self.evaluation_service - self.reward_committer = RewardCommitter( - db_url=self.cfg.db_url, - storage_type=self.cfg.storage_type, - data_manager=self.data_manager, - ) else: log.debug("EVAL FLOW disabled: launcher was not started with --enable-evaluation") self.worker_group = SimulationWorkerGroup( diff --git a/manager/simulation_worker.py b/manager/simulation_worker.py index 856cd7a..dc641dd 100644 --- a/manager/simulation_worker.py +++ b/manager/simulation_worker.py @@ -13,7 +13,7 @@ from core.data_manager.manager import DataManager, SessionContext from core.perf_trace import PerfTrace from core.runtime_metadata import strip_internal_env_params -from evaluator.eval_types import EvalRequest +from evaluator.eval_types import EvalRequest, EvalResult, EvalStatus from evaluator.gateway_client import GatewayClient from evaluator.reward_committer import RewardCommitter from evaluator.rule_evaluator import discover_rule_eval_spec @@ -65,6 +65,8 @@ async def wait_open(self) -> None: async def record(self, result: SimulationStartResult) -> bool: if not self.enabled or self._opened.is_set(): return False + if result.truncated or str(result.status or "").lower() == "truncated": + return False failed = str(result.status or "").lower() != "succeeded" timed_out = self._is_timeout(result) @@ -179,22 +181,29 @@ async def run_all(self) -> SimulationRunSummary: status="failed_no_episodes", total_episodes=0, succeeded_episodes=0, + truncated_episodes=0, failed_episodes=0, cancelled=cancelled, results={}, ) succeeded = sum(1 for result in results.values() if result.status == "succeeded") - failed = len(results) - succeeded + truncated = sum(1 for result in results.values() if result.status == "truncated") + failed = len(results) - succeeded - truncated if cancelled: status = "cancelled" + elif failed: + status = "completed_with_failures" + elif truncated: + status = "completed_with_truncations" else: - status = "succeeded" if failed == 0 else "completed_with_failures" + status = "succeeded" return SimulationRunSummary( job_id=self.cfg.job_id, status=status, total_episodes=len(results), succeeded_episodes=succeeded, + truncated_episodes=truncated, failed_episodes=failed, cancelled=cancelled, results={key: result.total_reward for key, result in results.items()}, @@ -259,12 +268,21 @@ async def _worker_loop(self, worker_id: int) -> None: ) with trace.span("agent_rollout"): result = await self._run_one_episode(lease, session, request, worker_id) + metrics = result.metrics if isinstance(result.metrics, dict) else {} + if result.truncated or str(metrics.get("timeout_layer") or "") in { + "docker_exec", + "sandbox_command", + "rjob_wait_terminal", + }: + result.status = "truncated" + result.truncated = True trace.update_context( rollout_status=result.status, rollout_steps=result.step_count, rollout_reward=result.total_reward, rollout_truncated=result.truncated, ) + result.total_reward = None completion_mode = self._gateway_completion_mode(result) trace.update_context(gateway_completion_mode=completion_mode) if self.gateway_client is not None: @@ -277,20 +295,31 @@ async def _worker_loop(self, worker_id: int) -> None: trace=trace, ) - if ( - (result.status == "succeeded" or result.truncated) - and completion_mode == "complete" - and gateway_finalized - ): + if not gateway_finalized: + result.status = "failed" + result.error_text = result.error_text or "gateway session finalization failed" + release_reusable = False + elif completion_mode == "abort": + release_reusable = False + elif result.truncated: + if self.reward_committer is None: + raise RuntimeError("reward committer is required for truncated sessions") + with trace.span("reward_commit_truncated"): + await self.reward_committer.commit( + session_id=result.session_id, + eval_result=EvalResult( + session_id=result.session_id, + status=EvalStatus.TRUNCATED.value, + normalized_score_10=0.0, + reason=result.error_text or "agent rollout timed out", + artifacts={"metrics": dict(result.metrics or {})}, + ), + ) + result.total_reward = 0.0 with trace.span("mark_environment_finished"): await self.data_manager.mark_environment_finished(lease.agent_id) - - if completion_mode == "abort": - result.total_reward = 0.0 - release_reusable = False - elif self.evaluation_service is None or self.reward_committer is None: release_reusable = False - else: + elif self.evaluation_service is not None and self.reward_committer is not None: with trace.span("eval_discover_rule"): public_env_params = strip_internal_env_params(lease.env_params) eval_spec = discover_rule_eval_spec( @@ -323,20 +352,32 @@ async def _worker_loop(self, worker_id: int) -> None: eval_status=eval_result.status, eval_score=eval_result.normalized_score_10, ) - with trace.span("reward_commit"): - await self.reward_committer.commit( - session_id=result.session_id, - eval_result=eval_result, - ) if eval_result.status == "succeeded": + with trace.span("reward_commit"): + await self.reward_committer.commit( + session_id=result.session_id, + eval_result=eval_result, + ) result.total_reward = eval_result.normalized_score_10 + with trace.span("mark_environment_finished"): + await self.data_manager.mark_environment_finished(lease.agent_id) else: - result.total_reward = 0.0 result.status = "failed" result.error_text = eval_result.error_text or eval_result.reason release_reusable = result.status == "succeeded" and eval_result.status == "succeeded" else: - release_reusable = None + result.status = "failed" + result.error_text = ( + f"rule evaluator not found for environment {lease.agent_name!r}" + ) + release_reusable = False + else: + await self.data_manager.mark_latest_session_completed( + result.session_id, + llm_model=self.cfg.llm_model, + ) + await self.data_manager.mark_environment_finished(lease.agent_id) + release_reusable = None with trace.span("store_result"): async with self._results_lock: @@ -353,7 +394,7 @@ async def _worker_loop(self, worker_id: int) -> None: result = SimulationStartResult( session_id=session.session_id if session is not None else lease.agent_id, status="failed", - total_reward=0.0, + total_reward=None, step_count=0, terminated=True, truncated=False, @@ -390,7 +431,7 @@ async def _worker_loop(self, worker_id: int) -> None: ) trace.emit_summary(status=result.status if result is not None else "failed") log.info( - "worker=%d agent=%s finished status=%s reward=%.6f time=%.2fs", + "worker=%d agent=%s finished status=%s reward=%s time=%.2fs", worker_id, agent_key, result.status, @@ -444,7 +485,7 @@ async def _run_one_episode( result = SimulationStartResult( session_id=session.session_id, status="failed", - total_reward=0.0, + total_reward=None, step_count=0, terminated=True, truncated=False, @@ -476,7 +517,11 @@ async def _finalize_gateway_session( ) -> bool: if self.gateway_client is None: return True - reason = "rollout_finished" if completion_mode == "complete" else "system_error" + reason = { + "complete": "rollout_finished", + "seal": "rollout_sealed", + "abort": "system_error", + }[completion_mode] try: if trace is None: await self.gateway_client.close_session( @@ -510,18 +555,18 @@ async def _finalize_gateway_session( @staticmethod def _gateway_completion_mode(result: SimulationStartResult) -> str: metrics = result.metrics if isinstance(result.metrics, dict) else {} - if str(metrics.get("rjob_status") or "") in {"Failed", "Stopped", "Killed"}: - return "abort" - if result.truncated: - return "complete" + if result.truncated or result.status == "truncated": + return "seal" if str(metrics.get("timeout_layer") or "") in { "docker_exec", "sandbox_command", "rjob_wait_terminal", }: - return "complete" + return "seal" + if str(metrics.get("rjob_status") or "") in {"Failed", "Stopped", "Killed"}: + return "abort" if result.status == "succeeded": - return "complete" + return "seal" return "abort" def _build_start_request( diff --git a/manager/types.py b/manager/types.py index 7208096..84bc24b 100644 --- a/manager/types.py +++ b/manager/types.py @@ -148,7 +148,7 @@ def __post_init__(self) -> None: class SimulationStartResult: session_id: str status: str - total_reward: float + total_reward: Optional[float] step_count: int terminated: bool truncated: bool @@ -162,6 +162,7 @@ class SimulationRunSummary: status: str total_episodes: int succeeded_episodes: int + truncated_episodes: int failed_episodes: int cancelled: bool - results: Dict[str, float] = field(default_factory=dict) + results: Dict[str, Optional[float]] = field(default_factory=dict) From 8600801d74bfb5b483af240b8cf3749aaee75f15 Mon Sep 17 00:00:00 2001 From: luhehb <278922717@qq.com> Date: Fri, 14 Aug 2026 13:50:42 +0800 Subject: [PATCH 06/11] add resume clean up --- clusters/rjob_cluster.py | 114 ++++++++++++++++-- .../strategy/sqlite_strategy_impl.py | 4 + manager/episode_common.py | 38 +++++- manager/resume_cleanup.py | 103 ++++++++++++++++ manager/simulation_config.py | 25 +++- manager/simulation_flow.py | 8 ++ 6 files changed, 278 insertions(+), 14 deletions(-) create mode 100644 manager/resume_cleanup.py diff --git a/clusters/rjob_cluster.py b/clusters/rjob_cluster.py index 30e4a00..1ac6c38 100644 --- a/clusters/rjob_cluster.py +++ b/clusters/rjob_cluster.py @@ -26,6 +26,8 @@ _DEFAULT_RUN_COMMAND = f"node {_DEFAULT_RUNNER_CONTAINER_PATH}" _INVALID_NAME_CHARS = re.compile(r"[^a-z0-9.-]+") _MAX_RJOB_NAME_LEN = 49 +_MAX_RJOB_AGENT_NAME_LEN = 12 +_MAX_RJOB_MODEL_NAME_LEN = 16 class RJobClusterBackend(ClusterBackend): @@ -355,6 +357,71 @@ async def cleanup_job( except Exception: log.warning("RJob delete failed for %s", job_name, exc_info=True) + async def cleanup_resume_session( + self, + *, + agent_name: str, + model: str, + job_id: str, + session_id: str, + ) -> List[str]: + """Force-delete current and legacy RJobs for one resumable session.""" + cfg = self._rjob_cfg_for_env(agent_name) + client = self.client(cfg) + names = [ + build_rjob_name(agent_name, model, session_id), + _build_legacy_rjob_name(cfg, agent_name, job_id, session_id), + ] + cleaned: List[str] = [] + for job_name in dict.fromkeys(names): + if await self.force_cleanup_job( + client, + job_name, + timeout_s=max(1.0, float(cfg.get("resume_cleanup_timeout_s", 120.0) or 120.0)), + poll_interval_s=max( + 0.1, + float(cfg.get("resume_cleanup_poll_interval_s", 1.0) or 1.0), + ), + ): + cleaned.append(job_name) + return cleaned + + async def force_cleanup_job( + self, + client: Any, + job_name: str, + *, + timeout_s: float = 120.0, + poll_interval_s: float = 1.0, + ) -> bool: + """Delete an existing RJob and wait until it disappears.""" + exists, status = await self._get_job_status(client, job_name) + if not exists: + return False + + log.info("cleaning stale RJob before resume: name=%s status=%s", job_name, status) + if status not in RJOB_SUCCEEDED_STATUSES | RJOB_FAILED_STATUSES | {"Killing", "Deleting"}: + await self.stop_job(client, job_name) + + if status != "Deleting": + try: + await asyncio.to_thread(client.delete, [job_name], async_=True) + except TypeError: + await asyncio.to_thread(client.delete, [job_name]) + + deadline = time.monotonic() + max(1.0, float(timeout_s)) + while True: + exists, status = await self._get_job_status(client, job_name) + if not exists: + log.info("stale RJob removed before resume: name=%s", job_name) + return True + if time.monotonic() >= deadline: + raise TimeoutError( + f"stale RJob {job_name} was not removed within {timeout_s:.1f}s; " + f"last_status={status}" + ) + await asyncio.sleep(max(0.1, float(poll_interval_s))) + def client(self, cfg: Dict[str, Any]) -> Any: symbols = self.symbols() kwargs = { @@ -449,11 +516,7 @@ def build_job_name( lease: SimulationAgentLease, request: SimulationStartRequest, ) -> str: - prefix = _safe_name(str(cfg.get("name_prefix") or "safactory"), max_len=12) - agent = _safe_name(lease.agent_name, max_len=8) - job = _safe_name(request.job_id, max_len=10) - session = _safe_name(request.session_id, max_len=10) - return f"{prefix}-{job}-{agent}-{session}".strip("-")[:_MAX_RJOB_NAME_LEN].strip("-") or "safactory-rjob" + return build_rjob_name(lease.agent_name, request.model, request.session_id) def _docker_cfg_for_env(self, env_name: str) -> Dict[str, Any]: env_cfg = dict(self._env_types.get(env_name, {}) or {}) @@ -546,19 +609,50 @@ def _coerce_enum(enum_cls: Any, value: Any) -> Any: @staticmethod async def _get_status(client: Any, job_name: str) -> str: + _, status = await RJobClusterBackend._get_job_status(client, job_name) + return status + + @staticmethod + async def _get_job_status(client: Any, job_name: str) -> tuple[bool, str]: jobs = await asyncio.to_thread(client.list, [job_name]) if not jobs: - return "Unknown" + return False, "Unknown" job = jobs[0] if isinstance(job, dict): - return str(job.get("status") or _nested_name(job.get("status")) or "Unknown") + raw_status = job.get("status") + if isinstance(raw_status, str): + return True, raw_status + return True, _nested_name(raw_status) or "Unknown" status = getattr(job, "status", None) if isinstance(status, str): - return status + return True, status current = getattr(status, "current", None) if current is not None: - return str(getattr(current, "name", current)) - return str(getattr(status, "name", status) or "Unknown") + return True, str(getattr(current, "name", current)) + return True, str(getattr(status, "name", status) or "Unknown") + + +def build_rjob_name(agent_name: str, model: str, session_id: str) -> str: + """Build a stable RJob name from start-config agent, model, and session prefix.""" + agent = _safe_name(agent_name, max_len=_MAX_RJOB_AGENT_NAME_LEN) + model_name = _safe_name(model, max_len=_MAX_RJOB_MODEL_NAME_LEN) + session_budget = _MAX_RJOB_NAME_LEN - len(agent) - len(model_name) - 2 + session = _safe_name(session_id, max_len=max(1, session_budget)) + return f"{agent}-{model_name}-{session}".strip("-") or "safactory-rjob" + + +def _build_legacy_rjob_name( + cfg: Dict[str, Any], + agent_name: str, + job_id: str, + session_id: str, +) -> str: + """Recreate the pre-change name so resume can remove migrated stale jobs.""" + prefix = _safe_name(str(cfg.get("name_prefix") or "safactory"), max_len=12) + agent = _safe_name(agent_name, max_len=8) + job = _safe_name(job_id, max_len=10) + session = _safe_name(session_id, max_len=10) + return f"{prefix}-{job}-{agent}-{session}".strip("-")[:_MAX_RJOB_NAME_LEN].strip("-") or "safactory-rjob" def merge_dicts(*values: Any) -> Dict[str, Any]: diff --git a/core/data_manager/strategy/sqlite_strategy_impl.py b/core/data_manager/strategy/sqlite_strategy_impl.py index 9107280..12a82a9 100644 --- a/core/data_manager/strategy/sqlite_strategy_impl.py +++ b/core/data_manager/strategy/sqlite_strategy_impl.py @@ -279,6 +279,8 @@ async def get_all_environments(self, job_id: Optional[str] = None) -> List[Dict] "env_params": e.env_params, "image": e.image, "group_id": e.group_id, + "finished": e.finished, + "is_deleted": e.is_deleted, "created_at": e.created_at.isoformat() if e.created_at else None } for e in envs @@ -320,6 +322,8 @@ async def get_environment_by_env_id(self, env_id: str) -> Optional[Dict[str, Any "env_params": env.env_params, "image": env.image, "group_id": env.group_id, + "finished": env.finished, + "is_deleted": env.is_deleted, "created_at": env.created_at.isoformat() if env.created_at else None, } trace.emit_summary(status="success", row_count=1, job_id=env.job_id, env_name=env.env_name) diff --git a/manager/episode_common.py b/manager/episode_common.py index cd49d21..7ab99fd 100644 --- a/manager/episode_common.py +++ b/manager/episode_common.py @@ -102,6 +102,10 @@ def containerize_local_gateway_url(url: str) -> str: def result_artifact_path(request: SimulationStartRequest) -> str: env_params = request.env_params if isinstance(request.env_params, dict) else {} + return _result_artifact_path(request.job_id, request.session_id, env_params) + + +def _result_artifact_path(job_id: str, session_id: str, env_params: Dict[str, Any]) -> str: dataset = env_params.get("dataset") if isinstance(env_params.get("dataset"), dict) else {} explicit = first_text( @@ -121,8 +125,8 @@ def result_artifact_path(request: SimulationStartRequest) -> str: return "/".join( [ root or DEFAULT_RESULT_ROOT, - safe_path_part(request.job_id), - safe_path_part(request.session_id), + safe_path_part(job_id), + safe_path_part(session_id), RESULT_FILENAME, ] ) @@ -130,6 +134,36 @@ def result_artifact_path(request: SimulationStartRequest) -> str: def result_artifact_candidates(request: SimulationStartRequest, artifact_path: str | None = None) -> list[Path]: raw = str(artifact_path or result_artifact_path(request) or "").strip() + return _result_path_candidates(raw) + + +def result_session_dir_candidates( + *, + job_id: str, + session_id: str, + env_params: Dict[str, Any] | None = None, +) -> list[Path]: + """Return launcher-visible candidates for results//.""" + params = env_params if isinstance(env_params, dict) else {} + dataset = params.get("dataset") if isinstance(params.get("dataset"), dict) else {} + explicit = first_text( + dataset.get("safactory_result_path"), + params.get("safactory_result_path"), + ) + artifact = _result_artifact_path(job_id, session_id, params) + candidates: list[Path] = [] + + def add(path: Path) -> None: + if path not in candidates: + candidates.append(path) + + for path in _result_path_candidates(artifact): + add(path if explicit else path.parent) + add(Path.cwd() / "results" / safe_path_part(job_id) / safe_path_part(session_id)) + return candidates + + +def _result_path_candidates(raw: str) -> list[Path]: if not raw: return [] diff --git a/manager/resume_cleanup.py b/manager/resume_cleanup.py new file mode 100644 index 0000000..5e72076 --- /dev/null +++ b/manager/resume_cleanup.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import asyncio +import logging +import shutil +from pathlib import Path +from typing import Any, Dict, List + +from clusters.rjob_cluster import RJobClusterBackend + +from .episode_common import result_session_dir_candidates + +log = logging.getLogger("manager.resume_cleanup") + + +async def cleanup_resume_artifacts( + *, + job_id: str, + model: str, + data_manager: Any, + manager_cfg: Dict[str, Any], + rjob_backend: RJobClusterBackend | None = None, +) -> List[Path]: + """Remove stale RJobs and result paths for unfinished resume sessions.""" + rows = await data_manager.get_all_environments(job_id) + owned_backend = rjob_backend is None + backend = rjob_backend or RJobClusterBackend( + cluster_cfg=dict(manager_cfg.get("cluster") or {}) + ) + removed: List[Path] = [] + + try: + for row in rows: + if _truthy(row.get("finished")) or _truthy(row.get("is_deleted")): + continue + + session_id = str(row.get("env_id") or "").strip() + if not session_id: + continue + env_params = row.get("env_params") if isinstance(row.get("env_params"), dict) else {} + result_paths = [ + path + for path in result_session_dir_candidates( + job_id=job_id, + session_id=session_id, + env_params=env_params, + ) + if path.exists() or path.is_symlink() + ] + if not result_paths: + continue + + agent_name = str(row.get("env_name") or "").strip() + if not agent_name: + raise RuntimeError( + f"cannot clean resume artifacts for session {session_id}: env_name is missing" + ) + + cleaned_jobs = await backend.cleanup_resume_session( + agent_name=agent_name, + model=model, + job_id=job_id, + session_id=session_id, + ) + for path in result_paths: + await asyncio.to_thread(_remove_result_path, path) + removed.append(path) + log.info( + "resume cleanup completed: job_id=%s session_id=%s rjobs=%s result_paths=%s", + job_id, + session_id, + cleaned_jobs, + [str(path) for path in result_paths], + ) + finally: + if owned_backend: + await backend.close() + + log.info( + "resume result preflight completed: job_id=%s unfinished=%d removed_paths=%d", + job_id, + sum( + 1 + for row in rows + if not _truthy(row.get("finished")) and not _truthy(row.get("is_deleted")) + ), + len(removed), + ) + return removed + + +def _remove_result_path(path: Path) -> None: + if path.is_symlink() or path.is_file(): + path.unlink() + return + if path.is_dir(): + shutil.rmtree(path) + + +def _truthy(value: Any) -> bool: + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) diff --git a/manager/simulation_config.py b/manager/simulation_config.py index d7f80c9..9b4f871 100644 --- a/manager/simulation_config.py +++ b/manager/simulation_config.py @@ -32,6 +32,8 @@ "auto_delete_duration": "", "keep_failed_jobs": False, "submit_concurrency": 0, + "resume_cleanup_timeout_s": 120.0, + "resume_cleanup_poll_interval_s": 1.0, } _SANDBOX_DEFAULT_CONFIG: Dict[str, Any] = { @@ -115,6 +117,14 @@ def _normalize_rjob_config(section: Dict[str, Any]) -> Dict[str, Any]: merged["keep_failed_jobs"] = _as_bool(merged.get("keep_failed_jobs"), default=False) merged["retries"] = max(0, int(merged.get("retries") or 0)) merged["poll_interval_s"] = max(0.1, float(merged.get("poll_interval_s") or 5.0)) + merged["resume_cleanup_timeout_s"] = max( + 1.0, + float(merged.get("resume_cleanup_timeout_s") or 120.0), + ) + merged["resume_cleanup_poll_interval_s"] = max( + 0.1, + float(merged.get("resume_cleanup_poll_interval_s") or 1.0), + ) merged["submit_concurrency"] = max(0, int(merged.get("submit_concurrency") or 0)) merged["name_prefix"] = merged["name_prefix"] or "safactory" return merged @@ -606,10 +616,21 @@ def _normalize_agent_start_rjob(agent_name: Any, spec: Any, cfg_path: Path) -> D if key in rjob_raw: rjob[key] = bool(rjob_raw.get(key)) - for key in ("replicas", "poll_interval_s", "termination_grace_period_seconds", "local_storage_in_mb"): + for key in ( + "replicas", + "poll_interval_s", + "resume_cleanup_timeout_s", + "resume_cleanup_poll_interval_s", + "termination_grace_period_seconds", + "local_storage_in_mb", + ): if key in rjob_raw and rjob_raw.get(key) is not None: value = rjob_raw.get(key) - rjob[key] = float(value) if key == "poll_interval_s" else int(value) + rjob[key] = ( + float(value) + if key in {"poll_interval_s", "resume_cleanup_timeout_s", "resume_cleanup_poll_interval_s"} + else int(value) + ) for key in ("env", "labels", "annotations", "resources", "requests", "affinity"): if key in rjob_raw: diff --git a/manager/simulation_flow.py b/manager/simulation_flow.py index 6498684..fa79ec2 100644 --- a/manager/simulation_flow.py +++ b/manager/simulation_flow.py @@ -26,6 +26,7 @@ from .agent_start_client import AgentStartClient from .db_loader import scheduler_db_reader from .manager import AgentPoolManager +from .resume_cleanup import cleanup_resume_artifacts from .simulation_config import ( build_manager_runtime_config, expand_rl_epoch, @@ -121,6 +122,13 @@ async def prepare_storage(self) -> None: resume=self.cfg.resume, ) self.manager_cfg = build_manager_runtime_config(self.cfg) + if self.cfg.resume and self.cfg.mode == "rjob": + await cleanup_resume_artifacts( + job_id=self.cfg.job_id, + model=self.cfg.llm_model, + data_manager=self.data_manager, + manager_cfg=self.manager_cfg, + ) log.info( "storage prepared: job_id=%s base_pool_size=%d warm_pool_size=%d startup_submit_count=%d followup_submit_batch=%d", self.cfg.job_id, From 72d7131ca35ad63b070545ad706332cfc6dc4e92 Mon Sep 17 00:00:00 2001 From: luhehb <278922717@qq.com> Date: Fri, 14 Aug 2026 14:34:17 +0800 Subject: [PATCH 07/11] DB interface refactor which simplify the functionality of DB DAO --- core/data_manager/__init__.py | 13 +- core/data_manager/contracts.py | 52 ++ core/data_manager/db_utils.py | 31 +- core/data_manager/manager.py | 136 ++++- core/data_manager/strategy/base_strategy.py | 107 ++-- .../strategy/cloud_strategy_impl.py | 128 +++- .../strategy/sqlite_strategy_impl.py | 221 ++++++- core/data_manager/yaml_aggregator.py | 559 +++--------------- core/llm/base_url_provider.py | 3 +- evaluator/reward_committer.py | 156 +---- evaluator/trajectory_reader.py | 49 +- gateway/storage.py | 4 +- manager/actor_pool.py | 2 +- manager/binding_plan.py | 6 +- manager/db_loader.py | 462 +++------------ manager/manager.py | 6 +- manager/repository.py | 121 ++-- manager/simulation_flow.py | 16 +- manager/simulation_worker.py | 3 +- 19 files changed, 829 insertions(+), 1246 deletions(-) create mode 100644 core/data_manager/contracts.py diff --git a/core/data_manager/__init__.py b/core/data_manager/__init__.py index 3b76de3..d2c025b 100644 --- a/core/data_manager/__init__.py +++ b/core/data_manager/__init__.py @@ -1,14 +1,11 @@ from .manager import DataManager -from .strategy.base_strategy import StorageStrategy, SessionContext -from .models import ( - JobEnvironment, - SessionStep, -) +from .contracts import EnvironmentQuery, SessionContext, SessionStepQuery +from .strategy.base_strategy import StorageStrategy __all__ = [ "DataManager", "StorageStrategy", "SessionContext", - "JobEnvironment", - "SessionStep", -] \ No newline at end of file + "EnvironmentQuery", + "SessionStepQuery", +] diff --git a/core/data_manager/contracts.py b/core/data_manager/contracts.py new file mode 100644 index 0000000..5d4d958 --- /dev/null +++ b/core/data_manager/contracts.py @@ -0,0 +1,52 @@ +"""Backend-neutral contracts used at the DataManager/DAO boundary.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + + +@dataclass +class SessionContext: + """In-memory workflow state; this object is never persisted as a row.""" + + session_id: str + env_id: str + env_name: str + llm_model: str + group_id: str = "" + job_id: str = "" + total_reward: float = 0.0 + start_time: float = 0.0 + message_history: List[Dict[str, Any]] = field(default_factory=list) + is_session_completed: bool = False + + +@dataclass(frozen=True) +class EnvironmentQuery: + """Portable environment-row query understood by every storage DAO.""" + + job_id: Optional[str] = None + env_id: Optional[str] = None + after_id: int = 0 + offset: int = 0 + limit: Optional[int] = None + finished: Optional[bool] = None + is_deleted: Optional[bool] = None + + +@dataclass(frozen=True) +class SessionStepQuery: + """Portable session-step query understood by every storage DAO.""" + + job_id: Optional[str] = None + session_id: Optional[str] = None + session_ids: tuple[str, ...] = () + step_id: Optional[int] = None + llm_model: Optional[str] = None + after_id: int = 0 + limit: Optional[int] = None + is_terminal: Optional[bool] = None + is_trainable: Optional[bool] = None + checkout_latest: bool = False + diff --git a/core/data_manager/db_utils.py b/core/data_manager/db_utils.py index 7de7e0f..c941044 100644 --- a/core/data_manager/db_utils.py +++ b/core/data_manager/db_utils.py @@ -1,10 +1,14 @@ -import sqlite3 -from typing import List, Tuple +from typing import Any, List, Optional, Tuple -def load_env_lists(db_path: str, table_name: str = "trad") -> Tuple[List[str], List[str]]: +async def load_env_lists( + data_manager: Any, + table_name: str = "job_environments", + *, + job_id: Optional[str] = None, +) -> Tuple[List[str], List[str]]: """ - Read env_name and env_id from a sqlite database table and return two lists: + Read env_name and env_id through DataManager and return two lists: (env_name_list, env_id_list) Schema expected: @@ -14,18 +18,9 @@ def load_env_lists(db_path: str, table_name: str = "trad") -> Tuple[List[str], L env_param TEXT NULL, image TEXT NULL """ - # basic hardening: avoid SQL injection via table_name - if not table_name.replace("_", "").isalnum(): - raise ValueError(f"Invalid table_name: {table_name!r}") - - conn = sqlite3.connect(db_path) - try: - cur = conn.cursor() - cur.execute(f'SELECT env_name, env_id FROM "{table_name}" ORDER BY id ASC') - rows = cur.fetchall() - finally: - conn.close() - - env_names = [r[0] for r in rows] - env_ids = [r[1] for r in rows] + if table_name != "job_environments": + raise ValueError("DataManager only exposes the job_environments dataset") + rows = await data_manager.list_environment_rows(job_id=job_id) + env_names = [str(row.get("env_name") or "") for row in rows] + env_ids = [str(row.get("env_id") or "") for row in rows] return env_names, env_ids diff --git a/core/data_manager/manager.py b/core/data_manager/manager.py index 7d4d64a..8159fdf 100644 --- a/core/data_manager/manager.py +++ b/core/data_manager/manager.py @@ -1,7 +1,9 @@ import logging +import time from typing import Optional, List, Dict, Any -from core.data_manager.strategy.base_strategy import StorageStrategy, SessionContext +from core.data_manager.contracts import EnvironmentQuery, SessionContext, SessionStepQuery +from core.data_manager.strategy.base_strategy import StorageStrategy from core.data_manager.strategy_factory import StorageFactory log = logging.getLogger("core.data_manager.manager") @@ -20,12 +22,12 @@ def __init__( ): self.job_id = job_id self.storage_type = storage_type - self.strategy: Optional[StorageStrategy] = None + self._strategy: StorageStrategy try: log.debug("Initializing DataManager with strategy: %r", storage_type) - self.strategy = StorageFactory.create(job_id, storage_type, **storage_config) - log.debug("DataManager initialized successfully using %s", self.strategy.__class__.__name__) + self._strategy = StorageFactory.create(job_id, storage_type, **storage_config) + log.debug("DataManager initialized successfully using %s", self.backend_name) except ValueError as e: error_msg = f"Unsupported storage type: '{storage_type}'. Please check registered types." @@ -44,7 +46,12 @@ def __init__( async def init(self) -> None: """Initialize the storage strategy""" - await self.strategy.init() + await self._strategy.init() + + @property + def backend_name(self) -> str: + """Diagnostic backend name without exposing the DAO instance.""" + return self._strategy.__class__.__name__ async def add_environment( self, @@ -67,7 +74,7 @@ async def add_environment( Returns: env_id: Generated environment UUID """ - return await self.strategy.add_environment( + return await self._strategy.add_environment( job_id=job_id or self.job_id, env_name=env_name, env_params=env_params, @@ -77,15 +84,81 @@ async def add_environment( async def get_all_environments(self, job_id: Optional[str] = None) -> List[Dict]: """Retrieve all registered environments""" - return await self.strategy.get_all_environments(job_id) + return await self._strategy.list_environment_rows(EnvironmentQuery( + job_id=job_id or self.job_id, + )) async def get_environment_by_env_id(self, env_id: str) -> Optional[Dict]: """Retrieve one environment config by env_id.""" - return await self.strategy.get_environment_by_env_id(env_id) + return await self._strategy.get_environment_by_env_id(env_id) async def mark_environment_finished(self, env_id: str) -> int: """Mark one environment completed for this job.""" - return await self.strategy.mark_environment_finished(env_id) + return await self._strategy.mark_environment_finished(env_id) + + async def list_environment_rows( + self, + *, + job_id: Optional[str] = None, + env_id: Optional[str] = None, + after_id: int = 0, + offset: int = 0, + limit: Optional[int] = None, + finished: Optional[bool] = None, + is_deleted: Optional[bool] = None, + ) -> List[Dict[str, Any]]: + """Query environment rows without exposing backend query objects.""" + return await self._strategy.list_environment_rows(EnvironmentQuery( + job_id=job_id or self.job_id, + env_id=env_id, + after_id=max(0, int(after_id)), + offset=max(0, int(offset)), + limit=None if limit is None else max(0, int(limit)), + finished=finished, + is_deleted=is_deleted, + )) + + async def insert_environment_rows(self, rows: List[Dict[str, Any]]) -> List[str]: + """Insert environment rows through the configured DAO.""" + normalized = [] + for row in rows: + item = dict(row) + item["job_id"] = str(item.get("job_id") or self.job_id) + normalized.append(item) + return await self._strategy.insert_environment_rows(normalized) + + async def update_environment_rows( + self, + *, + env_id: Optional[str] = None, + job_id: Optional[str] = None, + finished: Optional[bool] = None, + is_deleted: Optional[bool] = None, + updates: Dict[str, Any], + ) -> int: + return await self._strategy.update_environment_rows( + EnvironmentQuery( + job_id=job_id or self.job_id, + env_id=env_id, + finished=finished, + is_deleted=is_deleted, + ), + dict(updates), + ) + + async def delete_session_step_rows( + self, + *, + session_ids: Optional[List[str]] = None, + job_id: Optional[str] = None, + ) -> int: + return await self._strategy.delete_session_step_rows(SessionStepQuery( + job_id=job_id or self.job_id, + session_ids=tuple(session_ids or ()), + )) + + async def delete_job_rows(self, job_id: Optional[str] = None) -> None: + await self._strategy.delete_job_rows(job_id or self.job_id) def create_session( self, @@ -99,12 +172,14 @@ def create_session( Create a new session context. Note: session_id = env_id by design. """ - return self.strategy.create_session( + return SessionContext( + session_id=env_id, env_id=env_id, env_name=env_name, llm_model=llm_model, group_id=group_id, - job_id=job_id or self.job_id + job_id=job_id or self.job_id, + start_time=time.perf_counter(), ) async def record_step( @@ -128,7 +203,8 @@ async def record_step( For SQLite: base64 images stored directly in messages For Cloud: images uploaded to S3, URLs stored in messages """ - await self.strategy.record_step( + session.total_reward += float(step_reward or 0.0) + await self._strategy.record_step( session=session, step_id=step_id, messages=messages, @@ -145,11 +221,15 @@ async def record_step( async def record_steps_batch(self, steps: List[Dict[str, Any]]) -> List[Optional[str]]: """Persist multiple steps using the backend's native bulk API when available.""" - return await self.strategy.record_steps_batch(steps) + for step in steps: + session = step.get("session") + if isinstance(session, SessionContext): + session.total_reward += float(step.get("step_reward") or 0.0) + return await self._strategy.record_steps_batch(steps) async def mark_records_completed(self, record_ids: List[str]) -> int: """Mark known records completed without a latest-row lookup.""" - return await self.strategy.mark_records_completed(record_ids) + return await self._strategy.mark_records_completed(record_ids) async def list_session_steps( self, @@ -158,7 +238,7 @@ async def list_session_steps( checkout_latest: bool = False, ) -> List[Dict[str, Any]]: """Return persisted rows for one session in trajectory order.""" - return await self.strategy.list_session_steps( + return await self._strategy.list_session_steps( session_id, checkout_latest=checkout_latest, ) @@ -172,7 +252,7 @@ async def record_evaluation_summary( truncated: bool = False, ) -> int: """Persist a non-trainable evaluation summary row.""" - return await self.strategy.record_evaluation_summary( + return await self._strategy.record_evaluation_summary( session_id=session_id, step_id=step_id, reward=reward, @@ -191,7 +271,7 @@ async def update_session_step( Returns the number of matched records. """ - return await self.strategy.update_session_step( + return await self._strategy.update_session_step( session_id=session_id, step_id=step_id, updates=updates, @@ -206,7 +286,7 @@ async def patch_session_environment( group_id: Optional[str] = None, ) -> int: """Patch persisted session rows after environment metadata is known.""" - return await self.strategy.patch_session_environment( + return await self._strategy.patch_session_environment( session_id=session_id, job_id=job_id, env_name=env_name, @@ -228,7 +308,7 @@ async def mark_latest_session_completed( Returns the number of updated records. """ - return await self.strategy.mark_latest_session_completed( + return await self._strategy.mark_latest_session_completed( session_id=session_id, llm_model=llm_model, is_session_completed=is_session_completed, @@ -237,11 +317,7 @@ async def mark_latest_session_completed( async def close(self) -> None: """Close the storage strategy""" - await self.strategy.close() - - def get_sync_connection(self) -> Any: - """Get synchronous connection (SQLite only)""" - return self.strategy.get_sync_connection() + await self._strategy.close() async def fetch_done_steps_with_context( self, @@ -249,19 +325,19 @@ async def fetch_done_steps_with_context( limit: int = 100 ) -> List[Dict]: """Fetch completed steps for training data collection""" - if hasattr(self.strategy, 'fetch_done_steps_with_context'): - return await self.strategy.fetch_done_steps_with_context(self.job_id, after_id, limit) + if hasattr(self._strategy, 'fetch_done_steps_with_context'): + return await self._strategy.fetch_done_steps_with_context(self.job_id, after_id, limit) return [] async def get_max_step_id(self) -> int: """Get maximum primary key for pagination""" - if hasattr(self.strategy, 'get_max_step_id'): - return await self.strategy.get_max_step_id(self.job_id) + if hasattr(self._strategy, 'get_max_step_id'): + return await self._strategy.get_max_step_id(self.job_id) return 0 @property def buffer_stats(self) -> Optional[dict]: """Get buffer statistics (SQLite only)""" - if hasattr(self.strategy, 'buffer_stats'): - return self.strategy.buffer_stats + if hasattr(self._strategy, 'buffer_stats'): + return self._strategy.buffer_stats return None diff --git a/core/data_manager/strategy/base_strategy.py b/core/data_manager/strategy/base_strategy.py index 9f2b49b..90b0ed4 100644 --- a/core/data_manager/strategy/base_strategy.py +++ b/core/data_manager/strategy/base_strategy.py @@ -1,31 +1,15 @@ from abc import ABC, abstractmethod from typing import Any, Optional, List, Dict -from dataclasses import dataclass, field - -@dataclass -class SessionContext: - """ - Runtime session context object. - This is NOT persisted to database - it's an in-memory object to track session state. - """ - session_id: str - env_id: str - env_name: str - llm_model: str - group_id: str = "" - job_id: str = "" - - # Runtime state (not persisted) - total_reward: float = 0.0 - start_time: float = 0.0 - message_history: List[Dict] = field(default_factory=list) - is_session_completed: bool = False +from core.data_manager.contracts import EnvironmentQuery, SessionContext, SessionStepQuery class StorageStrategy(ABC): """ - Abstract base class for storage backends. + DAO contract for storage backends. + + Implementations translate these operations to a physical backend. Runtime + workflow policy belongs to ``DataManager`` and its callers, not here. Table design: - Table 1 (JobEnvironment): job_id + env_id mapping with env config @@ -93,29 +77,58 @@ async def get_environment_by_env_id(self, env_id: str) -> Optional[Dict[str, Any return env return None + async def list_environment_rows(self, query: EnvironmentQuery) -> List[Dict[str, Any]]: + """List environment rows using backend-neutral filters.""" + rows = await self.get_all_environments(query.job_id) + filtered = [] + for index, row in enumerate(rows, start=1): + item = dict(row) + item.setdefault("id", index) + if query.env_id and str(item.get("env_id") or "") != query.env_id: + continue + if int(item.get("id") or 0) <= query.after_id: + continue + if query.finished is not None and bool(item.get("finished", False)) != query.finished: + continue + if query.is_deleted is not None and bool(item.get("is_deleted", False)) != query.is_deleted: + continue + filtered.append(item) + filtered.sort(key=lambda item: int(item.get("id") or 0)) + start = max(0, query.offset) + end = None if query.limit is None else start + max(0, query.limit) + return filtered[start:end] + + async def insert_environment_rows(self, rows: List[Dict[str, Any]]) -> List[str]: + """Insert environment rows and return their env_ids.""" + env_ids = [] + for row in rows: + env_ids.append(await self.add_environment( + job_id=str(row.get("job_id") or ""), + env_name=str(row.get("env_name") or ""), + env_params=dict(row.get("env_params") or {}), + image=str(row.get("image") or ""), + group_id=str(row.get("group_id") or ""), + )) + return env_ids + + async def update_environment_rows( + self, + query: EnvironmentQuery, + updates: Dict[str, Any], + ) -> int: + raise NotImplementedError + + async def delete_session_step_rows(self, query: SessionStepQuery) -> int: + raise NotImplementedError + + async def delete_job_rows(self, job_id: str) -> None: + raise NotImplementedError + @abstractmethod async def mark_environment_finished(self, env_id: str) -> int: """Mark one environment completed after its full workflow succeeds.""" pass - @abstractmethod - async def create_session( - self, - env_id: str, - env_name: str, - llm_model: str, - group_id: str = "", - job_id: str = "" - ) -> SessionContext: - """ - Create a new session context (in-memory object). - Note: session_id = env_id by design. - - Returns: - SessionContext object for tracking session state - """ - pass - @abstractmethod async def record_step( self, @@ -254,19 +267,3 @@ async def mark_latest_session_completed( async def close(self) -> None: """Clean up resources (DB connections, clients, buffers)""" pass - - def get_sync_connection(self) -> Any: - """ - Get synchronous connection for direct queries (SQLite only). - Returns None for cloud storage. - """ - return None - - # Legacy compatibility methods (will be deprecated) - async def create_session_legacy(self, env_id, llm_model: str, group_id: str = ""): - """Legacy method for backward compatibility""" - pass - - async def update_session(self, session, trajectory, total_reward, is_session_completed): - """Legacy method for backward compatibility""" - pass diff --git a/core/data_manager/strategy/cloud_strategy_impl.py b/core/data_manager/strategy/cloud_strategy_impl.py index 8ec7d3d..3590607 100644 --- a/core/data_manager/strategy/cloud_strategy_impl.py +++ b/core/data_manager/strategy/cloud_strategy_impl.py @@ -11,7 +11,8 @@ from typing import List, Dict, Optional, Any, Set from datetime import date -from core.data_manager.strategy.base_strategy import StorageStrategy, SessionContext +from core.data_manager.contracts import EnvironmentQuery, SessionContext, SessionStepQuery +from core.data_manager.strategy.base_strategy import StorageStrategy from core.perf_trace import PerfTrace log = logging.getLogger("cloud_strategy") @@ -532,6 +533,125 @@ async def get_environment_by_env_id(self, env_id: str) -> Optional[Dict[str, Any self._env_configs[str(config["env_id"])] = config return dict(config) + async def list_environment_rows(self, query: EnvironmentQuery) -> List[Dict[str, Any]]: + """Read environment rows from the authoritative config store.""" + await self.init() + clauses = [] + if query.job_id: + clauses.append(f"job_id = '{_escape_sql_literal(query.job_id)}'") + if query.env_id: + clauses.append(f"env_id = '{_escape_sql_literal(query.env_id)}'") + filter_query = " AND ".join(clauses) or None + page_size = max(100, query.limit or 1000) + effective_offset = max(0, query.offset, query.after_id) + normalized: List[Dict[str, Any]] = [] + scanned = 0 + while True: + page = await asyncio.to_thread( + self.env_manager.get_env_configs, + limit=page_size, + offset=effective_offset + scanned, + filter_query=filter_query, + ) + if not page: + break + for index, config in enumerate(page, start=effective_offset + scanned + 1): + row = self._normalize_env_config(config) + row.setdefault("id", index) + if query.finished is not None and _truthy_bool(row.get("finished")) != query.finished: + continue + if query.is_deleted is not None and _truthy_bool(row.get("is_deleted")) != query.is_deleted: + continue + env_id = str(row.get("env_id") or "") + if env_id: + self._env_configs[env_id] = row + normalized.append(row) + if query.limit is not None and len(normalized) >= query.limit: + return normalized + scanned += len(page) + if len(page) < page_size: + break + return normalized + + async def insert_environment_rows(self, rows: List[Dict[str, Any]]) -> List[str]: + await self.init() + if not rows: + return [] + configs = [] + env_ids = [] + for row in rows: + env_id = str(row.get("env_id") or uuid.uuid4()) + env_ids.append(env_id) + config = { + "job_id": str(row.get("job_id") or self.job_id), + "env_id": env_id, + "env_name": str(row.get("env_name") or ""), + "env_params": dict(row.get("env_params") or {}), + "image": str(row.get("image") or ""), + "group_id": str(row.get("group_id") or ""), + "finished": bool(row.get("finished", False)), + "is_deleted": bool(row.get("is_deleted", False)), + "created_at": int(row.get("created_at") or time.time()), + } + configs.append(config) + await asyncio.to_thread(self.env_manager.save_config, configs) + self._env_configs.update({row["env_id"]: row for row in configs}) + return env_ids + + async def update_environment_rows( + self, + query: EnvironmentQuery, + updates: Dict[str, Any], + ) -> int: + await self.init() + allowed = {"env_name", "env_params", "image", "group_id", "finished", "is_deleted"} + unknown = set(updates) - allowed + if unknown: + raise ValueError(f"Unknown environment update fields: {sorted(unknown)}") + rows = await self.list_environment_rows(query) + updated = 0 + for row in rows: + env_id = str(row.get("env_id") or "") + if env_id and await asyncio.to_thread(self.env_manager.update_config, env_id, updates): + cached = self._env_configs.setdefault(env_id, dict(row)) + cached.update(updates) + updated += 1 + return updated + + async def delete_session_step_rows(self, query: SessionStepQuery) -> int: + await self.init() + session_ids = list(query.session_ids) + if query.session_id: + session_ids.append(query.session_id) + if not session_ids: + clauses = [] + if query.job_id: + clauses.append(f"job_id = '{_escape_sql_literal(query.job_id)}'") + if not clauses: + raise ValueError("job_id or session_ids is required for cloud deletion") + await asyncio.to_thread(self.client.delete_landing, " AND ".join(clauses)) + return 1 + for session_id in dict.fromkeys(session_ids): + clauses = [] + if query.job_id: + clauses.append(f"job_id = '{_escape_sql_literal(query.job_id)}'") + clauses.append(f"session_id = '{_escape_sql_literal(session_id)}'") + await asyncio.to_thread(self.client.delete_landing, " AND ".join(clauses)) + return len(set(session_ids)) + + async def delete_job_rows(self, job_id: str) -> None: + await self.init() + rows = await self.list_environment_rows(EnvironmentQuery(job_id=job_id)) + await asyncio.to_thread( + self.client.delete_landing, + f"job_id = '{_escape_sql_literal(job_id)}'", + ) + for row in rows: + env_id = str(row.get("env_id") or "") + if env_id and not await asyncio.to_thread(self.env_manager.delete_config, env_id): + raise RuntimeError(f"failed to delete cloud env config env_id={env_id}") + self._env_configs.pop(env_id, None) + async def mark_environment_finished(self, env_id: str) -> int: """Mark one cloud environment for the current job as finished.""" await self.init() @@ -790,8 +910,6 @@ async def _build_step_record( dataset: Optional[Any] = None, provider_meta: Optional[Dict[str, Any]] = None, ) -> tuple[Any, str]: - session.total_reward += step_reward - env_key = f"{session.env_name}_{session.env_id}" # Optimization: session.message_history already holds previously processed @@ -1170,10 +1288,6 @@ async def close(self) -> None: self.initialized = False log.debug("Cloud strategy closed") - def get_sync_connection(self) -> None: - """Not applicable for cloud storage""" - return None - @property def buffer_stats(self) -> Optional[dict]: """Get buffer statistics""" diff --git a/core/data_manager/strategy/sqlite_strategy_impl.py b/core/data_manager/strategy/sqlite_strategy_impl.py index 12a82a9..00dd2c8 100644 --- a/core/data_manager/strategy/sqlite_strategy_impl.py +++ b/core/data_manager/strategy/sqlite_strategy_impl.py @@ -1,8 +1,10 @@ -from core.data_manager.strategy.base_strategy import StorageStrategy, SessionContext +from core.data_manager.contracts import EnvironmentQuery, SessionContext, SessionStepQuery +from core.data_manager.strategy.base_strategy import StorageStrategy from core.data_manager.models import JobEnvironment, SessionStep from core.data_manager.write_buffer import WriteBuffer from core.perf_trace import PerfTrace from tortoise import Tortoise +from tortoise.transactions import in_transaction from typing import List, Dict, Optional, Tuple, Any import asyncio import uuid @@ -154,11 +156,44 @@ def ensure_schema() -> None: "is_truncated", "is_session_completed", "is_trainable", "created_at", ) - copied_columns = [column for column in target_columns if column in columns] - column_list = ", ".join(f'"{column}"' for column in copied_columns) + missing_defaults = { + "id": "NULL", + "session_id": "''", + "step_id": "0", + "env_name": "''", + "llm_model": "''", + "group_id": "NULL", + "job_id": "NULL", + "messages": "'[]'", + "request": "NULL", + "response": "''", + "step_reward": "0", + "reward": "NULL", + "env_state": "NULL", + "is_terminal": "0", + "is_truncated": "0", + "is_session_completed": "0", + "is_trainable": "0", + "created_at": "CURRENT_TIMESTAMP", + } + column_list = ", ".join(f'"{column}"' for column in target_columns) + nonnull_columns = { + "session_id", "step_id", "env_name", "llm_model", "messages", + "response", "step_reward", "is_terminal", "is_truncated", + "is_session_completed", "is_trainable", "created_at", + } + select_list = ", ".join( + ( + f'COALESCE("{column}", {missing_defaults[column]})' + if column in columns and column in nonnull_columns + else f'"{column}"' if column in columns + else missing_defaults[column] + ) + for column in target_columns + ) conn.execute( f"INSERT INTO session_steps_reward_migration ({column_list}) " - f"SELECT {column_list} FROM session_steps" + f"SELECT {select_list} FROM session_steps" ) conn.execute("DROP TABLE session_steps") conn.execute( @@ -332,6 +367,89 @@ async def get_environment_by_env_id(self, env_id: str) -> Optional[Dict[str, Any trace.emit_summary(status="failed", error_type=type(exc).__name__, error=str(exc)) raise + async def list_environment_rows(self, query: EnvironmentQuery) -> List[Dict[str, Any]]: + await self.init() + rows = JobEnvironment.all() + if query.job_id: + rows = rows.filter(job_id=query.job_id) + if query.env_id: + rows = rows.filter(env_id=query.env_id) + if query.after_id: + rows = rows.filter(id__gt=query.after_id) + if query.finished is not None: + rows = rows.filter(finished=query.finished) + if query.is_deleted is not None: + rows = rows.filter(is_deleted=query.is_deleted) + rows = rows.order_by("id").offset(query.offset) + if query.limit is not None: + rows = rows.limit(query.limit) + return [self._environment_to_dict(env) for env in await rows] + + async def insert_environment_rows(self, rows: List[Dict[str, Any]]) -> List[str]: + await self.init() + if not rows: + return [] + records: List[JobEnvironment] = [] + env_ids: List[str] = [] + for row in rows: + env_id = str(row.get("env_id") or uuid.uuid4()) + env_ids.append(env_id) + records.append(JobEnvironment( + job_id=str(row.get("job_id") or self.job_id), + env_id=env_id, + env_name=str(row.get("env_name") or ""), + env_params=dict(row.get("env_params") or {}), + image=str(row.get("image") or ""), + group_id=str(row.get("group_id") or ""), + finished=bool(row.get("finished", False)), + is_deleted=bool(row.get("is_deleted", False)), + )) + async with in_transaction(): + await JobEnvironment.bulk_create(records) + return env_ids + + async def update_environment_rows( + self, + query: EnvironmentQuery, + updates: Dict[str, Any], + ) -> int: + await self.init() + allowed = {"env_name", "env_params", "image", "group_id", "finished", "is_deleted"} + unknown = set(updates) - allowed + if unknown: + raise ValueError(f"Unknown JobEnvironment update fields: {sorted(unknown)}") + rows = JobEnvironment.all() + if query.job_id: + rows = rows.filter(job_id=query.job_id) + if query.env_id: + rows = rows.filter(env_id=query.env_id) + if query.finished is not None: + rows = rows.filter(finished=query.finished) + if query.is_deleted is not None: + rows = rows.filter(is_deleted=query.is_deleted) + return await rows.update(**updates) if updates else 0 + + async def delete_session_step_rows(self, query: SessionStepQuery) -> int: + await self.init() + if self._write_buffer: + await self._write_buffer.flush_model(SessionStep, operation="create") + rows = SessionStep.all() + if query.job_id: + rows = rows.filter(job_id=query.job_id) + if query.session_id: + rows = rows.filter(session_id=query.session_id) + if query.session_ids: + rows = rows.filter(session_id__in=query.session_ids) + return await rows.delete() + + async def delete_job_rows(self, job_id: str) -> None: + await self.init() + if self._write_buffer: + await self._write_buffer.flush_model(SessionStep, operation="create") + async with in_transaction() as connection: + await SessionStep.filter(job_id=job_id).using_db(connection).delete() + await JobEnvironment.filter(job_id=job_id).using_db(connection).delete() + async def mark_environment_finished(self, env_id: str) -> int: """Mark one active environment for the current job as finished.""" await self.init() @@ -407,9 +525,6 @@ async def record_step( ) try: - # Update session total reward - session.total_reward += step_reward - # Build full message history including current response full_messages = list(messages) # full_messages.append({"role": "assistant", "content": response}) @@ -426,7 +541,6 @@ async def record_step( step_record = SessionStep( session_id=session.session_id, step_id=step_id, - env_id=session.env_id, env_name=session.env_name, llm_model=session.llm_model, group_id=session.group_id, @@ -468,6 +582,47 @@ async def record_step( step_id, session.session_id, step_reward, reward, ) + async def list_session_steps( + self, + session_id: str, + *, + checkout_latest: bool = False, + ) -> List[Dict[str, Any]]: + await self.init() + if self._write_buffer: + await self._write_buffer.flush_model(SessionStep, operation="create") + rows = await SessionStep.filter(session_id=session_id).order_by("step_id", "id") + return [self._session_step_to_dict(row) for row in rows] + + async def record_evaluation_summary( + self, + session_id: str, + step_id: int, + reward: float, + env_state: str, + truncated: bool = False, + ) -> int: + await self.init() + row = SessionStep( + session_id=session_id, + step_id=step_id, + env_name="gateway", + llm_model="", + group_id="", + job_id=self.job_id, + messages="[]", + response="", + step_reward=reward, + reward=reward, + env_state=env_state, + is_terminal=True, + is_truncated=truncated, + is_session_completed=True, + is_trainable=False, + ) + await row.save() + return 1 + async def update_session_step( self, session_id: str, @@ -652,6 +807,44 @@ def _normalize_session_step_updates(self, updates: Dict[str, Any]) -> Dict[str, return normalized + @staticmethod + def _environment_to_dict(env: JobEnvironment) -> Dict[str, Any]: + return { + "id": env.id, + "job_id": env.job_id, + "env_id": env.env_id, + "env_name": env.env_name, + "env_params": env.env_params, + "image": env.image, + "group_id": env.group_id, + "finished": env.finished, + "is_deleted": env.is_deleted, + "created_at": env.created_at.isoformat() if env.created_at else None, + } + + @staticmethod + def _session_step_to_dict(step: SessionStep) -> Dict[str, Any]: + return { + "id": step.id, + "session_id": step.session_id, + "step_id": step.step_id, + "env_name": step.env_name, + "llm_model": step.llm_model, + "group_id": step.group_id, + "job_id": step.job_id, + "messages": step.messages, + "request": step.request, + "response": step.response, + "step_reward": step.step_reward, + "reward": step.reward, + "env_state": step.env_state, + "is_terminal": step.is_terminal, + "is_truncated": step.is_truncated, + "is_session_completed": step.is_session_completed, + "is_trainable": step.is_trainable, + "created_at": step.created_at.isoformat() if step.created_at else None, + } + async def close(self) -> None: """Clean up resources""" if self._write_buffer: @@ -663,18 +856,6 @@ async def close(self) -> None: log.debug("SQLite strategy closed") - def get_sync_connection(self) -> sqlite3.Connection: - """Get raw SQLite connection for direct queries""" - if not self.db_url.startswith("sqlite://"): - raise ValueError("Only sqlite:// protocol is supported") - - file_path = self.db_url[9:].split("?", 1)[0] - conn = sqlite3.connect(file_path, check_same_thread=False) - conn.execute("PRAGMA journal_mode=WAL") - conn.execute("PRAGMA busy_timeout=5000") - conn.row_factory = sqlite3.Row - return conn - @property def buffer_stats(self) -> Optional[dict]: """Get buffer statistics""" diff --git a/core/data_manager/yaml_aggregator.py b/core/data_manager/yaml_aggregator.py index 1ba57b8..5acaf57 100644 --- a/core/data_manager/yaml_aggregator.py +++ b/core/data_manager/yaml_aggregator.py @@ -1,45 +1,34 @@ -from pathlib import Path +"""Load environment YAML and synchronize it through :class:`DataManager`.""" + +from __future__ import annotations + import asyncio -import sqlite3 -import json +import logging import os -import time import uuid -import logging -from collections import defaultdict -from typing import List, Dict, Any, Optional, Union, Set - -from tortoise.transactions import in_transaction +from pathlib import Path +from typing import Any, Dict, List, Set, Union from .load_yaml import load_yaml_configs -from core.data_manager.models import JobEnvironment, SessionStep log = logging.getLogger("yaml_aggregator") -# Module-level set to keep background insert tasks alive until they complete _insert_tasks: Set[asyncio.Task] = set() _job_db_processing_done: Dict[str, bool] = {} def set_job_db_processing_done(job_id: str, done: bool) -> None: - """Record whether a job will append any more environment rows.""" normalized_job_id = str(job_id or "").strip() - if not normalized_job_id: - return - _job_db_processing_done[normalized_job_id] = bool(done) + if normalized_job_id: + _job_db_processing_done[normalized_job_id] = bool(done) def is_job_db_processing_done(job_id: str) -> bool: - """Return True once a job's env-config producer reaches terminal state.""" normalized_job_id = str(job_id or "").strip() - if not normalized_job_id: - return False - return bool(_job_db_processing_done.get(normalized_job_id, False)) + return bool(normalized_job_id and _job_db_processing_done.get(normalized_job_id, False)) def _schedule_insert_task(job_id: str, coro: Any, *, task_name: str) -> asyncio.Task: - """Track a background insert task and flip the job terminal flag on completion.""" - async def _runner() -> None: try: await coro @@ -52,87 +41,43 @@ async def _runner() -> None: return task -async def _do_bulk_insert(pending_records: list, batch_size: int = 500) -> None: - """Background coroutine: bulk-insert pending JobEnvironment records into SQLite. - - Each batch is committed in its own transaction so records become visible to - AgentPoolManager incrementally, rather than only after the entire insert completes. - """ - total = len(pending_records) +async def _do_bulk_insert(data_manager: Any, rows: List[Dict[str, Any]], batch_size: int) -> None: + """Submit follow-up config batches through the public data-manager boundary.""" + total = len(rows) batch_size_raw = os.environ.get("AIEVOBOX_SQLITE_BULK_INSERT_BATCH_SIZE") pause_raw = os.environ.get("AIEVOBOX_SQLITE_BULK_INSERT_PAUSE_S") - try: batch_size = max(1, int(batch_size_raw or batch_size)) except (TypeError, ValueError): - log.warning( - "Invalid AIEVOBOX_SQLITE_BULK_INSERT_BATCH_SIZE=%r; using %d", - batch_size_raw, - batch_size, - ) + log.warning("Invalid bulk insert batch size %r; using %d", batch_size_raw, batch_size) batch_size = max(1, int(batch_size)) - try: pause_s = max(0.0, float(pause_raw or 0.0)) except (TypeError, ValueError): - log.warning("Invalid AIEVOBOX_SQLITE_BULK_INSERT_PAUSE_S=%r; using 0.0", pause_raw) + log.warning("Invalid bulk insert pause %r; using 0.0", pause_raw) pause_s = 0.0 - try: - log.debug( - "Bulk insert start: total=%d batch_size=%d pause_s=%.3f", - total, - batch_size, - pause_s, - ) - for i in range(0, total, batch_size): - async with in_transaction(): - await JobEnvironment.bulk_create(pending_records[i:i + batch_size]) - log.debug("Bulk insert progress: %d/%d", min(i + batch_size, total), total) - if pause_s > 0.0 and i + batch_size < total: - await asyncio.sleep(pause_s) - log.debug("Bulk insert done: %d env records", total) - except Exception: - log.exception("Background bulk insert failed for %d records", total) - - -async def _do_bulk_cloud_insert(env_manager, pending_configs: list, batch_size: int = 500) -> None: - """Background coroutine: bulk-insert pending env config dicts into cloud storage. - - Saves configs in batches so records become visible incrementally. - """ - total = len(pending_configs) - try: - for i in range(0, total, batch_size): - batch = pending_configs[i:i + batch_size] - await asyncio.to_thread(env_manager.save_config, batch) - log.debug("Cloud bulk insert progress: %d/%d", min(i + batch_size, total), total) - log.debug("Cloud bulk insert done: %d env configs", total) - except Exception: - log.exception("Background cloud bulk insert failed for %d configs", total) + for index in range(0, total, batch_size): + await data_manager.insert_environment_rows(rows[index:index + batch_size]) + log.debug("Environment sync progress: %d/%d", min(index + batch_size, total), total) + if pause_s and index + batch_size < total: + await asyncio.sleep(pause_s) async def wait_for_pending_inserts() -> None: - """Wait for all background env-config insert tasks to complete.""" if _insert_tasks: - log.debug("Waiting for %d pending insert task(s)...", len(_insert_tasks)) - await asyncio.gather(*_insert_tasks, return_exceptions=True) - log.debug("All pending insert tasks completed.") + await asyncio.gather(*list(_insert_tasks), return_exceptions=True) def iter_child_yaml_files(env_root: Path): - """Iterate all child yaml files under the given env root.""" if not env_root.is_dir(): raise ValueError(f"env root {env_root} is not a directory") - for subdir in sorted(env_root.iterdir()): - if not subdir.is_dir(): - continue - if subdir.name.startswith("__"): + if not subdir.is_dir() or subdir.name.startswith("__"): continue - for p in sorted(subdir.iterdir()): - if p.is_file() and p.suffix.lower() in (".yaml", ".yml"): - yield p + for path in sorted(subdir.iterdir()): + if path.is_file() and path.suffix.lower() in (".yaml", ".yml"): + yield path def _resolve_env_config_path( @@ -140,28 +85,15 @@ def _resolve_env_config_path( env_config: Union[str, Path], env_root: Union[str, Path] = "env", ) -> Path: - """Resolve env_config to an existing yaml/yml file path. - - - If env_config is an absolute path, use it directly. - - If it's a relative path: - 1) try as-is (relative to current working dir) - 2) if not found, try joined with env_root - """ root = Path(env_root) - p = Path(env_config) - - if not p.is_absolute() and not p.exists(): - p2 = root / p - if p2.exists(): - p = p2 - - if not p.is_file(): - raise ValueError(f"env_config must be an existing yaml file, got: {p}") - - if p.suffix.lower() not in (".yaml", ".yml"): - raise ValueError(f"env_config must be a .yaml/.yml file, got: {p}") - - return p + path = Path(env_config) + if not path.is_absolute() and not path.exists() and (root / path).exists(): + path = root / path + if not path.is_file(): + raise ValueError(f"env_config must be an existing yaml file, got: {path}") + if path.suffix.lower() not in (".yaml", ".yml"): + raise ValueError(f"env_config must be a .yaml/.yml file, got: {path}") + return path def all_env_yaml_load( @@ -169,34 +101,22 @@ def all_env_yaml_load( *, env_config: Union[str, Path, None] = None, ) -> List[Dict]: - """Load env yaml configs. - - - If env_config is provided: only load that yaml file. - - Else: load all yaml files under env_root. - """ - yaml_config_list = [] - env_root = Path(env_root) - + yaml_config_list: List[Dict] = [] + root = Path(env_root) if env_config: - yaml_path = _resolve_env_config_path(env_config=env_config, env_root=env_root) - log.debug("Loading env config: %s", yaml_path) + yaml_path = _resolve_env_config_path(env_config=env_config, env_root=root) yaml_config_list.extend(load_yaml_configs(str(yaml_path)) or []) return yaml_config_list - - for yaml_path in iter_child_yaml_files(env_root): - log.debug("Loading env config: %s", yaml_path) + for yaml_path in iter_child_yaml_files(root): try: - yaml_configs = load_yaml_configs(str(yaml_path)) - except Exception as e: - log.warning("[SKIP] Failed to parse yaml file: %s -> %s", yaml_path, e) - continue - yaml_config_list.extend(yaml_configs) - + yaml_config_list.extend(load_yaml_configs(str(yaml_path)) or []) + except Exception as exc: + log.warning("[SKIP] Failed to parse yaml file: %s -> %s", yaml_path, exc) return yaml_config_list async def sync_configs_to_db( - data_manager, + data_manager: Any, yaml_configs: List[Dict], storage_type: str, startup_submit_count: int = 100, @@ -204,371 +124,86 @@ async def sync_configs_to_db( *, rebuild_table: bool = False, resume: bool = False, -) -> Any: - """ - Sync YAML configurations to the database. - - Existing jobs must explicitly choose either rebuild or resume. Rebuild - deletes only the current job; resume keeps existing configs and skips sync. +) -> None: + """Synchronize configs without leaking a connection or backend client.""" + if storage_type not in {"sqlite", "cloud"}: + raise ValueError(f"Unknown storage type: {storage_type}") + if rebuild_table and resume: + raise ValueError("--rebuild-table and --resume cannot be used together") - Returns: - SQLite: sqlite3.Connection for manager usage - Cloud: env_manager instance - """ await data_manager.init() job_id = data_manager.job_id set_job_db_processing_done(job_id, False) - try: - if rebuild_table and resume: - raise ValueError("--rebuild-table and --resume cannot be used together") - - existing_cloud_configs: List[Dict] = [] - if storage_type == "sqlite": - job_exists = await JobEnvironment.filter(job_id=job_id).exists() - elif storage_type == "cloud": - existing_cloud_configs = await _get_cloud_job_configs(data_manager) - job_exists = bool(existing_cloud_configs) - else: - raise ValueError(f"Unknown storage type: {storage_type}") - - if job_exists and resume: - await _delete_unfinished_session_steps( - data_manager, - storage_type, - existing_cloud_configs, - ) - if storage_type == "cloud": - _restore_cloud_env_cache(data_manager, existing_cloud_configs) + existing = await data_manager.list_environment_rows(job_id=job_id) + if existing and resume: + unfinished_ids = [ + str(row.get("env_id") or "") + for row in existing + if not bool(row.get("finished", False)) and row.get("env_id") + ] + if unfinished_ids: + await data_manager.delete_session_step_rows( + job_id=job_id, + session_ids=unfinished_ids, + ) set_job_db_processing_done(job_id, True) log.info("Resuming existing job_id=%s; finished environments will be skipped", job_id) - return data_manager.get_sync_connection() if storage_type == "sqlite" else data_manager.strategy.env_manager - if job_exists and not rebuild_table: + return + if existing and not rebuild_table: raise RuntimeError( f"job_id={job_id!r} already exists; use --resume to continue it " "or --rebuild-table to start it over" ) - - if job_exists: - await _delete_job_data(data_manager, storage_type, existing_cloud_configs) - log.info("Deleted existing data for job_id=%s before rebuild", job_id) - - if storage_type == "sqlite": - return await _sync_sqlite( - data_manager, - yaml_configs, - startup_submit_count, - followup_submit_batch, - ) - if storage_type == "cloud": - return await _sync_cloud( - data_manager, - yaml_configs, - startup_submit_count, - followup_submit_batch, - ) - except Exception: - set_job_db_processing_done(job_id, True) - raise - - -def _escape_sql_literal(value: str) -> str: - return str(value).replace("'", "''") - - -async def _get_cloud_job_configs(data_manager) -> List[Dict]: - env_manager = data_manager.strategy.env_manager - query = f"job_id = '{_escape_sql_literal(data_manager.job_id)}'" - rows: List[Dict] = [] - offset = 0 - page_size = 1000 - while True: - page = await asyncio.to_thread( - env_manager.get_env_configs, - limit=page_size, - offset=offset, - filter_query=query, - ) - if not page: - break - rows.extend(dict(row) for row in page) - if len(page) < page_size: - break - offset += len(page) - return rows - - -def _restore_cloud_env_cache(data_manager, configs: List[Dict]) -> None: - for config in configs: - row = data_manager.strategy._normalize_env_config(config) - env_id = str(row.get("env_id") or "") - if env_id: - data_manager.strategy._env_configs[env_id] = row - - -async def _delete_unfinished_session_steps( - data_manager, - storage_type: str, - cloud_configs: List[Dict], -) -> None: - job_id = data_manager.job_id - if storage_type == "sqlite": - unfinished_env_ids = await JobEnvironment.filter( - job_id=job_id, - finished=False, - ).values_list("env_id", flat=True) - if not unfinished_env_ids: - return - session_ids = await SessionStep.filter( - job_id=job_id, - session_id__in=unfinished_env_ids, - ).distinct().values_list("session_id", flat=True) - if session_ids: - await SessionStep.filter( - job_id=job_id, - session_id__in=session_ids, - ).delete() - return - - client = data_manager.strategy.client - session_ids = [] - for config in cloud_configs: - if config.get("finished", False): - continue - env_id = str(config.get("env_id") or "") - if not env_id: - continue - query = ( - f"job_id = '{_escape_sql_literal(job_id)}' AND " - f"session_id = '{_escape_sql_literal(env_id)}'" - ) - rows = await asyncio.to_thread( - client.query_data, - filter_query=query, - limit=1, - columns=["session_id"], - partition=job_id, - checkout_latest=True, - ) - if rows: - session_ids.append(str(rows[0].get("session_id") or env_id)) - - for session_id in session_ids: - query = ( - f"job_id = '{_escape_sql_literal(job_id)}' AND " - f"session_id = '{_escape_sql_literal(session_id)}'" - ) - await asyncio.to_thread(client.delete_landing, query) - - -async def _delete_job_data(data_manager, storage_type: str, cloud_configs: List[Dict]) -> None: - job_id = data_manager.job_id - if storage_type == "sqlite": - async with in_transaction() as connection: - await SessionStep.filter(job_id=job_id).using_db(connection).delete() - await JobEnvironment.filter(job_id=job_id).using_db(connection).delete() - return - - strategy = data_manager.strategy - query = f"job_id = '{_escape_sql_literal(job_id)}'" - await asyncio.to_thread(strategy.client.delete_landing, query) - for config in cloud_configs: - env_id = str(config.get("env_id") or "") - if env_id and not await asyncio.to_thread(strategy.env_manager.delete_config, env_id): - raise RuntimeError(f"failed to delete cloud env config env_id={env_id}") - strategy._env_configs.pop(env_id, None) - - -async def _sync_sqlite( - data_manager, - yaml_configs: List[Dict], - startup_submit_count: int, - followup_submit_batch: int, -) -> sqlite3.Connection: - """Sync configs to SQLite. - - - Reuses existing env records where the config matches (env_name + env_params). - - Soft-deletes (is_deleted=True) any active env no longer present in the YAML. - - Creates new records for newly added envs. - """ - job_id = data_manager.job_id - - def _params_key(env_params) -> str: - return json.dumps(env_params or {}, sort_keys=True) - - # Load existing active envs for this job only - existing_envs = await JobEnvironment.filter( - job_id=job_id, is_deleted=False - ).order_by("id") - - # Group existing records by (env_name, params_key) for efficient matching - existing_groups: Dict[str, List[JobEnvironment]] = defaultdict(list) - for env in existing_envs: - key = f"{env.env_name}:{_params_key(env.env_params)}" - existing_groups[key].append(env) - - added = updated = soft_deleted = 0 - matched_env_ids: set = set() - pending_records: list = [] - - for cfg in yaml_configs: - env_name = cfg["env_name"].strip() - env_params = cfg.get("env_params") or {} - image = cfg.get("env_image") or "" - env_num = cfg.get("env_num", 1) - task_idx = cfg.get("task_idx", 1) - - if not isinstance(env_num, int) or env_num < 1: - raise ValueError( - f"env_num must be a positive integer, got {env_num!r} for env '{env_name}'" - ) - - group_id = str(uuid.uuid5(uuid.NAMESPACE_OID, f"{env_name}:{task_idx}")) - group_key = f"{env_name}:{_params_key(env_params)}" - existing_list = existing_groups.get(group_key, []) - - for i in range(env_num): - if i < len(existing_list): - # Reuse the existing record; update metadata if needed - env = existing_list[i] - matched_env_ids.add(env.env_id) - changed = False - if env.group_id != group_id: - env.group_id = group_id - changed = True - if (env.image or "") != image: - env.image = image - changed = True - if changed: - await env.save() - updated += 1 - else: - # Collect for bulk insert - new_env_id = str(uuid.uuid4()) - pending_records.append(JobEnvironment( - job_id=job_id, - env_id=new_env_id, - env_name=env_name, - env_params=env_params, - image=image, - group_id=group_id, - )) - matched_env_ids.add(new_env_id) - added += 1 - - startup_submit_count = max(0, int(startup_submit_count)) - followup_submit_batch = max(1, int(followup_submit_batch)) - - # Commit the first batch synchronously so AgentPoolManager's initial DB query - # (build_binding_plan) always finds enough rows to warm the pool. - if pending_records: - async with in_transaction(): - await JobEnvironment.bulk_create(pending_records[:startup_submit_count]) - log.debug( - "Initial sync insert: %d/%d env records committed", - min(startup_submit_count, len(pending_records)), - len(pending_records), - ) - remaining = pending_records[startup_submit_count:] + if existing: + await data_manager.delete_job_rows(job_id) + + rows = _expand_environment_rows(job_id, yaml_configs) + startup_count = max(0, int(startup_submit_count)) + followup_batch = max(1, int(followup_submit_batch)) + first_batch = rows[:startup_count] + if first_batch: + await data_manager.insert_environment_rows(first_batch) + remaining = rows[startup_count:] if remaining: _schedule_insert_task( job_id, - _do_bulk_insert(remaining, batch_size=followup_submit_batch), - task_name="sqlite-env-sync", + _do_bulk_insert(data_manager, remaining, followup_batch), + task_name=f"{storage_type}-env-sync", ) - log.debug("Scheduled background bulk insert: %d remaining env records", len(remaining)) - - # Soft-delete any active envs that are no longer in the YAML - for env in existing_envs: - if env.env_id not in matched_env_ids: - env.is_deleted = True - await env.save() - soft_deleted += 1 - - log.debug( - "Sync complete: added=%d updated=%d soft_deleted=%d kept=%d", - added, updated, soft_deleted, len(matched_env_ids) - added, - ) - - if not pending_records or len(pending_records) <= startup_submit_count: + else: + set_job_db_processing_done(job_id, True) + log.debug( + "Environment sync scheduled: initial=%d remaining=%d job_id=%s", + len(first_batch), + len(remaining), + job_id, + ) + except Exception: set_job_db_processing_done(job_id, True) + raise - return data_manager.get_sync_connection() - - -async def _sync_cloud( - data_manager, - yaml_configs: List[Dict], - startup_submit_count: int, - followup_submit_batch: int, -) -> Any: - """Sync configs to cloud storage (S3) using append-only batched inserts. - - Commits the first batch synchronously so downstream consumers find data - immediately, then schedules remaining records as a background task so the - main training loop is not blocked. - """ - env_manager = data_manager.strategy.env_manager - - job_id = data_manager.job_id - pending_configs: list = [] - startup_submit_count = max(0, int(startup_submit_count)) - followup_submit_batch = max(1, int(followup_submit_batch)) - - for cfg in yaml_configs: - env_name = cfg["env_name"].strip() - env_params = cfg.get("env_params") or {} - image = cfg.get("env_image") or "" - env_num = cfg.get("env_num", 1) - task_idx = cfg.get("task_idx", 1) +def _expand_environment_rows(job_id: str, yaml_configs: List[Dict]) -> List[Dict[str, Any]]: + rows: List[Dict[str, Any]] = [] + for config in yaml_configs: + env_name = str(config["env_name"]).strip() + env_params = config.get("env_params") or {} + image = config.get("env_image") or "" + env_num = config.get("env_num", 1) + task_idx = config.get("task_idx", 1) if not isinstance(env_num, int) or env_num < 1: raise ValueError( f"env_num must be a positive integer, got {env_num!r} for env '{env_name}'" ) - group_id = str(uuid.uuid5(uuid.NAMESPACE_OID, f"{env_name}:{task_idx}")) - for _ in range(env_num): - env_id = str(uuid.uuid4()) - config_dict = { + rows.append({ "job_id": job_id, - "env_id": env_id, + "env_id": str(uuid.uuid4()), "env_name": env_name, - "env_params": env_params, - "image": image, + "env_params": dict(env_params), + "image": str(image), "group_id": group_id, - "created_at": int(time.time()), - } - pending_configs.append(config_dict) - # Update in-memory cache immediately so get_all_environments is consistent - data_manager.strategy._env_configs[env_id] = config_dict - - if pending_configs: - first_batch = pending_configs[:startup_submit_count] - await asyncio.to_thread(env_manager.save_config, first_batch) - log.debug( - "Initial cloud sync insert: %d/%d env configs committed", - len(first_batch), - len(pending_configs), - ) - remaining = pending_configs[startup_submit_count:] - if remaining: - _schedule_insert_task( - job_id, - _do_bulk_cloud_insert( - env_manager, - remaining, - batch_size=followup_submit_batch, - ), - task_name="cloud-env-sync", - ) - log.debug( - "Scheduled background cloud bulk insert: %d remaining env configs", - len(remaining), - ) - - if not pending_configs or len(pending_configs) <= startup_submit_count: - set_job_db_processing_done(job_id, True) - - return env_manager + }) + return rows diff --git a/core/llm/base_url_provider.py b/core/llm/base_url_provider.py index 96b9338..6743d13 100644 --- a/core/llm/base_url_provider.py +++ b/core/llm/base_url_provider.py @@ -1,7 +1,7 @@ from __future__ import annotations from typing import Optional -from core.data_manager.manager import SessionContext +from core.data_manager.contracts import SessionContext class BaseURLProvider: @@ -38,4 +38,3 @@ def get_base_url(self, session: Optional[SessionContext] = None) -> str: if session is None: raise ValueError("SessionSuffixBaseURLProvider requires a session") return f"{self.base_url_root}/{session.session_id}" - diff --git a/evaluator/reward_committer.py b/evaluator/reward_committer.py index 89ba0f3..bf08114 100644 --- a/evaluator/reward_committer.py +++ b/evaluator/reward_committer.py @@ -1,14 +1,12 @@ from __future__ import annotations -import asyncio import json import logging -import sqlite3 from typing import Any +from core.data_manager.manager import DataManager from core.perf_trace import PerfTrace from evaluator.eval_types import EvalResult, EvalStatus, to_jsonable -from evaluator.trajectory_reader import _sqlite_path log = logging.getLogger("evaluator.reward_committer") @@ -22,15 +20,13 @@ def __init__( data_manager: Any | None = None, ) -> None: self.storage_type = str(storage_type or "sqlite").strip().lower() - self.data_manager = data_manager - if self.storage_type == "sqlite": - self.db_path = _sqlite_path(db_url) - elif self.storage_type == "cloud": - if data_manager is None: - raise ValueError("RewardCommitter cloud mode requires a data manager") - self.db_path = "" - else: + if self.storage_type not in {"sqlite", "cloud"}: raise ValueError(f"RewardCommitter does not support storage type {storage_type!r}") + if data_manager is None: + if self.storage_type == "cloud": + raise ValueError("RewardCommitter cloud mode requires a data manager") + data_manager = DataManager(job_id="", storage_type="sqlite", db_url=db_url) + self.data_manager = data_manager async def commit( self, @@ -55,7 +51,6 @@ async def commit( "score": eval_result.normalized_score_10, "status": eval_result.status, "storage_type": self.storage_type, - "db_path": self.db_path or None, }, ) log.info( @@ -64,36 +59,28 @@ async def commit( eval_result.normalized_score_10, eval_result.status, self.storage_type, - self.db_path or None, + None, ) try: - if self.storage_type == "cloud": - with trace.span("cloud_commit"): - await self._commit_cloud( - session_id=session_id, - eval_result=eval_result, - ) - else: - with trace.span("sqlite_commit"): - await asyncio.to_thread( - self._commit_sqlite, - session_id=session_id, - eval_result=eval_result, - ) + init = getattr(self.data_manager, "init", None) + if callable(init): + await init() + with trace.span("data_manager_commit"): + await self._commit_data_manager( + session_id=session_id, + eval_result=eval_result, + ) log.info( "EVAL REWARD commit complete: session=%s score=%.4f", session_id, eval_result.normalized_score_10, ) trace.emit_summary(status="success") - except asyncio.CancelledError: - trace.emit_summary(status="cancelled", error_type="CancelledError") - raise except Exception as exc: trace.emit_summary(status="failed", error_type=type(exc).__name__, error=str(exc)) raise - async def _commit_cloud( + async def _commit_data_manager( self, *, session_id: str, @@ -112,7 +99,7 @@ async def _commit_cloud( None, ) log.info( - "EVAL REWARD cloud rows: session=%s total_rows=%d terminal_found=%s", + "EVAL REWARD rows: session=%s total_rows=%d terminal_found=%s", session_id, len(rows), terminal is not None, @@ -150,11 +137,11 @@ async def _commit_cloud( ) if recorded <= 0: raise RuntimeError( - "Cannot commit cloud evaluation reward: evaluation summary " + "Cannot commit evaluation reward: evaluation summary " f"was not persisted for {session_id}" ) log.info( - "EVAL REWARD cloud summary persisted: session=%s step_id=%d", + "EVAL REWARD summary persisted: session=%s step_id=%d", session_id, _next_step_id(rows) if summary is None else int(summary.get("step_id") or 0), ) @@ -179,101 +166,8 @@ async def _commit_cloud( ) if updated <= 0: raise RuntimeError( - f"Cannot commit cloud evaluation reward: session row was not updated for {session_id}" - ) - - def _commit_sqlite( - self, - *, - session_id: str, - eval_result: EvalResult, - ) -> None: - truncated = eval_result.status in { - EvalStatus.TRUNCATED, - EvalStatus.TRUNCATED.value, - } - metadata = self._build_reward_metadata(session_id=session_id, eval_result=eval_result) - with sqlite3.connect(self.db_path, timeout=30.0) as conn: - conn.execute("PRAGMA busy_timeout = 30000") - conn.row_factory = sqlite3.Row - rows = conn.execute( - """ - SELECT id, step_id, env_name, messages, response, env_state - FROM session_steps - WHERE session_id = ? - ORDER BY step_id ASC, id ASC - """, - (session_id,), - ).fetchall() - trainable_ids = [int(row["id"]) for row in rows if _is_trainable_step(row)] - terminal = _last_trainable_row(rows, trainable_ids) - log.info( - "EVAL REWARD commit rows: session=%s total_rows=%d trainable_rows=%d terminal_found=%s", - session_id, - len(rows), - len(trainable_ids), - terminal is not None, - ) - if terminal is None: - summary = _existing_eval_summary_row(rows, session_id) - summary_metadata = _as_eval_summary_metadata(metadata) - if summary is None: - conn.execute( - """ - INSERT INTO session_steps - (session_id, step_id, env_name, llm_model, group_id, job_id, messages, - response, step_reward, reward, env_state, is_terminal, - is_truncated, is_session_completed, is_trainable) - VALUES (?, ?, 'gateway', '', '', '', '[]', '', ?, ?, ?, 1, ?, 1, 0) - """, - ( - session_id, - _next_step_id(rows), - eval_result.normalized_score_10, - eval_result.normalized_score_10, - summary_metadata, - int(truncated), - ), - ) - else: - env_state = _merge_env_state(summary["env_state"], summary_metadata) - conn.execute( - """ - UPDATE session_steps - SET step_reward = ?, reward = ?, env_state = ?, - is_truncated = CASE WHEN ? THEN 1 ELSE is_truncated END, - is_terminal = 1, is_session_completed = 1 - WHERE id = ? - """, - ( - eval_result.normalized_score_10, - eval_result.normalized_score_10, - env_state, - int(truncated), - summary["id"], - ), - ) - conn.commit() - return - - env_state = _merge_env_state(terminal["env_state"], metadata) - conn.execute( - """ - UPDATE session_steps - SET step_reward = ?, reward = ?, env_state = ?, - is_truncated = CASE WHEN ? THEN 1 ELSE is_truncated END, - is_terminal = 1, is_session_completed = 1 - WHERE id = ? - """, - ( - eval_result.normalized_score_10, - eval_result.normalized_score_10, - env_state, - int(truncated), - terminal["id"], - ), + f"Cannot commit evaluation reward: session row was not updated for {session_id}" ) - conn.commit() def _build_reward_metadata(self, *, session_id: str, eval_result: EvalResult) -> str: return json.dumps( @@ -313,7 +207,7 @@ def _merge_env_state(existing: Any, new_metadata: str) -> str: } -def _is_trainable_step(row: sqlite3.Row) -> bool: +def _is_trainable_step(row: dict[str, Any]) -> bool: env_state = _load_env_state(row["env_state"]) event_type = env_state.get("event_type") if event_type in _NON_TRAINABLE_EVENT_TYPES: @@ -328,7 +222,7 @@ def _is_trainable_step(row: sqlite3.Row) -> bool: return bool(_has_messages(row["messages"]) or row["response"]) -def _last_trainable_row(rows: list[sqlite3.Row], trainable_ids: list[int]) -> sqlite3.Row | None: +def _last_trainable_row(rows: list[dict[str, Any]], trainable_ids: list[int]) -> dict[str, Any] | None: trainable = set(trainable_ids) for row in reversed(rows): if int(row["id"]) in trainable: @@ -336,7 +230,7 @@ def _last_trainable_row(rows: list[sqlite3.Row], trainable_ids: list[int]) -> sq return None -def _existing_eval_summary_row(rows: list[sqlite3.Row], session_id: str) -> sqlite3.Row | None: +def _existing_eval_summary_row(rows: list[dict[str, Any]], session_id: str) -> dict[str, Any] | None: for row in reversed(rows): env_state = _load_env_state(row["env_state"]) if env_state.get("event_type") != "evaluation_summary": @@ -347,7 +241,7 @@ def _existing_eval_summary_row(rows: list[sqlite3.Row], session_id: str) -> sqli return None -def _next_step_id(rows: list[sqlite3.Row]) -> int: +def _next_step_id(rows: list[dict[str, Any]]) -> int: if not rows: return 0 return max(int(row["step_id"] or 0) for row in rows) + 1 diff --git a/evaluator/trajectory_reader.py b/evaluator/trajectory_reader.py index 943699a..da1c32f 100644 --- a/evaluator/trajectory_reader.py +++ b/evaluator/trajectory_reader.py @@ -2,11 +2,10 @@ import asyncio import json -import sqlite3 import time -from pathlib import Path from typing import Any +from core.data_manager.manager import DataManager from evaluator.eval_types import Trajectory @@ -19,24 +18,23 @@ def __init__( data_manager: Any | None = None, ) -> None: self.storage_type = str(storage_type or "sqlite").strip().lower() - self.data_manager = data_manager - if self.storage_type == "sqlite": - self.db_path = _sqlite_path(db_url) - elif self.storage_type == "cloud": - if data_manager is None: - raise ValueError("TrajectoryReader cloud mode requires a data manager") - self.db_path = "" - else: + if self.storage_type not in {"sqlite", "cloud"}: raise ValueError(f"TrajectoryReader does not support storage type {storage_type!r}") + if data_manager is None: + if self.storage_type == "cloud": + raise ValueError("TrajectoryReader cloud mode requires a data manager") + data_manager = DataManager(job_id="", storage_type="sqlite", db_url=db_url) + self.data_manager = data_manager async def read_by_session(self, session_id: str) -> Trajectory: - if self.storage_type == "cloud": - rows = await self.data_manager.list_session_steps( - session_id, - checkout_latest=True, - ) - return self._trajectory_from_rows(session_id, rows) - return await asyncio.to_thread(self._read_by_session_sync, session_id) + init = getattr(self.data_manager, "init", None) + if callable(init): + await init() + rows = await self.data_manager.list_session_steps( + session_id, + checkout_latest=True, + ) + return self._trajectory_from_rows(session_id, rows) async def wait_until_sealed( self, @@ -55,23 +53,6 @@ async def wait_until_sealed( return last await asyncio.sleep(poll_interval_s) - def _read_by_session_sync(self, session_id: str) -> Trajectory: - if not Path(self.db_path).exists(): - return Trajectory(session_id=session_id, warnings=[f"db not found: {self.db_path}"]) - with sqlite3.connect(self.db_path) as conn: - conn.row_factory = sqlite3.Row - rows = conn.execute( - """ - SELECT * - FROM session_steps - WHERE session_id = ? - ORDER BY step_id ASC, id ASC - """, - (session_id,), - ).fetchall() - - return self._trajectory_from_rows(session_id, [dict(row) for row in rows]) - def _trajectory_from_rows( self, session_id: str, diff --git a/gateway/storage.py b/gateway/storage.py index 8973f08..307497c 100644 --- a/gateway/storage.py +++ b/gateway/storage.py @@ -8,8 +8,8 @@ from dataclasses import dataclass from typing import Any +from core.data_manager.contracts import SessionContext from core.data_manager.manager import DataManager -from core.data_manager.strategy.base_strategy import SessionContext from core.perf_trace import PerfTrace from gateway.anthropic_messages import ( @@ -61,7 +61,7 @@ async def from_config(cls, cfg: GatewayConfig) -> "GatewayStorage": **storage_config, ) await manager.init() - log.info("Gateway storage from_config complete: strategy=%s", manager.strategy.__class__.__name__) + log.info("Gateway storage from_config complete: strategy=%s", manager.backend_name) return cls(cfg, manager) async def get_or_create_session( diff --git a/manager/actor_pool.py b/manager/actor_pool.py index f76bfb9..fe0691d 100644 --- a/manager/actor_pool.py +++ b/manager/actor_pool.py @@ -74,7 +74,7 @@ async def prewarm(self, rows: Optional[List[Dict[str, Any]]] = None) -> None: log.debug("no active rows, skip %s prewarm", self._allocator.runtime) return - self._image_by_env = self._repo.get_env_image_map() + self._image_by_env = await self._repo.get_env_image_map() log.debug( "%s prewarm start: target_pool_size=%d initial_rows=%d", self._allocator.runtime, diff --git a/manager/binding_plan.py b/manager/binding_plan.py index 117a955..c719656 100644 --- a/manager/binding_plan.py +++ b/manager/binding_plan.py @@ -13,11 +13,11 @@ class BindingPlan: images_needed: Set[str] -def build_binding_plan(repo: AgentDataRepository) -> BindingPlan: +async def build_binding_plan(repo: AgentDataRepository) -> BindingPlan: """ Build agent->image bindings and discover distinct images needed. """ - env_image_map = repo.get_env_image_map() + env_image_map = await repo.get_env_image_map() if not env_image_map: return BindingPlan(env_to_image={}, image_to_env={}, images_needed=set()) @@ -28,7 +28,7 @@ def build_binding_plan(repo: AgentDataRepository) -> BindingPlan: + ", ".join(sorted(missing_images)) ) - image_to_env = repo.get_image_to_env_map() + image_to_env = await repo.get_image_to_env_map() images_needed: Set[str] = set(image_to_env.keys()) final_env_image: Dict[str, str] = {} diff --git a/manager/db_loader.py b/manager/db_loader.py index e409ac1..10ed4a3 100644 --- a/manager/db_loader.py +++ b/manager/db_loader.py @@ -1,417 +1,103 @@ -from __future__ import annotations +"""Compatibility query helpers backed exclusively by ``DataManager``. -import inspect -import sqlite3 -from collections.abc import Mapping -from typing import List, Dict, Any, Optional +Runtime code uses :mod:`manager.repository`; these helpers remain for callers +that need the historical names without receiving a raw DB connection. +""" -REMOTE_FETCH_PAGE_SIZE = 1000 +from __future__ import annotations +from typing import Any, Dict, List, Optional -class EnvConfigCacheReader: - """ - Synchronous scheduler reader backed by an in-memory env-config cache. +from core.data_manager.manager import DataManager - Cloud storage keeps scheduler rows in the strategy cache while the SDK owns - the remote persistence. The manager scheduler is intentionally synchronous - at this boundary, so this adapter exposes the same shape as remote SDK - readers without requiring an event loop hop inside repository fetches. - """ - def __init__(self, env_configs: Mapping[str, Any]) -> None: - self._env_configs = env_configs +class EnvConfigCacheReader: + def __init__(self, data_manager: DataManager) -> None: + self._data_manager = data_manager - def get_env_configs( + async def get_env_configs( self, - *, limit: Optional[int] = None, offset: int = 0, job_id: Optional[str] = None, ) -> List[Dict[str, Any]]: - rows = _rows_from_mapping_cache(self._env_configs, job_id=job_id) - start = max(0, int(offset or 0)) - if limit is None: - return rows[start:] - end = start + max(0, int(limit)) - return rows[start:end] - - def get_all_environments(self, job_id: Optional[str] = None) -> List[Dict[str, Any]]: - return _rows_from_mapping_cache(self._env_configs, job_id=job_id) - - -def scheduler_db_reader(storage_type: str, data_manager: Any, conn: Any) -> Any: - """ - Return the object AgentDataRepository should read from. - - SQLite uses the raw sqlite3 connection. Cloud mode does not have a raw sync - DB connection, so prefer a strategy-provided sync reader and fall back to the - strategy cache used by CloudStrategy. - """ - if str(storage_type or "").strip().lower() == "sqlite": - return conn - - strategy = getattr(data_manager, "strategy", None) - for candidate in (strategy, data_manager, conn): - if candidate is None: - continue - get_env_configs = getattr(candidate, "get_env_configs", None) - if callable(get_env_configs) and not inspect.iscoroutinefunction(get_env_configs): - return candidate - env_configs = getattr(candidate, "_env_configs", None) - if isinstance(env_configs, Mapping): - return EnvConfigCacheReader(env_configs) - env_cache = getattr(candidate, "_env_cache", None) - if isinstance(env_cache, Mapping): - return EnvConfigCacheReader(env_cache) - - return conn - - -def _supports_job_id_kw(fn: Any) -> bool: - try: - sig = inspect.signature(fn) - except (TypeError, ValueError): - return False - - for param in sig.parameters.values(): - if param.kind == inspect.Parameter.VAR_KEYWORD: - return True - if param.name == "job_id": - return True - return False - - -def _supports_kw(fn: Any, kw_name: str) -> bool: - try: - sig = inspect.signature(fn) - except (TypeError, ValueError): - return False - - for param in sig.parameters.values(): - if param.kind == inspect.Parameter.VAR_KEYWORD: - return True - if param.name == kw_name: - return True - return False - - -def _requires_kw(fn: Any, kw_name: str) -> bool: - try: - sig = inspect.signature(fn) - except (TypeError, ValueError): - return False - - for param in sig.parameters.values(): - if param.name != kw_name: - continue - return ( - param.kind in ( - inspect.Parameter.POSITIONAL_OR_KEYWORD, - inspect.Parameter.KEYWORD_ONLY, - ) - and param.default is inspect._empty + return await self._data_manager.list_environment_rows( + job_id=job_id, + offset=offset, + limit=limit, + finished=False, + is_deleted=False, ) - return False - - -def _invoke_sync_reader(fn: Any, *args: Any, job_id: Optional[str] = None, **kwargs: Any) -> Any: - if job_id and _supports_job_id_kw(fn): - kwargs["job_id"] = job_id - result = fn(*args, **kwargs) - if inspect.isawaitable(result): - raise TypeError("db_loader requires synchronous reader methods") - return result - - -def _normalize_remote_row(row: Dict[str, Any], index: int) -> Dict[str, Any]: - normalized = dict(row) - if "image" not in normalized and "env_image" in normalized: - normalized["image"] = normalized.get("env_image") - if normalized.get("id") is None: - normalized["id"] = index - return normalized - - -def _normalize_rows(rows: Any) -> List[Dict[str, Any]]: - if not rows: - return [] - - normalized: List[Dict[str, Any]] = [] - for index, row in enumerate(rows, start=1): - if isinstance(row, dict): - normalized.append(_normalize_remote_row(row, index)) - else: - try: - normalized.append(_normalize_remote_row(dict(row), index)) - except Exception: - continue - return normalized - - -def _is_finished(value: Any) -> bool: - if isinstance(value, str): - return value.strip().lower() in {"1", "true", "yes", "on"} - return bool(value) - - -def _rows_from_mapping_cache(env_configs: Mapping[str, Any], job_id: Optional[str] = None) -> List[Dict[str, Any]]: - rows: List[Dict[str, Any]] = [] - for index, value in enumerate(env_configs.values(), start=1): - if not isinstance(value, dict): - try: - value = dict(value) - except Exception: - continue - row = _normalize_remote_row(value, index) - if job_id and str(row.get("job_id") or "") != job_id: - continue - if _is_finished(row.get("finished", False)): - continue - rows.append(row) - return rows -def _filter_rows_by_job_id(rows: List[Dict[str, Any]], job_id: Optional[str]) -> List[Dict[str, Any]]: - if not job_id: - return rows +def scheduler_db_reader( + storage_type: str, + data_manager: DataManager, + conn: Any = None, +) -> DataManager: + """Return the public data boundary; ``conn`` is ignored for compatibility.""" + if storage_type not in {"sqlite", "cloud"}: + raise ValueError(f"Unknown storage type: {storage_type}") + return data_manager - has_job_id = any("job_id" in row for row in rows) - if not has_job_id: - return rows - return [row for row in rows if str(row.get("job_id") or "") == job_id] - - -def _load_remote_rows( - conn: Any, - *, - limit: Optional[int] = None, - offset: int = 0, - job_id: Optional[str] = None, -) -> Optional[List[Dict[str, Any]]]: - get_env_configs = getattr(conn, "get_env_configs", None) - if callable(get_env_configs): - supports_limit = _supports_kw(get_env_configs, "limit") - supports_offset = _supports_kw(get_env_configs, "offset") - requires_limit = _requires_kw(get_env_configs, "limit") - - def _fetch_page(page_offset: int, page_limit: Optional[int]) -> List[Dict[str, Any]]: - kwargs: Dict[str, Any] = {} - if supports_offset: - kwargs["offset"] = page_offset - if supports_limit and page_limit is not None: - kwargs["limit"] = page_limit - rows = _invoke_sync_reader(get_env_configs, job_id=job_id, **kwargs) - return _filter_rows_by_job_id(_normalize_rows(rows), job_id) - - if limit is not None: - if offset and supports_limit and not supports_offset: - rows = _fetch_page(0, offset + limit) - return rows[offset: offset + limit] - return _fetch_page(offset, limit) - - if supports_limit or requires_limit: - page_size = REMOTE_FETCH_PAGE_SIZE - all_rows: List[Dict[str, Any]] = [] - page_offset = offset - while True: - page_rows = _fetch_page(page_offset, page_size) - if not page_rows: - break - all_rows.extend(page_rows) - if len(page_rows) < page_size: - break - if not supports_offset: - break - page_offset += len(page_rows) - return all_rows - - return _fetch_page(offset, None) - - get_all_environments = getattr(conn, "get_all_environments", None) - if callable(get_all_environments): - rows = _invoke_sync_reader(get_all_environments, job_id=job_id) - normalized = _filter_rows_by_job_id(_normalize_rows(rows), job_id) - if limit is None: - return normalized[offset:] - return normalized[offset: offset + limit] - - return None - - -def _build_env_image_map(rows: List[Dict[str, Any]]) -> Dict[str, Any]: - result: Dict[str, Any] = {} - for row in rows: - env_name = row.get("env_name") - image = row.get("image") - if env_name is None: - continue - env_name = str(env_name) - if image: - result[env_name] = image - elif env_name not in result: - result[env_name] = None - return result - - -def _build_image_to_env_map(rows: List[Dict[str, Any]]) -> Dict[str, str]: - image_to_env: Dict[str, str] = {} - for row in rows: - img = str(row.get("image") or "").strip() - env = str(row.get("env_name") or "").strip() - if img and env and img not in image_to_env: - image_to_env[img] = env - return image_to_env - - -def _coerce_row_id(row: Dict[str, Any]) -> Optional[int]: - value = row.get("id") - try: - return int(value) - except (TypeError, ValueError): - return None - - -def get_active_data( - conn: Any, +async def get_active_data( + data_manager: DataManager, limit: int, offset: int, job_id: Optional[str] = None, ) -> List[Dict[str, Any]]: - """Return a paginated slice of active agent rows from the legacy table.""" - if isinstance(conn, sqlite3.Connection): - filters = ["is_deleted = 0", "finished = 0"] - params: List[Any] = [] - if job_id: - filters.append("job_id = ?") - params.append(job_id) - query = """ - SELECT - id, job_id, env_id, env_name, env_params, image, group_id - FROM job_environments - WHERE {where_clause} - ORDER BY id ASC - LIMIT ? OFFSET ?; - """ - cursor = conn.execute(query.format(where_clause=" AND ".join(filters)), tuple(params + [limit, offset])) - cols = [d[0] for d in cursor.description] - return [dict(zip(cols, row)) for row in cursor.fetchall()] + return await data_manager.list_environment_rows( + job_id=job_id, + offset=offset, + limit=limit, + finished=False, + is_deleted=False, + ) - rows = _load_remote_rows(conn, limit=limit, offset=offset, job_id=job_id) - if rows is not None: - return rows - return [] - -def get_active_data_after_id( - conn: Any, +async def get_active_data_after_id( + data_manager: DataManager, limit: int, after_id: int, job_id: Optional[str] = None, ) -> List[Dict[str, Any]]: - """Return active agent rows whose primary key is greater than ``after_id``.""" - if isinstance(conn, sqlite3.Connection): - filters = ["is_deleted = 0", "finished = 0", "id > ?"] - params: List[Any] = [after_id] - if job_id: - filters.append("job_id = ?") - params.append(job_id) - query = """ - SELECT - id, job_id, env_id, env_name, env_params, image, group_id - FROM job_environments - WHERE {where_clause} - ORDER BY id ASC - LIMIT ?; - """ - cursor = conn.execute(query.format(where_clause=" AND ".join(filters)), tuple(params + [limit])) - cols = [d[0] for d in cursor.description] - return [dict(zip(cols, row)) for row in cursor.fetchall()] - - rows = _load_remote_rows(conn, job_id=job_id) - if rows is None: - return [] - - filtered_rows: List[Dict[str, Any]] = [] - for row in rows: - row_id = _coerce_row_id(row) - if row_id is None or row_id <= after_id: - continue - normalized_row = dict(row) - normalized_row["id"] = row_id - filtered_rows.append(normalized_row) - - filtered_rows.sort(key=lambda row: int(row["id"])) - return filtered_rows[:limit] - + return await data_manager.list_environment_rows( + job_id=job_id, + after_id=after_id, + limit=limit, + finished=False, + is_deleted=False, + ) -def get_env_image_map(conn: Any, job_id: Optional[str] = None) -> Dict[str, Any]: - """Return a mapping of legacy env_name -> image for all active agents.""" - if isinstance(conn, sqlite3.Connection): - filters = ["is_deleted = 0", "finished = 0"] - params: List[Any] = [] - if job_id: - filters.append("job_id = ?") - params.append(job_id) - query = """ - SELECT env_name, image - FROM job_environments - WHERE {where_clause} - ORDER BY id ASC; - """ - cursor = conn.execute(query.format(where_clause=" AND ".join(filters)), tuple(params)) - result: Dict[str, Any] = {} - for env_name, image in cursor.fetchall(): - if env_name is None: - continue - if image: - result[env_name] = image - elif env_name not in result: - result[env_name] = None - return result - rows = _load_remote_rows(conn, job_id=job_id) - if rows is not None: - return _build_env_image_map(rows) - - get_map = getattr(conn, "get_env_image_map", None) - if callable(get_map): - return _invoke_sync_reader(get_map, job_id=job_id) or {} - return {} - - -def get_all_image(conn: Any, job_id: Optional[str] = None) -> Dict[str, str]: - """Return a mapping of image -> legacy env_name for all active agents.""" - if isinstance(conn, sqlite3.Connection): - filters = [ - "is_deleted = 0", - "finished = 0", - "image IS NOT NULL AND TRIM(image) != ''", - "env_name IS NOT NULL", - ] - params: List[Any] = [] - if job_id: - filters.append("job_id = ?") - params.append(job_id) - query = """ - SELECT image, env_name - FROM job_environments - WHERE {where_clause}; - """ - cursor = conn.execute(query.format(where_clause=" AND ".join(filters)), tuple(params)) - image_to_env: Dict[str, str] = {} - for image, env_name in cursor.fetchall(): - img = (image or "").strip() - env = (env_name or "").strip() - if img and env and img not in image_to_env: - image_to_env[img] = env - return image_to_env - - rows = _load_remote_rows(conn, job_id=job_id) - if rows is not None: - return _build_image_to_env_map(rows) - - get_map = getattr(conn, "get_all_image", None) - if callable(get_map): - return _invoke_sync_reader(get_map, job_id=job_id) or {} - return {} +async def get_env_image_map( + data_manager: DataManager, + job_id: Optional[str] = None, +) -> Dict[str, Any]: + rows = await data_manager.list_environment_rows( + job_id=job_id, + finished=False, + is_deleted=False, + ) + return { + str(row.get("env_name") or ""): row.get("image") + for row in rows + if row.get("env_name") + } + + +async def get_all_image( + data_manager: DataManager, + job_id: Optional[str] = None, +) -> Dict[str, str]: + rows = await data_manager.list_environment_rows( + job_id=job_id, + finished=False, + is_deleted=False, + ) + return { + str(row.get("image") or ""): str(row.get("env_name") or "") + for row in rows + if row.get("image") and row.get("env_name") + } diff --git a/manager/manager.py b/manager/manager.py index 3a959cd..f5cb245 100644 --- a/manager/manager.py +++ b/manager/manager.py @@ -30,7 +30,7 @@ class AgentPoolManager: def __init__( self, cfg: dict, - conn: Any, + data_manager: Any, *, job_id: str = "", db_processing_done_checker: Optional[Callable[[], bool]] = None, @@ -38,7 +38,7 @@ def __init__( self.cfg = cfg or {} self._job_id = str(job_id or "").strip() self._repo = AgentDataRepository( - conn, + data_manager, job_id=self._job_id, db_processing_done_checker=db_processing_done_checker, ) @@ -71,7 +71,7 @@ async def start(self) -> None: return self._closed = False - plan = build_binding_plan(self._repo) + plan = await build_binding_plan(self._repo) if not plan.env_to_image: log.warning("No agent/image mapping found in DB; nothing to start.") self._initialized = True diff --git a/manager/repository.py b/manager/repository.py index b6cc60c..6799dd4 100644 --- a/manager/repository.py +++ b/manager/repository.py @@ -2,21 +2,13 @@ import asyncio import logging -import sqlite3 import time from collections import deque -from concurrent.futures import ThreadPoolExecutor from typing import Any, Callable, Deque, Dict, List, Optional, Tuple +from core.data_manager.manager import DataManager from core.perf_trace import PerfTrace -from .db_loader import ( - get_active_data, - get_active_data_after_id, - get_all_image, - get_env_image_map, -) - log = logging.getLogger("manager.repository") DB_FETCH_WARN_SECONDS = 1.0 @@ -24,7 +16,7 @@ class AgentDataRepository: """ - Thin repository around db_loader helpers. + Scheduler-facing repository over the public DataManager query API. The repository owns row-reservation state so callers can reserve buffered rows without holding the actor-pool state lock across database reads. @@ -32,32 +24,27 @@ class AgentDataRepository: def __init__( self, - conn: Any, + data_manager: DataManager, *, job_id: str = "", db_processing_done_checker: Optional[Callable[[], bool]] = None, ) -> None: - self._conn = conn + self._data_manager = data_manager self._job_id = str(job_id or "").strip() or None self._db_processing_done_checker = db_processing_done_checker - self._cursor_reads_enabled = isinstance(conn, sqlite3.Connection) or callable( - getattr(conn, "get_env_configs", None) - ) self._last_seen_id: int = 0 self._fallback_offset: int = 0 self._row_buffer: Deque[Dict[str, Any]] = deque() self._fetch_lock = asyncio.Lock() self._db_processing_done_cached: bool = False self._stop_db_reads: bool = False - self._fetch_executor = ThreadPoolExecutor( - max_workers=1, - thread_name_prefix="safactory-agent-db-fetch", - ) - self._pending_fetch: Optional[asyncio.Future[Tuple[List[Dict[str, Any]], int, int]]] = None + self._pending_fetch: Optional[asyncio.Task[Tuple[List[Dict[str, Any]], int, int]]] = None self._pending_fetch_args: Optional[Tuple[int, int, int]] = None def reset_cursor(self) -> None: + if self._pending_fetch is not None and not self._pending_fetch.done(): + self._pending_fetch.cancel() self._last_seen_id = 0 self._fallback_offset = 0 self._row_buffer.clear() @@ -67,11 +54,12 @@ def reset_cursor(self) -> None: self._pending_fetch_args = None def close(self) -> None: + if self._pending_fetch is not None and not self._pending_fetch.done(): + self._pending_fetch.cancel() self._pending_fetch = None self._pending_fetch_args = None - self._fetch_executor.shutdown(wait=False, cancel_futures=True) - def get_env_image_map(self) -> Dict[str, str]: + async def get_env_image_map(self) -> Dict[str, str]: trace = PerfTrace( "manager.repository.get_env_image_map", logger=log, @@ -79,7 +67,16 @@ def get_env_image_map(self) -> Dict[str, str]: ) try: with trace.span("db_read.env_image_map"): - m = get_env_image_map(self._conn, job_id=self._job_id) or {} + rows = await self._data_manager.list_environment_rows( + job_id=self._job_id, + finished=False, + is_deleted=False, + ) + m = { + str(row.get("env_name") or ""): row.get("image") + for row in rows + if row.get("env_name") + } trace.emit_summary(status="success", row_count=len(m)) except Exception as exc: trace.emit_summary(status="failed", error_type=type(exc).__name__, error=str(exc)) @@ -89,7 +86,7 @@ def get_env_image_map(self) -> Dict[str, str]: out[str(k)] = "" if v is None else str(v) return out - def get_image_to_env_map(self) -> Dict[str, str]: + async def get_image_to_env_map(self) -> Dict[str, str]: trace = PerfTrace( "manager.repository.get_image_to_env_map", logger=log, @@ -97,7 +94,16 @@ def get_image_to_env_map(self) -> Dict[str, str]: ) try: with trace.span("db_read.image_to_env_map"): - m = get_all_image(self._conn, job_id=self._job_id) or {} + rows = await self._data_manager.list_environment_rows( + job_id=self._job_id, + finished=False, + is_deleted=False, + ) + m = { + str(row.get("image") or ""): str(row.get("env_name") or "") + for row in rows + if row.get("image") and row.get("env_name") + } trace.emit_summary(status="success", row_count=len(m)) except Exception as exc: trace.emit_summary(status="failed", error_type=type(exc).__name__, error=str(exc)) @@ -287,26 +293,26 @@ async def _fetch_rows_async( def _get_or_start_fetch_task( self, fetch_args: Tuple[int, int, int], - ) -> asyncio.Future[Tuple[List[Dict[str, Any]], int, int]]: + ) -> asyncio.Task[Tuple[List[Dict[str, Any]], int, int]]: if ( self._pending_fetch is not None and self._pending_fetch_args == fetch_args ): return self._pending_fetch - loop = asyncio.get_running_loop() - task = loop.run_in_executor( - self._fetch_executor, - self._fetch_rows_snapshot, - fetch_args[0], - fetch_args[1], - fetch_args[2], + task = asyncio.create_task( + self._fetch_rows_snapshot( + fetch_args[0], + fetch_args[1], + fetch_args[2], + ), + name="safactory-agent-db-fetch", ) self._pending_fetch = task self._pending_fetch_args = fetch_args return task - def _fetch_rows_snapshot( + async def _fetch_rows_snapshot( self, limit: int, last_seen_id: int, @@ -322,50 +328,27 @@ def _fetch_rows_snapshot( "limit": int(limit), "last_seen_id": int(last_seen_id), "fallback_offset": int(fallback_offset), - "cursor_reads_enabled": self._cursor_reads_enabled, }, ) - if self._cursor_reads_enabled: - try: - with trace.span("db_read.active_data_after_id"): - rows = get_active_data_after_id( - self._conn, - int(limit), - int(last_seen_id), - job_id=self._job_id, - ) or [] - next_last_seen_id = int(last_seen_id) - if rows: - next_last_seen_id = int(rows[-1].get("id") or next_last_seen_id) - trace.emit_summary( - status="success", - row_count=len(rows), - next_last_seen_id=next_last_seen_id, - next_fallback_offset=int(fallback_offset), - ) - return rows, next_last_seen_id, int(fallback_offset) - except Exception as exc: - trace.emit_summary(status="failed", error_type=type(exc).__name__, error=str(exc)) - raise - try: - with trace.span("db_read.active_data_page"): - rows = get_active_data( - self._conn, - int(limit), - int(fallback_offset), + with trace.span("db_read.active_data_after_id"): + rows = await self._data_manager.list_environment_rows( job_id=self._job_id, - ) or [] - next_fallback_offset = int(fallback_offset) + after_id=int(last_seen_id), + limit=int(limit), + finished=False, + is_deleted=False, + ) + next_last_seen_id = int(last_seen_id) if rows: - next_fallback_offset += len(rows) + next_last_seen_id = int(rows[-1].get("id") or next_last_seen_id) trace.emit_summary( status="success", row_count=len(rows), - next_last_seen_id=int(last_seen_id), - next_fallback_offset=next_fallback_offset, + next_last_seen_id=next_last_seen_id, + next_fallback_offset=int(fallback_offset), ) - return rows, int(last_seen_id), next_fallback_offset + return rows, next_last_seen_id, int(fallback_offset) except Exception as exc: trace.emit_summary(status="failed", error_type=type(exc).__name__, error=str(exc)) raise diff --git a/manager/simulation_flow.py b/manager/simulation_flow.py index fa79ec2..de390b3 100644 --- a/manager/simulation_flow.py +++ b/manager/simulation_flow.py @@ -24,7 +24,6 @@ from evaluator.service import EvaluationService from evaluator.trajectory_reader import TrajectoryReader from .agent_start_client import AgentStartClient -from .db_loader import scheduler_db_reader from .manager import AgentPoolManager from .resume_cleanup import cleanup_resume_artifacts from .simulation_config import ( @@ -43,8 +42,6 @@ class SimulationFlow: def __init__(self, cfg: SimulationRunConfig) -> None: self.cfg = cfg self.data_manager: Optional[DataManager] = None - self.conn: Any = None - self.scheduler_conn: Any = None self.manager_cfg: Optional[Dict[str, Any]] = None self.agent_pool_manager: Optional[AgentPoolManager] = None self.lease_pool: Optional[SimulationLeasePool] = None @@ -112,7 +109,7 @@ async def prepare_storage(self) -> None: yaml_config_list = expand_rl_group_size(yaml_config_list, self.cfg.rl_group_size) yaml_config_list = expand_rl_epoch(yaml_config_list, self.cfg.rl_epoch) - self.conn = await sync_configs_to_db( + await sync_configs_to_db( self.data_manager, yaml_config_list, self.cfg.storage_type, @@ -189,10 +186,11 @@ def _fetch_metrics() -> tuple[int, str]: async def start_agent_scheduler(self) -> None: if self.manager_cfg is None: self.manager_cfg = build_manager_runtime_config(self.cfg) - self.scheduler_conn = scheduler_db_reader(self.cfg.storage_type, self.data_manager, self.conn) + if self.data_manager is None: + raise RuntimeError("data manager is not prepared") self.agent_pool_manager = AgentPoolManager( self.manager_cfg, - self.scheduler_conn, + self.data_manager, job_id=self.cfg.job_id, db_processing_done_checker=lambda: is_job_db_processing_done(self.cfg.job_id), ) @@ -292,12 +290,6 @@ async def shutdown(self) -> None: except Exception: log.exception("data manager close failed (ignored)") - if self.conn is not None: - try: - with trace.span("manager_db_connection_close"): - self.conn.close() - except Exception: - log.exception("manager DB connection close failed (ignored)") trace.emit_summary(status="complete") def _gateway_origin(self) -> str: diff --git a/manager/simulation_worker.py b/manager/simulation_worker.py index dc641dd..0b106fd 100644 --- a/manager/simulation_worker.py +++ b/manager/simulation_worker.py @@ -10,7 +10,8 @@ import httpx from core.data_manager.load_yaml import materialize_dataset_env_params -from core.data_manager.manager import DataManager, SessionContext +from core.data_manager.contracts import SessionContext +from core.data_manager.manager import DataManager from core.perf_trace import PerfTrace from core.runtime_metadata import strip_internal_env_params from evaluator.eval_types import EvalRequest, EvalResult, EvalStatus From 883a9e53900cfe4a673421add087ba68f66a024c Mon Sep 17 00:00:00 2001 From: luhehb <278922717@qq.com> Date: Fri, 14 Aug 2026 15:00:38 +0800 Subject: [PATCH 08/11] bug fix --- .../strategy/cloud_strategy_impl.py | 3 +++ .../strategy/sqlite_strategy_impl.py | 9 ++++++-- evaluator/reward_committer.py | 4 ++++ evaluator/trajectory_reader.py | 21 ++++++++++++------- 4 files changed, 27 insertions(+), 10 deletions(-) diff --git a/core/data_manager/strategy/cloud_strategy_impl.py b/core/data_manager/strategy/cloud_strategy_impl.py index 3590607..6b3cd69 100644 --- a/core/data_manager/strategy/cloud_strategy_impl.py +++ b/core/data_manager/strategy/cloud_strategy_impl.py @@ -910,6 +910,9 @@ async def _build_step_record( dataset: Optional[Any] = None, provider_meta: Optional[Dict[str, Any]] = None, ) -> tuple[Any, str]: + # Keep the real per-session job_id available for later partitioned reads + # and updates, including sessions created by DataManager itself. + self._sessions[session.session_id] = session env_key = f"{session.env_name}_{session.env_id}" # Optimization: session.message_history already holds previously processed diff --git a/core/data_manager/strategy/sqlite_strategy_impl.py b/core/data_manager/strategy/sqlite_strategy_impl.py index 00dd2c8..ac3bede 100644 --- a/core/data_manager/strategy/sqlite_strategy_impl.py +++ b/core/data_manager/strategy/sqlite_strategy_impl.py @@ -655,10 +655,15 @@ async def update_session_step( await self._write_buffer.flush_model(SessionStep, operation="create") with trace.span("db_write.session_step_update", field_count=len(normalized_updates)): - updated = await SessionStep.filter( + latest = await SessionStep.filter( session_id=session_id, step_id=step_id, - ).update(**normalized_updates) + ).order_by("-id").first() + updated = ( + await SessionStep.filter(id=latest.id).update(**normalized_updates) + if latest is not None + else 0 + ) trace.emit_summary(status="success", updated_count=updated) return updated except Exception as exc: diff --git a/evaluator/reward_committer.py b/evaluator/reward_committer.py index bf08114..652005e 100644 --- a/evaluator/reward_committer.py +++ b/evaluator/reward_committer.py @@ -22,6 +22,7 @@ def __init__( self.storage_type = str(storage_type or "sqlite").strip().lower() if self.storage_type not in {"sqlite", "cloud"}: raise ValueError(f"RewardCommitter does not support storage type {storage_type!r}") + self._owns_data_manager = data_manager is None if data_manager is None: if self.storage_type == "cloud": raise ValueError("RewardCommitter cloud mode requires a data manager") @@ -79,6 +80,9 @@ async def commit( except Exception as exc: trace.emit_summary(status="failed", error_type=type(exc).__name__, error=str(exc)) raise + finally: + if self._owns_data_manager: + await self.data_manager.close() async def _commit_data_manager( self, diff --git a/evaluator/trajectory_reader.py b/evaluator/trajectory_reader.py index da1c32f..4ad7f70 100644 --- a/evaluator/trajectory_reader.py +++ b/evaluator/trajectory_reader.py @@ -20,6 +20,7 @@ def __init__( self.storage_type = str(storage_type or "sqlite").strip().lower() if self.storage_type not in {"sqlite", "cloud"}: raise ValueError(f"TrajectoryReader does not support storage type {storage_type!r}") + self._owns_data_manager = data_manager is None if data_manager is None: if self.storage_type == "cloud": raise ValueError("TrajectoryReader cloud mode requires a data manager") @@ -27,14 +28,18 @@ def __init__( self.data_manager = data_manager async def read_by_session(self, session_id: str) -> Trajectory: - init = getattr(self.data_manager, "init", None) - if callable(init): - await init() - rows = await self.data_manager.list_session_steps( - session_id, - checkout_latest=True, - ) - return self._trajectory_from_rows(session_id, rows) + try: + init = getattr(self.data_manager, "init", None) + if callable(init): + await init() + rows = await self.data_manager.list_session_steps( + session_id, + checkout_latest=True, + ) + return self._trajectory_from_rows(session_id, rows) + finally: + if self._owns_data_manager: + await self.data_manager.close() async def wait_until_sealed( self, From 93e5da43026f4c9dbc7df2d9a47064c74de55b80 Mon Sep 17 00:00:00 2001 From: luhehb <278922717@qq.com> Date: Fri, 14 Aug 2026 19:29:27 +0800 Subject: [PATCH 09/11] add safe guard for cloud related operation --- args.py | 52 +- core/data_manager/cloud_delete_guard.py | 202 +++ core/data_manager/contracts.py | 3 +- core/data_manager/image_processing.py | 143 ++ core/data_manager/job_claim.py | 138 ++ core/data_manager/manager.py | 264 +++- core/data_manager/models.py | 10 +- core/data_manager/strategy/base_strategy.py | 203 +-- .../strategy/cloud_strategy_impl.py | 1173 ++++------------- .../strategy/sqlite_strategy_impl.py | 663 ++++------ core/data_manager/write_buffer.py | 2 +- core/data_manager/yaml_aggregator.py | 25 +- docs/guides/S3+LanceDB-storage.md | 4 +- docs/guides/S3+LanceDB-storage_CN.md | 2 +- docs/guides/data-manager.md | 28 +- docs/guides/data-manager_CN.md | 28 +- docs/reference/configuration.md | 8 +- docs/reference/configuration_CN.md | 8 +- docs/reference/gateway.md | 2 +- docs/reference/gateway_CN.md | 2 +- evaluator/reward_committer.py | 135 +- evaluator/trajectory_policy.py | 66 + evaluator/trajectory_reader.py | 55 +- gateway/storage.py | 165 +-- gateway/trajectory_builder.py | 98 ++ manager/session_lifecycle.py | 50 + manager/simulation_config.py | 10 + manager/simulation_flow.py | 7 + manager/simulation_worker.py | 7 +- manager/types.py | 4 + 30 files changed, 1762 insertions(+), 1795 deletions(-) create mode 100644 core/data_manager/cloud_delete_guard.py create mode 100644 core/data_manager/image_processing.py create mode 100644 core/data_manager/job_claim.py create mode 100644 evaluator/trajectory_policy.py create mode 100644 gateway/trajectory_builder.py create mode 100644 manager/session_lifecycle.py diff --git a/args.py b/args.py index d6c1a7a..5d715c7 100644 --- a/args.py +++ b/args.py @@ -37,8 +37,56 @@ def parse_simulation_args(argv: Sequence[str] | None = None) -> argparse.Namespa default=None, help="SQLite storage DB URI. Cloud storage ignores this and uses wt-data-gateway defaults.", ) - parser.add_argument("--rebuild-table", action=argparse.BooleanOptionalAction, default=False) - parser.add_argument("--resume", action="store_true", help="Continue a job and skip finished environments") + parser.add_argument( + "--rebuild-table", + action=argparse.BooleanOptionalAction, + default=False, + help=( + "Delete this job's environment and landing rows before rebuilding. " + "Cloud mode requires exact deletion confirmation and applies " + "production archive safeguards." + ), + ) + parser.add_argument( + "--resume", + action="store_true", + help=( + "Continue a job and skip finished environments. Before continuing, " + "all landing rows for unfinished sessions are deleted; Cloud mode " + "requires the same destructive-operation safeguards as rebuild." + ), + ) + parser.add_argument( + "--confirm-cloud-delete-job-id", + default="", + help=( + "Exact job_id confirmation required before Cloud resume/rebuild may " + "delete landing rows" + ), + ) + parser.add_argument( + "--confirm-production", + action="store_true", + help=( + "Acknowledge that the resolved Cloud landing target is production." + ), + ) + parser.add_argument( + "--cloud-delete-archive-dir", + default="", + help=( + "Durable directory for verified pre-delete Cloud archives; required " + "for production landing deletes" + ), + ) + parser.add_argument( + "--cloud-job-claim-dir", + default="", + help=( + "Durable shared filesystem directory used for the Cloud environment-" + "initialization lease; required in Cloud mode" + ), + ) parser.add_argument("--disable-buffer", dest="enable_buffer", action="store_false", default=True) parser.add_argument("--buffer-size", type=int, default=100) parser.add_argument("--flush-interval", type=float, default=5.0) diff --git a/core/data_manager/cloud_delete_guard.py b/core/data_manager/cloud_delete_guard.py new file mode 100644 index 0000000..7ef6d5c --- /dev/null +++ b/core/data_manager/cloud_delete_guard.py @@ -0,0 +1,202 @@ +"""Fail-closed safeguards for destructive Cloud landing-table operations.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import logging +import os +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + + +log = logging.getLogger("core.data_manager.cloud_delete_guard") + + +class CloudDestructiveOperationError(RuntimeError): + """Raised when a Cloud delete has not passed every required safety check.""" + + +class CloudDeleteGuard: + """Target preflight and verified archive for Cloud landing deletes.""" + + def __init__( + self, + *, + client: Any, + db_uri: str, + landing_table: str, + confirmed_job_id: str = "", + confirm_production: bool = False, + archive_dir: str = "", + ) -> None: + self.client = client + self.db_uri = str(db_uri or "").strip() + self.landing_table = str(landing_table or "").strip() + self.confirmed_job_id = str(confirmed_job_id or "").strip() + self.confirm_production = bool(confirm_production) + self.archive_dir = str(archive_dir or "").strip() + + async def preflight( + self, + *, + operation: str, + job_id: str, + landing_filter: str, + environment_rows: Optional[List[Dict[str, Any]]] = None, + ) -> List[Dict[str, Any]]: + """Verify the exact landing target and archive before deletion.""" + normalized_job_id = str(job_id or "").strip() + if not normalized_job_id: + raise CloudDestructiveOperationError( + f"{operation} requires an exact job_id for a partition-scoped cloud delete" + ) + if not self.db_uri or not self.landing_table: + raise CloudDestructiveOperationError( + f"{operation} refused because the Cloud DB URI and landing table " + "could not both be resolved explicitly" + ) + + landing_rows = _rows_as_dicts(await asyncio.to_thread( + self.client.query_data, + filter_query=landing_filter, + limit=None, + partition=normalized_job_id, + checkout_latest=True, + deserialize_json=False, + table=self.landing_table, + )) + profile = str(os.environ.get("WT_SDK_PROFILE") or "").strip().lower() + production = ( + profile in {"prod", "production"} + or self.landing_table == "wind_tunnel_landing" + ) + log.warning( + "Cloud delete preflight: operation=%s profile=%s db_uri=%s " + "landing_table=%s job_id=%s landing_rows=%d filter=%s", + operation, + profile or "", + self.db_uri, + self.landing_table, + normalized_job_id, + len(landing_rows), + landing_filter, + ) + + if self.confirmed_job_id != normalized_job_id: + raise CloudDestructiveOperationError( + f"{operation} refused for cloud job_id={normalized_job_id!r}; pass " + f"--confirm-cloud-delete-job-id {normalized_job_id} after reviewing " + f"db_uri={self.db_uri!r}, landing_table={self.landing_table!r}, " + "and the preflight counts" + ) + if production and not self.confirm_production: + raise CloudDestructiveOperationError( + f"{operation} refused for production target " + f"{self.landing_table!r}; --confirm-production is required" + ) + if production and not self.archive_dir: + raise CloudDestructiveOperationError( + f"{operation} refused for production: --cloud-delete-archive-dir " + "is required and must point to durable storage" + ) + if self.archive_dir: + archive_path = self._write_verified_archive( + operation=operation, + job_id=normalized_job_id, + landing_filter=landing_filter, + landing_rows=landing_rows, + environment_rows=environment_rows or [], + profile=profile, + ) + log.warning("Cloud delete archive verified: %s", archive_path) + return landing_rows + + def _write_verified_archive( + self, + *, + operation: str, + job_id: str, + landing_filter: str, + landing_rows: List[Dict[str, Any]], + environment_rows: List[Dict[str, Any]], + profile: str, + ) -> Path: + archive_dir = Path(self.archive_dir).expanduser() + archive_dir.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S.%fZ") + job_digest = hashlib.sha256(job_id.encode("utf-8")).hexdigest()[:16] + archive_path = archive_dir / f"{timestamp}-{operation}-{job_digest}.json" + archive_data = { + "operation": operation, + "created_at": datetime.now(timezone.utc).isoformat(), + "profile": profile or None, + "db_uri": self.db_uri, + "landing_table": self.landing_table, + "job_id": job_id, + "landing_filter": landing_filter, + "landing_rows": landing_rows, + "environment_rows": environment_rows, + } + canonical = json.dumps( + archive_data, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + default=str, + ) + checksum = hashlib.sha256(canonical.encode("utf-8")).hexdigest() + archive_fd = os.open( + archive_path, + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + 0o600, + ) + with os.fdopen(archive_fd, "w", encoding="utf-8") as handle: + json.dump( + {"sha256": checksum, "data": archive_data}, + handle, + ensure_ascii=False, + default=str, + ) + handle.flush() + os.fsync(handle.fileno()) + directory_fd = os.open(archive_dir, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + + with archive_path.open("r", encoding="utf-8") as handle: + verified = json.load(handle) + verified_canonical = json.dumps( + verified.get("data"), + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + default=str, + ) + verified_checksum = hashlib.sha256(verified_canonical.encode("utf-8")).hexdigest() + if verified.get("sha256") != checksum or verified_checksum != checksum: + raise CloudDestructiveOperationError( + f"cloud delete archive verification failed: {archive_path}" + ) + if len(verified["data"].get("landing_rows") or []) != len(landing_rows): + raise CloudDestructiveOperationError( + f"cloud delete archive row-count verification failed: {archive_path}" + ) + return archive_path + + +def _rows_as_dicts(value: Any) -> List[Dict[str, Any]]: + if value is None: + return [] + if hasattr(value, "to_dict"): + try: + value = value.to_dict(orient="records") + except TypeError: + value = value.to_dict() + if isinstance(value, dict): + return [dict(value)] + return [dict(row) for row in value] diff --git a/core/data_manager/contracts.py b/core/data_manager/contracts.py index 5d4d958..18e7c74 100644 --- a/core/data_manager/contracts.py +++ b/core/data_manager/contracts.py @@ -42,6 +42,8 @@ class SessionStepQuery: job_id: Optional[str] = None session_id: Optional[str] = None session_ids: tuple[str, ...] = () + record_id: Optional[str] = None + record_ids: tuple[str, ...] = () step_id: Optional[int] = None llm_model: Optional[str] = None after_id: int = 0 @@ -49,4 +51,3 @@ class SessionStepQuery: is_terminal: Optional[bool] = None is_trainable: Optional[bool] = None checkout_latest: bool = False - diff --git a/core/data_manager/image_processing.py b/core/data_manager/image_processing.py new file mode 100644 index 0000000..3a33e67 --- /dev/null +++ b/core/data_manager/image_processing.py @@ -0,0 +1,143 @@ +"""Message image persistence kept at the data-manager boundary. + +Storage DAOs receive message payloads whose binary images have already been +externalized. SQLite bypasses this component and remains self-contained. +""" + +from __future__ import annotations + +import asyncio +import base64 +import json +import logging +import os +import re +from typing import Any, Dict, List + + +log = logging.getLogger("core.data_manager.image_processing") + + +class MessageImageProcessor: + def __init__( + self, + *, + job_id: str, + uploader: Any = None, + fallback_dir: str = "saved_images", + max_retries: int = 3, + ) -> None: + self.job_id = job_id + self.uploader = uploader + self.fallback_dir = fallback_dir + self.max_retries = max(1, int(max_retries)) + self._history: Dict[tuple[str, str], List[Dict[str, Any]]] = {} + + async def process_rows(self, rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + processed: List[Dict[str, Any]] = [] + for source in rows: + row = dict(source) + messages = row.get("messages") + if isinstance(messages, str): + try: + messages = json.loads(messages) + except Exception: + messages = None + if not isinstance(messages, list): + processed.append(row) + continue + + cache_key = ( + str(row.get("session_id") or ""), + str(row.get("llm_model") or ""), + ) + previous = self._history.get(cache_key, []) + if previous and len(messages) >= len(previous): + prefix = list(previous) + pending = messages[len(previous):] + else: + prefix = [] + pending = messages + env_key = f"{row.get('env_name') or 'gateway'}_{row.get('session_id') or ''}" + suffix = await self._process_messages( + pending, + env_key=env_key, + step_id=int(row.get("step_id") or 0), + ) + row["messages"] = prefix + suffix + self._history[cache_key] = row["messages"] + processed.append(row) + return processed + + async def _process_messages( + self, + messages: List[Dict[str, Any]], + *, + env_key: str, + step_id: int, + ) -> List[Dict[str, Any]]: + processed: List[Dict[str, Any]] = [] + image_count = 0 + for message_index, message in enumerate(messages): + if not isinstance(message, dict) or not isinstance(message.get("content"), list): + processed.append(message) + continue + new_message = dict(message) + new_content: List[Any] = [] + for item in message["content"]: + if not isinstance(item, dict) or item.get("type") != "image_url": + new_content.append(item) + continue + image_url = item.get("image_url") + url = image_url.get("url", "") if isinstance(image_url, dict) else "" + match = re.fullmatch(r"data:image/([\w.+-]+);base64,(.+)", url, re.DOTALL) + if not match: + new_content.append(item) + continue + extension, payload = match.groups() + file_name = f"step_{step_id}_m{message_index}_i{image_count}.{extension}" + final_url = await self._store_image( + payload, + env_key=env_key, + file_name=file_name, + ) + new_item = dict(item) + new_item["image_url"] = dict(image_url) + new_item["image_url"]["url"] = final_url + new_content.append(new_item) + image_count += 1 + new_message["content"] = new_content + processed.append(new_message) + return processed + + async def _store_image(self, payload: str, *, env_key: str, file_name: str) -> str: + try: + image = base64.b64decode(payload) + except Exception as exc: + log.warning("Cannot decode message image %s: %s", file_name, exc) + return f"[IMAGE_DECODE_FAILED:{file_name}]" + + local_dir = os.path.join(self.fallback_dir, env_key) + local_path = os.path.join(local_dir, file_name) + os.makedirs(local_dir, exist_ok=True) + with open(local_path, "wb") as file: + file.write(image) + + if self.uploader is None: + return local_path + key = f"aievobox/{self.job_id}/{env_key}/{file_name}" + for attempt in range(self.max_retries): + try: + uploaded = await asyncio.to_thread( + self.uploader.upload_file, + file_path=local_path, + key=key, + ) + if uploaded: + return str(uploaded) + except Exception as exc: + if attempt + 1 >= self.max_retries: + log.warning("Image upload failed; using local fallback %s: %s", local_path, exc) + break + await asyncio.sleep(2 ** attempt) + return local_path diff --git a/core/data_manager/job_claim.py b/core/data_manager/job_claim.py new file mode 100644 index 0000000..48f4e66 --- /dev/null +++ b/core/data_manager/job_claim.py @@ -0,0 +1,138 @@ +"""Cross-process claim held while one launcher initializes environment rows.""" + +from __future__ import annotations + +import fcntl +import hashlib +import json +import os +import socket +import uuid +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path + + +@dataclass +class JobInitializationClaim: + path: Path + file_descriptor: int + owner_token: str + job_id: str + _released: bool = False + + def release(self) -> None: + if self._released: + return + try: + _write_claim_state( + self.file_descriptor, + { + "state": "released", + "job_id": self.job_id, + "owner_token": self.owner_token, + "pid": os.getpid(), + "host": socket.gethostname(), + "released_at": datetime.now(timezone.utc).isoformat(), + }, + ) + fcntl.flock(self.file_descriptor, fcntl.LOCK_UN) + finally: + os.close(self.file_descriptor) + self._released = True + + +def acquire_job_initialization_claim( + *, + job_id: str, + storage_type: str, + storage_identity: str, + claim_dir: str = "", +) -> JobInitializationClaim: + """Acquire a non-blocking OS lease visible to every launcher using the path.""" + normalized_job_id = str(job_id or "").strip() + if not normalized_job_id: + raise ValueError("job initialization claim requires a non-empty job_id") + normalized_storage = str(storage_type or "").strip().lower() + configured_dir = str(claim_dir or "").strip() + if normalized_storage == "cloud": + configured_dir = configured_dir or str( + os.environ.get("SAFACTORY_CLOUD_JOB_CLAIM_DIR") or "" + ).strip() + if not configured_dir: + raise RuntimeError( + "Cloud environment initialization requires --cloud-job-claim-dir " + "or SAFACTORY_CLOUD_JOB_CLAIM_DIR pointing to a durable shared " + "filesystem visible to every launcher" + ) + # EnvConfigManager allocates physical IDs from the whole config table, + # so every writer for the same store must share one claim even when the + # logical job IDs differ. + identity_digest = hashlib.sha256( + str(storage_identity or normalized_storage).encode("utf-8") + ).hexdigest()[:16] + scope = f"cloud-environment-config-{identity_digest}" + else: + configured_dir = configured_dir or str( + os.environ.get("SAFACTORY_JOB_CLAIM_DIR") or ".safactory-locks" + ).strip() + identity_digest = hashlib.sha256( + str(storage_identity or normalized_storage).encode("utf-8") + ).hexdigest()[:16] + job_digest = hashlib.sha256(normalized_job_id.encode("utf-8")).hexdigest()[:16] + scope = f"sqlite-{identity_digest}-{job_digest}" + + root = Path(configured_dir).expanduser() + root.mkdir(parents=True, exist_ok=True) + path = root / f"{scope}.lock" + file_descriptor = os.open(path, os.O_RDWR | os.O_CREAT, 0o600) + try: + fcntl.flock(file_descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as exc: + owner = _read_claim_state(file_descriptor) + os.close(file_descriptor) + raise RuntimeError( + "environment initialization is already claimed by another launcher: " + f"job_id={normalized_job_id!r} claim={path} owner={owner}" + ) from exc + + owner_token = uuid.uuid4().hex + _write_claim_state( + file_descriptor, + { + "state": "held", + "job_id": normalized_job_id, + "owner_token": owner_token, + "pid": os.getpid(), + "host": socket.gethostname(), + "storage_type": normalized_storage, + "storage_identity": storage_identity, + "acquired_at": datetime.now(timezone.utc).isoformat(), + }, + ) + return JobInitializationClaim( + path=path, + file_descriptor=file_descriptor, + owner_token=owner_token, + job_id=normalized_job_id, + ) + + +def _write_claim_state(file_descriptor: int, payload: dict) -> None: + encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8") + os.lseek(file_descriptor, 0, os.SEEK_SET) + os.ftruncate(file_descriptor, 0) + written = 0 + while written < len(encoded): + written += os.write(file_descriptor, encoded[written:]) + os.fsync(file_descriptor) + + +def _read_claim_state(file_descriptor: int) -> dict: + try: + os.lseek(file_descriptor, 0, os.SEEK_SET) + raw = os.read(file_descriptor, 16 * 1024) + value = json.loads(raw.decode("utf-8")) if raw else {} + return value if isinstance(value, dict) else {"raw": value} + except Exception: + return {"state": "unknown"} diff --git a/core/data_manager/manager.py b/core/data_manager/manager.py index 8159fdf..0528e06 100644 --- a/core/data_manager/manager.py +++ b/core/data_manager/manager.py @@ -1,8 +1,10 @@ +import json import logging import time from typing import Optional, List, Dict, Any from core.data_manager.contracts import EnvironmentQuery, SessionContext, SessionStepQuery +from core.data_manager.image_processing import MessageImageProcessor from core.data_manager.strategy.base_strategy import StorageStrategy from core.data_manager.strategy_factory import StorageFactory @@ -23,6 +25,7 @@ def __init__( self.job_id = job_id self.storage_type = storage_type self._strategy: StorageStrategy + self._image_processor: Optional[MessageImageProcessor] = None try: log.debug("Initializing DataManager with strategy: %r", storage_type) @@ -47,12 +50,26 @@ def __init__( async def init(self) -> None: """Initialize the storage strategy""" await self._strategy.init() + if self.storage_type == "cloud" and self._image_processor is None: + self._image_processor = MessageImageProcessor( + job_id=self.job_id, + uploader=getattr(self._strategy, "s3_uploader", None), + ) @property def backend_name(self) -> str: """Diagnostic backend name without exposing the DAO instance.""" return self._strategy.__class__.__name__ + @property + def storage_identity(self) -> str: + """Stable identity used to scope cross-process initialization claims.""" + return ":".join(( + self.storage_type, + str(getattr(self._strategy, "db_url", "") or ""), + str(getattr(self._strategy, "env_config_table", "") or ""), + )) + async def add_environment( self, env_name: str, @@ -94,7 +111,16 @@ async def get_environment_by_env_id(self, env_id: str) -> Optional[Dict]: async def mark_environment_finished(self, env_id: str) -> int: """Mark one environment completed for this job.""" - return await self._strategy.mark_environment_finished(env_id) + updated = await self._strategy.update_environment_rows( + EnvironmentQuery(job_id=self.job_id, env_id=env_id, is_deleted=False), + {"finished": True}, + ) + if updated != 1: + raise RuntimeError( + f"expected one env config for job_id={self.job_id!r} env_id={env_id!r}, " + f"updated={updated}" + ) + return updated async def list_environment_rows( self, @@ -150,11 +176,13 @@ async def delete_session_step_rows( self, *, session_ids: Optional[List[str]] = None, + record_ids: Optional[List[str]] = None, job_id: Optional[str] = None, ) -> int: return await self._strategy.delete_session_step_rows(SessionStepQuery( job_id=job_id or self.job_id, session_ids=tuple(session_ids or ()), + record_ids=tuple(record_ids or ()), )) async def delete_job_rows(self, job_id: Optional[str] = None) -> None: @@ -190,13 +218,14 @@ async def record_step( response: str, step_reward: float, request: Optional[str] = None, + meta_json: Optional[Any] = None, env_state: Optional[str] = None, terminated: bool = False, truncated: bool = False, is_trainable: bool = False, dataset: Optional[Any] = None, reward: Optional[float] = None, - ) -> None: + ) -> Optional[str]: """ Record a single interaction step with full conversation history. @@ -204,43 +233,132 @@ async def record_step( For Cloud: images uploaded to S3, URLs stored in messages """ session.total_reward += float(step_reward or 0.0) - await self._strategy.record_step( - session=session, - step_id=step_id, - messages=messages, - response=response, - step_reward=step_reward, - reward=reward, - request=request, - env_state=env_state, - dataset=dataset, - terminated=terminated, - truncated=truncated, - is_trainable=is_trainable, - ) + metadata = _metadata_object(meta_json if meta_json is not None else env_state) + if dataset is not None: + metadata["dataset"] = dataset + record_ids = await self.insert_session_step_rows([{ + "session_id": session.session_id, + "env_id": session.env_id, + "step_id": step_id, + "env_name": session.env_name, + "llm_model": session.llm_model, + "group_id": session.group_id, + "job_id": session.job_id, + "messages": messages, + "request": request, + "response": response, + "step_reward": step_reward, + "reward": reward, + "meta_json": metadata, + "is_terminal": bool(terminated or truncated), + "is_truncated": bool(truncated), + "is_session_completed": bool(terminated), + # Compatibility behavior; new callers should use insert_session_step_rows. + "is_trainable": False, + }]) + session.message_history = list(messages) + return record_ids[0] if record_ids else None async def record_steps_batch(self, steps: List[Dict[str, Any]]) -> List[Optional[str]]: - """Persist multiple steps using the backend's native bulk API when available.""" + """Compatibility adapter for the former SessionContext-based write API.""" + rows: List[Dict[str, Any]] = [] for step in steps: session = step.get("session") - if isinstance(session, SessionContext): - session.total_reward += float(step.get("step_reward") or 0.0) - return await self._strategy.record_steps_batch(steps) + if not isinstance(session, SessionContext): + rows.append(dict(step)) + continue + session.total_reward += float(step.get("step_reward") or 0.0) + metadata = _metadata_object(step.get("meta_json", step.get("env_state"))) + if step.get("dataset") is not None: + metadata["dataset"] = step["dataset"] + if isinstance(step.get("provider_meta"), dict): + metadata.update(step["provider_meta"]) + messages = step.get("messages") or [] + session.message_history = list(messages) + rows.append({ + "record_id": step.get("record_id"), + "session_id": session.session_id, + "env_id": session.env_id, + "step_id": step.get("step_id", 0), + "env_name": session.env_name, + "llm_model": session.llm_model, + "group_id": session.group_id, + "job_id": session.job_id, + "messages": messages, + "request": step.get("request"), + "response": step.get("response", ""), + "step_reward": step.get("step_reward", 0.0), + "reward": step.get("reward"), + "meta_json": metadata, + "is_terminal": bool(step.get("terminated") or step.get("truncated")), + "is_truncated": bool(step.get("truncated")), + "is_session_completed": bool(step.get("terminated")), + "is_trainable": False, + }) + return list(await self.insert_session_step_rows(rows)) + + async def insert_session_step_rows( + self, + rows: List[Dict[str, Any]], + ) -> List[str]: + """Insert fully constructed logical rows through the configured DAO.""" + if self.storage_type == "cloud" and self._image_processor is None: + await self.init() + normalized: List[Dict[str, Any]] = [] + for row in rows: + item = dict(row) + item["job_id"] = str(item.get("job_id") or self.job_id) + item["meta_json"] = _metadata_object(item.get("meta_json")) + normalized.append(item) + if self._image_processor is not None: + normalized = await self._image_processor.process_rows(normalized) + return await self._strategy.insert_session_step_rows(normalized) async def mark_records_completed(self, record_ids: List[str]) -> int: - """Mark known records completed without a latest-row lookup.""" - return await self._strategy.mark_records_completed(record_ids) + """Compatibility wrapper for exact-ID lifecycle updates.""" + return await self.update_session_step_rows( + record_ids=record_ids, + updates={"is_session_completed": True, "is_terminal": True}, + ) async def list_session_steps( self, session_id: str, *, + job_id: Optional[str] = None, checkout_latest: bool = False, ) -> List[Dict[str, Any]]: """Return persisted rows for one session in trajectory order.""" - return await self._strategy.list_session_steps( - session_id, + return await self._strategy.list_session_step_rows(SessionStepQuery( + job_id=job_id or self.job_id or None, + session_id=session_id, checkout_latest=checkout_latest, + )) + + async def update_session_step_rows( + self, + *, + updates: Dict[str, Any], + job_id: Optional[str] = None, + session_id: Optional[str] = None, + session_ids: Optional[List[str]] = None, + record_id: Optional[str] = None, + record_ids: Optional[List[str]] = None, + step_id: Optional[int] = None, + llm_model: Optional[str] = None, + ) -> int: + """Update rows using explicit caller-owned selection and values.""" + return await self._strategy.update_session_step_rows( + SessionStepQuery( + job_id=job_id, + session_id=session_id, + session_ids=tuple(session_ids or ()), + record_id=record_id, + record_ids=tuple(record_ids or ()), + step_id=step_id, + llm_model=llm_model, + ), + dict(updates), ) async def record_evaluation_summary( @@ -251,14 +369,27 @@ async def record_evaluation_summary( env_state: str, truncated: bool = False, ) -> int: - """Persist a non-trainable evaluation summary row.""" - return await self._strategy.record_evaluation_summary( - session_id=session_id, - step_id=step_id, - reward=reward, - env_state=env_state, - truncated=truncated, - ) + """Compatibility wrapper; Evaluator now constructs summary rows directly.""" + record_ids = await self.insert_session_step_rows([{ + "session_id": session_id, + "env_id": session_id, + "step_id": step_id, + "env_name": "gateway", + "llm_model": "", + "group_id": "", + "job_id": self.job_id, + "messages": [], + "request": None, + "response": "", + "step_reward": reward, + "reward": reward, + "meta_json": _metadata_object(env_state), + "is_terminal": True, + "is_truncated": truncated, + "is_session_completed": True, + "is_trainable": False, + }]) + return len(record_ids) async def update_session_step( self, @@ -271,10 +402,13 @@ async def update_session_step( Returns the number of matched records. """ - return await self._strategy.update_session_step( - session_id=session_id, - step_id=step_id, - updates=updates, + return await self._strategy.update_session_step_rows( + SessionStepQuery( + job_id=self.job_id or None, + session_id=session_id, + step_id=step_id, + ), + dict(updates), ) async def patch_session_environment( @@ -286,11 +420,12 @@ async def patch_session_environment( group_id: Optional[str] = None, ) -> int: """Patch persisted session rows after environment metadata is known.""" - return await self._strategy.patch_session_environment( - session_id=session_id, - job_id=job_id, - env_name=env_name, - group_id=group_id, + updates: Dict[str, Any] = {"job_id": job_id, "env_name": env_name} + if group_id is not None: + updates["group_id"] = group_id + return await self._strategy.update_session_step_rows( + SessionStepQuery(session_id=session_id), + updates, ) async def mark_latest_session_completed( @@ -308,16 +443,33 @@ async def mark_latest_session_completed( Returns the number of updated records. """ - return await self._strategy.mark_latest_session_completed( - session_id=session_id, - llm_model=llm_model, - is_session_completed=is_session_completed, - is_terminal=is_terminal, + rows = await self.list_session_steps(session_id, checkout_latest=True) + if llm_model: + rows = [row for row in rows if row.get("llm_model") == llm_model] + if not rows: + return 0 + latest = max(rows, key=lambda row: ( + int(row.get("step_id") or 0), + str(row.get("created_at") or ""), + str(row.get("record_id") or row.get("id") or ""), + )) + completed = bool(is_session_completed) + updates: Dict[str, Any] = { + "is_session_completed": completed, + "is_terminal": completed if is_terminal is None else bool(is_terminal), + } + if not completed: + updates.update(step_reward=0.0, reward=None) + return await self.update_session_step_rows( + job_id=str(latest.get("job_id") or "") or None, + record_id=str(latest.get("record_id") or latest.get("id")), + updates=updates, ) async def close(self) -> None: """Close the storage strategy""" await self._strategy.close() + self._image_processor = None async def fetch_done_steps_with_context( self, @@ -341,3 +493,23 @@ def buffer_stats(self) -> Optional[dict]: if hasattr(self._strategy, 'buffer_stats'): return self._strategy.buffer_stats return None + + +def _metadata_object(value: Any) -> Dict[str, Any]: + if isinstance(value, dict): + metadata = dict(value) + elif not value: + metadata = {} + else: + try: + parsed = json.loads(value) + except Exception: + metadata = {"legacy_metadata": value} + else: + metadata = parsed if isinstance(parsed, dict) else {"legacy_metadata": parsed} + legacy_state = metadata.pop("env_state", None) + if legacy_state is None: + return metadata + legacy_metadata = _metadata_object(legacy_state) + legacy_metadata.update(metadata) + return legacy_metadata diff --git a/core/data_manager/models.py b/core/data_manager/models.py index b111299..7427d87 100644 --- a/core/data_manager/models.py +++ b/core/data_manager/models.py @@ -45,6 +45,11 @@ class SessionStep(Model): response, step_reward, total_reward, terminated, is_session_completed """ id = fields.IntField(pk=True, autoincrement=True) + record_id = fields.CharField( + max_length=64, + unique=True, + description="Backend-neutral persisted record identifier", + ) session_id = fields.CharField( max_length=36, description="Session identifier (equals env_id for compatibility)" @@ -82,7 +87,10 @@ class SessionStep(Model): ) # State tracking - env_state = fields.TextField(null=True, description="JSON: Environment state") + meta_json = fields.TextField( + null=True, + description="JSON: Unified trajectory and provider metadata", + ) is_terminal = fields.BooleanField(default=False, description="Whether this step is terminal") is_truncated = fields.BooleanField(default=False, description="Whether this step is truncated") is_session_completed = fields.BooleanField(default=False, description="Whether the session is completed (final record)") diff --git a/core/data_manager/strategy/base_strategy.py b/core/data_manager/strategy/base_strategy.py index 90b0ed4..e3e6459 100644 --- a/core/data_manager/strategy/base_strategy.py +++ b/core/data_manager/strategy/base_strategy.py @@ -15,10 +15,8 @@ class StorageStrategy(ABC): - Table 1 (JobEnvironment): job_id + env_id mapping with env config - Table 2 (SessionStep): session_id + step_id with full conversation history - Key design principles: - - session_id equals env_id for compatibility - - Each step record contains full conversation history up to that point - - Final reward remains null until the evaluator completes the session + Callers provide complete row values. Implementations must not infer workflow + state, classify events, construct evaluation rows, or change trainability. """ @abstractmethod @@ -60,207 +58,60 @@ async def get_all_environments(self, job_id: Optional[str] = None) -> List[Dict] """ pass + @abstractmethod async def get_environment_by_env_id(self, env_id: str) -> Optional[Dict[str, Any]]: - """ - Retrieve one environment config by env_id. - - Storage backends can override this with an indexed lookup. The default - implementation keeps older strategies compatible by scanning - get_all_environments(). - """ - for env in await self.get_all_environments(): - if not isinstance(env, dict): - continue - if str(env.get("env_id") or "") == str(env_id): - if bool(env.get("is_deleted", False)): - continue - return env - return None + """Retrieve one active environment by env_id.""" + pass + @abstractmethod async def list_environment_rows(self, query: EnvironmentQuery) -> List[Dict[str, Any]]: """List environment rows using backend-neutral filters.""" - rows = await self.get_all_environments(query.job_id) - filtered = [] - for index, row in enumerate(rows, start=1): - item = dict(row) - item.setdefault("id", index) - if query.env_id and str(item.get("env_id") or "") != query.env_id: - continue - if int(item.get("id") or 0) <= query.after_id: - continue - if query.finished is not None and bool(item.get("finished", False)) != query.finished: - continue - if query.is_deleted is not None and bool(item.get("is_deleted", False)) != query.is_deleted: - continue - filtered.append(item) - filtered.sort(key=lambda item: int(item.get("id") or 0)) - start = max(0, query.offset) - end = None if query.limit is None else start + max(0, query.limit) - return filtered[start:end] + pass + @abstractmethod async def insert_environment_rows(self, rows: List[Dict[str, Any]]) -> List[str]: """Insert environment rows and return their env_ids.""" - env_ids = [] - for row in rows: - env_ids.append(await self.add_environment( - job_id=str(row.get("job_id") or ""), - env_name=str(row.get("env_name") or ""), - env_params=dict(row.get("env_params") or {}), - image=str(row.get("image") or ""), - group_id=str(row.get("group_id") or ""), - )) - return env_ids + pass + @abstractmethod async def update_environment_rows( self, query: EnvironmentQuery, updates: Dict[str, Any], ) -> int: - raise NotImplementedError + pass + @abstractmethod async def delete_session_step_rows(self, query: SessionStepQuery) -> int: - raise NotImplementedError - - async def delete_job_rows(self, job_id: str) -> None: - raise NotImplementedError + pass @abstractmethod - async def mark_environment_finished(self, env_id: str) -> int: - """Mark one environment completed after its full workflow succeeds.""" + async def delete_job_rows(self, job_id: str) -> None: pass - + @abstractmethod - async def record_step( + async def insert_session_step_rows( self, - session: SessionContext, - step_id: int, - messages: List[Dict], - response: str, - step_reward: float, - request: Optional[str] = None, - env_state: Optional[str] = None, - terminated: bool = False, - truncated: bool = False, - is_trainable: bool = False, - dataset: Optional[Any] = None, - reward: Optional[float] = None, - ) -> None: - """ - Record a single interaction step with full conversation history. - - The messages parameter should contain the FULL conversation history - up to and including the current user message (but NOT the assistant response, - which is stored in the response parameter). - - For SQLite: stores base64 images directly in messages JSON - For Cloud: uploads binary images to S3, stores URLs in messages JSON - - Args: - session: Session context object - step_id: Step number (1-indexed) - messages: Full conversation history (list of {role, content} dicts) - response: LLM response/action for this step - step_reward: Reward for this step - request: Optional provider-bound request JSON for this step - env_state: Optional JSON string of environment state - terminated: Whether this is a terminal step - truncated: Whether episode was truncated - is_trainable: Whether this step is eligible for training - dataset: Optional task dataset stored with the current step - """ + rows: List[Dict[str, Any]], + ) -> List[str]: + """Insert fully constructed session-step rows without applying workflow policy.""" pass - async def record_steps_batch(self, steps: List[Dict[str, Any]]) -> List[Optional[str]]: - """Persist multiple steps. - - Backends may override this to use a native bulk API. The default keeps - existing strategies compatible and preserves input ordering. - """ - record_ids: List[Optional[str]] = [] - for step in steps: - await self.record_step(**step) - record_ids.append(None) - return record_ids - - async def mark_records_completed(self, record_ids: List[str]) -> int: - """Mark known records completed without discovering them by a table scan.""" - return 0 - - async def list_session_steps( - self, - session_id: str, - *, - checkout_latest: bool = False, - ) -> List[Dict[str, Any]]: - """ - Return persisted rows for one session in trajectory order. - - Storage backends may override this when callers such as evaluators need - storage-agnostic access to the completed trajectory. Cloud callers can - request the latest table version when another process performed writes. - The default keeps older/custom strategies compatible. - """ - return [] - - async def record_evaluation_summary( - self, - session_id: str, - step_id: int, - reward: float, - env_state: str, - truncated: bool = False, - ) -> int: - """Persist a non-trainable evaluation result when no trajectory row exists.""" - return 0 - @abstractmethod - async def update_session_step( + async def list_session_step_rows( self, - session_id: str, - step_id: int, - updates: Dict[str, Any], - ) -> int: - """ - Update fields for one persisted session step identified by session_id and step_id. - - Returns: - Number of matched records. - """ + query: SessionStepQuery, + ) -> List[Dict[str, Any]]: + """List raw session-step rows using backend-neutral filters.""" pass - async def patch_session_environment( - self, - session_id: str, - *, - job_id: str, - env_name: str, - group_id: Optional[str] = None, - ) -> int: - """ - Patch persisted session rows after their environment metadata is known. - - Backends that cannot efficiently or safely rewrite existing rows may - leave the default no-op behavior. - """ - return 0 - @abstractmethod - async def mark_latest_session_completed( + async def update_session_step_rows( self, - session_id: str, - llm_model: Optional[str] = None, - *, - is_session_completed: bool = True, - is_terminal: Optional[bool] = None, + query: SessionStepQuery, + updates: Dict[str, Any], ) -> int: - """ - Set the completion state of the latest persisted trajectory row. - When llm_model is provided, only rows for that model are considered. - is_terminal can seal a row before evaluator completion. - - Returns: - Number of updated records. - """ + """Update matching session-step rows with caller-decided field values.""" pass @abstractmethod diff --git a/core/data_manager/strategy/cloud_strategy_impl.py b/core/data_manager/strategy/cloud_strategy_impl.py index 6b3cd69..36ad810 100644 --- a/core/data_manager/strategy/cloud_strategy_impl.py +++ b/core/data_manager/strategy/cloud_strategy_impl.py @@ -5,13 +5,13 @@ import os import time import uuid -import re import base64 import tempfile from typing import List, Dict, Optional, Any, Set from datetime import date -from core.data_manager.contracts import EnvironmentQuery, SessionContext, SessionStepQuery +from core.data_manager.cloud_delete_guard import CloudDeleteGuard +from core.data_manager.contracts import EnvironmentQuery, SessionStepQuery from core.data_manager.strategy.base_strategy import StorageStrategy from core.perf_trace import PerfTrace @@ -21,8 +21,6 @@ GatewayConfig = None EnvConfigManager = None LandingRecord = None -ChatMessage = None -ContentItem = None generate_deterministic_id = None S3Uploader = None S3Downloader = None @@ -41,8 +39,6 @@ def _load_wt_sdk() -> None: global GatewayConfig global EnvConfigManager global LandingRecord - global ChatMessage - global ContentItem global generate_deterministic_id global S3Uploader global S3Downloader @@ -88,8 +84,6 @@ def _load_wt_sdk() -> None: GatewayConfig = GatewayConfig or wt_sdk.GatewayConfig EnvConfigManager = EnvConfigManager or wt_sdk.EnvConfigManager LandingRecord = LandingRecord or wt_sdk_models.LandingRecord - ChatMessage = ChatMessage or getattr(wt_sdk_models, "ChatMessage", None) - ContentItem = ContentItem or getattr(wt_sdk_models, "ContentItem", None) generate_deterministic_id = generate_deterministic_id or wt_sdk_utils.generate_deterministic_id S3Uploader = S3Uploader or wt_sdk_utils.S3Uploader S3Downloader = S3Downloader or wt_sdk_utils.S3Downloader @@ -118,8 +112,6 @@ def _install_mock_wt_sdk_fallbacks() -> None: """Provide tiny SDK-like objects for tests that monkeypatch cloud clients.""" global GatewayConfig global LandingRecord - global ChatMessage - global ContentItem global generate_deterministic_id global S3Uploader global S3Downloader @@ -140,11 +132,9 @@ def __init__( self, db_uri: str = "", landing_table: str = "", - serving_table: str = "", ): self.db_uri = db_uri self.landing_table = landing_table - self.serving_table = serving_table class _S3Config: def to_storage_options(self) -> Dict[str, Any]: @@ -169,21 +159,11 @@ def download_file(self, image_path: str, local_path: str) -> None: GatewayConfig = GatewayConfig or _GatewayConfig LandingRecord = LandingRecord or _Model - ChatMessage = ChatMessage or _Model - ContentItem = ContentItem or _Model generate_deterministic_id = generate_deterministic_id or _deterministic_id S3Uploader = S3Uploader or _S3Uploader S3Downloader = S3Downloader or _S3Downloader -# Retry configuration -MAX_UPLOAD_RETRIES = 3 -RETRY_BACKOFF_BASE = 1.0 -NON_TRAJECTORY_EVENT_TYPES = { - "gateway_session_close", - "episode_summary", - "evaluation_summary", -} CLOUD_DATASET_TYPE = "RL" @@ -205,17 +185,17 @@ def _escape_sql_literal(value: str) -> str: return value.replace("'", "''") -def _cloud_env_state_from_meta(meta_json: Any) -> Dict[str, Any]: +def _meta_json_object(meta_json: Any) -> Dict[str, Any]: + """Return canonical metadata and flatten records written by the legacy schema.""" meta = _json_value(meta_json, {}) if not isinstance(meta, dict): return {} - env_state = _json_value(meta.get("env_state"), {}) - return env_state if isinstance(env_state, dict) else {} - - -def _is_trajectory_meta_json(meta_json: Any) -> bool: - event_type = _cloud_env_state_from_meta(meta_json).get("event_type") - return event_type not in NON_TRAJECTORY_EVENT_TYPES + meta = dict(meta) + legacy_state = _json_value(meta.pop("env_state", None), {}) + if isinstance(legacy_state, dict): + legacy_state.update(meta) + return legacy_state + return meta def _truthy_bool(value: Any) -> bool: @@ -303,15 +283,10 @@ def extract_text(payload: Any) -> str: class CloudStrategy(StorageStrategy): """ - Cloud storage strategy: - - Table 1 (S3): Environment configs stored via EnvConfigManager - - Table 2 (LandingTable): Session steps with full conversation history - - Image handling: - - Extract base64 images from messages - - Upload binary to S3 with retry logic - - On failure: store locally as fallback - - Store S3 URLs (or local paths) in messages JSON + Cloud DAO for environment config rows and LandingTable session-step rows. + + Callers provide complete logical rows. Image externalization is deliberately + kept at the surrounding data-manager boundary, before rows reach this DAO. """ def __init__( @@ -322,21 +297,25 @@ def __init__( buffer_size: int = 1, flush_interval: float = 1.0, landing_table: Optional[str] = None, - serving_table: Optional[str] = None, env_config_table: str = "evaluation_env_config", dldb_model: Optional[str] = None, enable_dldb_timing_logs: bool = False, dldb_metrics_log_path: Optional[str] = None, + confirm_cloud_delete_job_id: str = "", + confirm_production: bool = False, + cloud_delete_archive_dir: str = "", ): self.db_url = str(db_url or "").strip() self.job_id = job_id self.initialized = False self.landing_table = str(landing_table or "").strip() or None - self.serving_table = str(serving_table or "").strip() or None self.env_config_table = env_config_table self.dldb_model = dldb_model self.enable_dldb_timing_logs = enable_dldb_timing_logs self.dldb_metrics_log_path = dldb_metrics_log_path + self.confirm_cloud_delete_job_id = str(confirm_cloud_delete_job_id or "").strip() + self.confirm_production = bool(confirm_production) + self.cloud_delete_archive_dir = str(cloud_delete_archive_dir or "").strip() self.client: Any = None self.env_manager: Any = None @@ -359,12 +338,8 @@ def __init__( # In-memory caches self._env_configs: Dict[str, Dict] = {} - self._sessions: Dict[str, SessionContext] = {} self._record_job_ids: Dict[str, str] = {} - # Local fallback directory for failed uploads - self._local_fallback_dir = "saved_images" - async def init(self): """Initialize cloud clients""" if self.initialized: @@ -383,17 +358,14 @@ async def init(self): config.tables.db_uri = self.db_url if self.landing_table: config.tables.landing_table = self.landing_table - if self.serving_table: - config.tables.serving_table = self.serving_table self.landing_table = config.tables.landing_table - self.serving_table = config.tables.serving_table + self.db_url = str(config.tables.db_uri or self.db_url) try: self.client = WTGatewayClient(config) log.debug( - "CloudStrategy initialized with landing_table=%s serving_table=%s db_uri=%s", + "CloudStrategy initialized with landing_table=%s db_uri=%s", config.tables.landing_table, - config.tables.serving_table, config.tables.db_uri, ) except Exception as e: @@ -541,9 +513,11 @@ async def list_environment_rows(self, query: EnvironmentQuery) -> List[Dict[str, clauses.append(f"job_id = '{_escape_sql_literal(query.job_id)}'") if query.env_id: clauses.append(f"env_id = '{_escape_sql_literal(query.env_id)}'") + if query.after_id: + clauses.append(f"id > {int(query.after_id)}") filter_query = " AND ".join(clauses) or None page_size = max(100, query.limit or 1000) - effective_offset = max(0, query.offset, query.after_id) + effective_offset = max(0, query.offset) normalized: List[Dict[str, Any]] = [] scanned = 0 while True: @@ -555,9 +529,13 @@ async def list_environment_rows(self, query: EnvironmentQuery) -> List[Dict[str, ) if not page: break - for index, config in enumerate(page, start=effective_offset + scanned + 1): + for config in page: row = self._normalize_env_config(config) - row.setdefault("id", index) + if row.get("id") is None: + raise RuntimeError( + "cloud environment pagination requires EnvConfigManager " + "to return the physical id column" + ) if query.finished is not None and _truthy_bool(row.get("finished")) != query.finished: continue if query.is_deleted is not None and _truthy_bool(row.get("is_deleted")) != query.is_deleted: @@ -618,33 +596,86 @@ async def update_environment_rows( updated += 1 return updated + async def _preflight_destructive_delete( + self, + *, + operation: str, + job_id: str, + landing_filter: str, + environment_rows: Optional[List[Dict[str, Any]]] = None, + ) -> List[Dict[str, Any]]: + guard = CloudDeleteGuard( + client=self.client, + db_uri=self.db_url, + landing_table=str(self.landing_table or ""), + confirmed_job_id=self.confirm_cloud_delete_job_id, + confirm_production=self.confirm_production, + archive_dir=self.cloud_delete_archive_dir, + ) + return await guard.preflight( + operation=operation, + job_id=job_id, + landing_filter=landing_filter, + environment_rows=environment_rows, + ) + async def delete_session_step_rows(self, query: SessionStepQuery) -> int: await self.init() + job_id = str(query.job_id or "").strip() + if query.record_id or query.record_ids: + record_ids = tuple(dict.fromkeys( + item for item in (query.record_id,) + query.record_ids if item + )) + quoted = ", ".join( + f"'{_escape_sql_literal(item)}'" for item in record_ids if item + ) + clauses = [f"id IN ({quoted})"] + if job_id: + clauses.insert(0, f"job_id = '{_escape_sql_literal(job_id)}'") + landing_filter = " AND ".join(clauses) + rows = await self._preflight_destructive_delete( + operation="delete_session_step_rows", + job_id=job_id, + landing_filter=landing_filter, + ) + await asyncio.to_thread(self.client.delete_landing, landing_filter) + return len(rows) session_ids = list(query.session_ids) if query.session_id: session_ids.append(query.session_id) - if not session_ids: - clauses = [] - if query.job_id: - clauses.append(f"job_id = '{_escape_sql_literal(query.job_id)}'") - if not clauses: - raise ValueError("job_id or session_ids is required for cloud deletion") - await asyncio.to_thread(self.client.delete_landing, " AND ".join(clauses)) - return 1 - for session_id in dict.fromkeys(session_ids): - clauses = [] - if query.job_id: - clauses.append(f"job_id = '{_escape_sql_literal(query.job_id)}'") - clauses.append(f"session_id = '{_escape_sql_literal(session_id)}'") - await asyncio.to_thread(self.client.delete_landing, " AND ".join(clauses)) - return len(set(session_ids)) + unique_session_ids = tuple(dict.fromkeys(item for item in session_ids if item)) + clauses = [] + if job_id: + clauses.append(f"job_id = '{_escape_sql_literal(job_id)}'") + if unique_session_ids: + quoted_sessions = ", ".join( + f"'{_escape_sql_literal(item)}'" for item in unique_session_ids + ) + clauses.append(f"session_id IN ({quoted_sessions})") + if not clauses: + raise ValueError("job_id or session_ids is required for cloud deletion") + landing_filter = " AND ".join(clauses) + rows = await self._preflight_destructive_delete( + operation="delete_session_step_rows", + job_id=job_id, + landing_filter=landing_filter, + ) + await asyncio.to_thread(self.client.delete_landing, landing_filter) + return len(rows) async def delete_job_rows(self, job_id: str) -> None: await self.init() rows = await self.list_environment_rows(EnvironmentQuery(job_id=job_id)) + landing_filter = f"job_id = '{_escape_sql_literal(job_id)}'" + await self._preflight_destructive_delete( + operation="delete_job_rows", + job_id=job_id, + landing_filter=landing_filter, + environment_rows=rows, + ) await asyncio.to_thread( self.client.delete_landing, - f"job_id = '{_escape_sql_literal(job_id)}'", + landing_filter, ) for row in rows: env_id = str(row.get("env_id") or "") @@ -652,630 +683,229 @@ async def delete_job_rows(self, job_id: str) -> None: raise RuntimeError(f"failed to delete cloud env config env_id={env_id}") self._env_configs.pop(env_id, None) - async def mark_environment_finished(self, env_id: str) -> int: - """Mark one cloud environment for the current job as finished.""" - await self.init() - config = await self.get_environment_by_env_id(env_id) - if config is None or str(config.get("job_id") or "") != str(self.job_id): - raise RuntimeError( - f"env config does not belong to job_id={self.job_id!r}: env_id={env_id!r}" - ) - updated = await asyncio.to_thread( - self.env_manager.update_config, - env_id, - {"finished": True}, - ) - if not updated: - raise RuntimeError(f"failed to mark cloud env config finished: env_id={env_id}") - self._env_configs[env_id]["finished"] = True - return 1 - - def get_env_configs( - self, - limit: Optional[int] = None, - offset: int = 0, - job_id: Optional[str] = None, - ) -> List[Dict]: - """Synchronous scheduler reader for cached cloud environment configs.""" - configs = [ - row for row in self._list_env_configs(job_id=job_id) - if not _truthy_bool(row.get("finished", False)) - ] - start = max(0, int(offset or 0)) - if limit is None: - return configs[start:] - end = start + max(0, int(limit)) - return configs[start:end] - - def _list_env_configs(self, job_id: Optional[str] = None) -> List[Dict]: - rows: List[Dict] = [] - for index, config in enumerate(self._env_configs.values(), start=1): - row = self._normalize_env_config(config) - row.setdefault("id", index) - if job_id and str(row.get("job_id") or "") != str(job_id): - continue - rows.append(row) - return rows - - def _normalize_env_config(self, config: Dict[str, Any]) -> Dict[str, Any]: - row = dict(config) - if "image" not in row and "env_image" in row: - row["image"] = row.get("env_image") - env_params = row.get("env_params") - if isinstance(env_params, str): - try: - parsed = json.loads(env_params) - row["env_params"] = parsed if isinstance(parsed, dict) else {} - except Exception: - row["env_params"] = {} - return row - - async def create_session( - self, - env_id: str, - env_name: str, - llm_model: str, - group_id: str = "", - job_id: str = "" - ) -> SessionContext: - """Create session context (in-memory only)""" - session = SessionContext( - session_id=env_id, - env_id=env_id, - env_name=env_name, - llm_model=llm_model, - group_id=group_id, - job_id=job_id or self.job_id, - total_reward=0.0, - start_time=time.perf_counter(), - message_history=[] - ) - - self._sessions[session.session_id] = session - log.debug("Created cloud session: %s", session.session_id) - return session - - async def record_step( - self, - session: SessionContext, - step_id: int, - messages: List[Dict], - response: str, - step_reward: float, - request: Optional[str] = None, - env_state: Optional[str] = None, - terminated: bool = False, - truncated: bool = False, - is_trainable: bool = False, - dataset: Optional[Any] = None, - reward: Optional[float] = None, - ): - """ - Record step to cloud LandingTable. - Images are extracted, uploaded to S3 (with retry), and URLs stored. - """ - await self.init() - - record, record_id = await self._build_step_record( - session=session, - step_id=step_id, - messages=messages, - response=response, - step_reward=step_reward, - reward=reward, - request=request, - env_state=env_state, - dataset=dataset, - terminated=terminated, - truncated=truncated, - is_trainable=is_trainable, - ) - - if self._enable_buffer: - await self._buffer_record(record) - else: - try: - await self._timed_db_call( - "ingest_landing", - self.client.ingest_landing, - record, - trace_context={"session_id": session.session_id, "step_id": step_id}, - ) - log.debug("Step %d recorded to cloud: %s", step_id, record_id) - except Exception as e: - log.error("Failed to ingest step %d: %s", step_id, e) - raise - - if record_id and session.job_id: - self._record_job_ids[record_id] = str(session.job_id) - return record_id - - async def record_steps_batch(self, steps: List[Dict[str, Any]]) -> List[Optional[str]]: - """Build step records in order and persist them with one cloud batch call.""" - await self.init() - if not steps: - return [] - - records: List[Any] = [] - record_ids: List[Optional[str]] = [] - for step in steps: - record, record_id = await self._build_step_record(**step) - records.append(record) - record_ids.append(record_id) - - if self._enable_buffer: - await self._buffer_records(records) - else: - try: - await self._timed_db_call( - "ingest_landing_batch", - self.client.ingest_landing_batch, - records, - trace_context={"record_count": len(records)}, - ) - log.debug("Recorded %d steps to cloud in one batch", len(records)) - except Exception as e: - log.error("Failed to ingest %d cloud steps as a batch: %s", len(records), e) - raise - - for record, record_id in zip(records, record_ids): - job_id = getattr(record, "job_id", None) - if record_id and job_id: - self._record_job_ids[record_id] = str(job_id) - return record_ids - - async def mark_records_completed(self, record_ids: List[str]) -> int: - """Mark known landing record IDs completed in their associated HASH buckets.""" - await self.init() - unique_ids = list(dict.fromkeys(str(record_id) for record_id in record_ids if record_id)) - if not unique_ids: - return 0 - if self._enable_buffer: - await self._flush_records() - - ids_by_job: Dict[str, List[str]] = {} - inferred_ids: List[str] = [] - missing_job_ids: List[str] = [] - for record_id in unique_ids: - job_id = self._record_job_ids.get(record_id) - if not job_id and self.job_id: - job_id = str(self.job_id) - inferred_ids.append(record_id) - if not job_id: - missing_job_ids.append(record_id) - continue - ids_by_job.setdefault(job_id, []).append(record_id) - - if inferred_ids: - log.warning( - "Record-to-job association unavailable for %d landing records; " - "falling back to configured job_id=%s", - len(inferred_ids), - self.job_id, - ) - if missing_job_ids: - log.error( - "Cannot mark %d landing records completed without job_id; " - "refusing an all-bucket HASH update", - len(missing_job_ids), - ) - raise ValueError( - "job_id is required to mark landing records completed without " - "scanning all HASH buckets" - ) - - for job_id, job_record_ids in ids_by_job.items(): - quoted_ids = ", ".join( - f"'{_escape_sql_literal(record_id)}'" - for record_id in job_record_ids - ) - filter_query = ( - f"job_id = '{_escape_sql_literal(job_id)}' " - f"AND id IN ({quoted_ids})" - ) - await self._timed_db_call( - "update_landing", - self.client.update_landing, - filter_query, - { - "is_session_completed": True, - "is_terminal": True, - }, - partition=job_id, - trace_context={ - "job_id": job_id, - "record_count": len(job_record_ids), - }, - ) - for record_id in job_record_ids: - self._record_job_ids.pop(record_id, None) - - log.debug("Marked %d known cloud records completed", len(unique_ids)) - return len(unique_ids) - - async def _build_step_record( - self, - *, - session: SessionContext, - step_id: int, - messages: Any, - response: Any, - step_reward: float, - reward: Optional[float] = None, - request: Optional[str] = None, - env_state: Optional[str] = None, - terminated: bool = False, - truncated: bool = False, - is_trainable: bool = False, - dataset: Optional[Any] = None, - provider_meta: Optional[Dict[str, Any]] = None, - ) -> tuple[Any, str]: - # Keep the real per-session job_id available for later partitioned reads - # and updates, including sessions created by DataManager itself. - self._sessions[session.session_id] = session - env_key = f"{session.env_name}_{session.env_id}" - - # Optimization: session.message_history already holds previously processed - # messages with S3 URLs substituted for base64. Reuse that prefix and only - # process images in the *new* messages appended since the last step, avoiding - # redundant re-uploads of the same images on every cumulative call. - if messages is None: - full_messages = None - session.message_history = [] - else: - if not isinstance(messages, list): - raise ValueError("messages must be a list or None") - prev_count = len(session.message_history) - if prev_count > 0 and len(messages) >= prev_count: - new_messages = messages[prev_count:] - new_processed, _ = await self._process_images( - new_messages, env_key, step_id - ) - full_messages = list(session.message_history) + list(new_processed) - else: - # First step or unexpected message count — process everything normally. - full_messages, _ = await self._process_images( - messages, env_key, step_id - ) - session.message_history = full_messages - - # Generate deterministic record ID - record_id = generate_deterministic_id({ - "session_id": session.session_id, - "step_id": step_id, - "llm_model": session.llm_model, - "env_name": session.env_name - }) - - meta_json = { - "source": "AIEvoBox", - "group_id": session.group_id, - "request": request, - "env_state": env_state, - } - if dataset is not None: - meta_json["dataset"] = dataset - if provider_meta: - meta_json.update(provider_meta) - - # Create LandingRecord + def _landing_record_from_row(self, row: Dict[str, Any]) -> tuple[Any, str]: + record_id = str(row.get("record_id") or generate_deterministic_id({ + "job_id": row.get("job_id"), + "session_id": row.get("session_id"), + "step_id": row.get("step_id"), + "llm_model": row.get("llm_model"), + })) + meta_json = _meta_json_object(row.get("meta_json")) + meta_json.setdefault("source", "AIEvoBox") + if row.get("group_id") not in (None, ""): + meta_json["group_id"] = row["group_id"] + if row.get("request") is not None: + meta_json.setdefault("request", row["request"]) record = LandingRecord( dataset_type=CLOUD_DATASET_TYPE, dt=date.today().isoformat(), id=record_id, - session_id=session.session_id, - step_id=step_id, - env_id=session.env_id, - job_id=session.job_id, - created_at=int(time.time()), - step_reward=step_reward, - reward=reward, - messages=self._messages_to_landing_value(full_messages), - response=self._response_to_landing_value(response), + session_id=str(row.get("session_id") or ""), + step_id=int(row.get("step_id") or 0), + env_id=str(row.get("env_id") or row.get("session_id") or ""), + job_id=str(row.get("job_id") or self.job_id), + created_at=int(row.get("created_at") or time.time()), + step_reward=float(row.get("step_reward") or 0.0), + reward=row.get("reward"), + messages=self._messages_to_landing_value(row.get("messages", [])), + response=self._response_to_landing_value(row.get("response", "")), ground_truth_answer=None, reference_answer=None, - agent_model=session.llm_model, - env_name=session.env_name, - is_terminal=terminated or truncated, - is_truncated=truncated, - is_session_completed=terminated, - # Training eligibility is assigned by a later, explicit workflow. - # Every newly recorded trajectory step starts non-trainable. - is_trainable=False, - meta_json=json.dumps(meta_json, ensure_ascii=False, default=str) + agent_model=str(row.get("llm_model") or ""), + env_name=str(row.get("env_name") or ""), + is_terminal=bool(row.get("is_terminal", False)), + is_truncated=bool(row.get("is_truncated", False)), + is_session_completed=bool(row.get("is_session_completed", False)), + is_trainable=bool(row.get("is_trainable", False)), + meta_json=json.dumps(meta_json, ensure_ascii=False, default=str), ) - return record, record_id - async def update_session_step( + async def insert_session_step_rows( self, - session_id: str, - step_id: int, - updates: Dict[str, Any], - ) -> int: - """Update one cloud-backed session step by session_id and step_id.""" + rows: List[Dict[str, Any]], + ) -> List[str]: + """Persist caller-constructed rows without applying lifecycle policy.""" await self.init() - + if not rows: + return [] + records: List[Any] = [] + record_ids: List[str] = [] + for row in rows: + record, record_id = self._landing_record_from_row(row) + records.append(record) + record_ids.append(record_id) + job_id = str(row.get("job_id") or self.job_id) + if job_id: + self._record_job_ids[record_id] = job_id if self._enable_buffer: - await self._flush_records() - - filter_query = self._build_session_step_filter(session_id, step_id) - job_id = self._job_id_for_session(session_id) - if not job_id: - log.warning( - "Updating landing session step without job_id; " - "falling back to an all-bucket HASH update: session_id=%s step_id=%s", - session_id, - step_id, + await self._buffer_records(records) + else: + await self._timed_db_call( + "ingest_landing_batch", + self.client.ingest_landing_batch, + records, + trace_context={"record_count": len(records)}, ) - normalized_updates = self._normalize_session_step_updates_for_cloud( - updates, - filter_query=filter_query, - job_id=job_id, - ) - if not normalized_updates: - return 0 - - result = await self._timed_db_call( - "update_landing", - self.client.update_landing, - filter_query, - normalized_updates, - partition=job_id or None, - trace_context={ - "session_id": session_id, - "step_id": step_id, - "field_count": len(normalized_updates), - }, - ) - log.debug( - "Submitted cloud session step update: session_id=%s step_id=%s result=%s", - session_id, - step_id, - result, - ) - return 1 + return record_ids - async def list_session_steps( + async def list_session_step_rows( self, - session_id: str, - *, - checkout_latest: bool = False, + query: SessionStepQuery, ) -> List[Dict[str, Any]]: - """Read one cloud-backed session in deterministic trajectory order.""" await self.init() - if self._enable_buffer: await self._flush_records() + clauses: List[str] = [] + if query.job_id: + clauses.append(f"job_id = '{_escape_sql_literal(query.job_id)}'") + if query.session_id: + clauses.append(f"session_id = '{_escape_sql_literal(query.session_id)}'") + if query.session_ids: + values = ", ".join(f"'{_escape_sql_literal(item)}'" for item in query.session_ids) + clauses.append(f"session_id IN ({values})") + if query.record_id: + clauses.append(f"id = '{_escape_sql_literal(query.record_id)}'") + if query.record_ids: + values = ", ".join(f"'{_escape_sql_literal(item)}'" for item in query.record_ids) + clauses.append(f"id IN ({values})") + if query.step_id is not None: + clauses.append(f"step_id = {int(query.step_id)}") + if query.llm_model: + clauses.append(f"agent_model = '{_escape_sql_literal(query.llm_model)}'") + if query.is_terminal is not None: + clauses.append(f"is_terminal = {str(bool(query.is_terminal))}") + if query.is_trainable is not None: + clauses.append(f"is_trainable = {str(bool(query.is_trainable))}") + if not clauses: + raise ValueError("cloud session-step query requires at least one filter") - job_id = self._job_id_for_session(session_id) - clauses = [] - if job_id: - clauses.append(f"job_id = '{_escape_sql_literal(job_id)}'") - else: - log.warning( - "Reading landing session steps without job_id; " - "falling back to an all-bucket HASH query: session_id=%s", - session_id, - ) - clauses.append(f"session_id = '{_escape_sql_literal(session_id)}'") - query = " AND ".join(clauses) columns = [ - "id", - "session_id", - "step_id", - "env_name", - "agent_model", - "job_id", - "messages", - "response", - "step_reward", - "reward", - "meta_json", - "is_terminal", - "is_truncated", - "is_session_completed", - "is_trainable", + "id", "session_id", "step_id", "env_id", "env_name", "agent_model", + "job_id", "messages", "response", "step_reward", "reward", "meta_json", + "is_terminal", "is_truncated", "is_session_completed", "is_trainable", "created_at", ] cloud_rows = await self._timed_db_call( "filter_landing", self.client.query_data, - filter_query=query, - limit=10000, + filter_query=" AND ".join(clauses), + limit=query.limit or 10000, columns=columns, - partition=job_id or None, - checkout_latest=checkout_latest, + partition=query.job_id or None, + checkout_latest=query.checkout_latest, deserialize_json=True, - trace_context={"session_id": session_id}, + trace_context={"job_id": query.job_id, "session_id": query.session_id}, ) - if not cloud_rows: - return [] - rows: List[Dict[str, Any]] = [] - for cloud_row in cloud_rows: - row = {key: cloud_row.get(key) for key in columns} - row["messages"] = _json_value(row.get("messages"), []) - row["response"] = _json_value( - row.get("response"), - row.get("response"), - ) - meta = _json_value(row.pop("meta_json", None), {}) - if not isinstance(meta, dict): - meta = {} + for cloud_row in cloud_rows or []: + meta_json = _meta_json_object(cloud_row.get("meta_json")) + row = {key: cloud_row.get(key) for key in columns if key != "meta_json"} + row["record_id"] = row.get("id") row["llm_model"] = row.pop("agent_model", None) - env_state = _json_value(meta.get("env_state"), {}) - row["env_state"] = env_state if isinstance(env_state, dict) else {} - row["group_id"] = meta.get("group_id") - if "dataset" in meta: - row["dataset"] = meta["dataset"] - if row.get("is_trainable") is None: - row["is_trainable"] = meta.get("is_trainable", False) + row["messages"] = _json_value(row.get("messages"), []) + row["response"] = _json_value(row.get("response"), row.get("response")) + row["meta_json"] = meta_json + row["group_id"] = meta_json.get("group_id") + row["request"] = meta_json.get("request") rows.append(row) - - rows.sort( - key=lambda row: ( - int(row.get("step_id") or 0), - str(row.get("created_at") or ""), - str(row.get("id") or ""), - ) - ) + if row.get("record_id") and row.get("job_id"): + self._record_job_ids[str(row["record_id"])] = str(row["job_id"]) + rows.sort(key=lambda row: ( + int(row.get("step_id") or 0), + str(row.get("created_at") or ""), + str(row.get("record_id") or ""), + )) return rows - async def record_evaluation_summary( + async def update_session_step_rows( self, - session_id: str, - step_id: int, - reward: float, - env_state: str, - truncated: bool = False, - ) -> int: - """Persist an evaluation-only row when a session has no trainable step.""" - await self.init() - session = self._sessions.get(session_id) - if session is None: - job_id = str(self.job_id or "") - if not job_id: - raise ValueError( - "job_id is required to persist a cloud evaluation summary" - ) - log.warning( - "Session context unavailable while recording evaluation summary; " - "using configured job_id=%s session_id=%s", - job_id, - session_id, - ) - session = SessionContext( - session_id=session_id, - env_id=session_id, - env_name="gateway", - llm_model="", - job_id=job_id, - ) - self._sessions[session_id] = session - - await self.record_step( - session=session, - step_id=step_id, - messages=[], - response="", - step_reward=reward, - reward=reward, - env_state=env_state, - terminated=True, - truncated=truncated, - is_trainable=False, - ) - return 1 - - async def mark_latest_session_completed( - self, - session_id: str, - llm_model: Optional[str] = None, - *, - is_session_completed: bool = True, - is_terminal: Optional[bool] = None, + query: SessionStepQuery, + updates: Dict[str, Any], ) -> int: - """Set the completion state of the latest cloud-backed trajectory row.""" await self.init() - completed = bool(is_session_completed) - terminal = completed if is_terminal is None else bool(is_terminal) - if self._enable_buffer: await self._flush_records() - - clauses = [] - job_id = self._job_id_for_session(session_id) - if job_id: - clauses.append(f"job_id = '{_escape_sql_literal(job_id)}'") - else: - log.warning( - "Reading/updating latest landing session row without job_id; " - "falling back to all HASH buckets: session_id=%s", - session_id, - ) - escaped_session_id = session_id.replace("'", "''") - clauses.append(f"session_id = '{escaped_session_id}'") - if llm_model: - escaped_llm_model = llm_model.replace("'", "''") - clauses.append(f"agent_model = '{escaped_llm_model}'") - query = " AND ".join(clauses) - - rows = await self._timed_db_call( - "filter_landing", - self.client.query_data, - filter_query=query, - limit=1000, - columns=[ - "step_id", - "is_terminal", - "is_session_completed", - "meta_json", - "agent_model", - ], - partition=job_id or None, - checkout_latest=True, - deserialize_json=True, - trace_context={"session_id": session_id, "model": llm_model}, - ) - if not rows: - return 0 - - candidates: List[tuple[int, bool, bool, Any]] = [] - for row in rows: - try: - candidates.append( - ( - int(row["step_id"]), - _truthy_bool(row.get("is_terminal")), - _truthy_bool(row.get("is_session_completed")), - row.get("meta_json"), - ) - ) - except Exception: - continue - if not candidates: - return 0 - - trajectory_candidates = [ - item for item in candidates if _is_trajectory_meta_json(item[3]) - ] - latest_step_id, latest_terminal, latest_completed, _latest_meta_json = max( - trajectory_candidates or candidates, - key=lambda item: item[0], + clauses: List[str] = [] + if query.job_id: + clauses.append(f"job_id = '{_escape_sql_literal(query.job_id)}'") + if query.session_id: + clauses.append(f"session_id = '{_escape_sql_literal(query.session_id)}'") + if query.session_ids: + values = ", ".join(f"'{_escape_sql_literal(item)}'" for item in query.session_ids) + clauses.append(f"session_id IN ({values})") + if query.record_id: + clauses.append(f"id = '{_escape_sql_literal(query.record_id)}'") + if query.record_ids: + values = ", ".join(f"'{_escape_sql_literal(item)}'" for item in query.record_ids) + clauses.append(f"id IN ({values})") + if query.step_id is not None: + clauses.append(f"step_id = {int(query.step_id)}") + if query.llm_model: + clauses.append(f"agent_model = '{_escape_sql_literal(query.llm_model)}'") + if not clauses: + raise ValueError("cloud session-step update requires at least one filter") + filter_query = " AND ".join(clauses) + job_id = query.job_id or next( + (self._record_job_ids.get(item) for item in query.record_ids if self._record_job_ids.get(item)), + None, + ) or (self._record_job_ids.get(query.record_id) if query.record_id else None) + normalized = self._normalize_session_step_updates_for_cloud( + updates, + filter_query=filter_query, + job_id=job_id, ) - if latest_completed == completed and latest_terminal == terminal: + if not normalized: return 0 - - update_query = self._build_session_step_filter( - session_id, - latest_step_id, - llm_model=llm_model, - ) - result = await self._timed_db_call( + await self._timed_db_call( "update_landing", self.client.update_landing, - update_query, - { - "is_session_completed": completed, - "is_terminal": terminal, - **({"step_reward": 0.0, "reward": None} if not completed else {}), - }, + filter_query, + normalized, partition=job_id or None, - trace_context={ - "session_id": session_id, - "step_id": latest_step_id, - "model": llm_model, - }, + trace_context={"job_id": job_id, "field_count": len(normalized)}, ) - log.debug( - "Submitted cloud latest-session completion update: session_id=%s step_id=%s model=%s result=%s", - session_id, - latest_step_id, - llm_model, - result, - ) - return 1 + return len(query.record_ids) or int(bool(query.record_id)) or 1 + + def get_env_configs( + self, + limit: Optional[int] = None, + offset: int = 0, + job_id: Optional[str] = None, + ) -> List[Dict]: + """Synchronous scheduler reader for cached cloud environment configs.""" + configs = [ + row for row in self._list_env_configs(job_id=job_id) + if not _truthy_bool(row.get("finished", False)) + ] + start = max(0, int(offset or 0)) + if limit is None: + return configs[start:] + end = start + max(0, int(limit)) + return configs[start:end] + + def _list_env_configs(self, job_id: Optional[str] = None) -> List[Dict]: + rows: List[Dict] = [] + for index, config in enumerate(self._env_configs.values(), start=1): + row = self._normalize_env_config(config) + row.setdefault("id", index) + if job_id and str(row.get("job_id") or "") != str(job_id): + continue + rows.append(row) + return rows + + def _normalize_env_config(self, config: Dict[str, Any]) -> Dict[str, Any]: + row = dict(config) + if "image" not in row and "env_image" in row: + row["image"] = row.get("env_image") + env_params = row.get("env_params") + if isinstance(env_params, str): + try: + parsed = json.loads(env_params) + row["env_params"] = parsed if isinstance(parsed, dict) else {} + except Exception: + row["env_params"] = {} + return row async def close(self) -> None: """Clean up cloud clients""" @@ -1296,28 +926,6 @@ def buffer_stats(self) -> Optional[dict]: """Get buffer statistics""" return self._stats if self._enable_buffer else None - def _build_session_step_filter( - self, - session_id: str, - step_id: int, - llm_model: Optional[str] = None, - ) -> str: - job_id = self._job_id_for_session(session_id) - clauses = [] - if job_id: - clauses.append(f"job_id = '{_escape_sql_literal(job_id)}'") - escaped_session_id = session_id.replace("'", "''") - clauses.append(f"session_id = '{escaped_session_id}'") - clauses.append(f"step_id = {int(step_id)}") - if llm_model: - clauses.append(f"agent_model = '{_escape_sql_literal(llm_model)}'") - return " AND ".join(clauses) - - def _job_id_for_session(self, session_id: str) -> str: - session = self._sessions.get(session_id) - job_id = session.job_id if session is not None else None - return str(job_id or self.job_id or "") - def _normalize_session_step_updates_for_cloud( self, updates: Dict[str, Any], @@ -1342,8 +950,9 @@ def _normalize_session_step_updates_for_cloud( "is_truncated": "is_truncated", "truncated": "is_truncated", "is_session_completed": "is_session_completed", + "is_trainable": "is_trainable", } - meta_fields = {"group_id", "env_state", "is_trainable", "request"} + meta_fields = {"group_id", "request"} blocked_fields = {"id", "created_at"} normalized: Dict[str, Any] = {} @@ -1359,10 +968,10 @@ def _normalize_session_step_updates_for_cloud( elif field in meta_fields: meta_updates[field] = value elif field == "meta_json": - if isinstance(value, str): - normalized["meta_json"] = value - else: - normalized["meta_json"] = json.dumps(value, ensure_ascii=False) + normalized["meta_json"] = json.dumps( + _meta_json_object(value), + ensure_ascii=False, + ) elif field in direct_field_map: normalized[direct_field_map[field]] = value else: @@ -1370,9 +979,7 @@ def _normalize_session_step_updates_for_cloud( if meta_updates: if "meta_json" in normalized: - meta_json = _json_value(normalized["meta_json"], {}) - if not isinstance(meta_json, dict): - meta_json = {"source": "AIEvoBox"} + meta_json = _meta_json_object(normalized["meta_json"]) else: meta_json = self._load_existing_meta_json( filter_query, @@ -1415,9 +1022,7 @@ def _load_existing_meta_json( if not raw_meta: return meta_json - parsed = _json_value(raw_meta, {}) - if isinstance(parsed, dict): - meta_json.update(parsed) + meta_json.update(_meta_json_object(raw_meta)) return meta_json @@ -1445,140 +1050,6 @@ def _response_to_landing_value(self, value: Any) -> Optional[str]: value = {"role": "assistant", "content": str(value)} return json.dumps(value, ensure_ascii=False, default=str) - def _convert_to_chat_messages(self, messages: List[Dict]) -> List[Any]: - """Adapt extended messages only for legacy SDK consumers.""" - _load_wt_sdk() - if ChatMessage is None or ContentItem is None: - raise RuntimeError( - "Installed wt_sdk does not expose legacy ChatMessage/ContentItem models" - ) - - result = [] - for message in messages: - content_items = [] - for field in ("reasoning_content", "encrypted_content"): - value = message.get(field) - if isinstance(value, str) and value: - content_items.append(ContentItem(type=field, text=value)) - - content = message.get("content") - if isinstance(content, str): - content_items.append(ContentItem(type="text", text=content)) - elif isinstance(content, list): - for item in content: - if not isinstance(item, dict): - continue - if item.get("type") == "image_url": - content_items.append( - ContentItem( - type="image_url", - image_url={"url": item.get("image_url", {}).get("url", "")}, - ) - ) - elif item.get("type") == "text": - content_items.append( - ContentItem(type="text", text=item.get("text", "")) - ) - else: - content_items.append( - ContentItem( - type=str(item.get("type") or "provider_content"), - text=json.dumps( - item, - ensure_ascii=False, - separators=(",", ":"), - default=str, - ), - ) - ) - - kwargs: Dict[str, Any] = { - "role": message.get("role", "user"), - "content": content_items, - } - for field in ("name", "refusal", "tool_calls", "tool_call_id", "function_call"): - if message.get(field) is not None: - kwargs[field] = message[field] - result.append(ChatMessage(**kwargs)) - return result - - async def _process_images( - self, - messages: List[Dict], - env_key: str, - step_id: int - ) -> tuple[List[Dict], List[str]]: - """ - Process images in messages: - 1. Extract base64 images - 2. Upload to S3 with retry - 3. On failure: save locally as fallback - 4. Replace base64 with URL/path in messages - """ - processed_messages = [] - uploaded_urls = [] - image_count = 0 - - for msg_idx, message in enumerate(messages): - content = message.get("content") - - # Skip non-list content or content without images - if not isinstance(content, list): - processed_messages.append(message) - continue - - has_images = any( - isinstance(item, dict) and item.get("type") == "image_url" - for item in content - ) - if not has_images: - processed_messages.append(message) - continue - - # Process images in content - new_message = message.copy() - new_content = [] - - for item_idx, item in enumerate(content): - if not isinstance(item, dict) or item.get("type") != "image_url": - new_content.append(item) - continue - - image_url = item.get("image_url", {}).get("url", "") - - # Check if base64 - match = re.match(r"data:image/(\w+);base64,(.+)", image_url) - if not match: - new_content.append(item) - continue - - # Extract image data - ext = match.group(1) - b64_str = match.group(2) - file_name = f"step_{step_id}_m{msg_idx}_i{image_count}.{ext}" - - # Upload with retry - final_url = await self._upload_image_with_retry( - b64_str, env_key, file_name, ext - ) - - # Update item - new_item = item.copy() - new_item["image_url"] = item["image_url"].copy() - new_item["image_url"]["url"] = final_url - - new_content.append(new_item) - uploaded_urls.append(final_url) - image_count += 1 - - new_message["content"] = new_content - processed_messages.append(new_message) - - return processed_messages, uploaded_urls - - async def _buffer_record(self, record: Any) -> None: - await self._buffer_records([record]) - async def _buffer_records(self, records: List[Any]) -> None: should_flush = False @@ -1647,71 +1118,6 @@ async def _flush_records(self) -> int: log.debug("Flushed %d cloud records", len(records)) return len(records) - async def _upload_image_with_retry( - self, - b64_str: str, - env_key: str, - file_name: str, - ext: str - ) -> str: - """ - Upload image to S3 with retry logic. - Falls back to local storage on failure. - """ - # Decode base64 - try: - img_data = base64.b64decode(b64_str) - except Exception as e: - log.error("Failed to decode base64: %s", e) - return f"data:image/{ext};base64,{b64_str[:50]}..." # Keep partial for debugging - - # Create local fallback path - local_dir = os.path.join(self._local_fallback_dir, env_key) - local_path = os.path.join(local_dir, file_name) - - # Try S3 upload with retry - if self.s3_uploader: - s3_key = f"aievobox/{self.job_id}/{env_key}/{file_name}" - - for attempt in range(MAX_UPLOAD_RETRIES): - try: - # Save to temp file first - os.makedirs(local_dir, exist_ok=True) - with open(local_path, "wb") as f: - f.write(img_data) - - # Upload to S3 - s3_url = await asyncio.to_thread( - self.s3_uploader.upload_file, - file_path=local_path, - key=s3_key - ) - - if s3_url: - log.debug("Uploaded image to S3: %s", s3_url) - return s3_url - - except Exception as e: - wait_time = RETRY_BACKOFF_BASE * (2 ** attempt) - log.warning( - "S3 upload failed (attempt %d/%d): %s. Retrying in %.1fs", - attempt + 1, MAX_UPLOAD_RETRIES, e, wait_time - ) - await asyncio.sleep(wait_time) - - log.error("S3 upload failed after %d retries. Using local fallback.", MAX_UPLOAD_RETRIES) - - # Fallback to local storage - try: - os.makedirs(local_dir, exist_ok=True) - with open(local_path, "wb") as f: - f.write(img_data) - log.debug("Image saved locally: %s", local_path) - return local_path - except Exception as e: - log.error("Local save also failed: %s", e) - return f"[IMAGE_SAVE_FAILED:{file_name}]" - async def fetch_done_steps_with_context( self, job_id: str, @@ -1741,9 +1147,7 @@ async def fetch_done_steps_with_context( rows = [] for _, row in results.iterrows(): - meta = _json_value(row.get("meta_json"), {}) - if not isinstance(meta, dict): - meta = {} + meta = _meta_json_object(row.get("meta_json")) messages = _json_value(row.get("messages"), []) if not isinstance(messages, (dict, list)): messages = [] @@ -1757,7 +1161,8 @@ async def fetch_done_steps_with_context( "step_id": row["step_id"], "env_name": row["env_name"], "env_id": row["session_id"], - "env_state": meta.get("env_state"), + # Derived compatibility key for the unchanged RL buffer contract. + "env_state": json.dumps(meta, ensure_ascii=False, default=str), "prompt": self.normalize_messages(messages), "request": meta.get("request"), "response": _response_text(response), diff --git a/core/data_manager/strategy/sqlite_strategy_impl.py b/core/data_manager/strategy/sqlite_strategy_impl.py index ac3bede..f82494d 100644 --- a/core/data_manager/strategy/sqlite_strategy_impl.py +++ b/core/data_manager/strategy/sqlite_strategy_impl.py @@ -1,18 +1,17 @@ -from core.data_manager.contracts import EnvironmentQuery, SessionContext, SessionStepQuery +from core.data_manager.contracts import EnvironmentQuery, SessionStepQuery from core.data_manager.strategy.base_strategy import StorageStrategy from core.data_manager.models import JobEnvironment, SessionStep from core.data_manager.write_buffer import WriteBuffer from core.perf_trace import PerfTrace from tortoise import Tortoise from tortoise.transactions import in_transaction -from typing import List, Dict, Optional, Tuple, Any +from typing import List, Dict, Optional, Any import asyncio import uuid import json import sqlite3 import time import logging -from datetime import datetime log = logging.getLogger("sqlite_strategy") @@ -27,28 +26,25 @@ """, ) -NON_TRAJECTORY_EVENT_TYPES = { - "gateway_session_close", - "episode_summary", - "evaluation_summary", -} - def _json_object(value: Any) -> Dict[str, Any]: if not value: return {} if isinstance(value, dict): - return dict(value) - try: - parsed = json.loads(value) - except Exception: - return {"previous_env_state": value} - return parsed if isinstance(parsed, dict) else {"previous_env_state": parsed} - - -def _is_trajectory_env_state(value: Any) -> bool: - event_type = _json_object(value).get("event_type") - return event_type not in NON_TRAJECTORY_EVENT_TYPES + metadata = dict(value) + else: + try: + parsed = json.loads(value) + except Exception: + metadata = {"legacy_meta_json": value} + else: + metadata = parsed if isinstance(parsed, dict) else {"legacy_meta_json": parsed} + legacy_state = metadata.pop("env_state", None) + if legacy_state is None: + return metadata + legacy_metadata = _json_object(legacy_state) + legacy_metadata.update(metadata) + return legacy_metadata class SqliteStrategy(StorageStrategy): @@ -121,88 +117,109 @@ def ensure_schema() -> None: (row for row in table_info if str(row[1]) == "reward"), None, ) - if reward_column and (bool(reward_column[3]) or reward_column[4] is not None): - conn.execute("BEGIN IMMEDIATE") - conn.execute("DROP TABLE IF EXISTS session_steps_reward_migration") - conn.execute( - """ - CREATE TABLE session_steps_reward_migration ( - id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - session_id VARCHAR(36) NOT NULL, - step_id INT NOT NULL, - env_name VARCHAR(100) NOT NULL, - llm_model VARCHAR(150) NOT NULL, - group_id VARCHAR(150), - job_id VARCHAR(64), - messages TEXT NOT NULL, - request TEXT, - response TEXT NOT NULL, - step_reward REAL NOT NULL DEFAULT 0, - reward REAL, - env_state TEXT, - is_terminal INT NOT NULL DEFAULT 0, - is_truncated INT NOT NULL DEFAULT 0, - is_session_completed INT NOT NULL DEFAULT 0, - is_trainable INT NOT NULL DEFAULT 0, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - UNIQUE (session_id, step_id, created_at) - ) - """ - ) - target_columns = ( - "id", "session_id", "step_id", "env_name", "llm_model", - "group_id", "job_id", "messages", "request", "response", - "step_reward", "reward", "env_state", "is_terminal", - "is_truncated", "is_session_completed", "is_trainable", - "created_at", - ) - missing_defaults = { - "id": "NULL", - "session_id": "''", - "step_id": "0", - "env_name": "''", - "llm_model": "''", - "group_id": "NULL", - "job_id": "NULL", - "messages": "'[]'", - "request": "NULL", - "response": "''", - "step_reward": "0", - "reward": "NULL", - "env_state": "NULL", - "is_terminal": "0", - "is_truncated": "0", - "is_session_completed": "0", - "is_trainable": "0", - "created_at": "CURRENT_TIMESTAMP", - } - column_list = ", ".join(f'"{column}"' for column in target_columns) - nonnull_columns = { - "session_id", "step_id", "env_name", "llm_model", "messages", - "response", "step_reward", "is_terminal", "is_truncated", - "is_session_completed", "is_trainable", "created_at", - } - select_list = ", ".join( - ( - f'COALESCE("{column}", {missing_defaults[column]})' - if column in columns and column in nonnull_columns - else f'"{column}"' if column in columns - else missing_defaults[column] - ) - for column in target_columns - ) - conn.execute( - f"INSERT INTO session_steps_reward_migration ({column_list}) " - f"SELECT {select_list} FROM session_steps" - ) - conn.execute("DROP TABLE session_steps") - conn.execute( - "ALTER TABLE session_steps_reward_migration RENAME TO session_steps" - ) - conn.commit() + needs_rebuild = bool(table_info) and ( + "record_id" not in columns + or "meta_json" not in columns + or "env_state" in columns + or "request" not in columns + or reward_column is None + or bool(reward_column[3]) + or reward_column[4] is not None + ) + if not needs_rebuild: return - if "request" not in columns: - conn.execute("ALTER TABLE session_steps ADD COLUMN request TEXT") + + conn.execute("BEGIN IMMEDIATE") + conn.execute("DROP TABLE IF EXISTS session_steps_schema_migration") + conn.execute( + """ + CREATE TABLE session_steps_schema_migration ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + record_id VARCHAR(64) NOT NULL UNIQUE, + session_id VARCHAR(36) NOT NULL, + step_id INT NOT NULL, + env_name VARCHAR(100) NOT NULL, + llm_model VARCHAR(150) NOT NULL, + group_id VARCHAR(150), + job_id VARCHAR(64), + messages TEXT NOT NULL, + request TEXT, + response TEXT NOT NULL, + step_reward REAL NOT NULL DEFAULT 0, + reward REAL, + meta_json TEXT, + is_terminal INT NOT NULL DEFAULT 0, + is_truncated INT NOT NULL DEFAULT 0, + is_session_completed INT NOT NULL DEFAULT 0, + is_trainable INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (session_id, step_id, created_at) + ) + """ + ) + target_columns = ( + "id", "record_id", "session_id", "step_id", "env_name", + "llm_model", "group_id", "job_id", "messages", "request", + "response", "step_reward", "reward", "meta_json", "is_terminal", + "is_truncated", "is_session_completed", "is_trainable", "created_at", + ) + missing_defaults = { + "id": "NULL", + "session_id": "''", + "step_id": "0", + "env_name": "''", + "llm_model": "''", + "group_id": "NULL", + "job_id": "NULL", + "messages": "'[]'", + "request": "NULL", + "response": "''", + "step_reward": "0", + "reward": "NULL", + "is_terminal": "0", + "is_truncated": "0", + "is_session_completed": "0", + "is_trainable": "0", + "created_at": "CURRENT_TIMESTAMP", + } + record_id_expr = ( + "COALESCE(NULLIF(\"record_id\", ''), 'legacy-' || CAST(\"id\" AS TEXT))" + if "record_id" in columns + else "'legacy-' || CAST(\"id\" AS TEXT)" + ) + if "meta_json" in columns and "env_state" in columns: + meta_json_expr = "COALESCE(NULLIF(\"meta_json\", ''), \"env_state\")" + elif "meta_json" in columns: + meta_json_expr = '"meta_json"' + elif "env_state" in columns: + meta_json_expr = '"env_state"' + else: + meta_json_expr = "NULL" + nonnull_columns = { + "session_id", "step_id", "env_name", "llm_model", "messages", + "response", "step_reward", "is_terminal", "is_truncated", + "is_session_completed", "is_trainable", "created_at", + } + select_expressions = [] + for column in target_columns: + if column == "record_id": + expression = record_id_expr + elif column == "meta_json": + expression = meta_json_expr + elif column in columns and column in nonnull_columns: + expression = f'COALESCE("{column}", {missing_defaults[column]})' + elif column in columns: + expression = f'"{column}"' + else: + expression = missing_defaults[column] + select_expressions.append(expression) + column_list = ", ".join(f'"{column}"' for column in target_columns) + conn.execute( + f"INSERT INTO session_steps_schema_migration ({column_list}) " + f"SELECT {', '.join(select_expressions)} FROM session_steps" + ) + conn.execute("DROP TABLE session_steps") + conn.execute("ALTER TABLE session_steps_schema_migration RENAME TO session_steps") conn.commit() finally: conn.close() @@ -440,6 +457,10 @@ async def delete_session_step_rows(self, query: SessionStepQuery) -> int: rows = rows.filter(session_id=query.session_id) if query.session_ids: rows = rows.filter(session_id__in=query.session_ids) + if query.record_id: + rows = rows.filter(record_id=query.record_id) + if query.record_ids: + rows = rows.filter(record_id__in=query.record_ids) return await rows.delete() async def delete_job_rows(self, job_id: str) -> None: @@ -450,344 +471,127 @@ async def delete_job_rows(self, job_id: str) -> None: await SessionStep.filter(job_id=job_id).using_db(connection).delete() await JobEnvironment.filter(job_id=job_id).using_db(connection).delete() - async def mark_environment_finished(self, env_id: str) -> int: - """Mark one active environment for the current job as finished.""" - await self.init() - updated = await JobEnvironment.filter( - job_id=self.job_id, - env_id=env_id, - is_deleted=False, - ).update(finished=True) - if updated != 1: - raise RuntimeError( - f"expected one env config for job_id={self.job_id!r} env_id={env_id!r}, " - f"updated={updated}" - ) - return updated - - async def create_session( - self, - env_id: str, - env_name: str, - llm_model: str, - group_id: str = "", - job_id: str = "" - ) -> SessionContext: - """Create a new session context (in-memory only)""" - # session_id = env_id - session = SessionContext( - session_id=env_id, - env_id=env_id, - env_name=env_name, - llm_model=llm_model, - group_id=group_id, - job_id=job_id or self.job_id, - total_reward=0.0, - start_time=time.perf_counter(), - message_history=[] - ) - - log.debug("Created session: %s for env %s", session.session_id, env_name) - return session - - async def record_step( + async def insert_session_step_rows( self, - session: SessionContext, - step_id: int, - messages: List[Dict], - response: str, - step_reward: float, - request: Optional[str] = None, - env_state: Optional[str] = None, - terminated: bool = False, - truncated: bool = False, - is_trainable: bool = False, - dataset: Optional[Any] = None, - reward: Optional[float] = None, - ) -> None: - """ - Record a single interaction step. - Base64 images in messages are stored directly (no extraction). - """ + rows: List[Dict[str, Any]], + ) -> List[str]: + """Persist caller-constructed rows without applying lifecycle policy.""" await self.init() - trace = PerfTrace( - "sqlite_strategy.record_step", - logger=log, - context={ - "operation": "db_write", - "table": "session_steps", - "session_id": session.session_id, - "step_id": step_id, - "model": session.llm_model, - "job_id": session.job_id, - "buffered": bool(self._write_buffer), - }, - ) - - try: - # Build full message history including current response - full_messages = list(messages) - # full_messages.append({"role": "assistant", "content": response}) - - # Update session's message history - session.message_history = full_messages - - if dataset is not None: - state = _json_object(env_state) - state["dataset"] = dataset - env_state = json.dumps(state, ensure_ascii=False, default=str) - - # Create step record - step_record = SessionStep( - session_id=session.session_id, - step_id=step_id, - env_name=session.env_name, - llm_model=session.llm_model, - group_id=session.group_id, - job_id=session.job_id, - messages=json.dumps(full_messages, ensure_ascii=False), - request=request, - response=response, - step_reward=step_reward, - reward=reward, - env_state=env_state, - is_terminal=terminated or truncated, - is_truncated=truncated, - is_session_completed=terminated, - # Training eligibility is assigned by a later, explicit workflow. - # Every newly recorded trajectory step starts non-trainable. - is_trainable=False, - ) + if not rows: + return [] - # Use buffer or direct save - if self._write_buffer: - with trace.span("db_write_buffer.enqueue_create", row_count=1): - await self._write_buffer.buffer_create(step_record) - else: - with trace.span("db_write.session_step_save", row_count=1): - await step_record.save() - trace.emit_summary( - status="success", - row_count=1, - buffered=bool(self._write_buffer), - is_terminal=terminated or truncated, - is_truncated=truncated, - ) - except Exception as exc: - trace.emit_summary(status="failed", error_type=type(exc).__name__, error=str(exc)) - raise + records: List[SessionStep] = [] + record_ids: List[str] = [] + for row in rows: + record_id = str(row.get("record_id") or uuid.uuid4()) + record_ids.append(record_id) + messages = row.get("messages", []) + request = row.get("request") + response = row.get("response", "") + meta_json = _json_object(row.get("meta_json")) + records.append(SessionStep( + record_id=record_id, + session_id=str(row.get("session_id") or ""), + step_id=int(row.get("step_id") or 0), + env_name=str(row.get("env_name") or ""), + llm_model=str(row.get("llm_model") or ""), + group_id=str(row.get("group_id") or ""), + job_id=str(row.get("job_id") or self.job_id), + messages=( + messages + if isinstance(messages, str) + else json.dumps(messages, ensure_ascii=False, default=str) + ), + request=( + request + if request is None or isinstance(request, str) + else json.dumps(request, ensure_ascii=False, default=str) + ), + response=( + response + if isinstance(response, str) + else json.dumps(response, ensure_ascii=False, default=str) + ), + step_reward=float(row.get("step_reward") or 0.0), + reward=row.get("reward"), + meta_json=json.dumps(meta_json, ensure_ascii=False, default=str), + is_terminal=bool(row.get("is_terminal", False)), + is_truncated=bool(row.get("is_truncated", False)), + is_session_completed=bool(row.get("is_session_completed", False)), + is_trainable=bool(row.get("is_trainable", False)), + )) - log.debug( - "Recorded step %d for session %s: step_reward=%.4f reward=%s", - step_id, session.session_id, step_reward, reward, - ) + if self._write_buffer: + for record in records: + await self._write_buffer.buffer_create(record) + else: + await SessionStep.bulk_create(records) + return record_ids - async def list_session_steps( + async def list_session_step_rows( self, - session_id: str, - *, - checkout_latest: bool = False, + query: SessionStepQuery, ) -> List[Dict[str, Any]]: await self.init() if self._write_buffer: await self._write_buffer.flush_model(SessionStep, operation="create") - rows = await SessionStep.filter(session_id=session_id).order_by("step_id", "id") - return [self._session_step_to_dict(row) for row in rows] - - async def record_evaluation_summary( - self, - session_id: str, - step_id: int, - reward: float, - env_state: str, - truncated: bool = False, - ) -> int: - await self.init() - row = SessionStep( - session_id=session_id, - step_id=step_id, - env_name="gateway", - llm_model="", - group_id="", - job_id=self.job_id, - messages="[]", - response="", - step_reward=reward, - reward=reward, - env_state=env_state, - is_terminal=True, - is_truncated=truncated, - is_session_completed=True, - is_trainable=False, - ) - await row.save() - return 1 + rows = SessionStep.all() + if query.job_id: + rows = rows.filter(job_id=query.job_id) + if query.session_id: + rows = rows.filter(session_id=query.session_id) + if query.session_ids: + rows = rows.filter(session_id__in=query.session_ids) + if query.record_id: + rows = rows.filter(record_id=query.record_id) + if query.record_ids: + rows = rows.filter(record_id__in=query.record_ids) + if query.step_id is not None: + rows = rows.filter(step_id=query.step_id) + if query.llm_model: + rows = rows.filter(llm_model=query.llm_model) + if query.after_id: + rows = rows.filter(id__gt=query.after_id) + if query.is_terminal is not None: + rows = rows.filter(is_terminal=query.is_terminal) + if query.is_trainable is not None: + rows = rows.filter(is_trainable=query.is_trainable) + rows = rows.order_by("step_id", "id") + if query.limit is not None: + rows = rows.limit(query.limit) + return [self._session_step_to_dict(row) for row in await rows] - async def update_session_step( + async def update_session_step_rows( self, - session_id: str, - step_id: int, + query: SessionStepQuery, updates: Dict[str, Any], ) -> int: - """Update one session_steps row by session_id and step_id.""" await self.init() - - normalized_updates = self._normalize_session_step_updates(updates) - if not normalized_updates: + normalized = self._normalize_session_step_updates(updates) + if not normalized: return 0 - - trace = PerfTrace( - "sqlite_strategy.update_session_step", - logger=log, - context={ - "operation": "db_write", - "table": "session_steps", - "session_id": session_id, - "step_id": step_id, - "field_count": len(normalized_updates), - "buffered": bool(self._write_buffer), - }, - ) - try: - # Make pending buffered creates visible before applying a direct query update. - if self._write_buffer: - with trace.span("db_write.flush_pending_creates"): - await self._write_buffer.flush_model(SessionStep, operation="create") - - with trace.span("db_write.session_step_update", field_count=len(normalized_updates)): - latest = await SessionStep.filter( - session_id=session_id, - step_id=step_id, - ).order_by("-id").first() - updated = ( - await SessionStep.filter(id=latest.id).update(**normalized_updates) - if latest is not None - else 0 - ) - trace.emit_summary(status="success", updated_count=updated) - return updated - except Exception as exc: - trace.emit_summary(status="failed", error_type=type(exc).__name__, error=str(exc)) - raise - - async def patch_session_environment( - self, - session_id: str, - *, - job_id: str, - env_name: str, - group_id: Optional[str] = None, - ) -> int: - """Patch session rows with their resolved environment metadata.""" - await self.init() - - trace = PerfTrace( - "sqlite_strategy.patch_session_environment", - logger=log, - context={ - "operation": "db_write", - "table": "session_steps", - "session_id": session_id, - "job_id": job_id, - "env_name": env_name, - "buffered": bool(self._write_buffer), - }, - ) - try: - if self._write_buffer: - with trace.span("db_write.flush_pending_creates"): - await self._write_buffer.flush_model(SessionStep, operation="create") - - updates: Dict[str, Any] = { - "job_id": job_id, - "env_name": env_name, - } - if group_id is not None: - updates["group_id"] = group_id - - with trace.span("db_write.patch_session_environment", field_count=len(updates)): - updated = await SessionStep.filter(session_id=session_id).update(**updates) - trace.emit_summary(status="success", updated_count=updated) - return updated - except Exception as exc: - trace.emit_summary(status="failed", error_type=type(exc).__name__, error=str(exc)) - raise - - async def mark_latest_session_completed( - self, - session_id: str, - llm_model: Optional[str] = None, - *, - is_session_completed: bool = True, - is_terminal: Optional[bool] = None, - ) -> int: - """Set the completion state of the latest trajectory row for a session.""" - await self.init() - completed = bool(is_session_completed) - terminal = completed if is_terminal is None else bool(is_terminal) - - trace = PerfTrace( - "sqlite_strategy.mark_latest_session_completed", - logger=log, - context={ - "operation": "db_write", - "table": "session_steps", - "session_id": session_id, - "model": llm_model, - "is_session_completed": completed, - "is_terminal": terminal, - "buffered": bool(self._write_buffer), - }, - ) - try: - # Make pending buffered creates visible before selecting the latest row. - if self._write_buffer: - with trace.span("db_write.flush_pending_creates"): - await self._write_buffer.flush_model(SessionStep, operation="create") - - query = SessionStep.filter(session_id=session_id) - if llm_model: - query = query.filter(llm_model=llm_model) - - with trace.span("db_read.select_latest_session_step", limit=50): - candidates = await query.order_by("-step_id", "-id").limit(50) - if not candidates: - trace.emit_summary(status="miss", candidate_count=0, updated_count=0) - return 0 - - latest = next( - (step for step in candidates if _is_trajectory_env_state(step.env_state)), - candidates[0], - ) - if latest.is_session_completed == completed and latest.is_terminal == terminal: - trace.emit_summary( - status="skipped", - candidate_count=len(candidates), - updated_count=0, - step_id=latest.step_id, - row_id=latest.id, - ) - return 0 - - updates: Dict[str, Any] = { - "is_session_completed": completed, - "is_terminal": terminal, - } - if not completed: - updates.update(step_reward=0.0, reward=None) - with trace.span("db_write.mark_session_completed", row_id=latest.id, step_id=latest.step_id): - updated = await SessionStep.filter(id=latest.id).update(**updates) - trace.emit_summary( - status="success", - candidate_count=len(candidates), - updated_count=updated, - step_id=latest.step_id, - row_id=latest.id, - ) - return updated - except Exception as exc: - trace.emit_summary(status="failed", error_type=type(exc).__name__, error=str(exc)) - raise + if self._write_buffer: + await self._write_buffer.flush_model(SessionStep, operation="create") + rows = SessionStep.all() + if query.job_id: + rows = rows.filter(job_id=query.job_id) + if query.session_id: + rows = rows.filter(session_id=query.session_id) + if query.session_ids: + rows = rows.filter(session_id__in=query.session_ids) + if query.record_id: + rows = rows.filter(record_id=query.record_id) + if query.record_ids: + rows = rows.filter(record_id__in=query.record_ids) + if query.step_id is not None: + rows = rows.filter(step_id=query.step_id) + if query.llm_model: + rows = rows.filter(llm_model=query.llm_model) + if query.is_terminal is not None: + rows = rows.filter(is_terminal=query.is_terminal) + if query.is_trainable is not None: + rows = rows.filter(is_trainable=query.is_trainable) + return await rows.update(**normalized) def _normalize_session_step_updates(self, updates: Dict[str, Any]) -> Dict[str, Any]: if not updates: @@ -803,10 +607,10 @@ def _normalize_session_step_updates(self, updates: Dict[str, Any]) -> Dict[str, if field in blocked_fields: raise ValueError(f"SessionStep field cannot be updated: {field}") - if field in {"messages", "request"} and not isinstance(value, str): - value = json.dumps(value, ensure_ascii=False) - elif field == "env_state" and isinstance(value, (dict, list)): + if field in {"messages", "request", "response"} and not isinstance(value, str): value = json.dumps(value, ensure_ascii=False) + elif field == "meta_json": + value = json.dumps(_json_object(value), ensure_ascii=False) normalized[field] = value @@ -831,6 +635,7 @@ def _environment_to_dict(env: JobEnvironment) -> Dict[str, Any]: def _session_step_to_dict(step: SessionStep) -> Dict[str, Any]: return { "id": step.id, + "record_id": step.record_id, "session_id": step.session_id, "step_id": step.step_id, "env_name": step.env_name, @@ -842,7 +647,7 @@ def _session_step_to_dict(step: SessionStep) -> Dict[str, Any]: "response": step.response, "step_reward": step.step_reward, "reward": step.reward, - "env_state": step.env_state, + "meta_json": _json_object(step.meta_json), "is_terminal": step.is_terminal, "is_truncated": step.is_truncated, "is_session_completed": step.is_session_completed, @@ -903,7 +708,9 @@ async def fetch_done_steps_with_context( "step_id": s.step_id, "env_name": s.env_name, "env_id": s.session_id, - "env_state": s.env_state, + # Kept as a derived compatibility key because rl/buffer_server.py + # intentionally remains unchanged in this refactor. + "env_state": s.meta_json, "prompt": s.messages, "request": s.request, "response": s.response, diff --git a/core/data_manager/write_buffer.py b/core/data_manager/write_buffer.py index 7e89eda..943a4ed 100644 --- a/core/data_manager/write_buffer.py +++ b/core/data_manager/write_buffer.py @@ -356,7 +356,7 @@ async def _update_pks_after_bulk_create( # 使用 Tortoise ORM 的 _meta.fields_map 来检查字段是否存在 unique_field = None fields_map = getattr(model_class._meta, 'fields_map', {}) - for field_name in ("session_id", "env_id", "uuid", "uid"): + for field_name in ("record_id", "session_id", "env_id", "uuid", "uid"): if field_name in fields_map: unique_field = field_name break diff --git a/core/data_manager/yaml_aggregator.py b/core/data_manager/yaml_aggregator.py index 5acaf57..2aee810 100644 --- a/core/data_manager/yaml_aggregator.py +++ b/core/data_manager/yaml_aggregator.py @@ -7,8 +7,9 @@ import os import uuid from pathlib import Path -from typing import Any, Dict, List, Set, Union +from typing import Any, Callable, Dict, List, Set, Union +from .job_claim import acquire_job_initialization_claim from .load_yaml import load_yaml_configs log = logging.getLogger("yaml_aggregator") @@ -28,12 +29,20 @@ def is_job_db_processing_done(job_id: str) -> bool: return bool(normalized_job_id and _job_db_processing_done.get(normalized_job_id, False)) -def _schedule_insert_task(job_id: str, coro: Any, *, task_name: str) -> asyncio.Task: +def _schedule_insert_task( + job_id: str, + coro: Any, + *, + task_name: str, + finalizer: Callable[[], None] | None = None, +) -> asyncio.Task: async def _runner() -> None: try: await coro finally: set_job_db_processing_done(job_id, True) + if finalizer is not None: + finalizer() task = asyncio.create_task(_runner(), name=f"{task_name}:{job_id}") _insert_tasks.add(task) @@ -124,6 +133,7 @@ async def sync_configs_to_db( *, rebuild_table: bool = False, resume: bool = False, + job_claim_dir: str = "", ) -> None: """Synchronize configs without leaking a connection or backend client.""" if storage_type not in {"sqlite", "cloud"}: @@ -133,6 +143,12 @@ async def sync_configs_to_db( await data_manager.init() job_id = data_manager.job_id + claim = acquire_job_initialization_claim( + job_id=job_id, + storage_type=storage_type, + storage_identity=str(getattr(data_manager, "storage_identity", storage_type)), + claim_dir=job_claim_dir, + ) set_job_db_processing_done(job_id, False) try: existing = await data_manager.list_environment_rows(job_id=job_id) @@ -170,7 +186,9 @@ async def sync_configs_to_db( job_id, _do_bulk_insert(data_manager, remaining, followup_batch), task_name=f"{storage_type}-env-sync", + finalizer=claim.release, ) + claim = None else: set_job_db_processing_done(job_id, True) log.debug( @@ -182,6 +200,9 @@ async def sync_configs_to_db( except Exception: set_job_db_processing_done(job_id, True) raise + finally: + if claim is not None: + claim.release() def _expand_environment_rows(job_id: str, yaml_configs: List[Dict]) -> List[Dict[str, Any]]: diff --git a/docs/guides/S3+LanceDB-storage.md b/docs/guides/S3+LanceDB-storage.md index 820824f..0c7351e 100644 --- a/docs/guides/S3+LanceDB-storage.md +++ b/docs/guides/S3+LanceDB-storage.md @@ -5,7 +5,7 @@ Safactory can optionally persist trajectory and environment data to an S3-backed Install the optional dependencies: ```bash -pip install -r requirements-cloud.txtExpand commentComment on line R203Resolved +pip install -r requirements-cloud.txt ``` Create a local `.env` file (do not commit credentials) with the data platform connection settings: @@ -30,4 +30,4 @@ source .env set +a ``` -Then set the gateway `storage_type` to `cloud` and launch Safactory with `--storage-type cloud`. The `production` profile selects the production landing/serving tables, while `test` selects the test tables. See [AI45Lab/wt-data-platform-sdk](https://github.com/AI45Lab/wt-data-platform-sdk) for the complete configuration and table documentation.x \ No newline at end of file +Then set the gateway `storage_type` to `cloud` and launch Safactory with `--storage-type cloud --cloud-job-claim-dir /path/on/shared-storage`. Every launcher writing the environment config table must use the same durable shared directory. The SDK profile selects Safactory's Cloud landing target; Safactory does not access serving tables. See [AI45Lab/wt-data-platform-sdk](https://github.com/AI45Lab/wt-data-platform-sdk) for the complete configuration and table documentation. diff --git a/docs/guides/S3+LanceDB-storage_CN.md b/docs/guides/S3+LanceDB-storage_CN.md index eb022b6..dd48e2f 100644 --- a/docs/guides/S3+LanceDB-storage_CN.md +++ b/docs/guides/S3+LanceDB-storage_CN.md @@ -30,4 +30,4 @@ source .env set +a ``` -然后将 gateway 的 `storage_type` 设置为 `cloud`,并使用 `--storage-type cloud` 启动 Safactory。`production` profile 会选择生产 landing/serving 表,`test` profile 会选择对应的测试表。完整配置和表说明请参阅 [AI45Lab/wt-data-platform-sdk](https://github.com/AI45Lab/wt-data-platform-sdk)。 \ No newline at end of file +然后将 gateway 的 `storage_type` 设置为 `cloud`,并使用 `--storage-type cloud --cloud-job-claim-dir /共享存储路径` 启动 Safactory。所有会写环境配置表的 launcher 必须使用同一个持久共享目录。SDK profile 只负责选择 Safactory 的 Cloud landing 目标;Safactory 不访问 serving 表。完整配置和表说明请参阅 [AI45Lab/wt-data-platform-sdk](https://github.com/AI45Lab/wt-data-platform-sdk)。 diff --git a/docs/guides/data-manager.md b/docs/guides/data-manager.md index 8d43ab9..537f10c 100644 --- a/docs/guides/data-manager.md +++ b/docs/guides/data-manager.md @@ -2,6 +2,8 @@ Safactory records task rows and session rows through `core.data_manager`. The default local backend is SQLite at `sqlite://env_trajs.db`; cloud mode delegates to `wt-data-gateway` defaults. +The storage boundary is intentionally narrow: Gateway constructs trajectory rows and owns session-close selection, Evaluator classifies rows and commits rewards, Manager handles the unevaluated-session fallback, and SQLite/Cloud strategies only perform row persistence. YAML aggregation and message-image persistence remain in `core.data_manager`. + For SQLite, keep these values identical: ```yaml @@ -46,22 +48,24 @@ One row per scheduled environment instance. ### `session_steps` -One row per gateway telemetry event, trainable trajectory step, close event, or evaluation summary. +One row per gateway inference, direct trajectory step, or evaluation summary. Session close updates the selected trajectory row in place. | Field | Meaning | |-------|---------| | `id` | Auto-increment primary key. | +| `record_id` | Backend-neutral unique row identifier used for exact updates. | | `session_id` | Session UUID. Matches `job_environments.env_id`. | | `step_id` | Step index inside the session. Gateway telemetry uses request sequence IDs. | | `env_name` | Adapter name. Gateway may patch it after resolving the environment row. | | `llm_model` | Gateway route key or model name associated with the row. | | `group_id` | RL group identifier. | | `job_id` | Launcher run identifier. | -| `messages` | JSON-serialized OpenAI-style messages. Gateway appends assistant output when possible. | -| `response` | Raw response/action for direct runtime rows. Gateway telemetry stores response in `messages` and leaves this empty. | +| `messages` | JSON-serialized request-side conversation history. | +| `request` | Original or normalized provider request for the current step. | +| `response` | Raw or normalized assistant response/action for the current step. | | `step_reward` | Per-row reward. Evaluation commit writes final score here. | | `reward` | Cumulative reward. Evaluation commit also writes final score here. | -| `env_state` | JSON metadata. Contains `event_type` for gateway/evaluation events. | +| `meta_json` | Unified JSON metadata. Contains `event_type` for gateway/evaluation events. | | `is_terminal` | Whether this row terminates the session. | | `is_truncated` | Whether termination was caused by truncation. | | `is_session_completed` | Whether the session is sealed for readers/evaluators. | @@ -70,7 +74,7 @@ One row per gateway telemetry event, trainable trajectory step, close event, or ## Row Types -The row type is usually determined by `env_state.event_type` and `is_trainable`. +The row type is usually determined by `meta_json.event_type` and `is_trainable`. | Type | Marker | Trainable | Produced by | |------|--------|-----------|-------------| @@ -122,11 +126,11 @@ Inspect gateway event types: ```bash sqlite3 env_trajs.db " SELECT id, session_id, step_id, - json_extract(env_state, '$.event_type') AS event_type, - json_extract(env_state, '$.status_code') AS status_code, - json_extract(env_state, '$.total_latency_ms') AS total_latency_ms + json_extract(meta_json, '$.event_type') AS event_type, + json_extract(meta_json, '$.status_code') AS status_code, + json_extract(meta_json, '$.total_latency_ms') AS total_latency_ms FROM session_steps - WHERE env_state IS NOT NULL + WHERE meta_json IS NOT NULL ORDER BY id DESC LIMIT 20;" ``` @@ -173,7 +177,7 @@ sqlite3 env_trajs.db " | `reward` | `session_steps.step_reward`. | | `instance_id` | `session_steps.group_id`. | | `extra_info.session_id` | `session_steps.session_id`. | -| `extra_info.weight_version` | Parsed from `env_state.weight_version` when present. | +| `extra_info.weight_version` | Parsed from persisted `meta_json.weight_version` through the unchanged training adapter. | | `extra_info.truncated` | `session_steps.is_truncated`. | Rows are grouped by `group_id`. Set `--rl-group-size` or `RL_GROUP_SIZE` so each prompt group has the expected number of samples. @@ -185,4 +189,6 @@ SQLite strategy creates runtime indexes: - `idx_job_environments_job_deleted_id` on `(job_id, is_deleted, id)`. - `idx_session_steps_job_trainable_id` on `(job_id, is_trainable, id)`. -An existing `job_id` requires either `--resume` or `--rebuild-table`. Resume skips completed environments; rebuild deletes only the current job's configs and trajectories. The options are mutually exclusive. +An existing `job_id` requires either `--resume` or `--rebuild-table`. Resume deletes stale landing rows for unfinished environments; rebuild deletes the current job's environment and landing rows. The options are mutually exclusive. + +Cloud launchers must point `--cloud-job-claim-dir` at the same durable shared filesystem. The held file lease covers the initial and background environment batches, preventing concurrent `max(id)+1` allocation. Cloud landing deletion is fail-closed: it requires an exact `--confirm-cloud-delete-job-id` and logs the resolved profile, DB URI, landing table, filter, and preflight row count. Production additionally requires `--confirm-production` and a verified archive under `--cloud-delete-archive-dir`. Serving publication and withdrawal remain entirely outside Safactory; this flow never queries or mutates a serving table. diff --git a/docs/guides/data-manager_CN.md b/docs/guides/data-manager_CN.md index 9639559..d8370b7 100644 --- a/docs/guides/data-manager_CN.md +++ b/docs/guides/data-manager_CN.md @@ -2,6 +2,8 @@ Safactory v2 通过 `core.data_manager` 记录任务行和 session 行。默认本地后端是 `sqlite://env_trajs.db`;cloud 模式交给 `wt-data-gateway` 默认配置。 +存储边界保持精简:Gateway 负责构造轨迹行及选择 session close 的目标行,Evaluator 负责行分类与奖励提交,Manager 负责未评测 session 的兜底完成逻辑,SQLite/Cloud strategy 只负责行持久化。YAML 聚合和消息图片持久化仍保留在 `core.data_manager`。 + 使用 SQLite 时,这两处必须一致: ```yaml @@ -46,22 +48,24 @@ storage_config: ### `session_steps` -每个 gateway telemetry event、可训练轨迹 step、close event 或 evaluation summary 一行。 +每个 gateway inference、直接轨迹 step 或 evaluation summary 一行;session close 会原位更新选中的轨迹行。 | 字段 | 含义 | |------|------| | `id` | 自增主键。 | +| `record_id` | 跨后端统一的行唯一标识,用于精确更新。 | | `session_id` | Session UUID。匹配 `job_environments.env_id`。 | | `step_id` | Session 内 step 索引。Gateway telemetry 使用请求序列号。 | | `env_name` | Adapter 名。Gateway 解析环境行后可能 patch 该字段。 | | `llm_model` | 与该行关联的 gateway route key 或 model name。 | | `group_id` | RL group 标识。 | | `job_id` | Launcher run 标识。 | -| `messages` | JSON 序列化的 OpenAI 风格 messages。Gateway 会尽量追加 assistant output。 | -| `response` | 直接 runtime 行的原始响应/action。Gateway telemetry 通常将响应存在 `messages`,这里为空。 | +| `messages` | JSON 序列化的请求侧对话历史。 | +| `request` | 当前 step 的原始或规范化 provider 请求。 | +| `response` | 当前 step 的原始或规范化 assistant 响应/action。 | | `step_reward` | 单行奖励。Evaluation commit 会把最终分数写到这里。 | | `reward` | 累计奖励。Evaluation commit 也会写入最终分数。 | -| `env_state` | JSON 元数据。Gateway/evaluation 事件中包含 `event_type`。 | +| `meta_json` | 统一 JSON 元数据。Gateway/evaluation 事件中包含 `event_type`。 | | `is_terminal` | 该行是否终止 session。 | | `is_truncated` | 是否因截断终止。 | | `is_session_completed` | Session 是否对 reader/evaluator sealed。 | @@ -70,7 +74,7 @@ storage_config: ## 行类型 -通常通过 `env_state.event_type` 和 `is_trainable` 判断行类型。 +通常通过 `meta_json.event_type` 和 `is_trainable` 判断行类型。 | 类型 | 标记 | 可训练 | 产生方 | |------|------|--------|--------| @@ -122,11 +126,11 @@ sqlite3 env_trajs.db " ```bash sqlite3 env_trajs.db " SELECT id, session_id, step_id, - json_extract(env_state, '$.event_type') AS event_type, - json_extract(env_state, '$.status_code') AS status_code, - json_extract(env_state, '$.total_latency_ms') AS total_latency_ms + json_extract(meta_json, '$.event_type') AS event_type, + json_extract(meta_json, '$.status_code') AS status_code, + json_extract(meta_json, '$.total_latency_ms') AS total_latency_ms FROM session_steps - WHERE env_state IS NOT NULL + WHERE meta_json IS NOT NULL ORDER BY id DESC LIMIT 20;" ``` @@ -173,7 +177,7 @@ sqlite3 env_trajs.db " | `reward` | `session_steps.step_reward`。 | | `instance_id` | `session_steps.group_id`。 | | `extra_info.session_id` | `session_steps.session_id`。 | -| `extra_info.weight_version` | 存在时从 `env_state.weight_version` 解析。 | +| `extra_info.weight_version` | 通过未修改的训练适配层从持久化的 `meta_json.weight_version` 解析。 | | `extra_info.truncated` | `session_steps.is_truncated`。 | 行会按 `group_id` 聚合。设置 `--rl-group-size` 或 `RL_GROUP_SIZE`,确保每个 prompt group 有预期数量的样本。 @@ -185,4 +189,6 @@ SQLite strategy 会创建运行时索引: - `idx_job_environments_job_deleted_id` on `(job_id, is_deleted, id)`。 - `idx_session_steps_job_trainable_id` on `(job_id, is_trainable, id)`。 -已有 `job_id` 必须显式选择 `--resume` 或 `--rebuild-table`。前者跳过已完成环境,后者只删除当前任务的配置和轨迹;两个参数不能同时使用。 +已有 `job_id` 必须显式选择 `--resume` 或 `--rebuild-table`。前者会删除未完成环境的旧 landing 行,后者删除当前任务的环境配置和 landing 行;两个参数不能同时使用。 + +所有 Cloud launcher 必须把 `--cloud-job-claim-dir` 指向同一个持久共享文件系统。文件租约会一直持有到首批及后台环境写入全部结束,避免并发触发 `max(id)+1` 分配。Cloud landing 删除默认关闭:必须用 `--confirm-cloud-delete-job-id` 精确确认任务,并输出解析后的 profile、DB URI、landing 表、过滤条件和预检行数。生产目标还必须提供 `--confirm-production`,并通过 `--cloud-delete-archive-dir` 生成且校验删除前归档。serving 的发布与撤回完全属于数据平台职责;该流程不会查询或修改 serving 表。 diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 5dc8eae..4bd6195 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -65,8 +65,12 @@ For the first smoke test, use `env/geo3k/datasets/geo3k_sample.jsonl` in a local | Category | Flag | Default | Description | |----------|------|---------|-------------| -| Storage | `--rebuild-table` / `--no-rebuild-table` | `false` | Delete configs and trajectories for the current `job_id`, then start over. Mutually exclusive with `--resume`. | -| Storage | `--resume` | `false` | Resume an existing `job_id` and skip environments with `finished=true`. | +| Storage | `--rebuild-table` / `--no-rebuild-table` | `false` | Delete configs and landing trajectories for the current `job_id`, then start over. Mutually exclusive with `--resume`; Cloud safety gates apply. | +| Storage | `--resume` | `false` | Resume an existing `job_id`, deleting stale landing rows for unfinished environments before continuing. Cloud safety gates apply. | +| Storage | `--cloud-job-claim-dir` | empty | Durable shared filesystem directory used to serialize Cloud environment initialization. Required in Cloud mode. | +| Storage | `--confirm-cloud-delete-job-id` | empty | Exact `job_id` confirmation required for Cloud resume/rebuild deletion. | +| Storage | `--confirm-production` | `false` | Additional acknowledgement required when the resolved Cloud profile/table is production. | +| Storage | `--cloud-delete-archive-dir` | empty | Durable directory for verified pre-delete archives. Required for production Cloud deletion. | | Storage | `--disable-buffer` | buffer enabled | Disable buffered writes. | | Storage | `--buffer-size` | `100` | Write buffer capacity. | | Storage | `--flush-interval` | `5.0` | Write buffer flush interval in seconds. | diff --git a/docs/reference/configuration_CN.md b/docs/reference/configuration_CN.md index 91f408b..03a7d4b 100644 --- a/docs/reference/configuration_CN.md +++ b/docs/reference/configuration_CN.md @@ -65,8 +65,12 @@ python launcher.py \ | 类别 | 参数 | 默认值 | 说明 | |------|------|--------|------| -| Storage | `--rebuild-table` / `--no-rebuild-table` | `false` | 删除当前 `job_id` 的配置和轨迹后重头运行。不能与 `--resume` 同时使用。 | -| Storage | `--resume` | `false` | 续跑已有 `job_id`,跳过 `finished=true` 的环境。 | +| Storage | `--rebuild-table` / `--no-rebuild-table` | `false` | 删除当前 `job_id` 的配置和 landing 轨迹后重头运行。不能与 `--resume` 同时使用;Cloud 会执行安全门检查。 | +| Storage | `--resume` | `false` | 续跑已有 `job_id`,继续前会删除未完成环境的旧 landing 行;Cloud 会执行安全门检查。 | +| Storage | `--cloud-job-claim-dir` | 空 | 用于串行化 Cloud 环境初始化的持久共享文件系统目录;Cloud 模式必填。 | +| Storage | `--confirm-cloud-delete-job-id` | 空 | Cloud resume/rebuild 删除前必须精确确认的 `job_id`。 | +| Storage | `--confirm-production` | `false` | 解析出的 Cloud profile/表为生产环境时必须额外确认。 | +| Storage | `--cloud-delete-archive-dir` | 空 | 删除前校验归档的持久目录;Cloud 生产删除必填。 | | Storage | `--disable-buffer` | buffer 启用 | 禁用缓冲写入。 | | Storage | `--buffer-size` | `100` | 写入缓冲区容量。 | | Storage | `--flush-interval` | `5.0` | 写入缓冲刷新间隔,单位秒。 | diff --git a/docs/reference/gateway.md b/docs/reference/gateway.md index afac27f..a8c7867 100644 --- a/docs/reference/gateway.md +++ b/docs/reference/gateway.md @@ -98,7 +98,7 @@ For cloud storage, start both processes with `storage_type: cloud` and the cloud ## Telemetry And Request Logs -Gateway telemetry writes rows into `session_steps` with `is_trainable = false`. These rows contain request and response payloads in `messages`, plus event metadata in `env_state`. +Gateway telemetry writes rows into `session_steps` with `is_trainable = false`. Request-side history is stored in `messages`, the current output in `response`, and event metadata in `meta_json`. Relevant config: diff --git a/docs/reference/gateway_CN.md b/docs/reference/gateway_CN.md index 3579d0a..664c913 100644 --- a/docs/reference/gateway_CN.md +++ b/docs/reference/gateway_CN.md @@ -98,7 +98,7 @@ python launcher.py \ ## Telemetry 与请求日志 -Gateway telemetry 会写入 `session_steps`,并设置 `is_trainable = false`。这些行的 `messages` 中保存请求/响应 payload,`env_state` 中保存事件元数据。 +Gateway telemetry 会写入 `session_steps`,并设置 `is_trainable = false`。请求侧历史保存在 `messages`,当前输出保存在 `response`,事件元数据保存在 `meta_json`。 相关配置: diff --git a/evaluator/reward_committer.py b/evaluator/reward_committer.py index 652005e..53f63de 100644 --- a/evaluator/reward_committer.py +++ b/evaluator/reward_committer.py @@ -2,11 +2,13 @@ import json import logging +import uuid from typing import Any from core.data_manager.manager import DataManager from core.perf_trace import PerfTrace from evaluator.eval_types import EvalResult, EvalStatus, to_jsonable +from evaluator.trajectory_policy import metadata_from_row, select_reward_target log = logging.getLogger("evaluator.reward_committer") @@ -98,10 +100,7 @@ async def _commit_data_manager( session_id, checkout_latest=True, ) - terminal = next( - (row for row in reversed(rows) if _is_trainable_step(row)), - None, - ) + terminal = select_reward_target(rows) log.info( "EVAL REWARD rows: session=%s total_rows=%d terminal_found=%s", session_id, @@ -116,22 +115,48 @@ async def _commit_data_manager( summary_metadata = _as_eval_summary_metadata(metadata) summary = _existing_eval_summary_row(rows, session_id) if summary is None: - recorded = await self.data_manager.record_evaluation_summary( - session_id=session_id, - step_id=_next_step_id(rows), - reward=eval_result.normalized_score_10, - env_state=summary_metadata, - truncated=truncated, - ) + reference = rows[-1] if rows else {} + insert_rows = getattr(self.data_manager, "insert_session_step_rows", None) + if callable(insert_rows): + record_ids = await insert_rows([{ + "record_id": str(uuid.uuid4()), + "session_id": session_id, + "env_id": session_id, + "step_id": _next_step_id(rows), + "env_name": str(reference.get("env_name") or "gateway"), + "llm_model": str(reference.get("llm_model") or ""), + "group_id": str(reference.get("group_id") or ""), + "job_id": str(reference.get("job_id") or self.data_manager.job_id or ""), + "messages": [], + "request": None, + "response": "", + "step_reward": eval_result.normalized_score_10, + "reward": eval_result.normalized_score_10, + "meta_json": _load_meta_json(summary_metadata), + "is_terminal": True, + "is_truncated": truncated, + "is_session_completed": True, + "is_trainable": False, + }]) + recorded = len(record_ids) + else: + # Compatibility for injected legacy test doubles. + recorded = await self.data_manager.record_evaluation_summary( + session_id=session_id, + step_id=_next_step_id(rows), + reward=eval_result.normalized_score_10, + env_state=summary_metadata, + truncated=truncated, + ) else: - recorded = await self.data_manager.update_session_step( - session_id, - int(summary.get("step_id") or 0), + recorded = await _update_persisted_row( + self.data_manager, + summary, { "step_reward": eval_result.normalized_score_10, "reward": eval_result.normalized_score_10, - "env_state": _merge_env_state( - summary.get("env_state"), + "meta_json": _merge_meta_json( + summary.get("meta_json"), summary_metadata, ), "is_terminal": True, @@ -155,14 +180,14 @@ async def _commit_data_manager( session_id=session_id, eval_result=eval_result, ) - env_state = _merge_env_state(terminal.get("env_state"), metadata) - updated = await self.data_manager.update_session_step( - session_id, - int(terminal.get("step_id") or 0), + meta_json = _merge_meta_json(terminal.get("meta_json"), metadata) + updated = await _update_persisted_row( + self.data_manager, + terminal, { "step_reward": eval_result.normalized_score_10, "reward": eval_result.normalized_score_10, - "env_state": env_state, + "meta_json": meta_json, "is_terminal": True, **({"is_truncated": True} if truncated else {}), "is_session_completed": True, @@ -188,14 +213,14 @@ def _build_reward_metadata(self, *, session_id: str, eval_result: EvalResult) -> ) -def _merge_env_state(existing: Any, new_metadata: str) -> str: +def _merge_meta_json(existing: Any, new_metadata: str) -> str: if isinstance(existing, dict): existing_obj = dict(existing) else: try: existing_obj = json.loads(existing) if existing else {} except Exception: - existing_obj = {"previous_env_state": existing} + existing_obj = {"legacy_meta_json": existing} try: new_obj = json.loads(new_metadata) except Exception: @@ -204,42 +229,12 @@ def _merge_env_state(existing: Any, new_metadata: str) -> str: return json.dumps(existing_obj, ensure_ascii=False) -_NON_TRAINABLE_EVENT_TYPES = { - "gateway_session_close", - "episode_summary", - "evaluation_summary", -} - - -def _is_trainable_step(row: dict[str, Any]) -> bool: - env_state = _load_env_state(row["env_state"]) - event_type = env_state.get("event_type") - if event_type in _NON_TRAINABLE_EVENT_TYPES: - return False - if env_state.get("synthetic_stop"): - return False - if event_type == "gateway_inference": - try: - return int(env_state.get("status_code") or 200) < 400 - except (TypeError, ValueError): - return True - return bool(_has_messages(row["messages"]) or row["response"]) - - -def _last_trainable_row(rows: list[dict[str, Any]], trainable_ids: list[int]) -> dict[str, Any] | None: - trainable = set(trainable_ids) - for row in reversed(rows): - if int(row["id"]) in trainable: - return row - return None - - def _existing_eval_summary_row(rows: list[dict[str, Any]], session_id: str) -> dict[str, Any] | None: for row in reversed(rows): - env_state = _load_env_state(row["env_state"]) - if env_state.get("event_type") != "evaluation_summary": + meta_json = metadata_from_row(row) + if meta_json.get("event_type") != "evaluation_summary": continue - eval_metadata = env_state.get("eval") + eval_metadata = meta_json.get("eval") if isinstance(eval_metadata, dict) and eval_metadata.get("session_id") == session_id: return row return None @@ -252,12 +247,12 @@ def _next_step_id(rows: list[dict[str, Any]]) -> int: def _as_eval_summary_metadata(metadata: str) -> str: - obj = _load_env_state(metadata) + obj = _load_meta_json(metadata) obj["event_type"] = "evaluation_summary" return json.dumps(obj, ensure_ascii=False) -def _load_env_state(value: Any) -> dict[str, Any]: +def _load_meta_json(value: Any) -> dict[str, Any]: if isinstance(value, dict): return dict(value) try: @@ -267,9 +262,21 @@ def _load_env_state(value: Any) -> dict[str, Any]: return parsed if isinstance(parsed, dict) else {} -def _has_messages(value: Any) -> bool: - try: - parsed = json.loads(value) if isinstance(value, str) else value - except Exception: - return bool(value) - return bool(parsed) +async def _update_persisted_row( + data_manager: Any, + row: dict[str, Any], + updates: dict[str, Any], +) -> int: + record_id = str(row.get("record_id") or row.get("id") or "") + update_rows = getattr(data_manager, "update_session_step_rows", None) + if record_id and callable(update_rows): + return await update_rows( + job_id=str(row.get("job_id") or "") or None, + record_id=record_id, + updates=updates, + ) + return await data_manager.update_session_step( + str(row.get("session_id") or ""), + int(row.get("step_id") or 0), + updates, + ) diff --git a/evaluator/trajectory_policy.py b/evaluator/trajectory_policy.py new file mode 100644 index 0000000..adcc8bd --- /dev/null +++ b/evaluator/trajectory_policy.py @@ -0,0 +1,66 @@ +"""Evaluator-owned rules for classifying persisted trajectory rows.""" + +from __future__ import annotations + +import json +from typing import Any, Dict, Iterable, Optional + + +NON_TRAJECTORY_EVENT_TYPES = { + "gateway_session_close", + "episode_summary", + "evaluation_summary", +} + + +def metadata_from_row(row: Dict[str, Any]) -> Dict[str, Any]: + value = row.get("meta_json") + if isinstance(value, dict): + return dict(value) + try: + parsed = json.loads(value) if value else {} + except Exception: + return {} + return parsed if isinstance(parsed, dict) else {} + + +def is_session_sealing_event(row: Dict[str, Any]) -> bool: + metadata = metadata_from_row(row) + return bool( + row.get("is_session_completed") + or row.get("is_terminal") + or metadata.get("is_session_completed") + or metadata.get("event_type") in NON_TRAJECTORY_EVENT_TYPES + ) + + +def is_trajectory_step(row: Dict[str, Any]) -> bool: + metadata = metadata_from_row(row) + event_type = metadata.get("event_type") + if event_type in NON_TRAJECTORY_EVENT_TYPES or metadata.get("synthetic_stop"): + return False + if event_type == "gateway_inference": + try: + return int(metadata.get("status_code") or 200) < 400 + except (TypeError, ValueError): + return True + return bool(_has_messages(row.get("messages")) or row.get("response")) + + +def select_reward_target(rows: Iterable[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + candidates = [row for row in rows if is_trajectory_step(row)] + if not candidates: + return None + return max(candidates, key=lambda row: ( + int(row.get("step_id") or 0), + str(row.get("created_at") or ""), + str(row.get("record_id") or row.get("id") or ""), + )) + + +def _has_messages(value: Any) -> bool: + try: + parsed = json.loads(value) if isinstance(value, str) else value + except Exception: + return bool(value) + return bool(parsed) diff --git a/evaluator/trajectory_reader.py b/evaluator/trajectory_reader.py index 4ad7f70..5122b4d 100644 --- a/evaluator/trajectory_reader.py +++ b/evaluator/trajectory_reader.py @@ -1,5 +1,4 @@ from __future__ import annotations - import asyncio import json import time @@ -7,6 +6,11 @@ from core.data_manager.manager import DataManager from evaluator.eval_types import Trajectory +from evaluator.trajectory_policy import ( + is_session_sealing_event, + is_trajectory_step, + metadata_from_row, +) class TrajectoryReader: @@ -69,17 +73,17 @@ def _trajectory_from_rows( final_response = None steps: list[dict[str, Any]] = [] for step in all_steps: - env_state = step.get("env_state") or {} - if _is_session_sealing_event(step): + meta_json = metadata_from_row(step) + if is_session_sealing_event(step): sealed = True - if not _is_trajectory_step(step): + if not is_trajectory_step(step): continue steps.append(step) response = step.get("response") or _last_assistant_content(step.get("messages")) if response: final_response = response for key in token_usage: - token_usage[key] += int(env_state.get(key) or 0) + token_usage[key] += int(meta_json.get(key) or 0) return Trajectory( session_id=session_id, steps=steps, @@ -92,17 +96,11 @@ def _trajectory_from_rows( def parse_gateway_row(self, row: dict[str, Any]) -> dict[str, Any]: parsed = dict(row) parsed["messages"] = _json_loads(row.get("messages"), default=[]) - parsed["env_state"] = _json_loads(row.get("env_state"), default={}) + parsed["meta_json"] = _json_loads(row.get("meta_json"), default={}) parsed["response"] = _extract_response_text(row.get("response")) return parsed -def _sqlite_path(db_url: str) -> str: - if db_url.startswith("sqlite://"): - return db_url[len("sqlite://") :] - return db_url - - def _json_loads(value: Any, *, default: Any) -> Any: if value is None: return default @@ -241,36 +239,3 @@ def _last_assistant_content(messages: Any) -> str: parts.append(item) return "".join(parts) return "" - - -_NON_TRAJECTORY_EVENT_TYPES = { - "gateway_session_close", - "episode_summary", - "evaluation_summary", -} - - -def _is_session_sealing_event(step: dict[str, Any]) -> bool: - env_state = step.get("env_state") or {} - event_type = env_state.get("event_type") - return bool( - step.get("is_session_completed") - or step.get("is_terminal") - or env_state.get("is_session_completed") - or event_type in _NON_TRAJECTORY_EVENT_TYPES - ) - - -def _is_trajectory_step(step: dict[str, Any]) -> bool: - env_state = step.get("env_state") or {} - event_type = env_state.get("event_type") - if event_type in _NON_TRAJECTORY_EVENT_TYPES: - return False - if env_state.get("synthetic_stop"): - return False - if event_type == "gateway_inference": - try: - return int(env_state.get("status_code") or 200) < 400 - except (TypeError, ValueError): - return True - return bool(step.get("messages") or step.get("response")) diff --git a/gateway/storage.py b/gateway/storage.py index 307497c..a8c635b 100644 --- a/gateway/storage.py +++ b/gateway/storage.py @@ -18,6 +18,10 @@ ) from gateway.config import GatewayConfig from gateway.models import GatewaySessionBinding, GatewayTelemetryRecord +from gateway.trajectory_builder import ( + build_gateway_step_row, + select_latest_trajectory_record_ids, +) GATEWAY_STORAGE_NAMESPACE = "gateway" log = logging.getLogger("gateway.storage") @@ -346,7 +350,7 @@ async def record_inference_steps_batch( steps: list[dict[str, Any]] = [] with trace.span("session_context.prepare", operation="in_memory"): for binding, record in batch: - session = await self.get_or_create_session(binding, record.requested_model) + await self.get_or_create_session(binding, record.requested_model) environment = await self._resolve_session_environment(record.session_id) dataset = environment.dataset if environment is not None else None @@ -379,26 +383,17 @@ async def record_inference_steps_batch( elif record.endpoint == "responses": stored_messages = _responses_request_input(request_payload) stored_response = _responses_output(record.response) - step = { - "session": session, - "step_id": record.seq_id, - "messages": stored_messages, - "request": record.request, - "response": stored_response, - "step_reward": 0.0, - "reward": None, - "env_state": json.dumps(self._metadata(record), ensure_ascii=False, default=str), - "terminated": False, - "truncated": record.is_truncated, - "is_trainable": False, - } - if provider_meta is not None: - step["provider_meta"] = provider_meta - if dataset is not None: - step["dataset"] = dataset - steps.append(step) + steps.append(build_gateway_step_row( + binding=binding, + record=record, + messages=stored_messages, + response=stored_response, + metadata=self._metadata(record), + dataset=dataset, + provider_meta=provider_meta, + )) with trace.span("storage.record_steps_batch", table="session_steps"): - record_ids = await self.data_manager.record_steps_batch(steps) + record_ids = await self.data_manager.insert_session_step_rows(steps) async with self._lock: for (_, record), record_id in zip(batch, record_ids): @@ -435,38 +430,6 @@ async def record_session_close( ) try: terminal = record.is_session_completed or binding.close_reason == "rollout_sealed" - if self.cfg.storage_type == "cloud" and record.is_session_completed: - async with self._lock: - record_ids = [ - record_id - for (session_id, _model), record_id in self._latest_record_ids.items() - if session_id == binding.session_id - ] - trace.update_context(record_id_count=len(record_ids), close_strategy="known_record_ids") - if not record_ids: - elapsed_ms = (time.perf_counter() - started) * 1000 - log.info( - "Gateway storage session_close skipped: session_id=%s has no persisted record IDs", - binding.session_id, - ) - trace.emit_summary(status="success", elapsed_ms=elapsed_ms, updated_count=0) - return - with trace.span( - "storage.mark_known_records_completed", - table="session_steps", - record_count=len(record_ids), - ): - updated_count = await self.data_manager.mark_records_completed(record_ids) - elapsed_ms = (time.perf_counter() - started) * 1000 - log.info( - "Gateway storage session_close complete: session_id=%s updated_count=%d elapsed_ms=%.2f", - binding.session_id, - updated_count, - elapsed_ms, - ) - trace.emit_summary(status="success", elapsed_ms=elapsed_ms, updated_count=updated_count) - return - with trace.span("models_for_session"): models = await self._models_for_session(binding) trace.update_context(model_count=len(models), models=models) @@ -477,72 +440,50 @@ async def record_session_close( binding.close_reason, record.is_session_completed, ) - if not models: - with trace.span( - "mark_latest_session_completed_without_model", - operation="db_write", - table="session_steps", - ): - await self.data_manager.mark_latest_session_completed( - session_id=binding.session_id, - is_session_completed=record.is_session_completed, - is_terminal=terminal, - ) + async with self._lock: + record_ids = [ + record_id + for (session_id, model), record_id in self._latest_record_ids.items() + if session_id == binding.session_id and (not models or model in models) + ] + close_strategy = "known_record_ids" + if not record_ids: + close_strategy = "query_then_select" + rows = await self.data_manager.list_session_steps( + binding.session_id, + job_id=binding.job_id, + checkout_latest=True, + ) + record_ids = select_latest_trajectory_record_ids(rows, models=models) + trace.update_context( + record_id_count=len(record_ids), + close_strategy=close_strategy, + ) + if not record_ids: elapsed_ms = (time.perf_counter() - started) * 1000 - trace.emit_summary(status="success", elapsed_ms=elapsed_ms, updated_without_model=True) log.info( - "Gateway storage session_close complete: session_id=%s updated_without_model elapsed_ms=%.2f", + "Gateway storage session_close skipped: session_id=%s has no trajectory records", binding.session_id, - elapsed_ms, ) + trace.emit_summary(status="success", elapsed_ms=elapsed_ms, updated_count=0) return - updated_count = 0 - if self.cfg.storage_type == "cloud" and len(models) > 1: - with trace.span( - "mark_latest_session_completed_models", - operation="db_write", - table="session_steps", - model_count=len(models), - ): - update_counts = await asyncio.gather( - *( - self.data_manager.mark_latest_session_completed( - session_id=binding.session_id, - llm_model=model, - is_session_completed=record.is_session_completed, - is_terminal=terminal, - ) - for model in models - ) - ) - updated_count = sum(update_counts) - else: - for model in models: - with trace.span( - "mark_latest_session_completed", - operation="db_write", - table="session_steps", - model=model, - ): - updated_count += await self.data_manager.mark_latest_session_completed( - session_id=binding.session_id, - llm_model=model, - is_session_completed=record.is_session_completed, - is_terminal=terminal, - ) - - if updated_count == 0: - with trace.span( - "mark_latest_session_completed_fallback", - operation="db_write", - table="session_steps", - ): - await self.data_manager.mark_latest_session_completed( - session_id=binding.session_id, - is_session_completed=record.is_session_completed, - is_terminal=terminal, - ) + updates: dict[str, Any] = { + "is_session_completed": bool(record.is_session_completed), + "is_terminal": bool(terminal), + } + if not record.is_session_completed: + updates.update(step_reward=0.0, reward=None) + with trace.span( + "storage.update_session_lifecycle", + table="session_steps", + record_count=len(record_ids), + ): + updated_count = await self.data_manager.update_session_step_rows( + job_id=binding.job_id, + record_ids=record_ids, + updates=updates, + ) elapsed_ms = (time.perf_counter() - started) * 1000 log.info( "Gateway storage session_close complete: session_id=%s updated_count=%d elapsed_ms=%.2f", diff --git a/gateway/trajectory_builder.py b/gateway/trajectory_builder.py new file mode 100644 index 0000000..4a34ae7 --- /dev/null +++ b/gateway/trajectory_builder.py @@ -0,0 +1,98 @@ +"""Gateway-owned construction and lifecycle selection for persisted trajectory rows.""" + +from __future__ import annotations + +import uuid +from typing import Any, Dict, Iterable, Optional + +from gateway.models import GatewaySessionBinding, GatewayTelemetryRecord + + +NON_TRAJECTORY_EVENT_TYPES = { + "gateway_session_close", + "episode_summary", + "evaluation_summary", +} + + +def build_gateway_step_row( + *, + binding: GatewaySessionBinding, + record: GatewayTelemetryRecord, + messages: Any, + response: Any, + metadata: Dict[str, Any], + dataset: Any = None, + provider_meta: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + meta_json = dict(metadata) + if dataset is not None: + meta_json["dataset"] = dataset + if provider_meta: + meta_json.update(provider_meta) + record_id = str(uuid.uuid5( + uuid.NAMESPACE_URL, + ":".join(( + str(binding.job_id or "gateway"), + record.session_id, + str(record.seq_id), + record.requested_model, + record.event_type, + )), + )) + return { + "record_id": record_id, + "session_id": record.session_id, + "env_id": record.session_id, + "step_id": record.seq_id, + "env_name": binding.env_name or "gateway", + "llm_model": record.requested_model, + "group_id": binding.group_id or "", + "job_id": binding.job_id or "gateway", + "messages": messages, + "request": record.request, + "response": response, + "step_reward": 0.0, + "reward": None, + "meta_json": meta_json, + "is_terminal": bool(record.is_truncated), + "is_truncated": bool(record.is_truncated), + "is_session_completed": False, + "is_trainable": False, + } + + +def select_latest_trajectory_record_ids( + rows: Iterable[Dict[str, Any]], + *, + models: Iterable[str] = (), +) -> list[str]: + requested_models = {str(model) for model in models if model} + latest: Dict[str, Dict[str, Any]] = {} + for row in rows: + meta_json = row.get("meta_json") + if not isinstance(meta_json, dict): + meta_json = {} + if meta_json.get("event_type") in NON_TRAJECTORY_EVENT_TYPES: + continue + model = str(row.get("llm_model") or "") + if requested_models and model not in requested_models: + continue + previous = latest.get(model) + current_key = ( + int(row.get("step_id") or 0), + str(row.get("created_at") or ""), + str(row.get("record_id") or row.get("id") or ""), + ) + previous_key = ( + int(previous.get("step_id") or 0), + str(previous.get("created_at") or ""), + str(previous.get("record_id") or previous.get("id") or ""), + ) if previous else None + if previous_key is None or current_key > previous_key: + latest[model] = row + return [ + str(row.get("record_id") or row.get("id")) + for row in latest.values() + if row.get("record_id") or row.get("id") + ] diff --git a/manager/session_lifecycle.py b/manager/session_lifecycle.py new file mode 100644 index 0000000..8b4c4f1 --- /dev/null +++ b/manager/session_lifecycle.py @@ -0,0 +1,50 @@ +"""Manager-owned fallback lifecycle operations for unevaluated runs.""" + +from __future__ import annotations + +from typing import Any, Dict + + +NON_TRAJECTORY_EVENT_TYPES = { + "gateway_session_close", + "episode_summary", + "evaluation_summary", +} + + +async def complete_latest_session_step( + data_manager: Any, + *, + session_id: str, + job_id: str, + llm_model: str | None = None, +) -> int: + rows = await data_manager.list_session_steps( + session_id, + job_id=job_id, + checkout_latest=True, + ) + candidates = [ + row for row in rows + if (not llm_model or str(row.get("llm_model") or "") == llm_model) + and _is_trajectory_row(row) + ] + if not candidates: + return 0 + latest = max(candidates, key=lambda row: ( + int(row.get("step_id") or 0), + str(row.get("created_at") or ""), + str(row.get("record_id") or row.get("id") or ""), + )) + return await data_manager.update_session_step_rows( + job_id=job_id, + record_id=str(latest.get("record_id") or latest.get("id")), + updates={"is_session_completed": True, "is_terminal": True}, + ) + + +def _is_trajectory_row(row: Dict[str, Any]) -> bool: + meta_json = row.get("meta_json") + if not isinstance(meta_json, dict): + meta_json = {} + return meta_json.get("event_type") not in NON_TRAJECTORY_EVENT_TYPES diff --git a/manager/simulation_config.py b/manager/simulation_config.py index 9b4f871..9dd14ff 100644 --- a/manager/simulation_config.py +++ b/manager/simulation_config.py @@ -327,6 +327,16 @@ def load_simulation_run_config(args: Any) -> SimulationRunConfig: max_workers=max_workers, rebuild_table=bool(args.rebuild_table), resume=bool(getattr(args, "resume", False)), + confirm_cloud_delete_job_id=str( + getattr(args, "confirm_cloud_delete_job_id", "") or "" + ).strip(), + confirm_production=bool(getattr(args, "confirm_production", False)), + cloud_delete_archive_dir=str( + getattr(args, "cloud_delete_archive_dir", "") or "" + ).strip(), + cloud_job_claim_dir=str( + getattr(args, "cloud_job_claim_dir", "") or "" + ).strip(), enable_buffer=bool(args.enable_buffer), buffer_size=int(args.buffer_size), flush_interval=float(args.flush_interval), diff --git a/manager/simulation_flow.py b/manager/simulation_flow.py index de390b3..e418d4e 100644 --- a/manager/simulation_flow.py +++ b/manager/simulation_flow.py @@ -98,6 +98,12 @@ async def prepare_storage(self) -> None: } if self.cfg.storage_type == "sqlite": storage_config["db_url"] = self.cfg.db_url + else: + storage_config.update({ + "confirm_cloud_delete_job_id": self.cfg.confirm_cloud_delete_job_id, + "confirm_production": self.cfg.confirm_production, + "cloud_delete_archive_dir": self.cfg.cloud_delete_archive_dir, + }) self.data_manager = DataManager( job_id=self.cfg.job_id, @@ -117,6 +123,7 @@ async def prepare_storage(self) -> None: self.cfg.followup_submit_batch, rebuild_table=self.cfg.rebuild_table, resume=self.cfg.resume, + job_claim_dir=self.cfg.cloud_job_claim_dir, ) self.manager_cfg = build_manager_runtime_config(self.cfg) if self.cfg.resume and self.cfg.mode == "rjob": diff --git a/manager/simulation_worker.py b/manager/simulation_worker.py index 0b106fd..800f4a6 100644 --- a/manager/simulation_worker.py +++ b/manager/simulation_worker.py @@ -21,6 +21,7 @@ from evaluator.service import EvaluationService from .agent_start_client import AgentStartClient +from .session_lifecycle import complete_latest_session_step from .simulation_lease_pool import SimulationLeasePool from .types import ( SimulationAgentLease, @@ -373,8 +374,10 @@ async def _worker_loop(self, worker_id: int) -> None: ) release_reusable = False else: - await self.data_manager.mark_latest_session_completed( - result.session_id, + await complete_latest_session_step( + self.data_manager, + session_id=result.session_id, + job_id=self.cfg.job_id, llm_model=self.cfg.llm_model, ) await self.data_manager.mark_environment_finished(lease.agent_id) diff --git a/manager/types.py b/manager/types.py index 84bc24b..dc0ad2f 100644 --- a/manager/types.py +++ b/manager/types.py @@ -81,6 +81,10 @@ class SimulationRunConfig: max_workers: Optional[int] = None rebuild_table: bool = False resume: bool = False + confirm_cloud_delete_job_id: str = "" + confirm_production: bool = False + cloud_delete_archive_dir: str = "" + cloud_job_claim_dir: str = "" enable_buffer: bool = True buffer_size: int = 100 flush_interval: float = 5.0 From af53cf053477f3bc25c6e2c5eb00eb4817595135 Mon Sep 17 00:00:00 2001 From: luhehb <278922717@qq.com> Date: Fri, 14 Aug 2026 23:49:58 +0800 Subject: [PATCH 10/11] add gateway session clean up --- evaluator/gateway_client.py | 10 ++++++++++ gateway/app.py | 22 ++++++++++++++++++++++ gateway/session_resolver.py | 8 ++++++++ manager/simulation_flow.py | 28 ++++++++++++++++++++++++++++ 4 files changed, 68 insertions(+) diff --git a/evaluator/gateway_client.py b/evaluator/gateway_client.py index e337671..a99d512 100644 --- a/evaluator/gateway_client.py +++ b/evaluator/gateway_client.py @@ -59,6 +59,16 @@ async def get_session_status(self, session_id: str) -> dict[str, Any] | None: response.raise_for_status() return response.json() + async def clear_session_cache(self, session_ids: list[str]) -> dict[str, Any]: + if not session_ids: + return {"session_ids": [], "removed": {}} + response = await self._client.post( + f"{self.gateway_base_url}/cache/cleanup", + json={"session_ids": session_ids}, + ) + response.raise_for_status() + return response.json() + async def aclose(self) -> None: await self._client.aclose() diff --git a/gateway/app.py b/gateway/app.py index 9e7baf8..5c82dda 100644 --- a/gateway/app.py +++ b/gateway/app.py @@ -729,6 +729,28 @@ async def close_session(session_id: str, request: Request) -> dict[str, Any]: "completion_mode": completion_mode, } + async def clear_session_cache(payload: dict[str, Any]) -> dict[str, Any]: + raw_session_ids = payload.get("session_ids") + if not isinstance(raw_session_ids, list): + raise HTTPException(status_code=400, detail="session_ids must be a non-empty list") + session_ids = list(dict.fromkeys( + item.strip() + for item in raw_session_ids + if isinstance(item, str) and item.strip() + )) + if not session_ids: + raise HTTPException(status_code=400, detail="session_ids must be a non-empty list") + + resolver: SessionResolver = app.state.gateway_resolver + removed = await resolver.clear_session_cache(session_ids) + log.info("Gateway session cache cleared: sessions=%d removed=%d", len(session_ids), removed) + return {"session_ids": session_ids, "removed": removed} + + app.add_api_route( + f"{session_root}/cache/cleanup", + clear_session_cache, + methods=["POST"], + ) app.add_api_route( f"{session_root}/{{session_id}}/chat/completions", handle_session_chat_completions, diff --git a/gateway/session_resolver.py b/gateway/session_resolver.py index 885aec0..d2d8ec4 100644 --- a/gateway/session_resolver.py +++ b/gateway/session_resolver.py @@ -91,6 +91,14 @@ async def close_session(self, session_id: str, reason: str = "gateway_close") -> binding.close(reason, now) return binding + async def clear_session_cache(self, session_ids: list[str]) -> int: + targets = set(session_ids) + async with self._lock: + removed = sum(session_id in self._bindings for session_id in targets) + for session_id in targets: + self._bindings.pop(session_id, None) + return removed + async def get_status(self, session_id: str) -> dict[str, Any] | None: async with self._lock: binding = self._bindings.get(session_id) diff --git a/manager/simulation_flow.py b/manager/simulation_flow.py index e418d4e..2fefa5a 100644 --- a/manager/simulation_flow.py +++ b/manager/simulation_flow.py @@ -70,6 +70,9 @@ async def run(self) -> SimulationRunSummary: await self.prepare_storage() with trace.span("check_gateway_ready"): await self.check_gateway_ready() + if self.cfg.resume: + with trace.span("clear_gateway_session_cache"): + await self.clear_resume_gateway_session_cache() with trace.span("start_agent_scheduler"): await self.start_agent_scheduler() with trace.span("run_workers"): @@ -160,6 +163,31 @@ def _probe() -> tuple[int, str]: await self.check_gateway_model_route() log.info("gateway ready: %s", ready_url) + async def clear_resume_gateway_session_cache(self) -> None: + if self.data_manager is None: + raise RuntimeError("data manager is not prepared") + rows = await self.data_manager.get_all_environments(self.cfg.job_id) + session_ids = [ + str(row.get("env_id")) + for row in rows + if row.get("env_id") + and not bool(row.get("finished")) + and not bool(row.get("is_deleted")) + ] + if not session_ids: + return + client = GatewayClient(gateway_base_url=self.cfg.gateway_base_url) + try: + result = await client.clear_session_cache(session_ids) + finally: + await client.aclose() + log.info( + "gateway resume session cache cleared: job_id=%s sessions=%d removed=%s", + self.cfg.job_id, + len(session_ids), + result.get("removed", 0), + ) + async def check_gateway_model_route(self) -> None: metrics_url = self._gateway_origin() + "/metrics" From 4946922979802236f22f5ef38710c16acea614a1 Mon Sep 17 00:00:00 2001 From: luhehb <278922717@qq.com> Date: Sat, 15 Aug 2026 09:51:17 +0800 Subject: [PATCH 11/11] update cybergym runner to distinguish the truncated --- env/cybergym/runner.sh | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/env/cybergym/runner.sh b/env/cybergym/runner.sh index db4fcd6..90d8051 100755 --- a/env/cybergym/runner.sh +++ b/env/cybergym/runner.sh @@ -131,11 +131,19 @@ agent_command=( printf 'command:' >"$NATIVE_OUTPUT" printf ' %q' "${agent_command[@]}" >>"$NATIVE_OUTPUT" printf '\n' >>"$NATIVE_OUTPUT" -set +e -timeout --signal=TERM --kill-after=30 "${process_timeout_s}s" \ - "${agent_command[@]}" >>"$NATIVE_OUTPUT" 2>&1 -native_returncode=$? -set -e +agent_started_s=$SECONDS +if timeout --signal=TERM --kill-after=30 "${process_timeout_s}s" \ + "${agent_command[@]}" >>"$NATIVE_OUTPUT" 2>&1; then + native_returncode=0 +else + native_returncode=$? +fi +agent_elapsed_s=$((SECONDS - agent_started_s)) +if (( native_returncode == 124 || \ + (native_returncode != 0 && agent_elapsed_s >= agent_timeout_s) )); then + printf '\nagent timed out: elapsed=%ss timeout=%ss\n' \ + "$agent_elapsed_s" "$agent_timeout_s" >>"$NATIVE_OUTPUT" +fi printf '\nreturncode: %s\n' "$native_returncode" >>"$NATIVE_OUTPUT" python3.12 "${CYBERGYM_RUNNER_ROOT}/result_writer.py" discover \