diff --git a/src/iac_code/a2a/artifacts.py b/src/iac_code/a2a/artifacts.py index 917caea9..34573345 100644 --- a/src/iac_code/a2a/artifacts.py +++ b/src/iac_code/a2a/artifacts.py @@ -12,7 +12,15 @@ from urllib.parse import quote, unquote, urlsplit from iac_code.i18n import _ +from iac_code.services.session_layout import ( + SESSION_LAYOUT_VERSION_V2, + SessionPaths, + UnsupportedSessionLayoutError, + ensure_session_owned_dir, + require_supported_session_layout, +) from iac_code.utils.public_errors import sanitize_public_text +from iac_code.utils.state_io import atomic_write_bytes PUBLIC_ARTIFACT_URI_PREFIX = "iac-code-artifact://" _PUBLIC_ARTIFACT_URI_SCHEME = "iac-code-artifact" @@ -333,8 +341,9 @@ def to_dict(self) -> dict[str, object]: class A2AArtifactStore: - def __init__(self, root: str | Path) -> None: + def __init__(self, root: str | Path, *, session_dir: str | Path | None = None) -> None: self.root = Path(root) + self._session_dir = Path(session_dir) if session_dir is not None else None def save_text(self, *, filename: str, content: str, media_type: str) -> A2AArtifactMetadata: encoded = content.encode("utf-8") @@ -348,9 +357,12 @@ def save_bytes(self, *, filename: str, content: bytes, media_type: str) -> A2AAr safe_name = self._safe_filename(filename) artifact_id = str(uuid.uuid4()) artifact_dir = self.root / artifact_id - artifact_dir.mkdir(parents=True, exist_ok=False) + if self._session_dir is not None: + ensure_session_owned_dir(self._session_dir, artifact_dir) + else: + artifact_dir.mkdir(parents=True, exist_ok=False) path = artifact_dir / safe_name - path.write_bytes(content) + atomic_write_bytes(path, content) return A2AArtifactMetadata( artifact_id=artifact_id, filename=safe_name, @@ -373,3 +385,13 @@ def _public_uri(artifact_id: str, filename: str) -> str: @staticmethod def _safe_filename(filename: str) -> str: return safe_artifact_filename(filename) + + +def artifact_store_for_session(session_dir: str | Path) -> A2AArtifactStore: + session_path = Path(session_dir) + version = require_supported_session_layout(session_path) + if version != SESSION_LAYOUT_VERSION_V2: + raise UnsupportedSessionLayoutError(_("Unsupported session layout version: {version}").format(version="legacy")) + session_paths = SessionPaths.from_session_dir(session_path) + root = ensure_session_owned_dir(session_path, session_paths.a2a_artifacts_dir) + return A2AArtifactStore(root, session_dir=session_path) diff --git a/src/iac_code/a2a/backup.py b/src/iac_code/a2a/backup.py new file mode 100644 index 00000000..7ccbcfd5 --- /dev/null +++ b/src/iac_code/a2a/backup.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from iac_code.i18n import _ +from iac_code.services.session_backup import BackupReason, SessionBackupBlocked + +logger = logging.getLogger(__name__) + + +async def backup_session_async( + backup_service: Any, + cwd: str, + session_id: str, + *, + reason: BackupReason, + critical: bool, + metrics: Any | None = None, +) -> Any | None: + failed_recorded = False + try: + result = await asyncio.to_thread( + backup_service.backup_session, + cwd, + session_id, + reason=reason, + critical=critical, + ) + retry_count = _retry_count(result) + if getattr(result, "enabled", False) and not getattr(result, "succeeded", True): + message = str( + getattr(result, "error", None) or _("Session backup failed. Retry after the backup path is available.") + ) + _record_backup_failed(metrics, reason=reason, critical=critical, retry_count=retry_count) + failed_recorded = True + if critical: + raise SessionBackupBlocked(message, retry_count=retry_count, result=result) + logger.warning( + "A2A session backup failed reason=%s critical=%s retry_count=%s: %s", + reason.value, + critical, + retry_count, + message, + ) + elif getattr(result, "enabled", False): + _record_backup_succeeded(metrics, reason=reason, critical=critical, retry_count=retry_count) + return result + except Exception as exc: + retry_count = _retry_count_from_exception(exc) + if not failed_recorded: + _record_backup_failed(metrics, reason=reason, critical=critical, retry_count=retry_count) + if critical: + raise + logger.warning( + "A2A session backup failed reason=%s critical=%s retry_count=%s error_type=%s", + reason.value, + critical, + retry_count, + type(exc).__name__, + ) + return None + + +def _retry_count(result: Any) -> int: + value = getattr(result, "retry_count", 0) + return value if isinstance(value, int) and value >= 0 else 0 + + +def _retry_count_from_exception(exc: BaseException) -> int: + value = getattr(exc, "retry_count", 0) + return value if isinstance(value, int) and value >= 0 else 0 + + +def _record_backup_succeeded(metrics: Any | None, *, reason: BackupReason, critical: bool, retry_count: int) -> None: + record = getattr(metrics, "record_backup_succeeded", None) + if callable(record): + try: + record(reason=reason.value, critical=critical, retry_count=retry_count) + except Exception as exc: + logger.debug("Failed to record A2A backup_succeeded metric: %s", type(exc).__name__) + + +def _record_backup_failed(metrics: Any | None, *, reason: BackupReason, critical: bool, retry_count: int) -> None: + record = getattr(metrics, "record_backup_failed", None) + if callable(record): + try: + record(reason=reason.value, critical=critical, retry_count=retry_count) + except Exception as exc: + logger.debug("Failed to record A2A backup_failed metric: %s", type(exc).__name__) diff --git a/src/iac_code/a2a/events.py b/src/iac_code/a2a/events.py index cdcd8426..aac6c32a 100644 --- a/src/iac_code/a2a/events.py +++ b/src/iac_code/a2a/events.py @@ -455,7 +455,7 @@ async def publish_stream_event( if event.error_id: error_metadata["errorId"] = event.error_id if event.is_retryable: - text = "A temporary error occurred. Please retry." + text = _("A temporary error occurred. Please retry.") state = TaskState.TASK_STATE_INPUT_REQUIRED else: raw = event.error or "Unknown error" diff --git a/src/iac_code/a2a/executor.py b/src/iac_code/a2a/executor.py index 25c6d4af..cbd32e6a 100644 --- a/src/iac_code/a2a/executor.py +++ b/src/iac_code/a2a/executor.py @@ -17,6 +17,7 @@ from a2a.utils.errors import InvalidParamsError from google.protobuf.json_format import MessageToDict, ParseDict +from iac_code.a2a.backup import backup_session_async from iac_code.a2a.events import ( iac_code_session_metadata, make_text_part, @@ -39,7 +40,7 @@ from iac_code.a2a.pipeline_journal import A2APipelineJournal from iac_code.a2a.pipeline_paths import existing_a2a_pipeline_dir_for_session from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore, reduce_pipeline_events -from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher +from iac_code.a2a.pipeline_stream import BACKUP_COMMITTED_EVENT_TYPE, PipelineA2AEventPublisher from iac_code.a2a.runtime_overrides import ( a2a_request_context, configure_runtime_model, @@ -79,6 +80,7 @@ from iac_code.services.agent_factory import AgentFactoryOptions, create_agent_runtime from iac_code.services.capabilities.multimodal import is_model_multimodal from iac_code.services.providers.aliyun import DEFAULT_REGION, AliyunCredential +from iac_code.services.session_backup import BackupReason, SessionBackupBlocked, SessionBackupService from iac_code.services.session_storage import SessionStorage from iac_code.types.stream_events import TextDeltaEvent from iac_code.utils.file_security import atomic_write_text, ensure_private_dir, ensure_private_file @@ -398,28 +400,55 @@ def _a2a_pipeline_state_for_session( *, cwd: str, session_id: str, -) -> tuple[A2APipelineSnapshotStore, A2APipelineJournal, dict[str, Any], list[dict[str, Any]] | None] | None: +) -> tuple[A2APipelineSnapshotStore, A2APipelineJournal, dict[str, Any], list[dict[str, Any]]] | None: try: pipeline_dir = existing_a2a_pipeline_dir_for_session(cwd=cwd, session_id=session_id) snapshot_store = A2APipelineSnapshotStore(pipeline_dir) journal = A2APipelineJournal(pipeline_dir) snapshot = snapshot_store.load() - except Exception: - logger.warning("Failed to load A2A pipeline snapshot", exc_info=True) + except Exception as exc: + logger.warning( + "Failed to load A2A pipeline snapshot error_type=%s", + type(exc).__name__, + ) return None - journal_events: list[dict[str, Any]] | None = None - if not isinstance(snapshot, dict): - try: - journal_events = journal.read_all_repairing_tail() - except Exception: - logger.warning("Failed to rebuild A2A pipeline snapshot from journal", exc_info=True) - return None - if not journal_events: - return None + try: + journal_events = journal.read_all_repairing_tail() + except Exception as exc: + # Route decisions must be conservative: a stale snapshot can expose an obsolete normalHandoff. + logger.warning( + "Failed to read A2A pipeline journal error_type=%s", + type(exc).__name__, + ) + return None + + snapshot_sequence = _a2a_pipeline_sequence_number(snapshot.get("lastSequence")) if isinstance(snapshot, dict) else 0 + journal_sequence = max( + (_a2a_pipeline_sequence_number(event.get("sequence")) for event in journal_events if isinstance(event, dict)), + default=0, + ) + if journal_events and (not isinstance(snapshot, dict) or journal_sequence != snapshot_sequence): snapshot = reduce_pipeline_events(journal_events) + if not isinstance(snapshot, dict): + return None + try: + snapshot_store.save(snapshot) + except Exception as exc: + logger.debug("Failed to save repaired A2A pipeline snapshot error_type=%s", type(exc).__name__) + elif not isinstance(snapshot, dict): + return None return snapshot_store, journal, snapshot, journal_events +def _a2a_pipeline_sequence_number(value: Any) -> int: + if isinstance(value, bool): + return 0 + try: + return int(value) + except (TypeError, ValueError): + return 0 + + def _prune_completed_cleanup_prompt_from_runtime(runtime: Any, ledger: CleanupLedger | None) -> None: if ledger is None and _runtime_has_cleanup_prompt(runtime): logger.warning("Keeping A2A cleanup prompt because cleanup ledger is unavailable") @@ -788,6 +817,7 @@ def __init__( permission_resolver: A2APermissionResolver | None = None, auto_approve_permissions: bool = False, thinking_exposure_types: Any = None, + backup_service: Any | None = None, ) -> None: self._task_store = task_store self._model = model @@ -798,6 +828,7 @@ def __init__( self._auto_approve_permissions = auto_approve_permissions self._thinking_exposure_types = normalize_a2a_exposure_types(thinking_exposure_types) self._metadata_echo_redactor = A2AMetadataEchoRedactor() + self._backup_service = backup_service or SessionBackupService() async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: requested_task_id = context.task_id or None @@ -869,7 +900,7 @@ async def publish_initial_task_if_missing() -> None: task_id=task_id, context_id=context_id, state=TaskState.TASK_STATE_INPUT_REQUIRED, - text="A temporary error occurred. Please retry.", + text=_("A temporary error occurred. Please retry."), ) if task is not None: task.state = TASK_STATE_INPUT_REQUIRED @@ -926,6 +957,7 @@ async def publish_initial_task_if_missing() -> None: model_from_metadata=metadata_model is not None, metadata_api_key=metadata_api_key, request_policy_override=request_policy_override, + backup_service=self._backup_service, ) await pipeline_executor.execute( context=context, @@ -946,6 +978,9 @@ async def publish_initial_task_if_missing() -> None: def runtime_factory(session_id: str) -> Any: session_storage = SessionStorage() + ensure_v2_session = getattr(session_storage, "ensure_v2_session_dir_for_new_session", None) + if callable(ensure_v2_session): + ensure_v2_session(cwd, session_id) resume_messages = None if session_storage.exists(cwd, session_id): loaded = session_storage.load(cwd, session_id) @@ -1152,6 +1187,19 @@ def runtime_factory(session_id: str) -> Any: iac_code_session_id=ctx.session_id, ) task.state = TASK_STATE_INPUT_REQUIRED + ctx.active_task_id = None + task.touch() + ctx.touch() + self._task_store.mirror_task(task) + self._task_store.mirror_context(ctx) + await backup_session_async( + self._backup_service, + cwd, + ctx.session_id, + reason=BackupReason.NORMAL_TURN_END, + critical=False, + metrics=self._metrics, + ) await self._publish_status( event_queue, task_id=task_id, @@ -1159,11 +1207,27 @@ def runtime_factory(session_id: str) -> Any: state=TaskState.TASK_STATE_INPUT_REQUIRED, session_id=ctx.session_id, ) - self._task_store.mirror_task(task) await self._notify_terminal_task(task_id=task.task_id, context_id=task.context_id, state=task.state) self._metrics.record_turn_completed() except asyncio.CancelledError: task.state = TASK_STATE_CANCELED + ctx.active_task_id = None + task.touch() + ctx.touch() + self._task_store.mirror_task(task) + self._task_store.mirror_context(ctx) + if await self._publish_backup_blocked_after_terminal_backup_failure( + event_queue, + task=task, + ctx=ctx, + cwd=cwd, + task_id=task_id, + context_id=context_id, + reason=BackupReason.TERMINAL, + blocked_terminal_state=TASK_STATE_CANCELED, + ): + self._metrics.record_task_canceled() + return await self._publish_status( event_queue, task_id=task_id, @@ -1172,26 +1236,55 @@ def runtime_factory(session_id: str) -> Any: text=_("Task canceled."), session_id=ctx.session_id, ) - self._task_store.mirror_task(task) await self._notify_terminal_task(task_id=task.task_id, context_id=task.context_id, state=task.state) self._metrics.record_task_canceled() except Exception as exc: if _is_retryable_executor_error(exc): task.state = TASK_STATE_INPUT_REQUIRED + ctx.active_task_id = None + task.touch() + ctx.touch() + self._task_store.mirror_task(task) + self._task_store.mirror_context(ctx) + await backup_session_async( + self._backup_service, + cwd, + ctx.session_id, + reason=BackupReason.INPUT_REQUIRED, + critical=False, + metrics=self._metrics, + ) await self._publish_status( event_queue, task_id=task_id, context_id=context_id, state=TaskState.TASK_STATE_INPUT_REQUIRED, - text="A temporary error occurred. Please retry.", + text=_("A temporary error occurred. Please retry."), session_id=ctx.session_id, ) - self._task_store.mirror_task(task) await self._notify_terminal_task(task_id=task.task_id, context_id=task.context_id, state=task.state) self._metrics.record_executor_error() else: - task.state = TASK_STATE_FAILED self._log_executor_exception("streaming", task_id=task_id, context_id=context_id) + task.state = TASK_STATE_FAILED + ctx.active_task_id = None + task.touch() + ctx.touch() + self._task_store.mirror_task(task) + self._task_store.mirror_context(ctx) + if await self._publish_backup_blocked_after_terminal_backup_failure( + event_queue, + task=task, + ctx=ctx, + cwd=cwd, + task_id=task_id, + context_id=context_id, + reason=BackupReason.TERMINAL, + blocked_terminal_state=TASK_STATE_FAILED, + ): + self._metrics.record_executor_error() + self._metrics.record_task_failed() + return await self._publish_status( event_queue, task_id=task_id, @@ -1200,7 +1293,6 @@ def runtime_factory(session_id: str) -> Any: text=self._sanitize_error(exc), session_id=ctx.session_id, ) - self._task_store.mirror_task(task) await self._notify_terminal_task(task_id=task.task_id, context_id=task.context_id, state=task.state) self._metrics.record_executor_error() self._metrics.record_task_failed() @@ -1397,14 +1489,76 @@ def _sanitize_error(self, exc: Exception) -> str: if isinstance(exc, ValueError): msg = str(exc).lower() if "provider" in msg or "configure" in msg or "/auth" in msg: - return "Authentication required. Please configure your API credentials." + return _("Authentication required. Configure credentials and retry.") if type(exc).__name__ == "AuthenticationError": - return "Authentication required. Please configure your API credentials." + return _("Authentication required. Configure credentials and retry.") status = getattr(exc, "status_code", None) or getattr(exc, "status", None) if status == 401: - return "Authentication required. Please configure your API credentials." + return _("Authentication required. Configure credentials and retry.") return _format_exception(exc) + async def _publish_backup_blocked_after_terminal_backup_failure( + self, + event_queue: EventQueue, + *, + task: Any, + ctx: Any, + cwd: str, + task_id: str, + context_id: str, + reason: BackupReason, + blocked_terminal_state: str, + ) -> bool: + try: + await backup_session_async( + self._backup_service, + cwd, + ctx.session_id, + reason=reason, + critical=True, + metrics=self._metrics, + ) + return False + except SessionBackupBlocked as exc: + logger.warning( + "A2A terminal session backup blocked task publication reason=%s retry_count=%s error=%s", + reason.value, + getattr(exc, "retry_count", 0), + public_exception_summary(exc, max_chars=_ERROR_TEXT_MAX_CHARS), + ) + record_backup_blocked = getattr(self._metrics, "record_backup_blocked", None) + if callable(record_backup_blocked): + try: + record_backup_blocked(reason=reason.value, recoverable=True) + except Exception as metric_exc: + logger.debug("Failed to record A2A backup_blocked metric: %s", type(metric_exc).__name__) + task.state = TASK_STATE_INPUT_REQUIRED + ctx.active_task_id = None + task.touch() + ctx.touch() + self._task_store.mirror_task(task) + self._task_store.mirror_context(ctx) + await self._publish_status( + event_queue, + task_id=task_id, + context_id=context_id, + state=TaskState.TASK_STATE_INPUT_REQUIRED, + text=_("Session backup failed. Retry after the backup path is available."), + metadata={ + "iac_code": { + "backupBlocked": { + "reason": reason.value, + "blockedTerminalState": blocked_terminal_state, + "error": public_exception_summary(exc, max_chars=_ERROR_TEXT_MAX_CHARS), + "recoverable": True, + } + } + }, + session_id=ctx.session_id, + ) + await self._notify_terminal_task(task_id=task.task_id, context_id=task.context_id, state=task.state) + return True + async def _should_route_pipeline_handoff_to_normal(self, *, context_id: str, cwd: str) -> bool: try: ctx = await self._task_store.get_context_record(context_id) @@ -1415,10 +1569,12 @@ async def _should_route_pipeline_handoff_to_normal(self, *, context_id: str, cwd state = _a2a_pipeline_state_for_session(cwd=cwd, session_id=ctx.session_id) if state is None: return False - _snapshot_store, _journal, snapshot, _journal_events = state + _snapshot_store, _journal, snapshot, journal_events = state handoff = snapshot.get("normalHandoff") if not isinstance(handoff, dict): return False + if not _normal_handoff_has_backup_ack(handoff, journal_events): + return False return handoff.get("action") == "switch_to_normal" and handoff.get("targetMode") == "normal" async def _ensure_pipeline_handoff_context_in_session(self, *, context_id: str, cwd: str) -> None: @@ -1431,10 +1587,12 @@ async def _ensure_pipeline_handoff_context_in_session(self, *, context_id: str, state = _a2a_pipeline_state_for_session(cwd=cwd, session_id=ctx.session_id) if state is None: return - _snapshot_store, _journal, snapshot, _journal_events = state + _snapshot_store, _journal, snapshot, journal_events = state handoff = snapshot.get("normalHandoff") if not isinstance(handoff, dict): return + if not _normal_handoff_has_backup_ack(handoff, journal_events): + return summary = handoff.get("summary") cleanup_payload = None data = handoff.get("data") @@ -1579,5 +1737,28 @@ async def _notify_terminal_task(self, *, task_id: str, context_id: str, state: s logger.warning("A2A push notification failed", exc_info=True) +def _normal_handoff_has_backup_ack(handoff: dict[str, Any], journal_events: list[dict[str, Any]]) -> bool: + if handoff.get("visibility") != "committed": + return True + handoff_sequence = _a2a_pipeline_sequence_number(handoff.get("sequence")) + handoff_event_id = handoff.get("eventId") + handoff_event_type = handoff.get("eventType") or "pipeline_handoff_ready" + for event in journal_events: + if event.get("eventType") != BACKUP_COMMITTED_EVENT_TYPE: + continue + if _a2a_pipeline_sequence_number(event.get("sequence")) <= handoff_sequence: + continue + data = event.get("data") + data = data if isinstance(data, dict) else {} + if data.get("committedEventId") == handoff_event_id: + return True + if ( + _a2a_pipeline_sequence_number(data.get("committedSequence")) == handoff_sequence + and data.get("committedEventType") == handoff_event_type + ): + return True + return False + + def _is_retryable_executor_error(exc: Exception) -> bool: return isinstance(exc, (TimeoutError, httpx.TimeoutException, httpx.TransportError, ConnectionError)) diff --git a/src/iac_code/a2a/metrics.py b/src/iac_code/a2a/metrics.py index 896cc084..37eb90be 100644 --- a/src/iac_code/a2a/metrics.py +++ b/src/iac_code/a2a/metrics.py @@ -31,6 +31,18 @@ def record_executor_error(self) -> None: """Record an executor-level error while handling a task.""" ... + def record_backup_blocked(self, *, reason: str, recoverable: bool) -> None: + """Record a task publication blocked by a recoverable session backup failure.""" + ... + + def record_backup_succeeded(self, *, reason: str, critical: bool, retry_count: int) -> None: + """Record a successful session backup.""" + ... + + def record_backup_failed(self, *, reason: str, critical: bool, retry_count: int) -> None: + """Record a session backup failure after retries are exhausted.""" + ... + def record_push_enqueued(self) -> None: """Record enqueueing of a push notification job.""" ... @@ -79,6 +91,15 @@ def record_context_evicted(self) -> None: def record_executor_error(self) -> None: logger.debug("a2a executor error") + def record_backup_blocked(self, *, reason: str, recoverable: bool) -> None: + logger.debug("a2a backup blocked reason=%s recoverable=%s", reason, recoverable) + + def record_backup_succeeded(self, *, reason: str, critical: bool, retry_count: int) -> None: + logger.debug("a2a backup succeeded reason=%s critical=%s retry_count=%d", reason, critical, retry_count) + + def record_backup_failed(self, *, reason: str, critical: bool, retry_count: int) -> None: + logger.debug("a2a backup failed reason=%s critical=%s retry_count=%d", reason, critical, retry_count) + def record_push_enqueued(self) -> None: logger.debug("a2a push enqueued") diff --git a/src/iac_code/a2a/pipeline_events.py b/src/iac_code/a2a/pipeline_events.py index 5b51d682..4b200c70 100644 --- a/src/iac_code/a2a/pipeline_events.py +++ b/src/iac_code/a2a/pipeline_events.py @@ -378,6 +378,16 @@ def _translate_pipeline_event(self, event: PipelineEvent) -> list[dict[str, Any] created_at=created_at, ) ] + if event.type == PipelineEventType.BACKUP_BLOCKED: + return [ + self._envelope( + "backup_blocked", + "pipeline", + "input_required", + _event_data(data), + created_at=created_at, + ) + ] if event.type == PipelineEventType.PIPELINE_COMPLETED: event_type = "pipeline_failed" if data.get("failed") is True else "pipeline_completed" status = "failed" if event_type == "pipeline_failed" else "completed" diff --git a/src/iac_code/a2a/pipeline_executor.py b/src/iac_code/a2a/pipeline_executor.py index de697ce1..7b109948 100644 --- a/src/iac_code/a2a/pipeline_executor.py +++ b/src/iac_code/a2a/pipeline_executor.py @@ -2,10 +2,12 @@ import asyncio import contextlib +import copy import inspect import logging from collections.abc import AsyncIterator, Callable from dataclasses import dataclass, field +from enum import Enum from pathlib import Path from typing import Any @@ -13,16 +15,23 @@ from a2a.types import Message, Role, TaskState, TaskStatus, TaskStatusUpdateEvent from a2a.utils.errors import InvalidParamsError +from iac_code.a2a.artifacts import artifact_store_for_session +from iac_code.a2a.backup import backup_session_async from iac_code.a2a.events import make_text_part, publish_mcp_warnings from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator from iac_code.a2a.pipeline_journal import A2APipelineJournal from iac_code.a2a.pipeline_paths import ( - a2a_pipeline_dir_for_session, a2a_pipeline_dir_for_sidecar_dir, existing_a2a_pipeline_dir_for_session, ) from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore, reduce_pipeline_events -from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher +from iac_code.a2a.pipeline_stream import ( + BACKUP_COMMITTED_EVENT_TYPE, + PipelineA2AEventPublisher, + backup_committed_delivery_envelope, + committed_backup_publication_envelope, + pending_backup_publication_envelope, +) from iac_code.a2a.runtime_overrides import ( a2a_request_context, configure_runtime_model, @@ -49,9 +58,11 @@ from iac_code.providers.request_policy import ProviderRequestPolicy from iac_code.services.agent_factory import AgentFactoryOptions, create_agent_runtime from iac_code.services.providers.aliyun import AliyunCredential +from iac_code.services.session_backup import BackupReason, SessionBackupBlocked, SessionBackupService from iac_code.services.session_storage import SessionStorage from iac_code.types.stream_events import AskUserQuestionEvent, SubPipelineStreamEvent -from iac_code.utils.public_errors import sanitize_public_text +from iac_code.utils.path_locks import PathLockRegistry +from iac_code.utils.public_errors import public_exception_summary, sanitize_public_text logger = logging.getLogger(__name__) _CONTEXT_LOCK_ACQUIRE_TIMEOUT_SECONDS = 1 @@ -62,6 +73,11 @@ _TERMINAL_A2A_STATUSES = {"completed", "failed", "canceled"} _WAITING_A2A_STATUSES = {"waiting_input", "input_required"} _RUNNING_A2A_STATUSES = {"working"} +_PENDING_BACKUP_VISIBILITY = "pending_backup" +_COMMITTED_BACKUP_VISIBILITY = "committed" +_WAITING_INPUT_CANCEL_LOCKS = PathLockRegistry() +_TERMINAL_PUBLICATION_UNAVAILABLE_KIND = "terminal_publication_unavailable" +_HANDOFF_PUBLICATION_UNAVAILABLE_ACTION = "switch_to_normal_unavailable" _TERMINAL_EVENT_BY_SIDECAR_STATUS = { "completed": ("pipeline_completed", "completed"), "failed": ("pipeline_failed", "failed"), @@ -74,6 +90,17 @@ _PENDING_QUESTION_STALE_FINISHED = "stale_finished" +class WaitingInputCancelResult(str, Enum): + CANCELED = "canceled" + NOT_OWNER = "not_owner" + PERSIST_FAILED = "persist_failed" + BACKUP_BLOCKED = "backup_blocked" + BACKUP_BLOCKED_PERSIST_FAILED = "backup_blocked_persist_failed" + + +_CANCEL_WAITING_INPUT_BACKUP_BLOCKED = WaitingInputCancelResult.BACKUP_BLOCKED + + def _retry_text() -> str: return _("A temporary error occurred. Please retry.") @@ -110,6 +137,7 @@ class A2APipelineRuntime: publisher: PipelineA2AEventPublisher | None = None current_stream: Any | None = None pending_question: "_PendingAskUserQuestion | None" = None + active_owner_task: asyncio.Task[Any] | None = None restart_after_interrupt: bool = False pause_after_interrupt: bool = False restart_requested: asyncio.Event = field(default_factory=asyncio.Event) @@ -119,6 +147,7 @@ class A2APipelineRuntime: class _StreamConsumeResult: had_events: bool restart_requested: bool + terminal_handoff_unavailable: bool = False @dataclass(frozen=True) @@ -141,6 +170,19 @@ class _PendingAskUserQuestion: envelope: dict[str, Any] +@dataclass(frozen=True) +class _NormalHandoffPublication: + status: str + data: dict[str, Any] + summary: str + + +@dataclass(frozen=True) +class _TerminalHandoffPublishResult: + attempted: bool + terminal_available: bool + + class _SidecarOwnerUnavailableError(RuntimeError): pass @@ -153,12 +195,21 @@ def __init__(self, status: str) -> None: class _SidecarRestoreFailedError(RuntimeError): def __init__(self, status: str, reason: str | None) -> None: - detail = reason or "unknown" - super().__init__(f"A2A pipeline sidecar restore failed: status={status}, reason={detail}") + detail = reason or _("unknown") + super().__init__( + _("A2A pipeline sidecar restore failed: status={status}, reason={reason}").format( + status=status, + reason=detail, + ) + ) self.status = status self.reason = reason +class _PipelineBackupBlockedTransitionError(Exception): + pass + + class IacCodeA2APipelineExecutor: def __init__( self, @@ -176,6 +227,7 @@ def __init__( model_from_metadata: bool = False, metadata_api_key: str | None = None, request_policy_override: ProviderRequestPolicy | None = None, + backup_service: Any | None = None, ) -> None: self._task_store = task_store self._model = model @@ -190,6 +242,7 @@ def __init__( self._model_from_metadata = model_from_metadata self._metadata_api_key = metadata_api_key self._request_policy_override = request_policy_override + self._backup_service = backup_service or SessionBackupService() async def execute( self, @@ -302,6 +355,14 @@ def runtime_factory(session_id: str) -> Any: session_id=ctx.session_id, cwd=cwd, ) + self._install_backup_hook( + publisher, + pipeline=pipeline, + cwd=cwd, + session_id=ctx.session_id, + task=task, + ctx=ctx, + ) pipeline_runtime = A2APipelineRuntime( agent_runtime=agent_runtime, pipeline=pipeline, @@ -325,7 +386,7 @@ def fresh_pipeline_factory() -> Any: ) return fresh_pipeline - selected = self._select_stream( + selected = await self._select_stream( pipeline, prompt, pipeline_input=pipeline_input, @@ -341,11 +402,13 @@ def fresh_pipeline_factory() -> Any: stream = selected.stream ctx.active_task_id = task.task_id task.active_task = owner_task + pipeline_runtime.active_owner_task = owner_task task.state = TASK_STATE_WORKING task_persistence_started = True self._task_store.mirror_task(task) self._task_store.mirror_context(ctx) stream_had_events = False + terminal_handoff_unavailable = False with self._request_context(session_id=ctx.session_id): while True: stream_result = await self._consume_stream_until_restart( @@ -355,6 +418,9 @@ def fresh_pipeline_factory() -> Any: task=task, ) stream_had_events = stream_had_events or stream_result.had_events + terminal_handoff_unavailable = ( + terminal_handoff_unavailable or stream_result.terminal_handoff_unavailable + ) if not stream_result.restart_requested: break @@ -363,16 +429,36 @@ def fresh_pipeline_factory() -> Any: terminal_status_published = False terminal_sidecar = _is_terminal_sidecar_status(getattr(pipeline, "sidecar_status", None)) - if terminal_sidecar and publisher is not None: + terminal_sidecar_recovery_allowed = not terminal_handoff_unavailable + if terminal_sidecar and terminal_sidecar_recovery_allowed and publisher is not None: terminal_status_published = await self._publish_terminal_sidecar_recovery_event( publisher, pipeline, task_id=task_id, context_id=context_id, ) + committed_terminal_status = ( + _committed_terminal_status_for_task_context( + publisher, + task_id=task_id, + context_id=context_id, + ) + if terminal_sidecar and terminal_sidecar_recovery_allowed and publisher is not None + else None + ) + sidecar_terminal_status = _terminal_status_from_sidecar(getattr(pipeline, "sidecar_status", None)) + terminal_snapshot_available = terminal_status_published or committed_terminal_status is not None + sidecar_terminal_fallback_available = terminal_status_published or ( + committed_terminal_status is not None and committed_terminal_status == sidecar_terminal_status + ) snapshot = publisher.snapshot_store.load() or {} - task.state = _task_state_from_pipeline(pipeline, snapshot) + task.state = _task_state_from_pipeline( + pipeline, + snapshot, + allow_terminal_snapshot=not terminal_sidecar or terminal_snapshot_available, + allow_sidecar_terminal_fallback=sidecar_terminal_fallback_available, + ) self._task_store.mirror_task(task) if not stream_had_events and terminal_sidecar and not terminal_status_published: await self._publish_status( @@ -384,31 +470,54 @@ def fresh_pipeline_factory() -> Any: await self._notify_terminal_task(task_id=task.task_id, context_id=task.context_id, state=task.state) self._record_state(task.state) except asyncio.CancelledError: - task.state = TASK_STATE_CANCELED - if pipeline is not None: - await self._mark_user_aborted(pipeline) - await self._publish_pipeline_terminal_event( - publisher, - event_type="pipeline_canceled", - status="canceled", - data={"source": "executor", "reason": _("Task canceled.")}, - ) - if pipeline is not None and publisher is not None: - await self._publish_normal_handoff_ready( - pipeline, - publisher, - {"canceled": True, "reason": _("Task canceled.")}, + try: + task.state = TASK_STATE_CANCELED + if pipeline is not None: + await self._mark_user_aborted(pipeline) + cancel_data = {"source": "executor", "reason": _("Task canceled.")} + cancel_handoff_data = {"canceled": True, "reason": _("Task canceled.")} + cancel_transaction_result = _TerminalHandoffPublishResult( + attempted=False, + terminal_available=False, ) - await self._publish_status( - event_queue, - task_id=task_id, - context_id=context_id, - state=TaskState.TASK_STATE_CANCELED, - text=_("Task canceled."), - ) - self._task_store.mirror_task(task) - await self._notify_terminal_task(task_id=task.task_id, context_id=task.context_id, state=task.state) - self._metrics.record_task_canceled() + if pipeline is not None and publisher is not None: + cancel_transaction_result = await self._publish_manual_terminal_with_normal_handoff( + pipeline, + publisher, + event_type="pipeline_canceled", + status="canceled", + terminal_data=cancel_data, + handoff_data=cancel_handoff_data, + ) + cancel_terminal_available = cancel_transaction_result.terminal_available + if not cancel_transaction_result.attempted: + cancel_terminal_available = await self._publish_pipeline_terminal_event( + publisher, + event_type="pipeline_canceled", + status="canceled", + data=cancel_data, + ) + if cancel_terminal_available and pipeline is not None and publisher is not None: + await self._publish_normal_handoff_ready(pipeline, publisher, cancel_handoff_data) + if not cancel_terminal_available: + task.state = TASK_STATE_INPUT_REQUIRED + await self._publish_status( + event_queue, + task_id=task_id, + context_id=context_id, + state=_a2a_state_from_task_state(task.state), + text=_("Task canceled."), + ) + self._task_store.mirror_task(task) + await self._notify_terminal_task( + task_id=task.task_id, + context_id=task.context_id, + state=task.state, + ) + self._record_state(task.state) + except _PipelineBackupBlockedTransitionError: + task_persistence_started = True + await self._complete_backup_blocked_transition(task=task, ctx=ctx) except _SidecarStateTerminalError as exc: task.state = _task_state_from_a2a_status(exc.status) self._task_store.mirror_task(task) @@ -422,21 +531,41 @@ def fresh_pipeline_factory() -> Any: self._record_state(task.state) except RecoverablePipelineInvalidParamsError: raise + except _PipelineBackupBlockedTransitionError: + task_persistence_started = True + await self._complete_backup_blocked_transition(task=task, ctx=ctx) except Exception as exc: task_persistence_started = True - await self._publish_exception_status( - event_queue, - task=task, - task_id=task_id, - context_id=context_id, - exc=exc, - pipeline_publisher=publisher, - ) + try: + await self._publish_exception_status( + event_queue, + task=task, + task_id=task_id, + context_id=context_id, + exc=exc, + pipeline_publisher=publisher, + ) + except _PipelineBackupBlockedTransitionError: + await self._complete_backup_blocked_transition(task=task, ctx=ctx) finally: - if task.active_task is owner_task: + replacement_owner = getattr(ctx.runtime, "active_owner_task", None) + if ( + replacement_owner is not None + and replacement_owner is not owner_task + and not replacement_owner.done() + ): + task.active_task = replacement_owner + ctx.active_task_id = task.task_id + elif ( + task.active_task is not None and task.active_task is not owner_task and not task.active_task.done() + ): + ctx.active_task_id = task.task_id + elif task.active_task is owner_task: task.active_task = None - if ctx.active_task_id == task.task_id: + if ctx.active_task_id == task.task_id and getattr(pipeline, "sidecar_status", None) != "running": ctx.active_task_id = None + if replacement_owner is owner_task and hasattr(ctx.runtime, "active_owner_task"): + ctx.runtime.active_owner_task = None ctx.touch() if task_persistence_started: task.touch() @@ -496,19 +625,32 @@ async def _route_active_pipeline_interrupt( pipeline = getattr(runtime, "pipeline", None) if pipeline is None: return False + publisher = getattr(runtime, "publisher", None) + if isinstance(publisher, PipelineA2AEventPublisher): + self._install_backup_hook( + publisher, + pipeline=pipeline, + cwd=cwd, + session_id=ctx.session_id, + task=task, + ctx=ctx, + ) try: pending_question_route = await self._route_pending_question_answer(runtime, pipeline_input) except Exception as exc: - await self._publish_exception_status( - event_queue, - task=task, - task_id=task_id, - context_id=context_id, - exc=exc, - preserve_task_record=preserve_task_record, - pipeline_publisher=getattr(runtime, "publisher", None), - ) + try: + await self._publish_exception_status( + event_queue, + task=task, + task_id=task_id, + context_id=context_id, + exc=exc, + preserve_task_record=preserve_task_record, + pipeline_publisher=publisher, + ) + except _PipelineBackupBlockedTransitionError: + await self._complete_backup_blocked_transition(task=task, ctx=ctx) return True if pending_question_route == _PENDING_QUESTION_ANSWERED: task.state = TASK_STATE_WORKING @@ -519,7 +661,6 @@ async def _route_active_pipeline_interrupt( self._task_store.mirror_task(task) return True - publisher = getattr(runtime, "publisher", None) publish_interrupt = getattr(publisher, "publish_interrupt", None) if not callable(publish_interrupt): try: @@ -539,6 +680,14 @@ async def _route_active_pipeline_interrupt( self._task_store.mirror_context(ctx) if publisher is None: return False + self._install_backup_hook( + publisher, + pipeline=pipeline, + cwd=cwd, + session_id=ctx.session_id, + task=task, + ctx=ctx, + ) if _pending_pipeline_pause_input_from_sidecar(publisher, task_id=task_id, context_id=context_id) is not None: await self._continue_active_pause_confirmation( @@ -550,6 +699,7 @@ async def _route_active_pipeline_interrupt( publisher=publisher, task_id=task_id, context_id=context_id, + cwd=cwd, session_id=ctx.session_id, pipeline_input=pipeline_input, preserve_task_record=preserve_task_record, @@ -600,14 +750,29 @@ async def _route_active_pipeline_interrupt( include_received=not interrupt_received_published, ) if _is_terminal_sidecar_status(getattr(pipeline, "sidecar_status", None)): - await self._publish_terminal_sidecar_recovery_event( + terminal_status_published = await self._publish_terminal_sidecar_recovery_event( publisher, pipeline, task_id=task_id, context_id=context_id, ) + committed_terminal_status = _committed_terminal_status_for_task_context( + publisher, + task_id=task_id, + context_id=context_id, + ) + sidecar_terminal_status = _terminal_status_from_sidecar(getattr(pipeline, "sidecar_status", None)) + terminal_snapshot_available = terminal_status_published or committed_terminal_status is not None + sidecar_terminal_fallback_available = terminal_status_published or ( + committed_terminal_status is not None and committed_terminal_status == sidecar_terminal_status + ) snapshot = publisher.snapshot_store.load() or {} - task.state = _task_state_from_pipeline(pipeline, snapshot) + task.state = _task_state_from_pipeline( + pipeline, + snapshot, + allow_terminal_snapshot=terminal_snapshot_available, + allow_sidecar_terminal_fallback=sidecar_terminal_fallback_available, + ) self._task_store.mirror_task(task) await self._notify_terminal_task(task_id=task_id, context_id=context_id, state=task.state) self._record_state(task.state) @@ -624,15 +789,21 @@ async def _route_active_pipeline_interrupt( runtime.pause_after_interrupt = True _restart_requested_event(runtime).set() return True + except _PipelineBackupBlockedTransitionError: + await self._complete_backup_blocked_transition(task=task, ctx=ctx) + return True except Exception as exc: - await self._publish_exception_status( - event_queue, - task=task, - task_id=task_id, - context_id=context_id, - exc=exc, - preserve_task_record=preserve_task_record, - ) + try: + await self._publish_exception_status( + event_queue, + task=task, + task_id=task_id, + context_id=context_id, + exc=exc, + preserve_task_record=preserve_task_record, + ) + except _PipelineBackupBlockedTransitionError: + await self._complete_backup_blocked_transition(task=task, ctx=ctx) return True finally: if paused and not bool(getattr(verdict, "paused", False)): @@ -654,6 +825,7 @@ async def _continue_active_pause_confirmation( publisher: PipelineA2AEventPublisher, task_id: str, context_id: str, + cwd: str, session_id: str, pipeline_input: PipelineUserInput, preserve_task_record: bool, @@ -661,7 +833,16 @@ async def _continue_active_pause_confirmation( pipeline_input = normalize_pipeline_user_input(pipeline_input) prompt = pipeline_input.display_text owner_task = asyncio.current_task() + self._install_backup_hook( + publisher, + pipeline=pipeline, + cwd=cwd, + session_id=session_id, + task=task, + ctx=ctx, + ) task.active_task = owner_task + runtime.active_owner_task = owner_task ctx.active_task_id = task_id restart_event = _restart_requested_event(runtime) if runtime.pause_after_interrupt and restart_event.is_set(): @@ -678,6 +859,7 @@ async def _continue_active_pause_confirmation( stream = pipeline.continue_from_sidecar() task.state = TASK_STATE_WORKING self._task_store.mirror_task(task) + terminal_handoff_unavailable = False with self._request_context(session_id=session_id): while True: stream_result = await self._consume_stream_until_restart( @@ -686,28 +868,68 @@ async def _continue_active_pause_confirmation( publisher=publisher, task=task, ) + terminal_handoff_unavailable = ( + terminal_handoff_unavailable or stream_result.terminal_handoff_unavailable + ) if not stream_result.restart_requested: break stream = self._continue_after_interrupt_stream(pipeline, pipeline_input) + terminal_status_published = False + terminal_sidecar = _is_terminal_sidecar_status(getattr(pipeline, "sidecar_status", None)) + terminal_sidecar_recovery_allowed = not terminal_handoff_unavailable + if terminal_sidecar and terminal_sidecar_recovery_allowed: + terminal_status_published = await self._publish_terminal_sidecar_recovery_event( + publisher, + pipeline, + task_id=task_id, + context_id=context_id, + ) + committed_terminal_status = ( + _committed_terminal_status_for_task_context( + publisher, + task_id=task_id, + context_id=context_id, + ) + if terminal_sidecar and terminal_sidecar_recovery_allowed + else None + ) + sidecar_terminal_status = _terminal_status_from_sidecar(getattr(pipeline, "sidecar_status", None)) + terminal_snapshot_available = terminal_status_published or committed_terminal_status is not None + sidecar_terminal_fallback_available = terminal_status_published or ( + committed_terminal_status is not None and committed_terminal_status == sidecar_terminal_status + ) + snapshot = publisher.snapshot_store.load() or {} - task.state = _task_state_from_pipeline(pipeline, snapshot) + task.state = _task_state_from_pipeline( + pipeline, + snapshot, + allow_terminal_snapshot=not terminal_sidecar or terminal_snapshot_available, + allow_sidecar_terminal_fallback=sidecar_terminal_fallback_available, + ) self._task_store.mirror_task(task) await self._notify_terminal_task(task_id=task_id, context_id=context_id, state=task.state) self._record_state(task.state) + except _PipelineBackupBlockedTransitionError: + await self._complete_backup_blocked_transition(task=task, ctx=ctx) except Exception as exc: - await self._publish_exception_status( - event_queue, - task=task, - task_id=task_id, - context_id=context_id, - exc=exc, - preserve_task_record=False, - pipeline_publisher=publisher, - ) + try: + await self._publish_exception_status( + event_queue, + task=task, + task_id=task_id, + context_id=context_id, + exc=exc, + preserve_task_record=False, + pipeline_publisher=publisher, + ) + except _PipelineBackupBlockedTransitionError: + await self._complete_backup_blocked_transition(task=task, ctx=ctx) finally: if task.active_task is owner_task: task.active_task = None + if runtime.active_owner_task is owner_task: + runtime.active_owner_task = None if ctx.active_task_id == task_id: ctx.active_task_id = None ctx.touch() @@ -733,6 +955,7 @@ def _create_pipeline( cwd=cwd, resume_from_sidecar=resume_from_sidecar, surface="a2a", + backup_service=self._backup_service, ) def _set_pipeline_telemetry_correlation(self, pipeline: Any, *, task_id: str, context_id: str) -> None: @@ -760,23 +983,36 @@ async def _consume_stream_until_restart( ) -> "_StreamConsumeResult": had_events = False stream_iter = stream.__aiter__() + owner_task = asyncio.current_task() + if owner_task is not None: + runtime.active_owner_task = owner_task + task.active_task = owner_task runtime.current_stream = stream_iter restart_event = _restart_requested_event(runtime) next_task: asyncio.Task[Any] | None = None restart_task: asyncio.Task[Any] | None = None close_stream_on_exit = False + terminal_handoff_unavailable = False try: while True: if runtime.pause_after_interrupt and restart_event.is_set(): restart_event.clear() runtime.pause_after_interrupt = False await _close_stream_safely(stream_iter) - return _StreamConsumeResult(had_events=had_events, restart_requested=False) + return _StreamConsumeResult( + had_events=had_events, + restart_requested=False, + terminal_handoff_unavailable=terminal_handoff_unavailable, + ) if runtime.restart_after_interrupt and restart_event.is_set(): restart_event.clear() runtime.restart_after_interrupt = False await _close_stream_safely(stream_iter) - return _StreamConsumeResult(had_events=had_events, restart_requested=True) + return _StreamConsumeResult( + had_events=had_events, + restart_requested=True, + terminal_handoff_unavailable=terminal_handoff_unavailable, + ) next_task = asyncio.create_task(_next_stream_event(stream_iter)) restart_task = asyncio.create_task(restart_event.wait()) @@ -793,7 +1029,11 @@ async def _consume_stream_until_restart( await _cancel_task_safely(restart_task) restart_task = None await _close_stream_safely(stream_iter) - return _StreamConsumeResult(had_events=had_events, restart_requested=True) + return _StreamConsumeResult( + had_events=had_events, + restart_requested=True, + terminal_handoff_unavailable=terminal_handoff_unavailable, + ) if restart_task in done and runtime.pause_after_interrupt: restart_event.clear() runtime.pause_after_interrupt = False @@ -802,7 +1042,11 @@ async def _consume_stream_until_restart( await _cancel_task_safely(restart_task) restart_task = None await _close_stream_safely(stream_iter) - return _StreamConsumeResult(had_events=had_events, restart_requested=False) + return _StreamConsumeResult( + had_events=had_events, + restart_requested=False, + terminal_handoff_unavailable=terminal_handoff_unavailable, + ) await _cancel_task_safely(restart_task) restart_task = None @@ -817,7 +1061,11 @@ async def _consume_stream_until_restart( runtime=runtime.agent_runtime, iac_code_session_id=publisher.translator.context.iac_code_session_id, ) - return _StreamConsumeResult(had_events=had_events, restart_requested=False) + return _StreamConsumeResult( + had_events=had_events, + restart_requested=False, + terminal_handoff_unavailable=terminal_handoff_unavailable, + ) finally: next_task = None @@ -829,18 +1077,32 @@ async def _consume_stream_until_restart( runtime=runtime.agent_runtime, iac_code_session_id=publisher.translator.context.iac_code_session_id, ) - text = await publisher.publish( + terminal_handoff_result = await self._maybe_publish_terminal_with_normal_handoff( + runtime.pipeline, + publisher, event, - permission_resolver=self._permission_resolver, - auto_approve_permissions=self._auto_approve_permissions, ) - self._track_pending_question(runtime, publisher, event) + if terminal_handoff_result.attempted: + if not terminal_handoff_result.terminal_available: + terminal_handoff_unavailable = True + text = None + else: + text = await publisher.publish( + event, + permission_resolver=self._permission_resolver, + auto_approve_permissions=self._auto_approve_permissions, + ) + self._track_pending_question(runtime, publisher, event) + await self._maybe_publish_normal_handoff_ready(runtime.pipeline, publisher, event) if text: task.output_text.append(text) - await self._maybe_publish_normal_handoff_ready(runtime.pipeline, publisher, event) if _ask_user_question_from(event) is not None: close_stream_on_exit = True - return _StreamConsumeResult(had_events=had_events, restart_requested=False) + return _StreamConsumeResult( + had_events=had_events, + restart_requested=False, + terminal_handoff_unavailable=terminal_handoff_unavailable, + ) except asyncio.CancelledError: close_stream_on_exit = True raise @@ -882,16 +1144,185 @@ def _publisher( translator.hydrate_from_events(journal.read_all_repairing_tail()) except Exception: logger.warning("Failed to hydrate A2A pipeline translator from journal", exc_info=True) + artifact_store = self._artifact_store_for_session(cwd=cwd, session_id=session_id) return PipelineA2AEventPublisher( event_queue=event_queue, translator=translator, journal=journal, snapshot_store=A2APipelineSnapshotStore(pipeline_dir), - artifact_store=self._artifact_store, + artifact_store=artifact_store, exposure_types=self._thinking_exposure_types, + backup_commit_gate=_requires_backup_committed_publication, ) - def _select_stream( + def _install_backup_hook( + self, + publisher: PipelineA2AEventPublisher, + *, + pipeline: Any, + cwd: str, + session_id: str, + task: Any, + ctx: Any, + ) -> None: + async def before_enqueue(envelope: dict[str, Any]) -> bool: + return await self._backup_before_pipeline_publication( + envelope, + publisher=publisher, + pipeline=pipeline, + cwd=cwd, + session_id=session_id, + task=task, + ctx=ctx, + ) + + async def after_backup_commit(envelope: dict[str, Any]) -> None: + self._mirror_a2a_snapshots_for_pipeline_publication(envelope, task=task, ctx=ctx) + + publisher.before_enqueue = before_enqueue + publisher.after_backup_commit = after_backup_commit + + async def _backup_before_pipeline_publication( + self, + envelope: dict[str, Any], + *, + publisher: PipelineA2AEventPublisher, + pipeline: Any, + cwd: str, + session_id: str, + task: Any, + ctx: Any, + ) -> bool: + reason = _backup_reason_for_pipeline_envelope(envelope) + if reason is None: + return True + if reason in {BackupReason.TERMINAL, BackupReason.HANDOFF_READY}: + if _is_pending_backup_publication_event(envelope): + return True + else: + self._mirror_a2a_snapshots_for_pipeline_publication(envelope, task=task, ctx=ctx) + try: + await backup_session_async( + self._backup_service, + cwd, + session_id, + reason=reason, + critical=True, + metrics=self._metrics, + ) + except SessionBackupBlocked as exc: + sidecar_synced = await _sync_pipeline_backup_blocked_sidecar( + pipeline, + reason=reason, + step_id=_pipeline_step_id_from_envelope(envelope), + ) + if not sidecar_synced: + logger.warning("A2A pipeline backup_blocked sidecar state was not durably persisted") + _record_backup_blocked_metric(self._metrics, reason=reason.value, recoverable=False) + await self._persist_terminal_publication_unavailable( + publisher, + terminal_envelope=envelope, + reason="backup_blocked_sidecar_persist_failed", + ) + task.state = TASK_STATE_INPUT_REQUIRED + task.touch() + ctx.touch() + self._task_store.mirror_task(task) + self._task_store.mirror_context(ctx) + raise _PipelineBackupBlockedTransitionError from exc + backup_blocked_published = await self._publish_backup_blocked( + publisher, + reason=reason, + exc=exc, + ) + if not backup_blocked_published: + logger.warning("A2A pipeline backup_blocked event was not durably published") + await self._persist_terminal_publication_unavailable( + publisher, + terminal_envelope=envelope, + reason="backup_blocked_publish_failed", + ) + task.state = TASK_STATE_INPUT_REQUIRED + task.touch() + ctx.touch() + self._task_store.mirror_task(task) + self._task_store.mirror_context(ctx) + raise _PipelineBackupBlockedTransitionError from exc + return True + + def _mirror_a2a_snapshots_for_pipeline_publication( + self, + envelope: dict[str, Any], + *, + task: Any, + ctx: Any, + ) -> None: + state = _task_state_for_pipeline_publication_envelope(envelope) + if state == TASK_STATE_INPUT_REQUIRED: + task_snapshot = copy.copy(task) + task_snapshot.state = state + context_snapshot = copy.copy(ctx) + self._task_store.mirror_task(task_snapshot) + self._task_store.mirror_context(context_snapshot) + return + + task.state = state + if task.state in {TASK_STATE_COMPLETED, TASK_STATE_FAILED, TASK_STATE_CANCELED}: + ctx.active_task_id = None + task.touch() + ctx.touch() + self._task_store.mirror_task(task) + self._task_store.mirror_context(ctx) + + async def _publish_backup_blocked( + self, + publisher: PipelineA2AEventPublisher, + *, + reason: BackupReason, + exc: SessionBackupBlocked, + ) -> bool: + try: + published = await publisher.publish_manual( + "backup_blocked", + "pipeline", + status="input_required", + data={ + "reason": reason.value, + "error": public_exception_summary(exc, max_chars=_ERROR_TEXT_MAX_CHARS), + "recoverable": True, + }, + require_durable_metadata=True, + require_journal_metadata=True, + ) + if published is not None: + _record_backup_blocked_metric(self._metrics, reason=reason.value, recoverable=True) + return True + _record_backup_blocked_metric(self._metrics, reason=reason.value, recoverable=False) + return False + except Exception as publish_exc: + logger.warning( + "Failed to publish A2A pipeline backup_blocked event error_type=%s", + type(publish_exc).__name__, + ) + _record_backup_blocked_metric(self._metrics, reason=reason.value, recoverable=False) + return False + + async def _complete_backup_blocked_transition(self, *, task: Any, ctx: Any) -> None: + task.state = TASK_STATE_INPUT_REQUIRED + task.touch() + ctx.touch() + self._task_store.mirror_task(task) + self._task_store.mirror_context(ctx) + await self._notify_terminal_task(task_id=task.task_id, context_id=task.context_id, state=task.state) + self._metrics.record_executor_error() + + def _artifact_store_for_session(self, *, cwd: str, session_id: str) -> Any | None: + session_dir = SessionStorage().v2_session_dir(cwd, session_id) + if session_dir is None: + return self._artifact_store + return artifact_store_for_session(session_dir) + + async def _select_stream( self, pipeline: Any, prompt: str, @@ -903,6 +1334,24 @@ def _select_stream( fresh_pipeline_factory: Callable[[], Any], ) -> _SelectedPipelineStream: status = getattr(pipeline, "sidecar_status", None) + pending_backup_blocked = _pending_backup_blocked_input_from_sidecar( + publisher, + task_id=task_id, + context_id=context_id, + ) + if pending_backup_blocked is not None and status != "backup_blocked": + sidecar_synced = await _sync_pipeline_backup_blocked_sidecar( + pipeline, + reason=_backup_reason_from_pending_backup_blocked_input(pending_backup_blocked), + step_id=_pipeline_step_id_from_pending_input(pending_backup_blocked), + ) + if not sidecar_synced: + raise _active_sidecar_mismatch_error_from_publisher( + publisher, + context_id=context_id, + sidecar_status="backup_blocked", + ) + status = getattr(pipeline, "sidecar_status", status) if status == "waiting_input": _raise_if_sidecar_restore_failed(pipeline, status) if not _sidecar_matches_task(publisher, task_id=task_id, context_id=context_id, sidecar_status=status): @@ -985,6 +1434,20 @@ def _select_stream( stream=pipeline.continue_from_sidecar(user_input=_pipeline_runner_input(pipeline_input)), ) return _SelectedPipelineStream(pipeline=pipeline, stream=pipeline.continue_from_sidecar()) + if status == "backup_blocked": + _raise_if_sidecar_restore_failed(pipeline, status) + if not _sidecar_matches_task(publisher, task_id=task_id, context_id=context_id, sidecar_status=status): + raise _active_sidecar_mismatch_error_from_publisher( + publisher, + context_id=context_id, + sidecar_status=status, + ) + stream = ( + pipeline.continue_from_sidecar(user_input=_pipeline_runner_input(pipeline_input)) + if prompt + else pipeline.continue_from_sidecar() + ) + return _SelectedPipelineStream(pipeline=pipeline, stream=stream) if status in _TERMINAL_SIDECAR_STATUSES: if _terminal_sidecar_matches_task(publisher, status, task_id=task_id, context_id=context_id): return _SelectedPipelineStream(pipeline=pipeline, stream=_empty_stream()) @@ -1031,6 +1494,8 @@ async def _publish_terminal_sidecar_recovery_event( snapshot = publisher.snapshot_store.load() journal_events = _safe_read_pipeline_journal(publisher.journal) scoped_journal_events = _events_for_task_context(journal_events, task_id=task_id, context_id=context_id) + if _terminal_publication_unavailable_blocks_recovery(scoped_journal_events): + return False existing_terminal_event = _latest_terminal_a2a_event(scoped_journal_events) if existing_terminal_event is not None: existing_status = _terminal_status_from_a2a_event(existing_terminal_event) @@ -1048,7 +1513,12 @@ async def _publish_terminal_sidecar_recovery_event( return False if _snapshot_has_conflicting_terminal_status(snapshot, status, task_id=task_id, context_id=context_id): return False - if not _terminal_snapshot_needs_recovery_event(snapshot, status, task_id=task_id, context_id=context_id): + if not _terminal_snapshot_needs_recovery_event( + snapshot, + status, + task_id=task_id, + context_id=context_id, + ) and not _has_unacknowledged_committed_terminal_event(scoped_journal_events): return False published = await publisher.publish_manual( @@ -1060,7 +1530,18 @@ async def _publish_terminal_sidecar_recovery_event( "recovered": True, }, ) - return published is not None + if published is None: + await self._persist_terminal_publication_unavailable( + publisher, + terminal_envelope={ + "eventType": event_type, + "status": status, + "visibility": _COMMITTED_BACKUP_VISIBILITY, + }, + reason="terminal_recovery_publication_failed", + ) + return False + return True def _rebuild_terminal_recovery_snapshot( self, @@ -1084,11 +1565,314 @@ async def _publish_pipeline_terminal_event( if publisher is None: return False try: - return await publisher.publish_manual(event_type, "pipeline", status=status, data=data) is not None + published = await publisher.publish_manual(event_type, "pipeline", status=status, data=data) + if published is None: + await self._persist_terminal_publication_unavailable( + publisher, + terminal_envelope={ + "eventType": event_type, + "status": status, + "visibility": _COMMITTED_BACKUP_VISIBILITY, + }, + reason="terminal_publication_failed", + ) + return False + return True + except _PipelineBackupBlockedTransitionError: + raise except Exception: logger.warning("Failed to publish A2A pipeline terminal event", exc_info=True) return False + async def _maybe_publish_terminal_with_normal_handoff( + self, + pipeline: Any, + publisher: PipelineA2AEventPublisher, + event: Any, + ) -> _TerminalHandoffPublishResult: + if not isinstance(event, PipelineEvent) or event.type != PipelineEventType.PIPELINE_COMPLETED: + return _TerminalHandoffPublishResult(attempted=False, terminal_available=False) + + publication = self._normal_handoff_publication(pipeline, event.data or {}) + if publication is None: + return _TerminalHandoffPublishResult(attempted=False, terminal_available=False) + + terminal_envelopes = publisher.translator.translate(event) + terminal_envelope = next( + ( + envelope + for envelope in terminal_envelopes + if _backup_reason_for_pipeline_envelope(envelope) == BackupReason.TERMINAL + ), + None, + ) + if terminal_envelope is None: + logger.warning("Skipping A2A pipeline handoff transaction because terminal envelope was not translated") + return _TerminalHandoffPublishResult(attempted=True, terminal_available=False) + + terminal_available = await self._publish_terminal_handoff_transaction( + pipeline, + publisher, + terminal_envelope=terminal_envelope, + publication=publication, + ) + return _TerminalHandoffPublishResult(attempted=True, terminal_available=terminal_available) + + async def _publish_manual_terminal_with_normal_handoff( + self, + pipeline: Any, + publisher: PipelineA2AEventPublisher, + *, + event_type: str, + status: str, + terminal_data: dict[str, Any], + handoff_data: dict[str, Any], + ) -> _TerminalHandoffPublishResult: + publication = self._normal_handoff_publication(pipeline, handoff_data) + if publication is None: + return _TerminalHandoffPublishResult(attempted=False, terminal_available=False) + terminal_envelope = publisher.translator.manual_event( + event_type, + "pipeline", + status=status, + data=terminal_data, + ) + terminal_available = await self._publish_terminal_handoff_transaction( + pipeline, + publisher, + terminal_envelope=terminal_envelope, + publication=publication, + ) + return _TerminalHandoffPublishResult(attempted=True, terminal_available=terminal_available) + + async def _publish_terminal_handoff_transaction( + self, + pipeline: Any, + publisher: PipelineA2AEventPublisher, + *, + terminal_envelope: dict[str, Any], + publication: _NormalHandoffPublication, + ) -> bool: + handoff_envelope = publisher.translator.manual_event( + "pipeline_handoff_ready", + "pipeline", + status=publication.status, + data=publication.data, + ) + pending_terminal_envelope = _pending_backup_publication_envelope(terminal_envelope) + pending_handoff_envelope = _pending_backup_publication_envelope(handoff_envelope) + pending_terminal_safe_envelope = await publisher.persist_envelope( + pending_terminal_envelope, + require_journal_metadata=True, + ) + if pending_terminal_safe_envelope is None: + await self._persist_terminal_publication_unavailable( + publisher, + terminal_envelope=terminal_envelope, + reason="pending_terminal_persist_failed", + ) + return False + pending_handoff_safe_envelope = await publisher.persist_envelope( + pending_handoff_envelope, + require_journal_metadata=True, + ) + if pending_handoff_safe_envelope is None: + await self._persist_terminal_publication_unavailable( + publisher, + terminal_envelope=pending_terminal_safe_envelope, + reason="pending_handoff_persist_failed", + ) + return False + + async with publisher.delivery_transaction(): + committed_terminal_envelope = _committed_backup_publication_envelope( + publisher, + pending_terminal_safe_envelope, + ) + committed_handoff_envelope = _committed_backup_publication_envelope( + publisher, + pending_handoff_safe_envelope, + ) + terminal_safe_envelope = await publisher.persist_envelope( + committed_terminal_envelope, + require_journal_metadata=True, + ) + if terminal_safe_envelope is None: + await self._persist_terminal_publication_unavailable( + publisher, + terminal_envelope=committed_terminal_envelope, + reason="committed_terminal_persist_failed", + ) + return False + handoff_safe_envelope = await publisher.persist_envelope( + committed_handoff_envelope, + require_journal_metadata=True, + ) + if handoff_safe_envelope is None: + await self._persist_terminal_publication_unavailable( + publisher, + terminal_envelope=terminal_safe_envelope, + reason="committed_handoff_persist_failed", + ) + return False + if not await self._run_before_enqueue_hook(publisher, terminal_safe_envelope): + await self._persist_terminal_publication_unavailable( + publisher, + terminal_envelope=terminal_safe_envelope, + reason="committed_terminal_before_enqueue_failed", + ) + return False + if not await self._run_before_enqueue_hook(publisher, handoff_safe_envelope): + await self._persist_terminal_publication_unavailable( + publisher, + terminal_envelope=terminal_safe_envelope, + reason="committed_handoff_before_enqueue_failed", + ) + return False + terminal_ack_envelope = await publisher.persist_backup_committed_ack(terminal_safe_envelope) + if terminal_ack_envelope is None: + await self._persist_terminal_publication_unavailable( + publisher, + terminal_envelope=terminal_safe_envelope, + reason="committed_terminal_backup_ack_failed", + ) + return False + handoff_ack_envelope = await publisher.persist_backup_committed_ack(handoff_safe_envelope) + if not await publisher.enqueue_persisted(terminal_safe_envelope, run_before_enqueue=False): + await self._persist_terminal_publication_unavailable( + publisher, + terminal_envelope=terminal_safe_envelope, + reason="committed_terminal_enqueue_failed", + ) + return False + if not await publisher.enqueue_persisted( + backup_committed_delivery_envelope(terminal_ack_envelope, terminal_safe_envelope), + run_before_enqueue=False, + ): + await self._persist_terminal_publication_unavailable( + publisher, + terminal_envelope=terminal_safe_envelope, + reason="committed_terminal_backup_ack_enqueue_failed", + ) + return False + await publisher._run_after_backup_commit_hook(terminal_safe_envelope) + if handoff_ack_envelope is None: + await self._persist_and_enqueue_handoff_publication_unavailable( + publisher, + handoff_envelope=handoff_safe_envelope, + reason="committed_handoff_backup_ack_failed", + ) + return True + if await publisher.enqueue_persisted(handoff_safe_envelope, run_before_enqueue=False): + if not await publisher.enqueue_persisted( + backup_committed_delivery_envelope(handoff_ack_envelope, handoff_safe_envelope), + run_before_enqueue=False, + ): + await self._persist_and_enqueue_handoff_publication_unavailable( + publisher, + handoff_envelope=handoff_safe_envelope, + reason="committed_handoff_backup_ack_enqueue_failed", + ) + return True + await publisher._run_after_backup_commit_hook(handoff_safe_envelope) + _persist_normal_handoff_summary(pipeline, publication.summary) + else: + await self._persist_handoff_publication_unavailable( + publisher, + handoff_envelope=handoff_safe_envelope, + reason="committed_handoff_enqueue_failed", + ) + return True + return True + + async def _persist_terminal_publication_unavailable( + self, + publisher: PipelineA2AEventPublisher, + *, + terminal_envelope: dict[str, Any], + reason: str, + ) -> None: + marker = publisher.translator.manual_event( + "input_required", + "pipeline", + status="input_required", + data={ + "kind": _TERMINAL_PUBLICATION_UNAVAILABLE_KIND, + "reason": reason, + "terminalEventId": terminal_envelope.get("eventId"), + "terminalEventType": terminal_envelope.get("eventType"), + "terminalVisibility": _publication_visibility_from_event(terminal_envelope), + }, + ) + persisted = await publisher.persist_envelope( + marker, + require_durable_metadata=True, + require_journal_metadata=True, + ) + if persisted is None: + logger.warning("Failed to persist A2A pipeline terminal publication unavailable marker") + + async def _persist_and_enqueue_handoff_publication_unavailable( + self, + publisher: PipelineA2AEventPublisher, + *, + handoff_envelope: dict[str, Any], + reason: str, + ) -> None: + persisted = await self._persist_handoff_publication_unavailable( + publisher, + handoff_envelope=handoff_envelope, + reason=reason, + ) + if persisted is None: + return + if not await publisher.enqueue_persisted(persisted, run_before_enqueue=False): + logger.warning("Failed to enqueue A2A pipeline handoff publication unavailable marker") + + async def _persist_handoff_publication_unavailable( + self, + publisher: PipelineA2AEventPublisher, + *, + handoff_envelope: dict[str, Any], + reason: str, + ) -> dict[str, Any] | None: + raw_data = handoff_envelope.get("data") + data = raw_data if isinstance(raw_data, dict) else {} + marker = publisher.translator.manual_event( + "pipeline_handoff_ready", + "pipeline", + status=str(handoff_envelope.get("status") or "input_required"), + data={ + "action": _HANDOFF_PUBLICATION_UNAVAILABLE_ACTION, + "targetMode": "pipeline", + "outcome": data.get("outcome"), + "summary": data.get("summary"), + "unavailable": True, + "reason": reason, + "handoffEventId": handoff_envelope.get("eventId"), + }, + ) + persisted = await publisher.persist_envelope( + marker, + require_durable_metadata=True, + require_journal_metadata=True, + ) + if persisted is None: + logger.warning("Failed to persist A2A pipeline handoff publication unavailable marker") + return persisted + + async def _run_before_enqueue_hook( + self, + publisher: PipelineA2AEventPublisher, + envelope: dict[str, Any], + ) -> bool: + if publisher.before_enqueue is None: + return True + should_enqueue = publisher.before_enqueue(envelope) + if inspect.isawaitable(should_enqueue): + should_enqueue = await should_enqueue + return should_enqueue is not False + async def _maybe_publish_normal_handoff_ready( self, pipeline: Any, @@ -1100,23 +1884,24 @@ async def _maybe_publish_normal_handoff_ready( await self._publish_normal_handoff_ready(pipeline, publisher, event.data or {}) - async def _publish_normal_handoff_ready( + def _normal_handoff_publication( self, pipeline: Any, - publisher: PipelineA2AEventPublisher, event_data: dict[str, Any], - ) -> None: + ) -> _NormalHandoffPublication | None: should_switch_to_normal = getattr(pipeline, "should_switch_to_normal", None) if not callable(should_switch_to_normal): - return + return None try: if not bool(should_switch_to_normal(event_data)): - return + return None summary = pipeline.build_normal_handoff_summary(event_data) outcome = terminal_outcome_from_completed_event(event_data) + except _PipelineBackupBlockedTransitionError: + raise except Exception: logger.warning("Failed to build A2A pipeline normal handoff event", exc_info=True) - return + return None data = { "action": "switch_to_normal", @@ -1127,15 +1912,33 @@ async def _publish_normal_handoff_ready( cleanup = _pipeline_cleanup_handoff_data(pipeline) if cleanup is not None: data["cleanup"] = cleanup - - published = await publisher.publish_manual( - "pipeline_handoff_ready", - "pipeline", + return _NormalHandoffPublication( status=_handoff_status_from_outcome(outcome), data=data, + summary=summary, ) + + async def _publish_normal_handoff_ready( + self, + pipeline: Any, + publisher: PipelineA2AEventPublisher, + event_data: dict[str, Any], + ) -> None: + publication = self._normal_handoff_publication(pipeline, event_data) + if publication is None: + return + + try: + published = await publisher.publish_manual( + "pipeline_handoff_ready", + "pipeline", + status=publication.status, + data=publication.data, + ) + except _PipelineBackupBlockedTransitionError: + raise if published is not None: - _persist_normal_handoff_summary(pipeline, summary) + _persist_normal_handoff_summary(pipeline, publication.summary) def _track_pending_question( self, @@ -1278,8 +2081,9 @@ async def _publish_exception_status( task_state = TASK_STATE_INPUT_REQUIRED if retryable else TASK_STATE_FAILED text = _retry_text() if retryable else _sanitize_error(exc) failure = None if retryable else public_error(message=text, error_type=type(exc).__name__) + terminal_status_available = retryable or preserve_task_record if not retryable and not preserve_task_record: - await self._publish_pipeline_terminal_event( + terminal_status_available = await self._publish_pipeline_terminal_event( pipeline_publisher, event_type="pipeline_failed", status="failed", @@ -1289,11 +2093,13 @@ async def _publish_exception_status( "errorDetails": _public_error_details_for_a2a(failure.details) if failure is not None else {}, }, ) + if not terminal_status_available: + task_state = TASK_STATE_INPUT_REQUIRED await self._publish_status( event_queue, task_id=task_id, context_id=context_id, - state=TaskState.TASK_STATE_INPUT_REQUIRED if retryable else TaskState.TASK_STATE_FAILED, + state=_a2a_state_from_task_state(task_state), text=text, ) if not preserve_task_record: @@ -1301,7 +2107,7 @@ async def _publish_exception_status( self._task_store.mirror_task(task) await self._notify_terminal_task(task_id=task.task_id, context_id=task.context_id, state=task.state) self._metrics.record_executor_error() - if not retryable and not preserve_task_record: + if not retryable and not preserve_task_record and terminal_status_available: self._metrics.record_task_failed() async def _publish_status( @@ -1363,6 +2169,174 @@ def _pipeline_runner_input(pipeline_input: PipelineUserInput) -> PipelineUserInp return pipeline_input if pipeline_input.has_images else pipeline_input.display_text +def _backup_reason_for_pipeline_envelope(envelope: dict[str, Any]) -> BackupReason | None: + event_type = envelope.get("eventType") + status = envelope.get("status") + if event_type == "backup_blocked": + return None + if event_type == "pipeline_handoff_ready": + return BackupReason.HANDOFF_READY + if event_type in {"pipeline_completed", "pipeline_failed", "pipeline_canceled"}: + return BackupReason.TERMINAL + if status in _TERMINAL_A2A_STATUSES: + return BackupReason.TERMINAL + if event_type == "input_required" or status in _WAITING_A2A_STATUSES: + return BackupReason.WAITING_INPUT if status == "waiting_input" else BackupReason.INPUT_REQUIRED + return None + + +def _backup_reason_from_pending_backup_blocked_input(pending_input: dict[str, Any]) -> BackupReason: + backup_blocked = pending_input.get("backupBlocked") + raw_reason = backup_blocked.get("reason") if isinstance(backup_blocked, dict) else pending_input.get("reason") + if isinstance(raw_reason, BackupReason): + return raw_reason + try: + return BackupReason(str(raw_reason)) + except ValueError: + return BackupReason.PIPELINE_STEP_COMPLETED + + +def _pipeline_step_id_from_envelope(envelope: dict[str, Any]) -> str | None: + step = envelope.get("step") + if not isinstance(step, dict): + return None + step_id = _string_value(step.get("id") or step.get("stepId") or step.get("step_id")) + return step_id or None + + +def _pipeline_step_id_from_pending_input(pending_input: dict[str, Any]) -> str | None: + step = pending_input.get("step") + if not isinstance(step, dict): + return None + step_id = _string_value(step.get("id") or step.get("stepId") or step.get("step_id")) + return step_id or None + + +async def _sync_pipeline_backup_blocked_sidecar( + pipeline: Any, + *, + reason: BackupReason, + step_id: str | None, +) -> bool: + save_backup_blocked_sidecar = getattr(pipeline, "_save_backup_blocked_sidecar", None) + if callable(save_backup_blocked_sidecar): + try: + result = save_backup_blocked_sidecar(step_id, reason) + if inspect.isawaitable(result): + result = await result + return result is not False + except Exception as exc: + logger.warning( + "Failed to sync pipeline backup_blocked sidecar state error_type=%s", + type(exc).__name__, + ) + return False + try: + setattr(pipeline, "sidecar_status", "backup_blocked") + return True + except Exception as exc: + logger.warning( + "Failed to mark pipeline backup_blocked sidecar status error_type=%s", + type(exc).__name__, + ) + return False + + +def _record_backup_blocked_metric(metrics: Any, *, reason: str, recoverable: bool) -> None: + record_backup_blocked = getattr(metrics, "record_backup_blocked", None) + if not callable(record_backup_blocked): + return + try: + record_backup_blocked(reason=reason, recoverable=recoverable) + except Exception as exc: + logger.debug("Failed to record A2A backup_blocked metric error_type=%s", type(exc).__name__) + + +def _record_backup_succeeded_metric(metrics: Any, *, reason: str, critical: bool, retry_count: int) -> None: + record_backup_succeeded = getattr(metrics, "record_backup_succeeded", None) + if not callable(record_backup_succeeded): + return + try: + record_backup_succeeded(reason=reason, critical=critical, retry_count=retry_count) + except Exception as exc: + logger.debug("Failed to record A2A backup_succeeded metric error_type=%s", type(exc).__name__) + + +def _record_backup_failed_metric(metrics: Any, *, reason: str, critical: bool, retry_count: int) -> None: + record_backup_failed = getattr(metrics, "record_backup_failed", None) + if not callable(record_backup_failed): + return + try: + record_backup_failed(reason=reason, critical=critical, retry_count=retry_count) + except Exception as exc: + logger.debug("Failed to record A2A backup_failed metric error_type=%s", type(exc).__name__) + + +def _backup_retry_count(result: Any) -> int: + value = getattr(result, "retry_count", 0) + return value if isinstance(value, int) and value >= 0 else 0 + + +def _backup_retry_count_from_exception(exc: BaseException) -> int: + value = getattr(exc, "retry_count", 0) + return value if isinstance(value, int) and value >= 0 else 0 + + +def _requires_backup_committed_publication(envelope: dict[str, Any]) -> bool: + if _publication_visibility_from_event(envelope) in {_PENDING_BACKUP_VISIBILITY, _COMMITTED_BACKUP_VISIBILITY}: + return False + return _backup_reason_for_pipeline_envelope(envelope) in {BackupReason.TERMINAL, BackupReason.HANDOFF_READY} + + +def _pending_backup_publication_envelope(envelope: dict[str, Any]) -> dict[str, Any]: + return pending_backup_publication_envelope(envelope) + + +def _committed_backup_publication_envelope( + publisher: PipelineA2AEventPublisher, + pending_envelope: dict[str, Any], +) -> dict[str, Any]: + return _committed_backup_publication_envelope_from_translator(publisher.translator, pending_envelope) + + +def _committed_backup_publication_envelope_from_translator( + translator: PipelineEventTranslator, + pending_envelope: dict[str, Any], +) -> dict[str, Any]: + return committed_backup_publication_envelope(translator, pending_envelope) + + +def _is_pending_backup_publication_event(event: dict[str, Any]) -> bool: + raw_data = event.get("data") + data = raw_data if isinstance(raw_data, dict) else {} + return _publication_visibility_from_event(event) == _PENDING_BACKUP_VISIBILITY or data.get("backupPending") is True + + +def _is_committed_backup_publication_event(event: dict[str, Any]) -> bool: + return _publication_visibility_from_event(event) == _COMMITTED_BACKUP_VISIBILITY + + +def _publication_visibility_from_event(event: dict[str, Any]) -> str | None: + visibility = event.get("visibility") + if isinstance(visibility, str): + return visibility + raw_data = event.get("data") + data = raw_data if isinstance(raw_data, dict) else {} + data_visibility = data.get("visibility") + return data_visibility if isinstance(data_visibility, str) else None + + +def _task_state_for_pipeline_publication_envelope(envelope: dict[str, Any]) -> str: + event_type = envelope.get("eventType") + if event_type == "pipeline_completed": + return TASK_STATE_COMPLETED + if event_type == "pipeline_failed": + return TASK_STATE_FAILED + if event_type == "pipeline_canceled": + return TASK_STATE_CANCELED + return _task_state_from_a2a_status(envelope.get("status")) + + async def _resume_pending_ask_user_question_stream( *, pipeline: Any, @@ -1551,6 +2525,27 @@ def _pending_pipeline_pause_input_from_sidecar( return pending_input if pending_input.get("kind") == "pipeline_pause_confirmation" else None +def _pending_backup_blocked_input_from_sidecar( + publisher: PipelineA2AEventPublisher, + *, + task_id: str, + context_id: str, +) -> dict[str, Any] | None: + pending_input = _pending_input_from_snapshot( + _authoritative_snapshot_for_task( + snapshot_store=publisher.snapshot_store, + journal=publisher.journal, + task_id=task_id, + context_id=context_id, + ), + task_id=task_id, + context_id=context_id, + ) + if pending_input is None: + return None + return pending_input if pending_input.get("kind") == "backup_blocked" else None + + def waiting_input_task_id_from_sidecar(*, cwd: str, session_id: str, context_id: str) -> str | None: return recoverable_task_id_from_sidecar( cwd=cwd, @@ -1567,11 +2562,45 @@ def cancel_waiting_input_task_from_sidecar( context_id: str, task_id: str, reason: str | None = None, -) -> bool: + backup_service: Any | None = None, + task_store: Any | None = None, + task_record: Any | None = None, + context_record: Any | None = None, + metrics: Any | None = None, +) -> WaitingInputCancelResult: if reason is None: reason = _("Task canceled.") + pipeline_dir = existing_a2a_pipeline_dir_for_session(cwd=cwd, session_id=session_id) + with _WAITING_INPUT_CANCEL_LOCKS.lock_for(pipeline_dir / ".waiting-input-cancel.lock"): + return _cancel_waiting_input_task_from_sidecar_locked( + cwd=cwd, + session_id=session_id, + context_id=context_id, + task_id=task_id, + reason=reason, + backup_service=backup_service, + task_store=task_store, + task_record=task_record, + context_record=context_record, + metrics=metrics, + ) + + +def _cancel_waiting_input_task_from_sidecar_locked( + *, + cwd: str, + session_id: str, + context_id: str, + task_id: str, + reason: str, + backup_service: Any | None = None, + task_store: Any | None = None, + task_record: Any | None = None, + context_record: Any | None = None, + metrics: Any | None = None, +) -> WaitingInputCancelResult: if waiting_input_task_id_from_sidecar(cwd=cwd, session_id=session_id, context_id=context_id) != task_id: - return False + return WaitingInputCancelResult.NOT_OWNER pipeline_dir = existing_a2a_pipeline_dir_for_session(cwd=cwd, session_id=session_id) journal = A2APipelineJournal(pipeline_dir) @@ -1580,12 +2609,15 @@ def cancel_waiting_input_task_from_sidecar( events = journal.read_all_repairing_tail() except Exception: logger.warning("Failed to cancel waiting A2A pipeline sidecar", exc_info=True) - return False + return WaitingInputCancelResult.PERSIST_FAILED snapshot = snapshot_store.load() pipeline_name = get_pipeline_name() if isinstance(snapshot, dict) and isinstance(snapshot.get("pipelineName"), str): pipeline_name = snapshot["pipelineName"] + pending_input = copy.deepcopy(snapshot.get("pendingInput")) if isinstance(snapshot, dict) else None + if not isinstance(pending_input, dict): + pending_input = None context = PipelineA2AContext( pipeline_run_id=context_id, task_id=task_id, @@ -1619,16 +2651,319 @@ def cancel_waiting_input_task_from_sidecar( envelope.get("sequence") or 0 ): handoff_envelope["sequence"] = int(envelope.get("sequence") or 0) + 1 + pending_envelope = _pending_backup_publication_envelope(envelope) + pending_handoff_envelope = ( + _pending_backup_publication_envelope(handoff_envelope) if handoff_envelope is not None else None + ) try: - events_to_append = [envelope] - if handoff_envelope is not None: - events_to_append.append(handoff_envelope) + events_to_append = [pending_envelope] + if pending_handoff_envelope is not None: + events_to_append.append(pending_handoff_envelope) journal.append_many(events_to_append, durable=True) - snapshot_store.save(reduce_pipeline_events(journal.read_all_repairing_tail())) + _save_pipeline_snapshot_or_raise(snapshot_store, reduce_pipeline_events(journal.read_all_repairing_tail())) except Exception: logger.warning("Failed to persist waiting A2A pipeline cancellation", exc_info=True) - return False - return True + return WaitingInputCancelResult.PERSIST_FAILED + _mirror_waiting_input_cancel_a2a_snapshots( + task_store=task_store, + task_record=task_record, + context_record=context_record, + state=TASK_STATE_INPUT_REQUIRED, + ) + committed_envelope = _committed_backup_publication_envelope_from_translator(translator, pending_envelope) + high_water_sequence = max( + [int(event.get("sequence") or 0) for event in journal.read_all_repairing_tail() if isinstance(event, dict)], + default=0, + ) + if int(committed_envelope.get("sequence") or 0) <= high_water_sequence: + committed_envelope["sequence"] = high_water_sequence + 1 + committed_handoff_envelope = None + if pending_handoff_envelope is not None: + committed_handoff_envelope = _committed_backup_publication_envelope_from_translator( + translator, + pending_handoff_envelope, + ) + if int(committed_handoff_envelope.get("sequence") or 0) <= int(committed_envelope.get("sequence") or 0): + committed_handoff_envelope["sequence"] = int(committed_envelope.get("sequence") or 0) + 1 + try: + committed_events = [committed_envelope] + if committed_handoff_envelope is not None: + committed_events.append(committed_handoff_envelope) + events_before_commit = journal.read_all_repairing_tail() + _save_pipeline_snapshot_or_raise( + snapshot_store, + reduce_pipeline_events([*events_before_commit, *committed_events]), + ) + journal.append_many(committed_events, durable=True) + except Exception as exc: + logger.warning( + "Failed to persist committed waiting A2A pipeline cancellation error_type=%s", + type(exc).__name__, + ) + try: + _save_pipeline_snapshot_or_raise(snapshot_store, reduce_pipeline_events(journal.read_all_repairing_tail())) + except Exception as restore_exc: + logger.debug( + "Failed to restore waiting A2A pipeline snapshot after commit failure error_type=%s", + type(restore_exc).__name__, + ) + try: + _persist_waiting_input_terminal_publication_unavailable( + journal=journal, + snapshot_store=snapshot_store, + translator=translator, + pending_input=pending_input, + reason="committed_cancel_persist_failed", + ) + except Exception as marker_exc: + logger.debug( + "Failed to persist waiting A2A pipeline publication unavailable marker after commit failure " + "error_type=%s", + type(marker_exc).__name__, + ) + _mirror_waiting_input_cancel_a2a_snapshots( + task_store=task_store, + task_record=task_record, + context_record=context_record, + state=TASK_STATE_INPUT_REQUIRED, + ) + return WaitingInputCancelResult.PERSIST_FAILED + try: + backup_result = (backup_service or SessionBackupService()).backup_session( + cwd, + session_id, + reason=BackupReason.TERMINAL, + critical=True, + ) + except SessionBackupBlocked as exc: + _record_backup_failed_metric( + metrics, + reason=BackupReason.TERMINAL.value, + critical=True, + retry_count=_backup_retry_count_from_exception(exc), + ) + _mirror_waiting_input_cancel_a2a_snapshots( + task_store=task_store, + task_record=task_record, + context_record=context_record, + state=TASK_STATE_INPUT_REQUIRED, + ) + result = _persist_waiting_input_backup_blocked_event( + journal=journal, + snapshot_store=snapshot_store, + translator=translator, + pending_input=pending_input, + error=public_exception_summary(exc, max_chars=_ERROR_TEXT_MAX_CHARS), + ) + _record_backup_blocked_metric( + metrics, + reason=BackupReason.TERMINAL.value, + recoverable=result == WaitingInputCancelResult.BACKUP_BLOCKED, + ) + return result + if getattr(backup_result, "enabled", True) and not getattr(backup_result, "succeeded", True): + backup_error = getattr(backup_result, "error", None) or type(backup_result).__name__ + blocked_exc = SessionBackupBlocked( + str(backup_error), + retry_count=_backup_retry_count(backup_result), + result=backup_result, + ) + _record_backup_failed_metric( + metrics, + reason=BackupReason.TERMINAL.value, + critical=True, + retry_count=_backup_retry_count(backup_result), + ) + _mirror_waiting_input_cancel_a2a_snapshots( + task_store=task_store, + task_record=task_record, + context_record=context_record, + state=TASK_STATE_INPUT_REQUIRED, + ) + result = _persist_waiting_input_backup_blocked_event( + journal=journal, + snapshot_store=snapshot_store, + translator=translator, + pending_input=pending_input, + error=public_exception_summary(blocked_exc, max_chars=_ERROR_TEXT_MAX_CHARS), + ) + _record_backup_blocked_metric( + metrics, + reason=BackupReason.TERMINAL.value, + recoverable=result == WaitingInputCancelResult.BACKUP_BLOCKED, + ) + return result + _record_backup_succeeded_metric( + metrics, + reason=BackupReason.TERMINAL.value, + critical=True, + retry_count=_backup_retry_count(backup_result), + ) + try: + _persist_waiting_input_backup_committed_acks( + journal=journal, + snapshot_store=snapshot_store, + translator=translator, + committed_events=committed_events, + ) + except Exception as ack_exc: + logger.warning( + "Failed to persist waiting A2A pipeline backup committed ack error_type=%s", + type(ack_exc).__name__, + ) + _mirror_waiting_input_cancel_a2a_snapshots( + task_store=task_store, + task_record=task_record, + context_record=context_record, + state=TASK_STATE_INPUT_REQUIRED, + ) + return WaitingInputCancelResult.PERSIST_FAILED + _mirror_waiting_input_cancel_a2a_snapshots( + task_store=task_store, + task_record=task_record, + context_record=context_record, + state=TASK_STATE_CANCELED, + ) + return WaitingInputCancelResult.CANCELED + + +def _persist_waiting_input_backup_committed_acks( + *, + journal: A2APipelineJournal, + snapshot_store: A2APipelineSnapshotStore, + translator: PipelineEventTranslator, + committed_events: list[dict[str, Any]], +) -> None: + ack_events: list[dict[str, Any]] = [] + high_water_sequence = max( + [int(event.get("sequence") or 0) for event in journal.read_all_repairing_tail() if isinstance(event, dict)], + default=0, + ) + for committed_event in committed_events: + ack = translator.manual_event( + BACKUP_COMMITTED_EVENT_TYPE, + "pipeline", + data={ + "committedEventId": committed_event.get("eventId"), + "committedEventType": committed_event.get("eventType"), + "committedSequence": committed_event.get("sequence"), + }, + ) + ack.pop("status", None) + high_water_sequence += 1 + ack["sequence"] = high_water_sequence + ack_events.append(ack) + journal.append_many(ack_events, durable=True) + _save_pipeline_snapshot_or_raise(snapshot_store, reduce_pipeline_events(journal.read_all_repairing_tail())) + + +def _persist_waiting_input_backup_blocked_event( + *, + journal: A2APipelineJournal, + snapshot_store: A2APipelineSnapshotStore, + translator: PipelineEventTranslator, + pending_input: dict[str, Any] | None, + error: str, +) -> WaitingInputCancelResult: + try: + backup_blocked = translator.manual_event( + "backup_blocked", + "pipeline", + status="input_required", + data={ + "reason": BackupReason.TERMINAL.value, + "error": error, + "recoverable": True, + }, + ) + if pending_input is not None: + backup_blocked["input"] = pending_input + high_water_sequence = max( + [int(event.get("sequence") or 0) for event in journal.read_all_repairing_tail() if isinstance(event, dict)], + default=0, + ) + if int(backup_blocked.get("sequence") or 0) <= high_water_sequence: + backup_blocked["sequence"] = high_water_sequence + 1 + journal.append(backup_blocked, durable=True) + _save_pipeline_snapshot_or_raise(snapshot_store, reduce_pipeline_events(journal.read_all_repairing_tail())) + except Exception as persist_exc: + logger.warning( + "Failed to persist waiting A2A pipeline backup_blocked event error_type=%s", + type(persist_exc).__name__, + ) + try: + _persist_waiting_input_terminal_publication_unavailable( + journal=journal, + snapshot_store=snapshot_store, + translator=translator, + pending_input=pending_input, + reason="backup_blocked_persist_failed", + ) + except Exception as marker_exc: + logger.debug( + "Failed to persist waiting A2A pipeline publication unavailable marker error_type=%s", + type(marker_exc).__name__, + ) + return WaitingInputCancelResult.BACKUP_BLOCKED_PERSIST_FAILED + return WaitingInputCancelResult.BACKUP_BLOCKED + + +def _persist_waiting_input_terminal_publication_unavailable( + *, + journal: A2APipelineJournal, + snapshot_store: A2APipelineSnapshotStore, + translator: PipelineEventTranslator, + pending_input: dict[str, Any] | None, + reason: str, +) -> None: + marker = translator.manual_event( + "input_required", + "pipeline", + status="input_required", + data={ + "kind": _TERMINAL_PUBLICATION_UNAVAILABLE_KIND, + "reason": reason, + }, + ) + if pending_input is not None: + marker["input"] = pending_input + high_water_sequence = max( + [int(event.get("sequence") or 0) for event in journal.read_all_repairing_tail() if isinstance(event, dict)], + default=0, + ) + if int(marker.get("sequence") or 0) <= high_water_sequence: + marker["sequence"] = high_water_sequence + 1 + journal.append(marker, durable=True) + _save_pipeline_snapshot_or_raise(snapshot_store, reduce_pipeline_events(journal.read_all_repairing_tail())) + + +def _save_pipeline_snapshot_or_raise( + snapshot_store: A2APipelineSnapshotStore, + snapshot: dict[str, Any], +) -> None: + if not snapshot_store.save(snapshot): + raise OSError(_("Failed to persist A2A pipeline snapshot")) + + +def _mirror_waiting_input_cancel_a2a_snapshots( + *, + task_store: Any | None, + task_record: Any | None, + context_record: Any | None, + state: str, +) -> None: + if task_store is None: + return + if task_record is not None: + task_record.state = state + task_record.active_task = None + task_record.touch() + task_store.mirror_task(task_record) + if context_record is not None: + if state in {TASK_STATE_COMPLETED, TASK_STATE_FAILED, TASK_STATE_CANCELED}: + context_record.active_task_id = None + context_record.touch() + task_store.mirror_context(context_record) def _waiting_input_cancel_handoff_event( @@ -1734,18 +3069,22 @@ def _flatten_pipeline_context_snapshot(snapshot: dict[str, Any]) -> dict[str, An def terminal_task_state_from_sidecar(*, cwd: str, session_id: str, context_id: str, task_id: str) -> str | None: pipeline_dir = existing_a2a_pipeline_dir_for_session(cwd=cwd, session_id=session_id) journal = A2APipelineJournal(pipeline_dir) - snapshot_store = A2APipelineSnapshotStore(pipeline_dir) try: - owner = _current_sidecar_owner_from_stores( - snapshot_store=snapshot_store, - journal=journal, + events = _events_for_task_context( + journal.read_all_repairing_tail(), + task_id=task_id, context_id=context_id, ) - except _SidecarOwnerUnavailableError: + except Exception as exc: + logger.warning( + "Failed to inspect A2A pipeline terminal task state error_type=%s", + type(exc).__name__, + ) return None - if owner is None or owner.task_id != task_id: + terminal_event = _latest_terminal_a2a_event(events) + if terminal_event is None: return None - status = _normalized_a2a_status(owner.status) + status = _terminal_status_from_a2a_event(terminal_event) if status not in _TERMINAL_A2A_STATUSES: return None return _task_state_from_sidecar_status(status) @@ -2004,10 +3343,11 @@ def _pipeline_sidecar_dir(pipeline: Any, cwd: str, session_id: str) -> Path: session = getattr(pipeline, "session", None) session_dir = getattr(session, "session_dir", None) if isinstance(session_dir, (str, Path)): - pipeline_dir = a2a_pipeline_dir_for_sidecar_dir(session_dir) - if pipeline_dir == a2a_pipeline_dir_for_session(cwd=cwd, session_id=session_id): + sidecar_dir = Path(session_dir) + root_session_dir = SessionStorage().session_dir(cwd, session_id) + if sidecar_dir.name == "pipeline" and sidecar_dir.parent == root_session_dir: return existing_a2a_pipeline_dir_for_session(cwd=cwd, session_id=session_id) - return pipeline_dir + return a2a_pipeline_dir_for_sidecar_dir(sidecar_dir) return existing_a2a_pipeline_dir_for_session(cwd=cwd, session_id=session_id) @@ -2092,12 +3432,42 @@ def _task_state_from_a2a_status(status: Any) -> str: return TASK_STATE_INPUT_REQUIRED -def _task_state_from_pipeline(pipeline: Any, snapshot: dict[str, Any]) -> str: +def _committed_terminal_status_for_task_context( + publisher: PipelineA2AEventPublisher, + *, + task_id: str, + context_id: str, +) -> str | None: + events = _safe_read_pipeline_journal(publisher.journal) + scoped_events = _events_for_task_context(events, task_id=task_id, context_id=context_id) + terminal_event = _latest_terminal_a2a_event(scoped_events) + if terminal_event is None: + return None + return _terminal_status_from_a2a_event(terminal_event) + + +def _terminal_status_from_sidecar(status: Any) -> str | None: + terminal_event = _terminal_event_from_sidecar_status(status) + if terminal_event is None: + return None + return terminal_event[1] + + +def _task_state_from_pipeline( + pipeline: Any, + snapshot: dict[str, Any], + *, + allow_terminal_snapshot: bool = True, + allow_sidecar_terminal_fallback: bool = True, +) -> str: snapshot_status = snapshot.get("status") + snapshot_state = _task_state_from_snapshot(snapshot) + if snapshot_status in _TERMINAL_SNAPSHOT_STATUSES: + return snapshot_state if allow_terminal_snapshot else TASK_STATE_INPUT_REQUIRED sidecar_status = getattr(pipeline, "sidecar_status", None) - if _is_terminal_sidecar_status(sidecar_status) and snapshot_status not in _TERMINAL_SNAPSHOT_STATUSES: + if allow_sidecar_terminal_fallback and _is_terminal_sidecar_status(sidecar_status): return _task_state_from_sidecar_status(sidecar_status) - return _task_state_from_snapshot(snapshot) + return snapshot_state def _is_terminal_sidecar_status(status: Any) -> bool: @@ -2142,6 +3512,11 @@ def _terminal_sidecar_matches_task( _current_sidecar_owner(publisher, context_id=context_id), task_id=task_id, context_id=context_id, + ) or _unacknowledged_committed_terminal_matches_task( + publisher, + sidecar_status, + task_id=task_id, + context_id=context_id, ) @@ -2167,6 +3542,16 @@ def _sidecar_matches_task( if _pending_pipeline_pause_input_from_sidecar(publisher, task_id=task_id, context_id=context_id): return True return status in _RUNNING_A2A_STATUSES + if sidecar_status == "backup_blocked": + return ( + status in _WAITING_A2A_STATUSES + and _pending_backup_blocked_input_from_sidecar( + publisher, + task_id=task_id, + context_id=context_id, + ) + is not None + ) return False @@ -2200,14 +3585,25 @@ def _current_sidecar_owner_from_stores( journal: A2APipelineJournal, context_id: str, ) -> _TaskContextOwner | None: - snapshot_owner = _owner_from_snapshot(snapshot_store.load()) - if snapshot_owner is not None and snapshot_owner.context_id != context_id: - snapshot_owner = None + snapshot = snapshot_store.load() try: journal_events = journal.read_all_repairing_tail() except Exception: logger.warning("Failed to inspect A2A pipeline sidecar owner journal", exc_info=True) - raise _SidecarOwnerUnavailableError("A2A pipeline sidecar owner is unavailable") from None + raise _SidecarOwnerUnavailableError(_("A2A pipeline sidecar owner is unavailable")) from None + snapshot = _journal_authoritative_snapshot( + snapshot_store=snapshot_store, + snapshot=snapshot, + journal_events=journal_events, + ) + snapshot_owner = _owner_from_snapshot(snapshot) + if snapshot_owner is not None and snapshot_owner.context_id != context_id: + snapshot_owner = None + if snapshot_owner is not None and _has_unacknowledged_committed_event_at_or_after( + journal_events, + snapshot_owner.sequence, + ): + snapshot_owner = None journal_owner = _owner_from_journal_events(journal_events, context_id=context_id) if snapshot_owner is not None and (journal_owner is None or snapshot_owner.sequence >= journal_owner.sequence): return snapshot_owner @@ -2228,27 +3624,61 @@ def _authoritative_snapshot_for_task( task_id=task_id, context_id=context_id, ) - except Exception: - logger.warning("Failed to build A2A pipeline snapshot from journal", exc_info=True) - return snapshot + except Exception as exc: + logger.warning( + "Failed to build A2A pipeline snapshot from journal error_type=%s", + type(exc).__name__, + ) + return None if not events: return snapshot try: rebuilt = reduce_pipeline_events(events) - except Exception: - logger.warning("Failed to reduce A2A pipeline journal events", exc_info=True) - return snapshot + except Exception as exc: + logger.warning( + "Failed to reduce A2A pipeline journal events error_type=%s", + type(exc).__name__, + ) + return None if not isinstance(rebuilt, dict): - return snapshot + return None snapshot_sequence = _sequence_number(snapshot.get("lastSequence")) if isinstance(snapshot, dict) else 0 rebuilt_sequence = _sequence_number(rebuilt.get("lastSequence")) - if rebuilt_sequence >= snapshot_sequence: + if rebuilt_sequence != snapshot_sequence: try: snapshot_store.save(rebuilt) - except Exception: - logger.debug("Failed to save repaired A2A pipeline snapshot", exc_info=True) - return rebuilt - return snapshot + except Exception as exc: + logger.debug("Failed to save repaired A2A pipeline snapshot error_type=%s", type(exc).__name__) + return rebuilt + + +def _journal_authoritative_snapshot( + *, + snapshot_store: A2APipelineSnapshotStore, + snapshot: dict[str, Any] | None, + journal_events: list[dict[str, Any]], +) -> dict[str, Any] | None: + if not journal_events: + return snapshot + snapshot_sequence = _sequence_number(snapshot.get("lastSequence")) if isinstance(snapshot, dict) else 0 + journal_sequence = max((_sequence_number(event.get("sequence")) for event in journal_events), default=0) + if isinstance(snapshot, dict) and snapshot_sequence == journal_sequence: + return snapshot + try: + rebuilt = reduce_pipeline_events(journal_events) + except Exception as exc: + logger.warning( + "Failed to reduce A2A pipeline journal events error_type=%s", + type(exc).__name__, + ) + return None + if not isinstance(rebuilt, dict): + return None + try: + snapshot_store.save(rebuilt) + except Exception as exc: + logger.debug("Failed to save repaired A2A pipeline snapshot error_type=%s", type(exc).__name__) + return rebuilt def _owner_matches_task(owner: _TaskContextOwner | None, *, task_id: str, context_id: str) -> bool: @@ -2271,6 +3701,10 @@ def _owner_from_journal_events(events: list[dict[str, Any]], *, context_id: str) for event in events: if event.get("contextId") != context_id: continue + if _is_pending_backup_publication_event(event): + continue + if _requires_backup_committed_ack(event) and not _has_backup_committed_ack(events, event): + continue candidate = _owner_from_values( event.get("taskId"), event.get("contextId"), @@ -2325,9 +3759,14 @@ def _events_for_task_context( def _latest_terminal_a2a_event(events: list[dict[str, Any]]) -> dict[str, Any] | None: terminal_event: dict[str, Any] | None = None + unavailable_sequence = _latest_terminal_recovery_blocking_sequence(events) for event in events: + if _sequence_number(event.get("sequence")) <= unavailable_sequence: + continue if _terminal_status_from_a2a_event(event) is None: continue + if _requires_backup_committed_ack(event) and not _has_backup_committed_ack(events, event): + continue if terminal_event is None or _sequence_number(event.get("sequence")) >= _sequence_number( terminal_event.get("sequence") ): @@ -2335,7 +3774,122 @@ def _latest_terminal_a2a_event(events: list[dict[str, Any]]) -> dict[str, Any] | return terminal_event +def _terminal_publication_unavailable_blocks_recovery(events: list[dict[str, Any]]) -> bool: + unavailable_sequence = _latest_terminal_recovery_blocking_sequence(events) + if unavailable_sequence <= 0: + return False + latest_terminal_sequence = max( + ( + _sequence_number(event.get("sequence")) + for event in events + if _terminal_status_from_a2a_event(event) is not None + ), + default=0, + ) + return unavailable_sequence >= latest_terminal_sequence + + +def _latest_terminal_publication_unavailable_sequence(events: list[dict[str, Any]]) -> int: + return max( + ( + _sequence_number(event.get("sequence")) + for event in events + if _is_terminal_publication_unavailable_event(event) + ), + default=0, + ) + + +def _latest_terminal_recovery_blocking_sequence(events: list[dict[str, Any]]) -> int: + return max( + _latest_terminal_publication_unavailable_sequence(events), + max( + (_sequence_number(event.get("sequence")) for event in events if event.get("eventType") == "backup_blocked"), + default=0, + ), + ) + + +def _is_terminal_publication_unavailable_event(event: dict[str, Any]) -> bool: + if event.get("eventType") != "input_required": + return False + raw_data = event.get("data") + data = raw_data if isinstance(raw_data, dict) else {} + return data.get("kind") == _TERMINAL_PUBLICATION_UNAVAILABLE_KIND + + +def _requires_backup_committed_ack(event: dict[str, Any]) -> bool: + return event.get("visibility") == _COMMITTED_BACKUP_VISIBILITY + + +def _has_unacknowledged_committed_terminal_event(events: list[dict[str, Any]]) -> bool: + return any( + _terminal_status_from_a2a_event(event) is not None + and _requires_backup_committed_ack(event) + and not _has_backup_committed_ack(events, event) + for event in events + ) + + +def _unacknowledged_committed_terminal_matches_task( + publisher: PipelineA2AEventPublisher, + sidecar_status: Any, + *, + task_id: str, + context_id: str, +) -> bool: + expected_status = _terminal_status_from_sidecar(sidecar_status) + if expected_status is None: + return False + events = _events_for_task_context( + _safe_read_pipeline_journal(publisher.journal), + task_id=task_id, + context_id=context_id, + ) + blocking_sequence = _latest_terminal_recovery_blocking_sequence(events) + for event in events: + if _sequence_number(event.get("sequence")) <= blocking_sequence: + continue + if _terminal_status_from_a2a_event(event) != expected_status: + continue + if _requires_backup_committed_ack(event) and not _has_backup_committed_ack(events, event): + return True + return False + + +def _has_unacknowledged_committed_event_at_or_after(events: list[dict[str, Any]], sequence: int) -> bool: + return any( + _sequence_number(event.get("sequence")) >= sequence + and _requires_backup_committed_ack(event) + and not _has_backup_committed_ack(events, event) + for event in events + ) + + +def _has_backup_committed_ack(events: list[dict[str, Any]], terminal_event: dict[str, Any]) -> bool: + terminal_sequence = _sequence_number(terminal_event.get("sequence")) + terminal_event_id = terminal_event.get("eventId") + terminal_event_type = terminal_event.get("eventType") + for event in events: + if event.get("eventType") != BACKUP_COMMITTED_EVENT_TYPE: + continue + if _sequence_number(event.get("sequence")) <= terminal_sequence: + continue + raw_data = event.get("data") + data = raw_data if isinstance(raw_data, dict) else {} + if data.get("committedEventId") == terminal_event_id: + return True + if ( + _sequence_number(data.get("committedSequence")) == terminal_sequence + and data.get("committedEventType") == terminal_event_type + ): + return True + return False + + def _terminal_status_from_a2a_event(event: dict[str, Any]) -> str | None: + if _is_pending_backup_publication_event(event): + return None status = _normalized_a2a_status(event.get("status") if isinstance(event.get("status"), str) else None) if status in _TERMINAL_A2A_STATUSES: return status @@ -2380,7 +3934,7 @@ def _terminal_snapshot_needs_journal_rebuild( return True snapshot_sequence = _sequence_number(snapshot.get("lastSequence")) journal_sequence = max((_sequence_number(event.get("sequence")) for event in journal_events), default=0) - return snapshot_sequence < journal_sequence + return bool(journal_events) and snapshot_sequence != journal_sequence def _terminal_snapshot_needs_recovery_event( diff --git a/src/iac_code/a2a/pipeline_journal.py b/src/iac_code/a2a/pipeline_journal.py index 201bc915..5aa18fbd 100644 --- a/src/iac_code/a2a/pipeline_journal.py +++ b/src/iac_code/a2a/pipeline_journal.py @@ -5,14 +5,23 @@ import math import os import uuid +from collections.abc import Iterator +from contextlib import contextmanager from pathlib import Path from typing import Any -from iac_code.utils.state_io import fsync_parent_dir +from iac_code.i18n import _ +from iac_code.utils.path_locks import PathLockRegistry +from iac_code.utils.state_io import cross_process_append_lock, fsync_parent_dir logger = logging.getLogger(__name__) _EVENT_GROUP_RECORD_TYPE = "event_group" _EVENT_GROUP_RECORD_KEY = "__iac_code_record_type" +_JOURNAL_PATH_LOCKS = PathLockRegistry() +_APPEND_TAIL_CLEAN = "clean" +_APPEND_TAIL_REPAIRABLE = "repairable_tail" +_APPEND_TAIL_UNREPAIRABLE = "unrepairable" +_APPEND_TAIL_SCAN_BYTES = 1024 * 1024 class A2APipelineJournalReadError(ValueError): @@ -26,27 +35,19 @@ def __init__(self, pipeline_dir: str | Path) -> None: def append(self, event: dict[str, Any], durable: bool = False) -> None: self.pipeline_dir.mkdir(parents=True, exist_ok=True) - created = not self.path.exists() safe_event = to_json_safe(event) try: line = json.dumps(safe_event, ensure_ascii=False, separators=(",", ":"), allow_nan=False) except (TypeError, ValueError): logger.warning("Skipping non-JSON-safe A2A pipeline journal event in %s", self.path, exc_info=True) return - with self.path.open("a", encoding="utf-8") as handle: - handle.write(line + "\n") - handle.flush() - if durable: - os.fsync(handle.fileno()) - if durable and created: - fsync_parent_dir(self.path) + _append_journal_bytes(self.path, (line + "\n").encode("utf-8"), durable=durable) def append_many(self, events: list[dict[str, Any]], durable: bool = False) -> None: if not events: return self.pipeline_dir.mkdir(parents=True, exist_ok=True) - created = not self.path.exists() safe_events = [] for event in events: safe_event = to_json_safe(event) @@ -60,13 +61,7 @@ def append_many(self, events: list[dict[str, Any]], durable: bool = False) -> No "events": safe_events, } line = json.dumps(record, ensure_ascii=False, separators=(",", ":"), allow_nan=False) - with self.path.open("a", encoding="utf-8") as handle: - handle.write(line + "\n") - handle.flush() - if durable: - os.fsync(handle.fileno()) - if durable and created: - fsync_parent_dir(self.path) + _append_journal_bytes(self.path, (line + "\n").encode("utf-8"), durable=durable) def read_all(self) -> list[dict[str, Any]]: return self._read_all(strict=False) @@ -75,14 +70,23 @@ def read_all_strict(self) -> list[dict[str, Any]]: return self._read_all(strict=True) def read_all_repairing_tail(self) -> list[dict[str, Any]]: - try: - return self.read_all_strict() - except A2APipelineJournalReadError: - if not self.repair_tail(): - raise - return self.read_all_strict() + if not self.path.exists() and not _journal_lock_path(self.path).exists(): + return [] + with _journal_transaction_lock(self.path): + if not self.path.exists(): + return [] + try: + return self._read_all(strict=True) + except A2APipelineJournalReadError: + if not self._repair_tail_locked(): + raise + return self._read_all(strict=True) def repair_tail(self) -> bool: + with _journal_transaction_lock(self.path): + return self._repair_tail_locked() + + def _repair_tail_locked(self) -> bool: if not self.path.exists(): return False try: @@ -161,6 +165,129 @@ def read_after(self, sequence: int) -> list[dict[str, Any]]: return [event for event in self.read_all() if _sequence_value(event) > sequence] +def _append_journal_bytes(path: Path, payload: bytes, *, durable: bool) -> None: + with _journal_transaction_lock(path): + _repair_existing_tail_before_append(path) + created = not path.exists() + offset: int | None = None + try: + with path.open("ab+") as handle: + handle.seek(0, os.SEEK_END) + offset = handle.tell() + handle.write(payload) + handle.flush() + if durable: + os.fsync(handle.fileno()) + if durable and created: + fsync_parent_dir(path) + except Exception: + if offset is not None: + _rollback_journal_append( + path, + offset=offset, + durable=durable, + unlink_empty_created=created and offset == 0, + ) + raise + + +def _rollback_journal_append( + path: Path, + *, + offset: int, + durable: bool, + unlink_empty_created: bool = False, +) -> None: + try: + with path.open("r+b") as handle: + handle.truncate(offset) + handle.flush() + if durable: + os.fsync(handle.fileno()) + if unlink_empty_created: + path.unlink() + except OSError as exc: + logger.warning( + "Failed to roll back A2A pipeline journal append error_type=%s", + type(exc).__name__, + ) + + +def _repair_existing_tail_before_append(path: Path) -> None: + if not path.exists(): + return + tail_state = _append_tail_state(path) + if tail_state == _APPEND_TAIL_CLEAN: + return + if tail_state == _APPEND_TAIL_UNREPAIRABLE: + raise A2APipelineJournalReadError(_("Unrepairable A2A pipeline journal tail")) + journal = A2APipelineJournal(path.parent) + if not journal._repair_tail_locked(): + raise A2APipelineJournalReadError(_("Unrepairable A2A pipeline journal tail")) + + +def _append_tail_state(path: Path) -> str: + try: + file_size = path.stat().st_size + except OSError: + return _APPEND_TAIL_CLEAN + if file_size <= 0: + return _APPEND_TAIL_CLEAN + read_size = min(file_size, _APPEND_TAIL_SCAN_BYTES) + try: + with path.open("rb") as handle: + handle.seek(file_size - read_size) + content = handle.read(read_size) + except OSError: + return _APPEND_TAIL_CLEAN + if not content: + return _APPEND_TAIL_CLEAN + if read_size < file_size: + newline_index = content.find(b"\n") + if newline_index < 0: + return _APPEND_TAIL_UNREPAIRABLE + content = content[newline_index + 1 :] + nonempty_lines = [line for line in content.splitlines(keepends=True) if line.strip()] + if not nonempty_lines: + return _APPEND_TAIL_CLEAN + last_line = nonempty_lines[-1] + for line in nonempty_lines[:-1]: + if not _json_line_is_valid_journal_record(line, use_json_loads=False): + return _APPEND_TAIL_UNREPAIRABLE + last_line_valid = _json_line_is_valid_journal_record(last_line, use_json_loads=True) + if not last_line_valid: + return _APPEND_TAIL_REPAIRABLE + if not content.endswith(b"\n"): + return _APPEND_TAIL_REPAIRABLE + return _APPEND_TAIL_CLEAN + + +def _json_line_is_valid_journal_record(line: bytes | str, *, use_json_loads: bool) -> bool: + try: + text = line.decode("utf-8") if isinstance(line, bytes) else line + value = json.loads(text) if use_json_loads else json.JSONDecoder().decode(text) + except (UnicodeDecodeError, json.JSONDecodeError, TypeError, ValueError): + return False + if not isinstance(value, dict): + return False + try: + _events_from_journal_record(value, strict=True, line_number=0, path=Path("")) + except A2APipelineJournalReadError: + return False + return True + + +@contextmanager +def _journal_transaction_lock(path: Path) -> Iterator[None]: + with _JOURNAL_PATH_LOCKS.lock_for(path): + with cross_process_append_lock(path): + yield + + +def _journal_lock_path(path: Path) -> Path: + return path.with_name(f".{path.name}.lock") + + def _sequence_value(event: dict[str, Any]) -> int: value = event.get("sequence", 0) try: @@ -227,12 +354,7 @@ def _repairable_tail(content: str) -> tuple[str, str] | None: line = raw_line.strip() if not line: continue - try: - value = json.loads(line) - except json.JSONDecodeError: - invalid_index = index - break - if not isinstance(value, dict): + if not _json_line_is_valid_journal_record(line, use_json_loads=True): invalid_index = index break @@ -256,11 +378,7 @@ def _contains_only_complete_json_records(content: str) -> bool: for line in content.splitlines(): if not line.strip(): continue - try: - value = json.loads(line) - except json.JSONDecodeError: - return False - if not isinstance(value, dict): + if not _json_line_is_valid_journal_record(line, use_json_loads=True): return False return True diff --git a/src/iac_code/a2a/pipeline_paths.py b/src/iac_code/a2a/pipeline_paths.py index bd0e24fa..79f264de 100644 --- a/src/iac_code/a2a/pipeline_paths.py +++ b/src/iac_code/a2a/pipeline_paths.py @@ -1,7 +1,14 @@ from __future__ import annotations +import stat from pathlib import Path +from iac_code.i18n import _ +from iac_code.services.session_layout import ( + SESSION_LAYOUT_VERSION_V2, + UnsupportedSessionLayoutError, + require_supported_session_layout, +) from iac_code.services.session_storage import SessionStorage @@ -13,17 +20,50 @@ def a2a_pipeline_dir_for_sidecar_dir(sidecar_dir: str | Path) -> Path: def a2a_pipeline_dir_for_session(*, cwd: str, session_id: str) -> Path: - return SessionStorage().session_dir(cwd, session_id) / "a2a" / "pipeline" + storage = SessionStorage() + session_dir = storage.ensure_v2_session_dir_for_new_session(cwd, session_id) + if session_dir is None: + session_dir = storage.session_dir(cwd, session_id) + version = require_supported_session_layout(session_dir) + else: + version = SESSION_LAYOUT_VERSION_V2 + preferred = session_dir / "a2a" / "pipeline" + legacy = session_dir / "pipeline" + if version != SESSION_LAYOUT_VERSION_V2 and (_has_a2a_metadata(preferred) or _has_a2a_metadata(legacy)): + return preferred if _has_a2a_metadata(preferred) or not _has_a2a_metadata(legacy) else legacy + if version != SESSION_LAYOUT_VERSION_V2: + raise UnsupportedSessionLayoutError(_("Unsupported session layout version: {version}").format(version="legacy")) + return preferred def existing_a2a_pipeline_dir_for_session(*, cwd: str, session_id: str) -> Path: session_dir = SessionStorage().session_dir(cwd, session_id) preferred = session_dir / "a2a" / "pipeline" legacy = session_dir / "pipeline" + version = require_supported_session_layout(session_dir) + if version != SESSION_LAYOUT_VERSION_V2 and (_has_a2a_metadata(preferred) or _has_a2a_metadata(legacy)): + return preferred if _has_a2a_metadata(preferred) or not _has_a2a_metadata(legacy) else legacy if _has_a2a_metadata(preferred) or not _has_a2a_metadata(legacy): return preferred return legacy def _has_a2a_metadata(path: Path) -> bool: - return (path / "a2a-events.jsonl").exists() or (path / "a2a-snapshot.json").exists() + return _is_regular_file_entry(path / "a2a-events.jsonl") or _is_regular_file_entry(path / "a2a-snapshot.json") + + +def _is_regular_file_entry(path: Path) -> bool: + if path.is_symlink() or _is_reparse_point(path): + return False + try: + return stat.S_ISREG(path.stat(follow_symlinks=False).st_mode) + except OSError: + return False + + +def _is_reparse_point(path: Path) -> bool: + try: + attributes = getattr(path.stat(follow_symlinks=False), "st_file_attributes", 0) + except OSError: + return False + return bool(attributes & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)) diff --git a/src/iac_code/a2a/pipeline_snapshot.py b/src/iac_code/a2a/pipeline_snapshot.py index 6d54b69e..e54f086e 100644 --- a/src/iac_code/a2a/pipeline_snapshot.py +++ b/src/iac_code/a2a/pipeline_snapshot.py @@ -47,6 +47,9 @@ "last_error", } _PIPELINE_WARNING_PRIVATE_DATA_KEYS = {"ledger_path", "ledgerPath", "load_error", "loadError"} +_PENDING_BACKUP_VISIBILITY = "pending_backup" +_COMMITTED_BACKUP_VISIBILITY = "committed" +_BACKUP_COMMITTED_EVENT_TYPE = "backup_committed" class A2APipelineSnapshotStore: @@ -93,7 +96,7 @@ def reduce_pipeline_events( existing_snapshot: dict[str, Any] | None = None, ) -> dict[str, Any]: reducer = _PipelineSnapshotReducer(existing_snapshot) - reducer.reduce(events) + reducer.reduce(_backup_ack_authoritative_events(events)) return reducer.snapshot() @@ -417,8 +420,12 @@ def _apply(self, event: dict[str, Any]) -> None: self._apply_pipeline_started(data) elif event_type == "pipeline_handoff_ready": handoff = _normal_handoff(event) - self._snapshot["normalHandoff"] = handoff - self._append_control_history("handoffHistory", self._handoff_history_keys, handoff) + if _is_pending_backup_publication(event): + self._snapshot["pendingNormalHandoff"] = handoff + else: + self._snapshot["normalHandoff"] = handoff + self._snapshot["pendingNormalHandoff"] = None + self._append_control_history("handoffHistory", self._handoff_history_keys, handoff) cleanup_data = _dict_or_none(data.get("cleanup")) if cleanup_data is not None: self._apply_cleanup_data(cleanup_data, event) @@ -467,19 +474,37 @@ def _apply(self, event: dict[str, Any]) -> None: elif event_type == "candidate_restart_requested": self._append_candidate_restart(event) elif event_type == "input_required": + if data.get("kind") == "terminal_publication_unavailable": + self._snapshot["normalHandoff"] = None + self._snapshot["pendingNormalHandoff"] = None + self._snapshot["pendingTerminal"] = None self._snapshot["pendingInput"] = self._pending_input(event) self._snapshot["status"] = "waiting_input" elif event_type == "input_received": self._snapshot["pendingInput"] = None self._snapshot["status"] = "working" + elif event_type == "backup_blocked": + self._snapshot["normalHandoff"] = None + self._snapshot["pendingNormalHandoff"] = None + self._snapshot["pendingTerminal"] = None + self._snapshot["pendingInput"] = self._backup_blocked_pending_input(event) + self._snapshot["status"] = "waiting_input" terminal_status = _TERMINAL_STATUS_BY_EVENT_TYPE.get(event_type) - if terminal_status is not None: + if terminal_status is not None and _is_pending_backup_publication(event): + self._snapshot["pendingTerminal"] = _terminal_publication(event) + elif terminal_status is not None: self._snapshot["status"] = terminal_status + self._snapshot["pendingTerminal"] = None self._snapshot["pendingInput"] = None self._snapshot["control"]["activeCandidateRunIds"] = [] - elif event_type not in {"input_required", "input_received", *_CLEANUP_STATUS_BY_EVENT_TYPE} and not ( - event_type == "pipeline_handoff_ready" and self._snapshot["status"] in {"completed", "failed", "canceled"} + elif ( + event_type not in {"input_required", "input_received", *_CLEANUP_STATUS_BY_EVENT_TYPE} + and not _is_pending_backup_publication(event) + and not ( + event_type == "pipeline_handoff_ready" + and self._snapshot["status"] in {"completed", "failed", "canceled"} + ) ): self._apply_event_status(event) @@ -491,6 +516,8 @@ def _merge_pipeline_identity(self, event: dict[str, Any]) -> None: def _apply_pipeline_started(self, data: dict[str, Any]) -> None: self._snapshot["normalHandoff"] = None + self._snapshot["pendingNormalHandoff"] = None + self._snapshot["pendingTerminal"] = None control = self._snapshot["control"] if "totalSteps" in data: control["totalSteps"] = data["totalSteps"] @@ -972,6 +999,14 @@ def _pending_input(self, event: dict[str, Any]) -> dict[str, Any]: _merge_event_coordinates(pending, event) return pending + def _backup_blocked_pending_input(self, event: dict[str, Any]) -> dict[str, Any]: + pending = self._pending_input(event) + pending.setdefault("kind", "backup_blocked") + data = copy.deepcopy(_dict_or_empty(event.get("data"))) + if data: + pending["backupBlocked"] = data + return pending + def _normal_handoff(event: dict[str, Any]) -> dict[str, Any]: data = _sanitize_cleanup_private_fields(copy.deepcopy(_dict_or_empty(event.get("data")))) @@ -981,6 +1016,7 @@ def _normal_handoff(event: dict[str, Any]) -> dict[str, Any]: "sequence": _sequence_value(event), "createdAt": _string_or_none(event.get("createdAt")), "status": _string_or_none(event.get("status")), + "visibility": _publication_visibility(event), "action": data.get("action"), "targetMode": data.get("targetMode"), "outcome": data.get("outcome"), @@ -991,6 +1027,78 @@ def _normal_handoff(event: dict[str, Any]) -> dict[str, Any]: return handoff +def _terminal_publication(event: dict[str, Any]) -> dict[str, Any]: + data = _sanitize_cleanup_private_fields(copy.deepcopy(_dict_or_empty(event.get("data")))) + terminal = { + "eventType": _string_or_none(event.get("eventType")), + "eventId": _string_or_none(event.get("eventId")), + "sequence": _sequence_value(event), + "createdAt": _string_or_none(event.get("createdAt")), + "status": _string_or_none(event.get("status")), + "visibility": _publication_visibility(event), + "data": data, + } + _merge_event_coordinates(terminal, event) + return terminal + + +def _is_pending_backup_publication(event: dict[str, Any]) -> bool: + data = _dict_or_empty(event.get("data")) + return _publication_visibility(event) == _PENDING_BACKUP_VISIBILITY or data.get("backupPending") is True + + +def _is_committed_backup_publication(event: dict[str, Any]) -> bool: + event_type = _string_or_none(event.get("eventType")) + return _publication_visibility(event) == _COMMITTED_BACKUP_VISIBILITY and event_type in { + "pipeline_handoff_ready", + *_TERMINAL_STATUS_BY_EVENT_TYPE, + } + + +def _backup_ack_authoritative_events(events: list[dict[str, Any]]) -> list[dict[str, Any]]: + authoritative: list[dict[str, Any]] = [] + pending_by_id: dict[str, dict[str, Any]] = {} + pending_by_sequence_type: dict[tuple[int, str], dict[str, Any]] = {} + for event in sorted(events, key=_sequence_value): + if not isinstance(event, dict): + authoritative.append(event) + continue + if _is_committed_backup_publication(event): + event_id = _string_or_none(event.get("eventId")) + event_type = _string_or_none(event.get("eventType")) or "" + sequence = _sequence_value(event) + if event_id: + pending_by_id[event_id] = event + pending_by_sequence_type[(sequence, event_type)] = event + continue + if event.get("eventType") == _BACKUP_COMMITTED_EVENT_TYPE: + data = _dict_or_empty(event.get("data")) + committed_event = None + committed_event_id = _string_or_none(data.get("committedEventId")) + if committed_event_id: + committed_event = pending_by_id.pop(committed_event_id, None) + if committed_event is None: + committed_event_type = _string_or_none(data.get("committedEventType")) or "" + committed_event = pending_by_sequence_type.pop( + (_sequence_value(data.get("committedSequence")), committed_event_type), + None, + ) + if committed_event is not None: + authoritative.append(committed_event) + authoritative.append(event) + continue + authoritative.append(event) + return authoritative + + +def _publication_visibility(event: dict[str, Any]) -> str | None: + value = _string_or_none(event.get("visibility")) + if value is not None: + return value + data = _dict_or_empty(event.get("data")) + return _string_or_none(data.get("visibility")) + + def _warning_history_entry(event: dict[str, Any]) -> dict[str, Any]: entry = { "eventId": _string_or_none(event.get("eventId")), @@ -1090,6 +1198,8 @@ def _empty_snapshot() -> dict[str, Any]: "history": [], }, "normalHandoff": None, + "pendingNormalHandoff": None, + "pendingTerminal": None, "pendingInput": None, "control": { "activeCandidateRunIds": [], @@ -1165,6 +1275,14 @@ def _snapshot_from_existing(existing_snapshot: dict[str, Any] | None) -> dict[st snapshot["normalHandoff"] = ( _sanitize_cleanup_private_fields(normal_handoff) if isinstance(normal_handoff, dict) else None ) + pending_normal_handoff = snapshot.get("pendingNormalHandoff") + snapshot["pendingNormalHandoff"] = ( + _sanitize_cleanup_private_fields(pending_normal_handoff) if isinstance(pending_normal_handoff, dict) else None + ) + pending_terminal = snapshot.get("pendingTerminal") + snapshot["pendingTerminal"] = ( + _sanitize_cleanup_private_fields(pending_terminal) if isinstance(pending_terminal, dict) else None + ) cleanup = snapshot.get("cleanup") if not isinstance(cleanup, dict): cleanup = {} @@ -1214,6 +1332,12 @@ def _sanitize_public_snapshot_private_cleanup_fields(value: dict[str, Any]) -> d normal_handoff = sanitized.get("normalHandoff") if isinstance(normal_handoff, dict): sanitized["normalHandoff"] = _sanitize_cleanup_private_fields(normal_handoff) + pending_normal_handoff = sanitized.get("pendingNormalHandoff") + if isinstance(pending_normal_handoff, dict): + sanitized["pendingNormalHandoff"] = _sanitize_cleanup_private_fields(pending_normal_handoff) + pending_terminal = sanitized.get("pendingTerminal") + if isinstance(pending_terminal, dict): + sanitized["pendingTerminal"] = _sanitize_cleanup_private_fields(pending_terminal) cleanup = sanitized.get("cleanup") if isinstance(cleanup, dict): sanitized["cleanup"] = _sanitize_cleanup_private_fields(cleanup, root_is_cleanup=True) diff --git a/src/iac_code/a2a/pipeline_stream.py b/src/iac_code/a2a/pipeline_stream.py index 8dccd40f..28f14164 100644 --- a/src/iac_code/a2a/pipeline_stream.py +++ b/src/iac_code/a2a/pipeline_stream.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import contextlib import inspect import logging import os @@ -30,7 +31,13 @@ from iac_code.types.stream_events import PermissionRequestEvent, SubPipelineStreamEvent, ToolResultEvent PipelinePermissionResolver = Callable[[PermissionRequestEvent], bool | Awaitable[bool]] +PipelineBeforeEnqueueHook = Callable[[dict[str, Any]], bool | Awaitable[bool]] +PipelineAfterBackupCommitHook = Callable[[dict[str, Any]], None | Awaitable[None]] +PipelineBackupCommitGate = Callable[[dict[str, Any]], bool] logger = logging.getLogger(__name__) +PENDING_BACKUP_VISIBILITY = "pending_backup" +COMMITTED_BACKUP_VISIBILITY = "committed" +BACKUP_COMMITTED_EVENT_TYPE = "backup_committed" _RECOVERY_SEMANTIC_EVENT_TYPES = { "pipeline_started", "pipeline_resumed", @@ -46,6 +53,8 @@ "candidate_step_failed", "input_required", "input_received", + "backup_blocked", + BACKUP_COMMITTED_EVENT_TYPE, "pipeline_completed", "pipeline_failed", "pipeline_canceled", @@ -71,6 +80,17 @@ _RECOVERY_STATE_STATUSES = {"working"} +def backup_committed_delivery_envelope( + ack_envelope: dict[str, Any], + committed_envelope: dict[str, Any], +) -> dict[str, Any]: + delivery_envelope = dict(ack_envelope) + status = committed_envelope.get("status") + if isinstance(status, str): + delivery_envelope["status"] = status + return delivery_envelope + + class _SnapshotCatchUpUnavailableError(Exception): pass @@ -90,6 +110,9 @@ def __init__( exposure_types: Any = None, delivery_task_id: str | None = None, delivery_context_id: str | None = None, + before_enqueue: PipelineBeforeEnqueueHook | None = None, + after_backup_commit: PipelineAfterBackupCommitHook | None = None, + backup_commit_gate: PipelineBackupCommitGate | None = None, ) -> None: self.event_queue = event_queue self.translator = translator @@ -99,7 +122,13 @@ def __init__( self.exposure_types = normalize_a2a_exposure_types(exposure_types) self.delivery_task_id = delivery_task_id self.delivery_context_id = delivery_context_id + self.before_enqueue = before_enqueue + self.after_backup_commit = after_backup_commit + self.backup_commit_gate = backup_commit_gate self._sequence_lock = asyncio.Lock() + self._delivery_lock = asyncio.Lock() + self._delivery_lock_owner: asyncio.Task[Any] | None = None + self._delivery_lock_depth = 0 self._last_sequence = 0 self.last_envelope: dict[str, Any] | None = None @@ -286,6 +315,7 @@ async def publish_manual( data: dict[str, Any] | None = None, coordinates: dict[str, Any] | None = None, require_durable_metadata: bool = False, + require_journal_metadata: bool = False, ) -> dict[str, Any] | None: envelope = self.translator.manual_event(event_type, scope, status=status, data=data) if coordinates: @@ -293,10 +323,10 @@ async def publish_manual( value = coordinates.get(key) if isinstance(value, dict): envelope[key] = dict(value) - return ( - envelope - if await self._persist_and_enqueue(envelope, require_durable_metadata=require_durable_metadata) - else None + return await self._persist_and_enqueue_envelope( + envelope, + require_durable_metadata=require_durable_metadata, + require_journal_metadata=require_journal_metadata, ) def _next_snapshot(self, envelope: dict[str, Any]) -> dict[str, Any]: @@ -304,8 +334,11 @@ def _next_snapshot(self, envelope: dict[str, Any]) -> dict[str, Any]: if existing_snapshot is not None: try: events = self.journal.read_all_repairing_tail() - except Exception: - logger.warning("Failed to read A2A pipeline journal snapshot catch-up events", exc_info=True) + except Exception as exc: + logger.warning( + "Failed to read A2A pipeline journal snapshot catch-up events error_type=%s", + type(exc).__name__, + ) raise _SnapshotCatchUpUnavailableError from None scoped_events = _events_for_envelope_identity(events, envelope) @@ -316,6 +349,7 @@ def _next_snapshot(self, envelope: dict[str, Any]) -> dict[str, Any]: catch_up_events = [ event for event in scoped_events if _int_value(event.get("sequence"), 0) > snapshot_sequence ] + catch_up_events = _include_backup_ack_committed_event(catch_up_events, scoped_events, envelope) snapshot_base = existing_snapshot else: catch_up_events = scoped_events @@ -324,38 +358,45 @@ def _next_snapshot(self, envelope: dict[str, Any]) -> dict[str, Any]: try: journal_events = self.journal.read_all_repairing_tail() - except Exception: - logger.warning("Failed to read A2A pipeline journal snapshot events", exc_info=True) + except Exception as exc: + logger.warning( + "Failed to read A2A pipeline journal snapshot events error_type=%s", + type(exc).__name__, + ) raise _SnapshotCatchUpUnavailableError from None events = _events_for_envelope_identity(journal_events, envelope) return reduce_pipeline_events([*events, envelope]) - async def _persist_and_enqueue( + async def persist_envelope( self, envelope: dict[str, Any], *, artifact_metadata: dict[str, Any] | None = None, require_durable_metadata: bool = False, - ) -> bool: + require_journal_metadata: bool = False, + ) -> dict[str, Any] | None: async with self._sequence_lock: self._annotate_delivery_alias(envelope) try: self._ensure_monotonic_sequence(envelope) except _SequenceHighWaterUnavailableError: logger.warning("Skipping A2A pipeline event until journal high-water sequence is readable") - return False + return None safe_envelope = to_json_safe(envelope) if not isinstance(safe_envelope, dict): logger.warning("Skipping invalid A2A pipeline envelope: %r", envelope) - return False + return None durable_required = require_durable_metadata or is_recovery_semantic_event(safe_envelope) journal_persisted = False snapshot_persisted = False try: self.journal.append(safe_envelope, durable=durable_required) journal_persisted = True - except Exception: - logger.warning("Failed to append A2A pipeline journal event", exc_info=True) + except Exception as exc: + logger.warning( + "Failed to append A2A pipeline journal event error_type=%s", + type(exc).__name__, + ) try: snapshot = self._next_snapshot(safe_envelope) snapshot_persisted = self.snapshot_store.save(snapshot) @@ -365,17 +406,183 @@ async def _persist_and_enqueue( logger.warning("Failed to persist A2A pipeline snapshot", exc_info=True) if snapshot_persisted: _maybe_inject_test_fault("after_a2a_pipeline_snapshot_saved") + if require_journal_metadata and not journal_persisted: + logger.warning("Skipping A2A pipeline status update because journal metadata was not persisted") + return None if durable_required and not (journal_persisted or snapshot_persisted): logger.warning("Skipping A2A pipeline status update because durable metadata was not persisted") - return False + return None if artifact_metadata is not None and not (journal_persisted or snapshot_persisted): logger.warning("Skipping A2A artifact update because pipeline metadata was not persisted") - return False + return None + return safe_envelope + + async def enqueue_persisted( + self, + envelope: dict[str, Any], + *, + artifact_metadata: dict[str, Any] | None = None, + run_before_enqueue: bool = True, + ) -> bool: + async with self._delivery_guard(): + if run_before_enqueue: + if not await self._run_before_enqueue_hook(envelope): + return False if artifact_metadata is not None: - await self._enqueue_artifact_update(safe_envelope, artifact_metadata) - await self._enqueue_status(safe_envelope) - self.last_envelope = safe_envelope + await self._enqueue_artifact_update(envelope, artifact_metadata) + await self._enqueue_status(envelope) + self.last_envelope = envelope + return True + + async def _persist_and_enqueue( + self, + envelope: dict[str, Any], + *, + artifact_metadata: dict[str, Any] | None = None, + require_durable_metadata: bool = False, + require_journal_metadata: bool = False, + ) -> bool: + return ( + await self._persist_and_enqueue_envelope( + envelope, + artifact_metadata=artifact_metadata, + require_durable_metadata=require_durable_metadata, + require_journal_metadata=require_journal_metadata, + ) + is not None + ) + + async def _persist_and_enqueue_envelope( + self, + envelope: dict[str, Any], + *, + artifact_metadata: dict[str, Any] | None = None, + require_durable_metadata: bool = False, + require_journal_metadata: bool = False, + ) -> dict[str, Any] | None: + if self._requires_backup_commit(envelope): + return await self._persist_backup_gated_publication( + envelope, + artifact_metadata=artifact_metadata, + require_durable_metadata=require_durable_metadata, + require_journal_metadata=require_journal_metadata, + ) + safe_envelope = await self.persist_envelope( + envelope, + artifact_metadata=artifact_metadata, + require_durable_metadata=require_durable_metadata, + require_journal_metadata=require_journal_metadata, + ) + if safe_envelope is None: + return None + if not await self.enqueue_persisted(safe_envelope, artifact_metadata=artifact_metadata): + return None + return safe_envelope + + async def _persist_backup_gated_publication( + self, + envelope: dict[str, Any], + *, + artifact_metadata: dict[str, Any] | None = None, + require_durable_metadata: bool = False, + require_journal_metadata: bool = False, + ) -> dict[str, Any] | None: + pending_envelope = pending_backup_publication_envelope(envelope) + pending_safe_envelope = await self.persist_envelope( + pending_envelope, + artifact_metadata=artifact_metadata, + require_durable_metadata=require_durable_metadata, + require_journal_metadata=require_journal_metadata, + ) + if pending_safe_envelope is None: + return None + async with self.delivery_transaction(): + committed_envelope = committed_backup_publication_envelope( + self.translator, + pending_safe_envelope, + ) + committed_safe_envelope = await self.persist_envelope( + committed_envelope, + artifact_metadata=artifact_metadata, + require_durable_metadata=require_durable_metadata, + require_journal_metadata=True, + ) + if committed_safe_envelope is None: + return None + if not await self._run_before_enqueue_hook(committed_safe_envelope): + return None + ack_envelope = await self.persist_backup_committed_ack(committed_safe_envelope) + if ack_envelope is None: + return None + if not await self.enqueue_persisted( + committed_safe_envelope, + artifact_metadata=artifact_metadata, + run_before_enqueue=False, + ): + return None + if not await self.enqueue_persisted( + backup_committed_delivery_envelope(ack_envelope, committed_safe_envelope), + run_before_enqueue=False, + ): + return None + await self._run_after_backup_commit_hook(committed_safe_envelope) + return committed_safe_envelope + + async def persist_backup_committed_ack(self, committed_envelope: dict[str, Any]) -> dict[str, Any] | None: + ack = self.translator.manual_event( + BACKUP_COMMITTED_EVENT_TYPE, + "pipeline", + data={ + "committedEventId": committed_envelope.get("eventId"), + "committedEventType": committed_envelope.get("eventType"), + "committedSequence": committed_envelope.get("sequence"), + }, + ) + ack.pop("status", None) + return await self.persist_envelope(ack, require_durable_metadata=True, require_journal_metadata=True) + + async def _run_before_enqueue_hook(self, envelope: dict[str, Any]) -> bool: + if self.before_enqueue is None: return True + should_enqueue = self.before_enqueue(envelope) + if inspect.isawaitable(should_enqueue): + should_enqueue = await should_enqueue + return should_enqueue is not False + + async def _run_after_backup_commit_hook(self, envelope: dict[str, Any]) -> None: + if self.after_backup_commit is None: + return + result = self.after_backup_commit(envelope) + if inspect.isawaitable(result): + await result + + def _requires_backup_commit(self, envelope: dict[str, Any]) -> bool: + if self.backup_commit_gate is None: + return False + return bool(self.backup_commit_gate(envelope)) + + def delivery_transaction(self): + return self._delivery_guard() + + @contextlib.asynccontextmanager + async def _delivery_guard(self): + owner = asyncio.current_task() + if owner is not None and self._delivery_lock_owner is owner: + self._delivery_lock_depth += 1 + try: + yield + finally: + self._delivery_lock_depth -= 1 + return + + async with self._delivery_lock: + self._delivery_lock_owner = owner + self._delivery_lock_depth = 1 + try: + yield + finally: + self._delivery_lock_depth = 0 + self._delivery_lock_owner = None def _annotate_delivery_alias(self, envelope: dict[str, Any]) -> None: delivery_task_id = self._delivery_task_id(envelope) @@ -403,8 +610,11 @@ def _last_persisted_sequence(self) -> int: (_int_value(event.get("sequence"), 0) for event in self.journal.read_all_repairing_tail()), default=0, ) - except Exception: - logger.warning("Failed to read A2A pipeline journal high-water sequence", exc_info=True) + except Exception as exc: + logger.warning( + "Failed to read A2A pipeline journal high-water sequence error_type=%s", + type(exc).__name__, + ) raise _SequenceHighWaterUnavailableError from None return max(sequence, journal_sequence) @@ -548,6 +758,35 @@ def _permission_approval_metadata(approved: bool) -> dict[str, Any]: return {"approved": approved, "decision": "allow_once" if approved else "deny"} +def pending_backup_publication_envelope(envelope: dict[str, Any]) -> dict[str, Any]: + pending = dict(to_json_safe(envelope) or {}) + pending["visibility"] = PENDING_BACKUP_VISIBILITY + return pending + + +def committed_backup_publication_envelope( + translator: PipelineEventTranslator, + pending_envelope: dict[str, Any], +) -> dict[str, Any]: + pending_data = pending_envelope.get("data") + data = dict(pending_data) if isinstance(pending_data, dict) else {} + envelope = translator.manual_event( + str(pending_envelope.get("eventType") or ""), + str(pending_envelope.get("scope") or "pipeline"), + status=str(pending_envelope.get("status") or "working"), + data=data, + ) + envelope["visibility"] = COMMITTED_BACKUP_VISIBILITY + created_at = pending_envelope.get("createdAt") + if isinstance(created_at, str) and created_at: + envelope["createdAt"] = created_at + for key in ("step", "candidate", "candidateStep"): + value = pending_envelope.get(key) + if isinstance(value, dict): + envelope[key] = dict(value) + return envelope + + def is_recovery_semantic_event(envelope: dict[str, Any]) -> bool: event_type = envelope.get("eventType") event_type = event_type if isinstance(event_type, str) else None @@ -619,6 +858,44 @@ def _snapshot_schema_is_current(snapshot: dict[str, Any]) -> bool: return snapshot.get("schemaVersion") == SNAPSHOT_SCHEMA_VERSION +def _include_backup_ack_committed_event( + catch_up_events: list[dict[str, Any]], + scoped_events: list[dict[str, Any]], + envelope: dict[str, Any], +) -> list[dict[str, Any]]: + if envelope.get("eventType") != BACKUP_COMMITTED_EVENT_TYPE: + return catch_up_events + committed_event = _matching_committed_event_for_backup_ack(scoped_events, envelope) + if committed_event is None: + return catch_up_events + committed_event_id = committed_event.get("eventId") + if committed_event_id is not None and any(event.get("eventId") == committed_event_id for event in catch_up_events): + return catch_up_events + return [committed_event, *catch_up_events] + + +def _matching_committed_event_for_backup_ack( + events: list[dict[str, Any]], + ack_envelope: dict[str, Any], +) -> dict[str, Any] | None: + data = ack_envelope.get("data") + data = data if isinstance(data, dict) else {} + committed_event_id = data.get("committedEventId") + committed_event_type = data.get("committedEventType") + committed_sequence = _int_value(data.get("committedSequence"), 0) + for event in events: + if event.get("visibility") != COMMITTED_BACKUP_VISIBILITY: + continue + if committed_event_id is not None and event.get("eventId") == committed_event_id: + return event + if ( + _int_value(event.get("sequence"), 0) == committed_sequence + and event.get("eventType") == committed_event_type + ): + return event + return None + + def _int_value(value: Any, default: int) -> int: try: return int(value) @@ -626,4 +903,15 @@ def _int_value(value: Any, default: int) -> int: return default -__all__ = ["PipelineA2AEventPublisher", "PipelinePermissionResolver", "is_recovery_semantic_event"] +__all__ = [ + "COMMITTED_BACKUP_VISIBILITY", + "PENDING_BACKUP_VISIBILITY", + "PipelineA2AEventPublisher", + "PipelineAfterBackupCommitHook", + "PipelineBackupCommitGate", + "PipelineBeforeEnqueueHook", + "PipelinePermissionResolver", + "committed_backup_publication_envelope", + "is_recovery_semantic_event", + "pending_backup_publication_envelope", +] diff --git a/src/iac_code/a2a/task_store.py b/src/iac_code/a2a/task_store.py index 5cbb1bce..5e61957c 100644 --- a/src/iac_code/a2a/task_store.py +++ b/src/iac_code/a2a/task_store.py @@ -1,11 +1,14 @@ from __future__ import annotations import asyncio +import json import logging import time import uuid from collections.abc import Callable +from dataclasses import asdict from datetime import datetime, timezone +from pathlib import Path from typing import Any, TypeAlias from a2a.server.context import ServerCallContext @@ -31,6 +34,9 @@ validate_protocol_id, ) from iac_code.i18n import _ +from iac_code.services.session_layout import SessionPaths, ensure_session_owned_parent +from iac_code.services.session_storage import SessionStorage +from iac_code.utils.file_security import atomic_write_text logger = logging.getLogger(__name__) A2ATaskSnapshotList: TypeAlias = list[A2ATaskSnapshot] @@ -257,6 +263,7 @@ async def get_or_create_context( if session_id is None: session_id = str(uuid.uuid4()) + _ensure_new_a2a_session_layout(cwd, session_id) record = A2AContextRecord( context_id=context_id, session_id=session_id, @@ -509,36 +516,67 @@ async def _cleanup_loop(self) -> None: logger.exception("A2A cleanup loop failed") def _mirror_task(self, record: A2ATaskRecord) -> None: - if self._persistence is None: - return + snapshot = A2ATaskSnapshot( + task_id=record.task_id, + context_id=record.context_id, + state=record.state, + owner=record.owner, + output_text=list(record.output_text), + updated_at=record.updated_at, + ) + if self._persistence is not None: + try: + self._persistence.save_task(snapshot) + except Exception: + logger.exception("Failed to persist A2A task %s", record.task_id) + self._mirror_session_task(snapshot) + + def _mirror_context(self, record: A2AContextRecord) -> None: + snapshot = A2AContextSnapshot( + context_id=record.context_id, + session_id=record.session_id, + cwd=record.cwd, + active_task_id=record.active_task_id, + ) + if self._persistence is not None: + try: + self._persistence.save_context(snapshot) + except Exception: + logger.exception("Failed to persist A2A context %s", record.context_id) + self._mirror_session_context(snapshot) + + def _mirror_session_task(self, snapshot: A2ATaskSnapshot) -> None: try: - self._persistence.save_task( - A2ATaskSnapshot( - task_id=record.task_id, - context_id=record.context_id, - state=record.state, - owner=record.owner, - output_text=list(record.output_text), - updated_at=record.updated_at, - ) + session_paths = self._session_paths_for_task_context(snapshot.context_id) + if session_paths is None: + return + _write_session_snapshot(session_paths.session_dir, session_paths.a2a_task_path, asdict(snapshot)) + except Exception as exc: + logger.warning( + "Failed to persist A2A session task snapshot error_type=%s", + type(exc).__name__, ) - except Exception: - logger.exception("Failed to persist A2A task %s", record.task_id) - def _mirror_context(self, record: A2AContextRecord) -> None: - if self._persistence is None: - return + def _mirror_session_context(self, snapshot: A2AContextSnapshot) -> None: try: - self._persistence.save_context( - A2AContextSnapshot( - context_id=record.context_id, - session_id=record.session_id, - cwd=record.cwd, - active_task_id=record.active_task_id, - ) + session_paths = _session_paths_for_a2a_snapshot(snapshot) + if session_paths is None: + return + _write_session_snapshot(session_paths.session_dir, session_paths.a2a_context_path, asdict(snapshot)) + except Exception as exc: + logger.warning( + "Failed to persist A2A session context snapshot error_type=%s", + type(exc).__name__, ) - except Exception: - logger.exception("Failed to persist A2A context %s", record.context_id) + + def _session_paths_for_task_context(self, context_id: str) -> SessionPaths | None: + context = self._contexts.get(context_id) + if context is not None: + return _session_paths_for_a2a_context(context) + snapshot = self._load_context_snapshot(context_id) + if snapshot is None: + return None + return _session_paths_for_a2a_snapshot(snapshot) def _load_task_snapshot(self, task_id: str) -> A2ATaskSnapshot | None: if self._persistence is None: @@ -564,6 +602,22 @@ def _restore_task_snapshot(self, task_id: str) -> A2ATaskSnapshot | None: logger.exception("Failed to restore persisted A2A task %s", task_id) return None + def _load_context_snapshot(self, context_id: str) -> A2AContextSnapshot | None: + if self._persistence is None: + return None + load_context = getattr(self._persistence, "load_context", None) + if load_context is None: + return None + try: + snapshot = load_context(context_id) + except Exception as exc: + logger.warning( + "Failed to load persisted A2A context error_type=%s", + type(exc).__name__, + ) + return None + return snapshot if isinstance(snapshot, A2AContextSnapshot) else None + def _list_task_snapshots(self, owner: str) -> A2ATaskSnapshotList: if self._persistence is None: return [] @@ -607,6 +661,35 @@ def _remove_sdk_task_from_index(self, owner: str, task_id: str, context_id: str) self._sdk_tasks_by_context.pop(owner, None) +def _session_paths_for_a2a_context(record: A2AContextRecord) -> SessionPaths | None: + session_dir = SessionStorage().v2_session_dir(record.cwd, record.session_id) + if session_dir is None: + return None + return SessionPaths.require_supported(session_dir) + + +def _session_paths_for_a2a_snapshot(snapshot: A2AContextSnapshot) -> SessionPaths | None: + session_dir = SessionStorage().v2_session_dir(snapshot.cwd, snapshot.session_id) + if session_dir is None: + return None + return SessionPaths.require_supported(session_dir) + + +def _ensure_new_a2a_session_layout(cwd: str, session_id: str) -> None: + try: + SessionStorage().ensure_v2_session_dir_for_new_session(cwd, session_id) + except Exception as exc: + logger.warning( + "Failed to prepare A2A session layout error_type=%s", + type(exc).__name__, + ) + + +def _write_session_snapshot(session_dir: Path, path: Path, data: dict[str, Any]) -> None: + ensure_session_owned_parent(session_dir, path) + atomic_write_text(path, json.dumps(data, ensure_ascii=False, sort_keys=True)) + + async def _close_runtime(runtime: Any | None) -> None: if runtime is None: return diff --git a/src/iac_code/a2a/transports/dispatcher.py b/src/iac_code/a2a/transports/dispatcher.py index 714c8079..761ec184 100644 --- a/src/iac_code/a2a/transports/dispatcher.py +++ b/src/iac_code/a2a/transports/dispatcher.py @@ -56,6 +56,8 @@ from iac_code.a2a.metrics import NoOpA2AMetrics from iac_code.a2a.persistence import A2APersistenceStore from iac_code.a2a.pipeline_executor import ( + _CANCEL_WAITING_INPUT_BACKUP_BLOCKED, + WaitingInputCancelResult, cancel_waiting_input_task_from_sidecar, recoverable_task_id_from_sidecar, terminal_task_state_from_sidecar, @@ -72,6 +74,7 @@ from iac_code.a2a.task_store import A2ATaskStore from iac_code.i18n import _ from iac_code.pipeline.config import RunMode, get_run_mode +from iac_code.services.session_backup import SessionBackupService from iac_code.utils.public_errors import public_exception_summary logger = logging.getLogger(__name__) @@ -142,9 +145,11 @@ def create_runtime_components( agent_extensions: object | None = None, auto_approve_permissions: bool = False, thinking_exposure: object | None = None, + backup_service: Any | None = None, ) -> A2ARuntimeComponents: metrics = NoOpA2AMetrics() thinking_exposure_types = normalize_a2a_exposure_types(thinking_exposure) + backup_service = backup_service or SessionBackupService() persistence = A2APersistenceStore(persistence_dir) if persistence_dir is not None else None artifact_store = A2AArtifactStore(artifact_dir) if artifact_dir is not None else None push_config_store = None @@ -196,6 +201,7 @@ def create_runtime_components( artifact_store=artifact_store, auto_approve_permissions=auto_approve_permissions, thinking_exposure_types=thinking_exposure_types, + backup_service=backup_service, ) card = build_agent_card( host=host, @@ -218,6 +224,8 @@ def create_runtime_components( push_config_store=push_config_store, push_sender=push_sender, extended_agent_card=build_extended_agent_card(card), + backup_service=backup_service, + metrics=metrics, ) return A2ARuntimeComponents( handler=handler, @@ -231,6 +239,17 @@ def create_runtime_components( class IacCodeRequestHandler(DefaultRequestHandler): + def __init__( + self, + *args: Any, + backup_service: Any | None = None, + metrics: Any | None = None, + **kwargs: Any, + ) -> None: + super().__init__(*args, **kwargs) + self._backup_service = backup_service or SessionBackupService() + self._metrics = metrics or NoOpA2AMetrics() + async def on_get_task(self, params: GetTaskRequest, context): self._validate_extensions(context) return await super().on_get_task(params, context) @@ -384,13 +403,31 @@ async def _cancel_inactive_pipeline_waiting_input_task(self, task: Task, context context_record = await self.task_store.get_context_record(task.context_id) except Exception: return None - if not cancel_waiting_input_task_from_sidecar( + try: + task_record = await self.task_store.get_task_record(task.id) + except Exception: + task_record = None + cancel_result = await asyncio.to_thread( + cancel_waiting_input_task_from_sidecar, cwd=context_record.cwd, session_id=context_record.session_id, context_id=task.context_id, task_id=task.id, reason=_("Task canceled while waiting for input."), - ): + backup_service=self._backup_service, + task_store=self.task_store, + task_record=task_record, + context_record=context_record, + metrics=self._metrics, + ) + if cancel_result in { + _CANCEL_WAITING_INPUT_BACKUP_BLOCKED, + WaitingInputCancelResult.BACKUP_BLOCKED_PERSIST_FAILED, + }: + return await self._reconcile_inactive_pipeline_input_required_task(task, context) + if cancel_result == WaitingInputCancelResult.PERSIST_FAILED: + return None + if cancel_result == WaitingInputCancelResult.NOT_OWNER: terminal_state = terminal_task_state_from_sidecar( cwd=context_record.cwd, session_id=context_record.session_id, @@ -401,8 +438,16 @@ async def _cancel_inactive_pipeline_waiting_input_task(self, task: Task, context return None return await self._reconcile_inactive_pipeline_terminal_task(task, context, terminal_state) + if cancel_result != WaitingInputCancelResult.CANCELED: + return None return await self._reconcile_inactive_pipeline_terminal_task(task, context, "canceled") + async def _reconcile_inactive_pipeline_input_required_task(self, task: Task, context) -> Task: + task.status.CopyFrom(TaskStatus(state=TaskState.Name(TaskState.TASK_STATE_INPUT_REQUIRED))) + task.status.timestamp.GetCurrentTime() + await self.task_store.save(task, context) + return task + async def _reconcile_inactive_pipeline_terminal_task(self, task: Task, context, terminal_state: str) -> Task: proto_state = { "completed": TaskState.TASK_STATE_COMPLETED, diff --git a/src/iac_code/acp/session.py b/src/iac_code/acp/session.py index 0e595dfe..9caffdf0 100644 --- a/src/iac_code/acp/session.py +++ b/src/iac_code/acp/session.py @@ -36,6 +36,7 @@ is_permission_audit_non_read_only, should_fail_closed_permission_audit, ) +from iac_code.services.session_backup import BackupReason, SessionBackupService from iac_code.services.telemetry import use_session_id from iac_code.state.app_state import lookup_permission, record_permission from iac_code.types.permissions import PermissionDecision @@ -317,6 +318,34 @@ def touch(self) -> None: """Update last active timestamp.""" self.last_active = time.monotonic() + async def _backup_normal_turn_end(self) -> None: + cwd = getattr(self.agent_loop, "_cwd", None) + session_storage = getattr(self.agent_loop, "_session_storage", None) + if not isinstance(cwd, str) or session_storage is None: + return + try: + result = await asyncio.to_thread( + SessionBackupService(session_storage=session_storage).backup_session, + cwd, + self.id, + reason=BackupReason.NORMAL_TURN_END, + critical=False, + ) + if getattr(result, "enabled", False) and not getattr(result, "succeeded", True): + logger.warning( + "ACP session backup failed (reason=%s, retry_count=%s): %s", + BackupReason.NORMAL_TURN_END.value, + getattr(result, "retry_count", 0), + getattr(result, "error", None) or "unknown", + ) + except Exception as exc: + logger.warning( + "ACP session backup failed (reason=%s, retry_count=%s, error_type=%s)", + BackupReason.NORMAL_TURN_END.value, + getattr(exc, "retry_count", 0), + type(exc).__name__, + ) + async def replay_history(self, messages: list[Message]) -> None: """Replay persisted history as ACP session_update events. @@ -419,6 +448,8 @@ async def prompt(self, prompt: list[ACPContentBlock]) -> acp.PromptResponse: content=acp.schema.TextContentBlock(type="text", text=result), ), ) + await self._backup_normal_turn_end() + self.touch() return acp.PromptResponse(stop_reason="end_turn") if stream_factory is None: @@ -496,6 +527,7 @@ async def _run() -> None: except Exception: logger.debug("flush_telemetry after prompt failed", exc_info=True) + await self._backup_normal_turn_end() self.touch() # Build _meta with timing and token usage diff --git a/src/iac_code/agent/agent_loop.py b/src/iac_code/agent/agent_loop.py index 4eeb656a..bef6c830 100644 --- a/src/iac_code/agent/agent_loop.py +++ b/src/iac_code/agent/agent_loop.py @@ -10,6 +10,7 @@ from collections.abc import AsyncGenerator, Callable from contextlib import suppress from dataclasses import dataclass, replace +from pathlib import Path from typing import Any, Literal from loguru import logger @@ -110,6 +111,7 @@ def _emit_no_prompt_permission_audit( permission: PermissionResult, decision: Literal["allow", "deny"], settings: PermissionAuditSettings | None, + audit_log_path: str | None = None, ) -> bool: audit = permission.audit if audit is None or is_routine_read_only_allow(decision, audit): @@ -131,6 +133,7 @@ def _emit_no_prompt_permission_audit( operation=permission_audit_operation(audit), input_summary=build_input_summary(request.name, request.input), tool_input_redacted=redacted_tool_input_for_settings(request.input, settings), + audit_log_path=audit_log_path, ), settings=settings, ) @@ -196,6 +199,10 @@ def __init__( tool_context_trusted_read_directories: list[str] | None = None, tool_context_relative_read_directories: list[str] | None = None, pipeline_mode: bool = False, + root_session_id: str | None = None, + transcript_id: str | None = None, + result_storage_dir: str | Path | None = None, + audit_log_path: str | Path | None = None, ) -> None: self._provider_manager = provider_manager self.system_prompt = system_prompt @@ -203,6 +210,10 @@ def __init__( self._max_turns = max_turns self._session_storage = session_storage self._session_id = session_id or str(uuid.uuid4())[:8] + self._root_session_id = root_session_id or self._session_id + self._transcript_id = transcript_id + self._has_session_hierarchy = root_session_id is not None or transcript_id is not None + self._audit_log_path = str(audit_log_path) if audit_log_path is not None else None self._cwd = cwd or os.getcwd() self._session_usage_store = session_usage_store or SessionUsageStore() self._session_usage_totals = self._session_usage_store.load(self._cwd, self._session_id) @@ -234,9 +245,12 @@ def __init__( self._tool_executor = ToolExecutor(registry=tool_registry) from iac_code.config import get_config_dir - self._result_storage = ResultStorage( - storage_dir=os.path.join(str(get_config_dir()), "tool-results", self._session_id), + storage_dir = ( + str(result_storage_dir) + if result_storage_dir is not None + else os.path.join(str(get_config_dir()), "tool-results", self._session_id) ) + self._result_storage = ResultStorage(storage_dir=storage_dir) self._pending_injections: deque[str | list[ContentBlock]] = deque() self._current_turn_text: str = "" self._accepting_injected_user_messages = False @@ -1025,6 +1039,11 @@ async def _run_streaming_inner( "settings": perm_ctx.audit_settings if perm_ctx is not None else None, "metadata": permission.audit, } + if self._has_session_hierarchy: + audit_context["root_session_id"] = self._root_session_id + audit_context["transcript_id"] = self._transcript_id + if self._audit_log_path is not None: + audit_context["audit_log_path"] = self._audit_log_path if permission.behavior == "allow": audit_ok = _emit_no_prompt_permission_audit( @@ -1034,6 +1053,7 @@ async def _run_streaming_inner( permission=permission, decision="allow", settings=audit_context["settings"], + audit_log_path=self._audit_log_path, ) if not audit_ok: denied_results.append((request, ToolResult.error(_("Permission denied.")))) @@ -1048,6 +1068,7 @@ async def _run_streaming_inner( permission=permission, decision="deny", settings=audit_context["settings"], + audit_log_path=self._audit_log_path, ) msg = permission.message or _("Permission denied.") denied_results.append((request, ToolResult.error(msg))) @@ -1302,11 +1323,35 @@ def _apply_context_modifier(self, modifier: Any) -> None: "allowed_tool_rules": getattr(self, "_allowed_tool_rules", []), "model_override": getattr(self, "_model_override", None), "effort_override": getattr(self, "_effort_override", None), + "tool_context_trusted_read_directories": list(self._tool_context_trusted_read_directories), + "tool_context_relative_read_directories": list(self._tool_context_relative_read_directories), } modified = modifier(current_ctx) self._allowed_tool_rules = modified.get("allowed_tool_rules", []) self._model_override = modified.get("model_override") self._effort_override = modified.get("effort_override") + self._tool_context_trusted_read_directories = [] + self._tool_context_relative_read_directories = [] + _extend_unique( + self._tool_context_trusted_read_directories, + list( + modified.get( + "tool_context_trusted_read_directories", + current_ctx["tool_context_trusted_read_directories"], + ) + or [] + ), + ) + _extend_unique( + self._tool_context_relative_read_directories, + list( + modified.get( + "tool_context_relative_read_directories", + current_ctx["tool_context_relative_read_directories"], + ) + or [] + ), + ) async def _auto_compact(self) -> CompactionEvent | None: """Perform automatic context compaction via provider.""" @@ -1411,20 +1456,48 @@ def replace_session(self, session_id: str, resume_messages: list | None) -> None self._memory_recall_generation += 1 self._last_provider_request_snapshot = None self._session_id = session_id + self._root_session_id = session_id + self._transcript_id = None + self._has_session_hierarchy = False + self._audit_log_path = None self._current_git_branch = None self._auto_loaded_skills.clear() self.context_manager.reset() if resume_messages: self.context_manager.load_messages(resume_messages) + if self._session_usage_store.uses_direct_path_provider: + self._session_usage_store = SessionUsageStore() self._session_usage_totals = self._session_usage_store.load(self._cwd, self._session_id) reset_recall_stats = getattr(self._memory_recall_service, "reset_stats", None) if callable(reset_recall_stats): reset_recall_stats() self._sync_recall_suppression_from_context() self._result_storage = ResultStorage( - storage_dir=os.path.join(str(get_config_dir()), "tool-results", session_id), + storage_dir=str( + self._result_storage_dir_for_replaced_session(session_id) + or Path(get_config_dir()) / "tool-results" / session_id + ), ) + def _result_storage_dir_for_replaced_session(self, session_id: str) -> Path | None: + session_dir_factory = getattr(self._session_storage, "v2_session_dir", None) + if not callable(session_dir_factory): + return None + try: + raw_session_dir = session_dir_factory(self._cwd, session_id) + except (AttributeError, TypeError): + return None + if raw_session_dir is None: + return None + if not isinstance(raw_session_dir, (str, os.PathLike)): + return None + session_dir = Path(raw_session_dir) + from iac_code.services.session_layout import SessionPaths, session_layout_version + + if session_layout_version(session_dir) is None: + return None + return SessionPaths.require_supported(session_dir).tool_results_dir + def _refresh_git_branch(self) -> None: """Probe ``git`` once per turn and cache the result. diff --git a/src/iac_code/cli/headless.py b/src/iac_code/cli/headless.py index 66f8fd19..b5a8c46b 100644 --- a/src/iac_code/cli/headless.py +++ b/src/iac_code/cli/headless.py @@ -11,6 +11,7 @@ from __future__ import annotations +import asyncio import sys import time from typing import IO, Any @@ -21,6 +22,7 @@ from iac_code.i18n import _ from iac_code.providers.manager import ProviderNotConfiguredError from iac_code.services.permissions.audit import emit_auto_permission_audit, is_aliyun_api_non_read_only_permission_event +from iac_code.services.session_backup import BackupReason, SessionBackupService from iac_code.services.telemetry import graceful_shutdown, log_event from iac_code.services.telemetry.names import Events from iac_code.types.stream_events import ( @@ -181,6 +183,35 @@ def _print_mcp_config_warnings(self) -> None: self._progress_stream.flush() self._mcp_warnings_printed_count = len(warnings) + async def _backup_normal_turn_end(self, agent_loop: Any) -> None: + cwd = getattr(agent_loop, "_cwd", None) + session_id = getattr(agent_loop, "_session_id", None) + session_storage = getattr(agent_loop, "_session_storage", None) + if not isinstance(cwd, str) or not isinstance(session_id, str) or session_storage is None: + return + try: + result = await asyncio.to_thread( + SessionBackupService(session_storage=session_storage).backup_session, + cwd, + session_id, + reason=BackupReason.NORMAL_TURN_END, + critical=False, + ) + if getattr(result, "enabled", False) and not getattr(result, "succeeded", True): + logger.warning( + "Headless session backup failed (reason={}, retry_count={}): {}", + BackupReason.NORMAL_TURN_END.value, + getattr(result, "retry_count", 0), + getattr(result, "error", None) or "unknown", + ) + except Exception as exc: + logger.warning( + "Headless session backup failed (reason={}, retry_count={}, error_type={})", + BackupReason.NORMAL_TURN_END.value, + getattr(exc, "retry_count", 0), + type(exc).__name__, + ) + async def run(self, prompt: str) -> int: """Execute a single prompt to completion and return an exit code.""" started = time.monotonic() @@ -191,6 +222,7 @@ async def run(self, prompt: str) -> int: has_error = False hit_max_turns = False + agent_loop = None try: agent_loop = self._create_agent_loop() @@ -222,6 +254,8 @@ async def run(self, prompt: str) -> int: progress_writer.handle(event) writer.handle(event) + if not has_error: + await self._backup_normal_turn_end(agent_loop) except ProviderNotConfiguredError as exc: self._print_provider_not_configured(exc) self._record_structured_error(writer, exc) diff --git a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po index 0b09a992..25f28e17 100644 --- a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po @@ -48,11 +48,28 @@ msgstr "Unsicherer Artefaktdateiname" msgid "Unknown error" msgstr "Unbekannter Fehler" +#: src/iac_code/a2a/artifacts.py src/iac_code/a2a/pipeline_paths.py +#: src/iac_code/services/session_layout.py +#, python-brace-format +msgid "Unsupported session layout version: {version}" +msgstr "Nicht unterstützte Sitzungslayout-Version: {version}" + +#: src/iac_code/a2a/backup.py src/iac_code/a2a/executor.py +msgid "Session backup failed. Retry after the backup path is available." +msgstr "" +"Sitzungsbackup fehlgeschlagen. Wiederholen Sie den Vorgang, sobald der " +"Backup-Pfad verfügbar ist." + #: src/iac_code/a2a/events.py src/iac_code/acp/server.py #, python-brace-format msgid "MCP warning: {message}" msgstr "MCP-Warnung: {message}" +#: src/iac_code/a2a/events.py src/iac_code/a2a/executor.py +#: src/iac_code/a2a/pipeline_executor.py +msgid "A temporary error occurred. Please retry." +msgstr "Ein temporärer Fehler ist aufgetreten. Bitte erneut versuchen." + #: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py msgid "" "Cleanup state unavailable. Inspect the session file and cloud resources " @@ -99,11 +116,7 @@ msgstr "Die Aufgabe läuft bereits." msgid "Current model {model} does not support image input." msgstr "Aktuelles Modell {model} unterstützt keine Bildeingabe." -#: src/iac_code/a2a/pipeline_executor.py -msgid "A temporary error occurred. Please retry." -msgstr "Ein temporärer Fehler ist aufgetreten. Bitte erneut versuchen." - -#: src/iac_code/a2a/pipeline_executor.py +#: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py msgid "Authentication required. Configure credentials and retry." msgstr "" "Authentifizierung erforderlich. Anmeldedaten konfigurieren und erneut " @@ -114,6 +127,29 @@ msgstr "" msgid "Pipeline already running. Resume task {task_id}." msgstr "Pipeline läuft bereits. Setzen Sie Aufgabe {task_id} fort." +#: src/iac_code/a2a/pipeline_executor.py +msgid "unknown" +msgstr "unbekannt" + +#: src/iac_code/a2a/pipeline_executor.py +#, python-brace-format +msgid "A2A pipeline sidecar restore failed: status={status}, reason={reason}" +msgstr "" +"Wiederherstellung des A2A-Pipeline-Sidecars fehlgeschlagen: " +"status={status}, reason={reason}" + +#: src/iac_code/a2a/pipeline_executor.py +msgid "Failed to persist A2A pipeline snapshot" +msgstr "A2A-Pipeline-Snapshot konnte nicht persistiert werden" + +#: src/iac_code/a2a/pipeline_executor.py +msgid "A2A pipeline sidecar owner is unavailable" +msgstr "A2A-Pipeline-Sidecar-Owner ist nicht verfügbar" + +#: src/iac_code/a2a/pipeline_journal.py +msgid "Unrepairable A2A pipeline journal tail" +msgstr "Nicht reparierbares Ende des A2A-Pipeline-Journals" + #: src/iac_code/a2a/pipeline_recovery.py msgid "A2A pipeline state not found" msgstr "A2A-Pipeline-Zustand nicht gefunden" @@ -3256,6 +3292,10 @@ msgstr "Schritt {step_id} abgeschlossen. Schlussfolgerung übermittelt." msgid "Pipeline state persistence failed." msgstr "Persistenz des Pipeline-Zustands fehlgeschlagen." +#: src/iac_code/pipeline/engine/pipeline_runner.py +msgid "Session backup failed." +msgstr "Sitzungsbackup fehlgeschlagen." + #: src/iac_code/pipeline/engine/pipeline_runner.py #: src/iac_code/pipeline/engine/sub_pipeline_executor.py #: src/iac_code/ui/repl.py @@ -3551,11 +3591,161 @@ msgstr "" "deaktivieren Sie den QwenPaw-Modus (entfernen Sie 'llm_source: qwenpaw' " "aus settings.yml)." +#: src/iac_code/services/session_backup.py +msgid "Session backup requires a supported session layout." +msgstr "Sitzungsbackup erfordert ein unterstütztes Sitzungslayout." + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup root must be an absolute directory: {path}" +msgstr "Backup-Root muss ein absolutes Verzeichnis sein: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup root must not be a filesystem root: {path}" +msgstr "Backup-Root darf kein Dateisystem-Root sein: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup root must not be drive-relative: {path}" +msgstr "Backup-Root darf kein laufwerksrelativer Pfad sein: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "unsafe session_id: {session_id!r}" +msgstr "Unsichere session_id: {session_id!r}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup destination overlaps session source: {destination}" +msgstr "Backup-Ziel überschneidet sich mit der Sitzungsquelle: {destination}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup destination resolves outside backup root: {destination}" +msgstr "" +"Backup-Ziel wird außerhalb des Backup-Stammverzeichnisses aufgelöst: " +"{destination}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup destination is not under backup root: {destination}" +msgstr "Backup-Ziel liegt nicht unter dem Backup-Stammverzeichnis: {destination}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup destination ancestry contains symlink: {path}" +msgstr "Der übergeordnete Pfad des Backup-Ziels enthält einen Symlink: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "{label} ancestry contains symlink: {path}" +msgstr "Der übergeordnete Pfad von {label} enthält einen Symlink: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "session source is a symlink: {source}" +msgstr "Sitzungsquelle ist ein Symlink: {source}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "session source is not a directory: {source}" +msgstr "Sitzungsquelle ist kein Verzeichnis: {source}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "session source cannot be resolved: {source}" +msgstr "Sitzungsquelle kann nicht aufgelöst werden: {source}" + +#: src/iac_code/services/session_backup.py +msgid "session source" +msgstr "Sitzungsquelle" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "{label} is a symlink: {path}" +msgstr "{label} ist ein Symlink: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "{label} is not a directory: {path}" +msgstr "{label} ist kein Verzeichnis: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "{label} cannot be resolved: {path}" +msgstr "{label} kann nicht aufgelöst werden: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup destination cannot be resolved: {destination}" +msgstr "Backup-Ziel kann nicht aufgelöst werden: {destination}" + +#: src/iac_code/services/session_backup.py +msgid "backup root" +msgstr "Backup-Stammverzeichnis" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup lock is a symlink: {path}" +msgstr "Backup-Sperre ist ein Symlink: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup lock is not a regular file: {path}" +msgstr "Backup-Sperre ist keine regulaere Datei: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "could not acquire backup lock for {source}" +msgstr "Backup-Sperre für {source} konnte nicht erworben werden" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "session source entry is not a regular file: {source}" +msgstr "Sitzungsquelleneintrag ist keine reguläre Datei: {source}" + +#: src/iac_code/services/session_backup.py src/iac_code/utils/state_io.py +#, python-brace-format +msgid "could not open file without following reparse point: {path}" +msgstr "" +"Datei konnte nicht geöffnet werden, ohne dem Reparse Point zu folgen: " +"{path}" + +#: src/iac_code/services/session_layout.py +#: src/iac_code/services/session_storage.py +#: src/iac_code/services/session_usage.py +#, python-brace-format +msgid "Unsupported session metadata: {path}" +msgstr "Nicht unterstützte Sitzungsmetadaten: {path}" + +#: src/iac_code/services/session_layout.py +#, python-brace-format +msgid "Path is outside session directory: {path}" +msgstr "Pfad liegt ausserhalb des Sitzungsverzeichnisses: {path}" + +#: src/iac_code/services/session_layout.py +#, python-brace-format +msgid "Unsafe session-owned path: {path}" +msgstr "Unsicherer sitzungseigener Pfad: {path}" + +#: src/iac_code/services/session_layout.py +msgid "unsafe transcript id" +msgstr "Unsichere transcript id" + #: src/iac_code/services/session_metadata.py #, python-brace-format msgid "Session name must match {pattern}" msgstr "Der Sitzungsname muss {pattern} entsprechen" +#: src/iac_code/services/session_metadata.py +msgid "invalid session metadata" +msgstr "Ungültige Sitzungsmetadaten" + +#: src/iac_code/services/session_metadata.py +msgid "invalid session layout version" +msgstr "Ungültige Sitzungslayout-Version" + #: src/iac_code/services/session_storage.py #, python-brace-format msgid "Session name already exists in this project: {name}" @@ -5024,6 +5214,19 @@ msgstr " ✘ {name}: fehlgeschlagen" msgid "Pipeline warning: {reason}" msgstr "Pipeline-Warnung: {reason}" +#: src/iac_code/ui/repl.py +msgid "backup unavailable" +msgstr "Sicherung nicht verfügbar" + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "" +"Pipeline backup is blocked; the pipeline is paused and recoverable: " +"{error}" +msgstr "" +"Die Pipeline-Sicherung ist blockiert; die Pipeline wurde angehalten und " +"kann wiederhergestellt werden: {error}" + #: src/iac_code/ui/repl.py #, python-brace-format msgid "Option {index}" @@ -5598,6 +5801,16 @@ msgstr "" msgid "Stack trace omitted from public event; see error_id." msgstr "Stacktrace im öffentlichen Ereignis ausgelassen; siehe error_id." +#: src/iac_code/utils/state_io.py +#, python-brace-format +msgid "refusing to follow symlink or reparse point: {path}" +msgstr "Symbolischem Link oder Reparse Point wird nicht gefolgt: {path}" + +#: src/iac_code/utils/state_io.py +#, python-brace-format +msgid "refusing to open non-regular file: {path}" +msgstr "Öffnen einer nicht regulären Datei verweigert: {path}" + #~ msgid "toggle preview" #~ msgstr "Vorschau umschalten" diff --git a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po index 5395f9e3..e4b5700a 100644 --- a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po @@ -49,11 +49,28 @@ msgstr "Nombre de archivo de artefacto no seguro" msgid "Unknown error" msgstr "Error desconocido" +#: src/iac_code/a2a/artifacts.py src/iac_code/a2a/pipeline_paths.py +#: src/iac_code/services/session_layout.py +#, python-brace-format +msgid "Unsupported session layout version: {version}" +msgstr "Versión de layout de sesión no compatible: {version}" + +#: src/iac_code/a2a/backup.py src/iac_code/a2a/executor.py +msgid "Session backup failed. Retry after the backup path is available." +msgstr "" +"El backup de la sesión falló. Reintenta cuando la ruta de backup esté " +"disponible." + #: src/iac_code/a2a/events.py src/iac_code/acp/server.py #, python-brace-format msgid "MCP warning: {message}" msgstr "Advertencia MCP: {message}" +#: src/iac_code/a2a/events.py src/iac_code/a2a/executor.py +#: src/iac_code/a2a/pipeline_executor.py +msgid "A temporary error occurred. Please retry." +msgstr "Se produjo un error temporal. Inténtalo de nuevo." + #: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py msgid "" "Cleanup state unavailable. Inspect the session file and cloud resources " @@ -100,11 +117,7 @@ msgstr "La tarea ya está en ejecución." msgid "Current model {model} does not support image input." msgstr "El modelo actual {model} no admite entrada de imagen." -#: src/iac_code/a2a/pipeline_executor.py -msgid "A temporary error occurred. Please retry." -msgstr "Se produjo un error temporal. Inténtalo de nuevo." - -#: src/iac_code/a2a/pipeline_executor.py +#: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py msgid "Authentication required. Configure credentials and retry." msgstr "" "Se requiere autenticación. Configura las credenciales e inténtalo de " @@ -115,6 +128,29 @@ msgstr "" msgid "Pipeline already running. Resume task {task_id}." msgstr "La pipeline ya se está ejecutando. Reanude la tarea {task_id}." +#: src/iac_code/a2a/pipeline_executor.py +msgid "unknown" +msgstr "desconocido" + +#: src/iac_code/a2a/pipeline_executor.py +#, python-brace-format +msgid "A2A pipeline sidecar restore failed: status={status}, reason={reason}" +msgstr "" +"Error al restaurar el sidecar del pipeline A2A: status={status}, " +"reason={reason}" + +#: src/iac_code/a2a/pipeline_executor.py +msgid "Failed to persist A2A pipeline snapshot" +msgstr "No se pudo persistir el snapshot del pipeline A2A" + +#: src/iac_code/a2a/pipeline_executor.py +msgid "A2A pipeline sidecar owner is unavailable" +msgstr "El owner del sidecar del pipeline A2A no está disponible" + +#: src/iac_code/a2a/pipeline_journal.py +msgid "Unrepairable A2A pipeline journal tail" +msgstr "Cola del journal del pipeline A2A irreparable" + #: src/iac_code/a2a/pipeline_recovery.py msgid "A2A pipeline state not found" msgstr "No se encontró el estado del pipeline A2A" @@ -3246,6 +3282,10 @@ msgstr "Paso {step_id} completado. Conclusión enviada." msgid "Pipeline state persistence failed." msgstr "Error al persistir el estado de la pipeline." +#: src/iac_code/pipeline/engine/pipeline_runner.py +msgid "Session backup failed." +msgstr "Error en la copia de seguridad de la sesión." + #: src/iac_code/pipeline/engine/pipeline_runner.py #: src/iac_code/pipeline/engine/sub_pipeline_executor.py #: src/iac_code/ui/repl.py @@ -3540,11 +3580,163 @@ msgstr "" "Solución: cambie a un proveedor soportado en QwenPaw, o desactive el modo" " QwenPaw (elimine 'llm_source: qwenpaw' de settings.yml)." +#: src/iac_code/services/session_backup.py +msgid "Session backup requires a supported session layout." +msgstr "" +"La copia de seguridad de la sesión requiere un layout de sesión " +"compatible." + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup root must be an absolute directory: {path}" +msgstr "La raíz de copia de seguridad debe ser un directorio absoluto: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup root must not be a filesystem root: {path}" +msgstr "" +"La raíz de copia de seguridad no debe ser la raíz del sistema de " +"archivos: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup root must not be drive-relative: {path}" +msgstr "" +"La raíz de copia de seguridad no debe ser una ruta relativa a la unidad: " +"{path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "unsafe session_id: {session_id!r}" +msgstr "session_id no seguro: {session_id!r}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup destination overlaps session source: {destination}" +msgstr "El destino de backup se solapa con el origen de la sesión: {destination}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup destination resolves outside backup root: {destination}" +msgstr "El destino de backup se resuelve fuera de la raíz de backup: {destination}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup destination is not under backup root: {destination}" +msgstr "El destino de backup no está dentro de la raíz de backup: {destination}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup destination ancestry contains symlink: {path}" +msgstr "La ruta padre del destino de backup contiene un enlace simbólico: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "{label} ancestry contains symlink: {path}" +msgstr "La ruta padre de {label} contiene un enlace simbólico: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "session source is a symlink: {source}" +msgstr "El origen de la sesión es un enlace simbólico: {source}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "session source is not a directory: {source}" +msgstr "El origen de la sesión no es un directorio: {source}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "session source cannot be resolved: {source}" +msgstr "No se puede resolver el origen de la sesión: {source}" + +#: src/iac_code/services/session_backup.py +msgid "session source" +msgstr "origen de sesión" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "{label} is a symlink: {path}" +msgstr "{label} es un enlace simbólico: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "{label} is not a directory: {path}" +msgstr "{label} no es un directorio: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "{label} cannot be resolved: {path}" +msgstr "No se puede resolver {label}: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup destination cannot be resolved: {destination}" +msgstr "No se puede resolver el destino de backup: {destination}" + +#: src/iac_code/services/session_backup.py +msgid "backup root" +msgstr "raíz de backup" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup lock is a symlink: {path}" +msgstr "El bloqueo de backup es un enlace simbólico: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup lock is not a regular file: {path}" +msgstr "El bloqueo de backup no es un archivo normal: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "could not acquire backup lock for {source}" +msgstr "No se pudo adquirir el bloqueo de backup para {source}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "session source entry is not a regular file: {source}" +msgstr "La entrada de origen de la sesión no es un archivo normal: {source}" + +#: src/iac_code/services/session_backup.py src/iac_code/utils/state_io.py +#, python-brace-format +msgid "could not open file without following reparse point: {path}" +msgstr "No se pudo abrir el archivo sin seguir el punto de reanálisis: {path}" + +#: src/iac_code/services/session_layout.py +#: src/iac_code/services/session_storage.py +#: src/iac_code/services/session_usage.py +#, python-brace-format +msgid "Unsupported session metadata: {path}" +msgstr "Metadatos de sesión no compatibles: {path}" + +#: src/iac_code/services/session_layout.py +#, python-brace-format +msgid "Path is outside session directory: {path}" +msgstr "La ruta está fuera del directorio de sesión: {path}" + +#: src/iac_code/services/session_layout.py +#, python-brace-format +msgid "Unsafe session-owned path: {path}" +msgstr "Ruta propiedad de la sesión no segura: {path}" + +#: src/iac_code/services/session_layout.py +msgid "unsafe transcript id" +msgstr "transcript id no seguro" + #: src/iac_code/services/session_metadata.py #, python-brace-format msgid "Session name must match {pattern}" msgstr "El nombre de sesión debe coincidir con {pattern}" +#: src/iac_code/services/session_metadata.py +msgid "invalid session metadata" +msgstr "metadatos de sesión no válidos" + +#: src/iac_code/services/session_metadata.py +msgid "invalid session layout version" +msgstr "versión de layout de sesión no válida" + #: src/iac_code/services/session_storage.py #, python-brace-format msgid "Session name already exists in this project: {name}" @@ -5016,6 +5208,19 @@ msgstr " ✘ {name}: error" msgid "Pipeline warning: {reason}" msgstr "Advertencia de la pipeline: {reason}" +#: src/iac_code/ui/repl.py +msgid "backup unavailable" +msgstr "copia de seguridad no disponible" + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "" +"Pipeline backup is blocked; the pipeline is paused and recoverable: " +"{error}" +msgstr "" +"La copia de seguridad del pipeline está bloqueada; el pipeline está " +"pausado y se puede recuperar: {error}" + #: src/iac_code/ui/repl.py #, python-brace-format msgid "Option {index}" @@ -5592,6 +5797,16 @@ msgstr "" msgid "Stack trace omitted from public event; see error_id." msgstr "La traza de pila se omitió del evento público; consulta error_id." +#: src/iac_code/utils/state_io.py +#, python-brace-format +msgid "refusing to follow symlink or reparse point: {path}" +msgstr "Se rechazó seguir el enlace simbólico o punto de reanálisis: {path}" + +#: src/iac_code/utils/state_io.py +#, python-brace-format +msgid "refusing to open non-regular file: {path}" +msgstr "Se rechazó abrir un archivo no regular: {path}" + #~ msgid "toggle preview" #~ msgstr "alternar vista previa" diff --git a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po index a42eee70..7f289156 100644 --- a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po @@ -49,11 +49,28 @@ msgstr "Nom de fichier d’artefact non sûr" msgid "Unknown error" msgstr "Erreur inconnue" +#: src/iac_code/a2a/artifacts.py src/iac_code/a2a/pipeline_paths.py +#: src/iac_code/services/session_layout.py +#, python-brace-format +msgid "Unsupported session layout version: {version}" +msgstr "Version de layout de session non prise en charge : {version}" + +#: src/iac_code/a2a/backup.py src/iac_code/a2a/executor.py +msgid "Session backup failed. Retry after the backup path is available." +msgstr "" +"La sauvegarde de session a échoué. Réessayez lorsque le chemin de " +"sauvegarde sera disponible." + #: src/iac_code/a2a/events.py src/iac_code/acp/server.py #, python-brace-format msgid "MCP warning: {message}" msgstr "Avertissement MCP : {message}" +#: src/iac_code/a2a/events.py src/iac_code/a2a/executor.py +#: src/iac_code/a2a/pipeline_executor.py +msgid "A temporary error occurred. Please retry." +msgstr "Une erreur temporaire s’est produite. Veuillez réessayer." + #: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py msgid "" "Cleanup state unavailable. Inspect the session file and cloud resources " @@ -100,11 +117,7 @@ msgstr "La tâche est déjà en cours." msgid "Current model {model} does not support image input." msgstr "Le modèle actuel {model} ne prend pas en charge l’entrée d’image." -#: src/iac_code/a2a/pipeline_executor.py -msgid "A temporary error occurred. Please retry." -msgstr "Une erreur temporaire s’est produite. Veuillez réessayer." - -#: src/iac_code/a2a/pipeline_executor.py +#: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py msgid "Authentication required. Configure credentials and retry." msgstr "Authentification requise. Configurez les identifiants puis réessayez." @@ -113,6 +126,29 @@ msgstr "Authentification requise. Configurez les identifiants puis réessayez." msgid "Pipeline already running. Resume task {task_id}." msgstr "Le pipeline est déjà en cours d'exécution. Reprenez la tâche {task_id}." +#: src/iac_code/a2a/pipeline_executor.py +msgid "unknown" +msgstr "inconnu" + +#: src/iac_code/a2a/pipeline_executor.py +#, python-brace-format +msgid "A2A pipeline sidecar restore failed: status={status}, reason={reason}" +msgstr "" +"Échec de la restauration du sidecar du pipeline A2A : status={status}, " +"reason={reason}" + +#: src/iac_code/a2a/pipeline_executor.py +msgid "Failed to persist A2A pipeline snapshot" +msgstr "Impossible de persister le snapshot du pipeline A2A" + +#: src/iac_code/a2a/pipeline_executor.py +msgid "A2A pipeline sidecar owner is unavailable" +msgstr "Le owner du sidecar du pipeline A2A est indisponible" + +#: src/iac_code/a2a/pipeline_journal.py +msgid "Unrepairable A2A pipeline journal tail" +msgstr "Fin du journal du pipeline A2A irréparable" + #: src/iac_code/a2a/pipeline_recovery.py msgid "A2A pipeline state not found" msgstr "État du pipeline A2A introuvable" @@ -3253,6 +3289,10 @@ msgstr "Étape {step_id} terminée. Conclusion soumise." msgid "Pipeline state persistence failed." msgstr "Échec de la persistance de l’état du pipeline." +#: src/iac_code/pipeline/engine/pipeline_runner.py +msgid "Session backup failed." +msgstr "Échec de la sauvegarde de session." + #: src/iac_code/pipeline/engine/pipeline_runner.py #: src/iac_code/pipeline/engine/sub_pipeline_executor.py #: src/iac_code/ui/repl.py @@ -3544,11 +3584,169 @@ msgstr "" "désactivez le mode QwenPaw (supprimez 'llm_source: qwenpaw' de " "settings.yml)." +#: src/iac_code/services/session_backup.py +msgid "Session backup requires a supported session layout." +msgstr "La sauvegarde de session nécessite un layout de session pris en charge." + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup root must be an absolute directory: {path}" +msgstr "La racine de sauvegarde doit être un répertoire absolu : {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup root must not be a filesystem root: {path}" +msgstr "" +"La racine de sauvegarde ne doit pas être la racine du système de fichiers" +" : {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup root must not be drive-relative: {path}" +msgstr "" +"La racine de sauvegarde ne doit pas être un chemin relatif au lecteur : " +"{path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "unsafe session_id: {session_id!r}" +msgstr "session_id non sûr : {session_id!r}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup destination overlaps session source: {destination}" +msgstr "" +"La destination de sauvegarde chevauche la source de session : " +"{destination}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup destination resolves outside backup root: {destination}" +msgstr "" +"La destination de sauvegarde se résout hors de la racine de sauvegarde : " +"{destination}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup destination is not under backup root: {destination}" +msgstr "" +"La destination de sauvegarde n’est pas sous la racine de sauvegarde : " +"{destination}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup destination ancestry contains symlink: {path}" +msgstr "" +"Les répertoires parents de la destination de sauvegarde contiennent un " +"lien symbolique : {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "{label} ancestry contains symlink: {path}" +msgstr "Les répertoires parents de {label} contiennent un lien symbolique : {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "session source is a symlink: {source}" +msgstr "La source de session est un lien symbolique : {source}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "session source is not a directory: {source}" +msgstr "La source de session n’est pas un répertoire : {source}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "session source cannot be resolved: {source}" +msgstr "Impossible de résoudre la source de session : {source}" + +#: src/iac_code/services/session_backup.py +msgid "session source" +msgstr "source de session" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "{label} is a symlink: {path}" +msgstr "{label} est un lien symbolique : {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "{label} is not a directory: {path}" +msgstr "{label} n’est pas un répertoire : {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "{label} cannot be resolved: {path}" +msgstr "Impossible de résoudre {label} : {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup destination cannot be resolved: {destination}" +msgstr "Impossible de résoudre la destination de sauvegarde : {destination}" + +#: src/iac_code/services/session_backup.py +msgid "backup root" +msgstr "racine de sauvegarde" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup lock is a symlink: {path}" +msgstr "Le verrou de sauvegarde est un lien symbolique : {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup lock is not a regular file: {path}" +msgstr "Le verrou de sauvegarde n'est pas un fichier normal : {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "could not acquire backup lock for {source}" +msgstr "Impossible d’acquérir le verrou de sauvegarde pour {source}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "session source entry is not a regular file: {source}" +msgstr "L’entrée de la source de session n’est pas un fichier ordinaire : {source}" + +#: src/iac_code/services/session_backup.py src/iac_code/utils/state_io.py +#, python-brace-format +msgid "could not open file without following reparse point: {path}" +msgstr "Impossible d’ouvrir le fichier sans suivre le point de réanalyse : {path}" + +#: src/iac_code/services/session_layout.py +#: src/iac_code/services/session_storage.py +#: src/iac_code/services/session_usage.py +#, python-brace-format +msgid "Unsupported session metadata: {path}" +msgstr "Métadonnées de session non prises en charge : {path}" + +#: src/iac_code/services/session_layout.py +#, python-brace-format +msgid "Path is outside session directory: {path}" +msgstr "Le chemin se trouve en dehors du répertoire de session : {path}" + +#: src/iac_code/services/session_layout.py +#, python-brace-format +msgid "Unsafe session-owned path: {path}" +msgstr "Chemin appartenant à la session non sûr : {path}" + +#: src/iac_code/services/session_layout.py +msgid "unsafe transcript id" +msgstr "transcript id non sûr" + #: src/iac_code/services/session_metadata.py #, python-brace-format msgid "Session name must match {pattern}" msgstr "Le nom de session doit correspondre à {pattern}" +#: src/iac_code/services/session_metadata.py +msgid "invalid session metadata" +msgstr "métadonnées de session invalides" + +#: src/iac_code/services/session_metadata.py +msgid "invalid session layout version" +msgstr "version de layout de session invalide" + #: src/iac_code/services/session_storage.py #, python-brace-format msgid "Session name already exists in this project: {name}" @@ -5026,6 +5224,19 @@ msgstr " ✘ {name} : échec" msgid "Pipeline warning: {reason}" msgstr "Avertissement du pipeline : {reason}" +#: src/iac_code/ui/repl.py +msgid "backup unavailable" +msgstr "sauvegarde indisponible" + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "" +"Pipeline backup is blocked; the pipeline is paused and recoverable: " +"{error}" +msgstr "" +"La sauvegarde du pipeline est bloquée ; le pipeline est en pause et " +"récupérable : {error}" + #: src/iac_code/ui/repl.py #, python-brace-format msgid "Option {index}" @@ -5602,6 +5813,16 @@ msgstr "" msgid "Stack trace omitted from public event; see error_id." msgstr "Trace de pile omise de l’événement public ; consultez error_id." +#: src/iac_code/utils/state_io.py +#, python-brace-format +msgid "refusing to follow symlink or reparse point: {path}" +msgstr "Refus de suivre le lien symbolique ou le point de réanalyse : {path}" + +#: src/iac_code/utils/state_io.py +#, python-brace-format +msgid "refusing to open non-regular file: {path}" +msgstr "Refus d’ouvrir un fichier non ordinaire : {path}" + #~ msgid "DashScope Token Plan" #~ msgstr "DashScope Token Plan" diff --git a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po index dc79f664..4fda6950 100644 --- a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po @@ -44,11 +44,26 @@ msgstr "安全でないアーティファクトファイル名" msgid "Unknown error" msgstr "不明なエラー" +#: src/iac_code/a2a/artifacts.py src/iac_code/a2a/pipeline_paths.py +#: src/iac_code/services/session_layout.py +#, python-brace-format +msgid "Unsupported session layout version: {version}" +msgstr "サポートされていないセッションレイアウトバージョン: {version}" + +#: src/iac_code/a2a/backup.py src/iac_code/a2a/executor.py +msgid "Session backup failed. Retry after the backup path is available." +msgstr "セッションのバックアップに失敗しました。バックアップパスが利用可能になってから再試行してください。" + #: src/iac_code/a2a/events.py src/iac_code/acp/server.py #, python-brace-format msgid "MCP warning: {message}" msgstr "MCP 警告: {message}" +#: src/iac_code/a2a/events.py src/iac_code/a2a/executor.py +#: src/iac_code/a2a/pipeline_executor.py +msgid "A temporary error occurred. Please retry." +msgstr "一時的なエラーが発生しました。再試行してください。" + #: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py msgid "" "Cleanup state unavailable. Inspect the session file and cloud resources " @@ -87,11 +102,7 @@ msgstr "タスクはすでに実行中です。" msgid "Current model {model} does not support image input." msgstr "現在のモデル {model} は画像入力をサポートしていません。" -#: src/iac_code/a2a/pipeline_executor.py -msgid "A temporary error occurred. Please retry." -msgstr "一時的なエラーが発生しました。再試行してください。" - -#: src/iac_code/a2a/pipeline_executor.py +#: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py msgid "Authentication required. Configure credentials and retry." msgstr "認証が必要です。認証情報を設定して再試行してください。" @@ -100,6 +111,27 @@ msgstr "認証が必要です。認証情報を設定して再試行してくだ msgid "Pipeline already running. Resume task {task_id}." msgstr "パイプラインはすでに実行中です。タスク {task_id} を再開してください。" +#: src/iac_code/a2a/pipeline_executor.py +msgid "unknown" +msgstr "不明" + +#: src/iac_code/a2a/pipeline_executor.py +#, python-brace-format +msgid "A2A pipeline sidecar restore failed: status={status}, reason={reason}" +msgstr "A2A pipeline sidecar の復元に失敗しました: status={status}, reason={reason}" + +#: src/iac_code/a2a/pipeline_executor.py +msgid "Failed to persist A2A pipeline snapshot" +msgstr "A2A pipeline snapshot を永続化できませんでした" + +#: src/iac_code/a2a/pipeline_executor.py +msgid "A2A pipeline sidecar owner is unavailable" +msgstr "A2A pipeline sidecar owner が利用できません" + +#: src/iac_code/a2a/pipeline_journal.py +msgid "Unrepairable A2A pipeline journal tail" +msgstr "A2A pipeline journal の末尾を修復できません" + #: src/iac_code/a2a/pipeline_recovery.py msgid "A2A pipeline state not found" msgstr "A2A パイプライン状態が見つかりません" @@ -3095,6 +3127,10 @@ msgstr "ステップ {step_id} が完了しました。結論を送信しまし msgid "Pipeline state persistence failed." msgstr "パイプライン状態の永続化に失敗しました。" +#: src/iac_code/pipeline/engine/pipeline_runner.py +msgid "Session backup failed." +msgstr "セッションバックアップに失敗しました。" + #: src/iac_code/pipeline/engine/pipeline_runner.py #: src/iac_code/pipeline/engine/sub_pipeline_executor.py #: src/iac_code/ui/repl.py @@ -3370,11 +3406,157 @@ msgstr "" "対処法:QwenPaw でサポートされているプロバイダーに切り替えるか、QwenPaw モードを無効にしてください(settings.yml から" " 'llm_source: qwenpaw' を削除)。" +#: src/iac_code/services/session_backup.py +msgid "Session backup requires a supported session layout." +msgstr "セッションバックアップにはサポートされているセッションレイアウトが必要です。" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup root must be an absolute directory: {path}" +msgstr "バックアップルートは絶対ディレクトリである必要があります: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup root must not be a filesystem root: {path}" +msgstr "バックアップルートをファイルシステムのルートにすることはできません: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup root must not be drive-relative: {path}" +msgstr "バックアップルートをドライブ相対パスにすることはできません: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "unsafe session_id: {session_id!r}" +msgstr "安全でない session_id: {session_id!r}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup destination overlaps session source: {destination}" +msgstr "バックアップ先がセッション元ディレクトリと重複しています: {destination}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup destination resolves outside backup root: {destination}" +msgstr "バックアップ先がバックアップルート外に解決されます: {destination}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup destination is not under backup root: {destination}" +msgstr "バックアップ先がバックアップルート配下にありません: {destination}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup destination ancestry contains symlink: {path}" +msgstr "バックアップ先の親パスにシンボリックリンクが含まれています: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "{label} ancestry contains symlink: {path}" +msgstr "{label} の親パスにシンボリックリンクが含まれています: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "session source is a symlink: {source}" +msgstr "セッション元ディレクトリがシンボリックリンクです: {source}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "session source is not a directory: {source}" +msgstr "セッション元がディレクトリではありません: {source}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "session source cannot be resolved: {source}" +msgstr "セッション元ディレクトリを解決できません: {source}" + +#: src/iac_code/services/session_backup.py +msgid "session source" +msgstr "セッションソース" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "{label} is a symlink: {path}" +msgstr "{label} がシンボリックリンクです: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "{label} is not a directory: {path}" +msgstr "{label} がディレクトリではありません: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "{label} cannot be resolved: {path}" +msgstr "{label} を解決できません: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup destination cannot be resolved: {destination}" +msgstr "バックアップ先を解決できません: {destination}" + +#: src/iac_code/services/session_backup.py +msgid "backup root" +msgstr "バックアップルート" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup lock is a symlink: {path}" +msgstr "バックアップロックがシンボリックリンクです: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup lock is not a regular file: {path}" +msgstr "バックアップロックは通常ファイルではありません: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "could not acquire backup lock for {source}" +msgstr "{source} のバックアップロックを取得できませんでした" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "session source entry is not a regular file: {source}" +msgstr "セッションソースのエントリは通常ファイルではありません: {source}" + +#: src/iac_code/services/session_backup.py src/iac_code/utils/state_io.py +#, python-brace-format +msgid "could not open file without following reparse point: {path}" +msgstr "再解析ポイントをたどらずにファイルを開けませんでした: {path}" + +#: src/iac_code/services/session_layout.py +#: src/iac_code/services/session_storage.py +#: src/iac_code/services/session_usage.py +#, python-brace-format +msgid "Unsupported session metadata: {path}" +msgstr "サポートされていないセッションメタデータ: {path}" + +#: src/iac_code/services/session_layout.py +#, python-brace-format +msgid "Path is outside session directory: {path}" +msgstr "パスはセッションディレクトリの外にあります: {path}" + +#: src/iac_code/services/session_layout.py +#, python-brace-format +msgid "Unsafe session-owned path: {path}" +msgstr "安全でないセッション所有パスです: {path}" + +#: src/iac_code/services/session_layout.py +msgid "unsafe transcript id" +msgstr "安全でない transcript id" + #: src/iac_code/services/session_metadata.py #, python-brace-format msgid "Session name must match {pattern}" msgstr "セッション名は {pattern} に一致する必要があります" +#: src/iac_code/services/session_metadata.py +msgid "invalid session metadata" +msgstr "無効なセッションメタデータ" + +#: src/iac_code/services/session_metadata.py +msgid "invalid session layout version" +msgstr "無効なセッションレイアウトバージョン" + #: src/iac_code/services/session_storage.py #, python-brace-format msgid "Session name already exists in this project: {name}" @@ -4786,6 +4968,17 @@ msgstr " ✘ {name}: 失敗" msgid "Pipeline warning: {reason}" msgstr "パイプライン警告: {reason}" +#: src/iac_code/ui/repl.py +msgid "backup unavailable" +msgstr "バックアップを利用できません" + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "" +"Pipeline backup is blocked; the pipeline is paused and recoverable: " +"{error}" +msgstr "パイプラインのバックアップがブロックされました。パイプラインは一時停止しており、復旧可能です: {error}" + #: src/iac_code/ui/repl.py #, python-brace-format msgid "Option {index}" @@ -5341,6 +5534,16 @@ msgstr " オプション 2 - github.com にアクセスできない場合は、 msgid "Stack trace omitted from public event; see error_id." msgstr "公開イベントではスタックトレースを省略しました。error_id を確認してください。" +#: src/iac_code/utils/state_io.py +#, python-brace-format +msgid "refusing to follow symlink or reparse point: {path}" +msgstr "シンボリックリンクまたは再解析ポイントの追跡を拒否しました: {path}" + +#: src/iac_code/utils/state_io.py +#, python-brace-format +msgid "refusing to open non-regular file: {path}" +msgstr "通常ファイルではないファイルを開くことを拒否しました: {path}" + #~ msgid "toggle preview" #~ msgstr "プレビューを切り替え" diff --git a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po index 55c52ac6..92b3ca77 100644 --- a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po @@ -48,11 +48,28 @@ msgstr "Nome de arquivo de artefato inseguro" msgid "Unknown error" msgstr "Erro desconhecido" +#: src/iac_code/a2a/artifacts.py src/iac_code/a2a/pipeline_paths.py +#: src/iac_code/services/session_layout.py +#, python-brace-format +msgid "Unsupported session layout version: {version}" +msgstr "Versão de layout de sessão não suportada: {version}" + +#: src/iac_code/a2a/backup.py src/iac_code/a2a/executor.py +msgid "Session backup failed. Retry after the backup path is available." +msgstr "" +"Falha no backup da sessão. Tente novamente quando o caminho de backup " +"estiver disponível." + #: src/iac_code/a2a/events.py src/iac_code/acp/server.py #, python-brace-format msgid "MCP warning: {message}" msgstr "Aviso MCP: {message}" +#: src/iac_code/a2a/events.py src/iac_code/a2a/executor.py +#: src/iac_code/a2a/pipeline_executor.py +msgid "A temporary error occurred. Please retry." +msgstr "Ocorreu um erro temporário. Tente novamente." + #: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py msgid "" "Cleanup state unavailable. Inspect the session file and cloud resources " @@ -99,11 +116,7 @@ msgstr "A tarefa já está em execução." msgid "Current model {model} does not support image input." msgstr "O modelo atual {model} não oferece suporte a entrada de imagem." -#: src/iac_code/a2a/pipeline_executor.py -msgid "A temporary error occurred. Please retry." -msgstr "Ocorreu um erro temporário. Tente novamente." - -#: src/iac_code/a2a/pipeline_executor.py +#: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py msgid "Authentication required. Configure credentials and retry." msgstr "Autenticação necessária. Configure as credenciais e tente novamente." @@ -112,6 +125,29 @@ msgstr "Autenticação necessária. Configure as credenciais e tente novamente." msgid "Pipeline already running. Resume task {task_id}." msgstr "O pipeline já está em execução. Retome a tarefa {task_id}." +#: src/iac_code/a2a/pipeline_executor.py +msgid "unknown" +msgstr "desconhecido" + +#: src/iac_code/a2a/pipeline_executor.py +#, python-brace-format +msgid "A2A pipeline sidecar restore failed: status={status}, reason={reason}" +msgstr "" +"Falha ao restaurar o sidecar do pipeline A2A: status={status}, " +"reason={reason}" + +#: src/iac_code/a2a/pipeline_executor.py +msgid "Failed to persist A2A pipeline snapshot" +msgstr "Não foi possível persistir o snapshot do pipeline A2A" + +#: src/iac_code/a2a/pipeline_executor.py +msgid "A2A pipeline sidecar owner is unavailable" +msgstr "O owner do sidecar do pipeline A2A está indisponível" + +#: src/iac_code/a2a/pipeline_journal.py +msgid "Unrepairable A2A pipeline journal tail" +msgstr "Cauda irreparável do journal do pipeline A2A" + #: src/iac_code/a2a/pipeline_recovery.py msgid "A2A pipeline state not found" msgstr "Estado do pipeline A2A não encontrado" @@ -3224,6 +3260,10 @@ msgstr "Etapa {step_id} concluída. Conclusão enviada." msgid "Pipeline state persistence failed." msgstr "Falha ao persistir o estado do pipeline." +#: src/iac_code/pipeline/engine/pipeline_runner.py +msgid "Session backup failed." +msgstr "Falha no backup da sessão." + #: src/iac_code/pipeline/engine/pipeline_runner.py #: src/iac_code/pipeline/engine/sub_pipeline_executor.py #: src/iac_code/ui/repl.py @@ -3516,11 +3556,157 @@ msgstr "" "Solução: mude para um provider suportado no QwenPaw, ou desative o modo " "QwenPaw (remova 'llm_source: qwenpaw' do settings.yml)." +#: src/iac_code/services/session_backup.py +msgid "Session backup requires a supported session layout." +msgstr "O backup da sessão requer um layout de sessão compatível." + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup root must be an absolute directory: {path}" +msgstr "A raiz de backup deve ser um diretório absoluto: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup root must not be a filesystem root: {path}" +msgstr "A raiz de backup não deve ser a raiz do sistema de arquivos: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup root must not be drive-relative: {path}" +msgstr "A raiz de backup não deve ser um caminho relativo à unidade: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "unsafe session_id: {session_id!r}" +msgstr "session_id inseguro: {session_id!r}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup destination overlaps session source: {destination}" +msgstr "O destino de backup se sobrepõe à origem da sessão: {destination}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup destination resolves outside backup root: {destination}" +msgstr "O destino de backup resolve para fora da raiz de backup: {destination}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup destination is not under backup root: {destination}" +msgstr "O destino de backup não está abaixo da raiz de backup: {destination}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup destination ancestry contains symlink: {path}" +msgstr "O caminho pai do destino de backup contém um link simbólico: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "{label} ancestry contains symlink: {path}" +msgstr "O caminho pai de {label} contém um link simbólico: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "session source is a symlink: {source}" +msgstr "A origem da sessão é um link simbólico: {source}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "session source is not a directory: {source}" +msgstr "A origem da sessão não é um diretório: {source}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "session source cannot be resolved: {source}" +msgstr "Não foi possível resolver a origem da sessão: {source}" + +#: src/iac_code/services/session_backup.py +msgid "session source" +msgstr "origem da sessão" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "{label} is a symlink: {path}" +msgstr "{label} é um link simbólico: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "{label} is not a directory: {path}" +msgstr "{label} não é um diretório: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "{label} cannot be resolved: {path}" +msgstr "Não foi possível resolver {label}: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup destination cannot be resolved: {destination}" +msgstr "Não foi possível resolver o destino de backup: {destination}" + +#: src/iac_code/services/session_backup.py +msgid "backup root" +msgstr "raiz do backup" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup lock is a symlink: {path}" +msgstr "O lock de backup é um link simbólico: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup lock is not a regular file: {path}" +msgstr "O lock de backup nao e um arquivo regular: {path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "could not acquire backup lock for {source}" +msgstr "Não foi possível adquirir o lock de backup para {source}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "session source entry is not a regular file: {source}" +msgstr "A entrada de origem da sessão não é um arquivo regular: {source}" + +#: src/iac_code/services/session_backup.py src/iac_code/utils/state_io.py +#, python-brace-format +msgid "could not open file without following reparse point: {path}" +msgstr "Não foi possível abrir o arquivo sem seguir o ponto de reanálise: {path}" + +#: src/iac_code/services/session_layout.py +#: src/iac_code/services/session_storage.py +#: src/iac_code/services/session_usage.py +#, python-brace-format +msgid "Unsupported session metadata: {path}" +msgstr "Metadados de sessão sem suporte: {path}" + +#: src/iac_code/services/session_layout.py +#, python-brace-format +msgid "Path is outside session directory: {path}" +msgstr "O caminho está fora do diretório da sessão: {path}" + +#: src/iac_code/services/session_layout.py +#, python-brace-format +msgid "Unsafe session-owned path: {path}" +msgstr "Caminho pertencente à sessão inseguro: {path}" + +#: src/iac_code/services/session_layout.py +msgid "unsafe transcript id" +msgstr "transcript id inseguro" + #: src/iac_code/services/session_metadata.py #, python-brace-format msgid "Session name must match {pattern}" msgstr "O nome da sessão deve corresponder a {pattern}" +#: src/iac_code/services/session_metadata.py +msgid "invalid session metadata" +msgstr "metadados de sessão inválidos" + +#: src/iac_code/services/session_metadata.py +msgid "invalid session layout version" +msgstr "versão de layout de sessão inválida" + #: src/iac_code/services/session_storage.py #, python-brace-format msgid "Session name already exists in this project: {name}" @@ -4980,6 +5166,19 @@ msgstr " ✘ {name}: falhou" msgid "Pipeline warning: {reason}" msgstr "Aviso do pipeline: {reason}" +#: src/iac_code/ui/repl.py +msgid "backup unavailable" +msgstr "backup indisponível" + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "" +"Pipeline backup is blocked; the pipeline is paused and recoverable: " +"{error}" +msgstr "" +"O backup do pipeline está bloqueado; o pipeline foi pausado e pode ser " +"recuperado: {error}" + #: src/iac_code/ui/repl.py #, python-brace-format msgid "Option {index}" @@ -5556,6 +5755,16 @@ msgstr "" msgid "Stack trace omitted from public event; see error_id." msgstr "Rastreamento de pilha omitido do evento público; veja error_id." +#: src/iac_code/utils/state_io.py +#, python-brace-format +msgid "refusing to follow symlink or reparse point: {path}" +msgstr "Recusado seguir link simbólico ou ponto de reanálise: {path}" + +#: src/iac_code/utils/state_io.py +#, python-brace-format +msgid "refusing to open non-regular file: {path}" +msgstr "Recusado abrir arquivo não regular: {path}" + #~ msgid "DashScope Token Plan" #~ msgstr "DashScope Token Plan" diff --git a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po index 623b4dc6..71fe40be 100644 --- a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po @@ -44,11 +44,26 @@ msgstr "不安全的工件文件名" msgid "Unknown error" msgstr "未知错误" +#: src/iac_code/a2a/artifacts.py src/iac_code/a2a/pipeline_paths.py +#: src/iac_code/services/session_layout.py +#, python-brace-format +msgid "Unsupported session layout version: {version}" +msgstr "不支持的会话布局版本:{version}" + +#: src/iac_code/a2a/backup.py src/iac_code/a2a/executor.py +msgid "Session backup failed. Retry after the backup path is available." +msgstr "会话备份失败。备份路径可用后请重试。" + #: src/iac_code/a2a/events.py src/iac_code/acp/server.py #, python-brace-format msgid "MCP warning: {message}" msgstr "MCP 警告:{message}" +#: src/iac_code/a2a/events.py src/iac_code/a2a/executor.py +#: src/iac_code/a2a/pipeline_executor.py +msgid "A temporary error occurred. Please retry." +msgstr "发生临时错误,请重试。" + #: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py msgid "" "Cleanup state unavailable. Inspect the session file and cloud resources " @@ -87,11 +102,7 @@ msgstr "任务已在运行。" msgid "Current model {model} does not support image input." msgstr "当前模型 {model} 不支持图片输入。" -#: src/iac_code/a2a/pipeline_executor.py -msgid "A temporary error occurred. Please retry." -msgstr "发生临时错误,请重试。" - -#: src/iac_code/a2a/pipeline_executor.py +#: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py msgid "Authentication required. Configure credentials and retry." msgstr "需要身份验证。请配置凭据后重试。" @@ -100,6 +111,27 @@ msgstr "需要身份验证。请配置凭据后重试。" msgid "Pipeline already running. Resume task {task_id}." msgstr "管道已在运行。请恢复任务 {task_id}。" +#: src/iac_code/a2a/pipeline_executor.py +msgid "unknown" +msgstr "未知" + +#: src/iac_code/a2a/pipeline_executor.py +#, python-brace-format +msgid "A2A pipeline sidecar restore failed: status={status}, reason={reason}" +msgstr "A2A pipeline sidecar 恢复失败:status={status},reason={reason}" + +#: src/iac_code/a2a/pipeline_executor.py +msgid "Failed to persist A2A pipeline snapshot" +msgstr "无法持久化 A2A pipeline 快照" + +#: src/iac_code/a2a/pipeline_executor.py +msgid "A2A pipeline sidecar owner is unavailable" +msgstr "A2A pipeline sidecar owner 不可用" + +#: src/iac_code/a2a/pipeline_journal.py +msgid "Unrepairable A2A pipeline journal tail" +msgstr "无法修复 A2A pipeline journal 尾部" + #: src/iac_code/a2a/pipeline_recovery.py msgid "A2A pipeline state not found" msgstr "未找到 A2A pipeline 状态" @@ -3057,6 +3089,10 @@ msgstr "步骤 {step_id} 已完成。结论已提交。" msgid "Pipeline state persistence failed." msgstr "管道状态持久化失败。" +#: src/iac_code/pipeline/engine/pipeline_runner.py +msgid "Session backup failed." +msgstr "会话备份失败。" + #: src/iac_code/pipeline/engine/pipeline_runner.py #: src/iac_code/pipeline/engine/sub_pipeline_executor.py #: src/iac_code/ui/repl.py @@ -3327,11 +3363,157 @@ msgstr "" "解决方法:在 QwenPaw 中切换到已支持的 provider,或关闭 QwenPaw 模式(从 settings.yml 移除 " "'llm_source: qwenpaw')。" +#: src/iac_code/services/session_backup.py +msgid "Session backup requires a supported session layout." +msgstr "会话备份需要受支持的 session layout。" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup root must be an absolute directory: {path}" +msgstr "备份根目录必须是绝对目录:{path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup root must not be a filesystem root: {path}" +msgstr "备份根目录不能是文件系统根目录:{path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup root must not be drive-relative: {path}" +msgstr "备份根目录不能是驱动器相对路径:{path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "unsafe session_id: {session_id!r}" +msgstr "不安全的 session_id:{session_id!r}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup destination overlaps session source: {destination}" +msgstr "备份目标与会话源目录重叠:{destination}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup destination resolves outside backup root: {destination}" +msgstr "备份目标解析到了备份根目录之外:{destination}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup destination is not under backup root: {destination}" +msgstr "备份目标不在备份根目录下:{destination}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup destination ancestry contains symlink: {path}" +msgstr "备份目标路径的上级目录包含符号链接:{path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "{label} ancestry contains symlink: {path}" +msgstr "{label} 的上级路径包含符号链接:{path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "session source is a symlink: {source}" +msgstr "会话源目录是符号链接:{source}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "session source is not a directory: {source}" +msgstr "会话源不是目录:{source}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "session source cannot be resolved: {source}" +msgstr "无法解析会话源目录:{source}" + +#: src/iac_code/services/session_backup.py +msgid "session source" +msgstr "会话源目录" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "{label} is a symlink: {path}" +msgstr "{label} 是符号链接:{path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "{label} is not a directory: {path}" +msgstr "{label} 不是目录:{path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "{label} cannot be resolved: {path}" +msgstr "无法解析 {label}:{path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup destination cannot be resolved: {destination}" +msgstr "无法解析备份目标路径:{destination}" + +#: src/iac_code/services/session_backup.py +msgid "backup root" +msgstr "备份根目录" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup lock is a symlink: {path}" +msgstr "备份锁文件是符号链接:{path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "backup lock is not a regular file: {path}" +msgstr "备份锁不是普通文件:{path}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "could not acquire backup lock for {source}" +msgstr "无法获取会话备份锁:{source}" + +#: src/iac_code/services/session_backup.py +#, python-brace-format +msgid "session source entry is not a regular file: {source}" +msgstr "会话源条目不是常规文件:{source}" + +#: src/iac_code/services/session_backup.py src/iac_code/utils/state_io.py +#, python-brace-format +msgid "could not open file without following reparse point: {path}" +msgstr "无法在不跟随重解析点的情况下打开文件:{path}" + +#: src/iac_code/services/session_layout.py +#: src/iac_code/services/session_storage.py +#: src/iac_code/services/session_usage.py +#, python-brace-format +msgid "Unsupported session metadata: {path}" +msgstr "不支持的会话元数据:{path}" + +#: src/iac_code/services/session_layout.py +#, python-brace-format +msgid "Path is outside session directory: {path}" +msgstr "路径位于会话目录之外:{path}" + +#: src/iac_code/services/session_layout.py +#, python-brace-format +msgid "Unsafe session-owned path: {path}" +msgstr "不安全的会话所属路径:{path}" + +#: src/iac_code/services/session_layout.py +msgid "unsafe transcript id" +msgstr "不安全的 transcript id" + #: src/iac_code/services/session_metadata.py #, python-brace-format msgid "Session name must match {pattern}" msgstr "会话名称必须匹配 {pattern}" +#: src/iac_code/services/session_metadata.py +msgid "invalid session metadata" +msgstr "无效的会话元数据" + +#: src/iac_code/services/session_metadata.py +msgid "invalid session layout version" +msgstr "无效的会话布局版本" + #: src/iac_code/services/session_storage.py #, python-brace-format msgid "Session name already exists in this project: {name}" @@ -4729,6 +4911,17 @@ msgstr " ✘ {name}: 失败" msgid "Pipeline warning: {reason}" msgstr "管道警告:{reason}" +#: src/iac_code/ui/repl.py +msgid "backup unavailable" +msgstr "备份不可用" + +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "" +"Pipeline backup is blocked; the pipeline is paused and recoverable: " +"{error}" +msgstr "管道备份被阻塞;管道已暂停且可恢复:{error}" + #: src/iac_code/ui/repl.py #, python-brace-format msgid "Option {index}" @@ -5284,6 +5477,16 @@ msgstr " 方式 2 - 如果无法访问 github.com,运行以下命令通过 np msgid "Stack trace omitted from public event; see error_id." msgstr "公开事件中已省略堆栈跟踪;请查看 error_id。" +#: src/iac_code/utils/state_io.py +#, python-brace-format +msgid "refusing to follow symlink or reparse point: {path}" +msgstr "拒绝跟随符号链接或重解析点:{path}" + +#: src/iac_code/utils/state_io.py +#, python-brace-format +msgid "refusing to open non-regular file: {path}" +msgstr "拒绝打开非常规文件:{path}" + #~ msgid "toggle preview" #~ msgstr "切换预览" diff --git a/src/iac_code/mcp/output.py b/src/iac_code/mcp/output.py index b30fda0a..dd41cf00 100644 --- a/src/iac_code/mcp/output.py +++ b/src/iac_code/mcp/output.py @@ -4,10 +4,12 @@ import hashlib import json import re +from pathlib import Path from typing import Any, Mapping from iac_code.config import get_config_dir from iac_code.i18n import _ +from iac_code.services.session_layout import SessionPaths, ensure_session_owned_dir from iac_code.tools.base import ToolResult from iac_code.utils.file_security import ensure_private_dir from iac_code.utils.state_io import atomic_write_bytes @@ -19,6 +21,7 @@ def convert_mcp_tool_result( server_name: str, tool_name: str, session_id: str, + session_dir: Path | str | None = None, ) -> ToolResult: """Convert an MCP tool result into iac-code's model-visible ToolResult.""" @@ -31,6 +34,7 @@ def convert_mcp_tool_result( server_name=server_name, tool_name=tool_name, session_id=session_id, + session_dir=session_dir, index=index, artifacts=artifacts, ) @@ -67,6 +71,7 @@ def _convert_content_block( server_name: str, tool_name: str, session_id: str, + session_dir: Path | str | None, index: int, artifacts: list[dict[str, Any]], ) -> str: @@ -82,6 +87,7 @@ def _convert_content_block( server_name=server_name, tool_name=tool_name, session_id=session_id, + session_dir=session_dir, index=index, artifacts=artifacts, ) @@ -106,6 +112,7 @@ def _convert_content_block( server_name=server_name, tool_name=tool_name, session_id=session_id, + session_dir=session_dir, index=index, artifacts=artifacts, uri=uri, @@ -131,6 +138,7 @@ def _store_base64_artifact( server_name: str, tool_name: str, session_id: str, + session_dir: Path | str | None, index: int, artifacts: list[dict[str, Any]], uri: str | None = None, @@ -141,15 +149,17 @@ def _store_base64_artifact( data = base64.b64decode(encoded, validate=True) digest = hashlib.sha256(data).hexdigest()[:16] extension = _extension_for_mime_type(mime_type) - directory = ( - get_config_dir() - / "tool-results" - / session_id - / "mcp" - / _safe_path_segment(server_name) - / _safe_path_segment(tool_name) - ) - ensure_private_dir(directory) + if session_dir is not None: + session_root = Path(session_dir) + session_paths = SessionPaths.require_supported(session_root) + artifact_root = ensure_session_owned_dir(session_root, session_paths.tool_results_dir) + else: + artifact_root = get_config_dir() / "tool-results" / session_id + directory = artifact_root / "mcp" / _safe_path_segment(server_name) / _safe_path_segment(tool_name) + if session_dir is not None: + ensure_session_owned_dir(session_dir, directory) + else: + ensure_private_dir(directory) path = directory / "{:02d}-{}-{}{}".format(index, _safe_path_segment(kind), digest, extension) atomic_write_bytes(path, data) diff --git a/src/iac_code/mcp/tools.py b/src/iac_code/mcp/tools.py index 87c92de9..9789b860 100644 --- a/src/iac_code/mcp/tools.py +++ b/src/iac_code/mcp/tools.py @@ -3,6 +3,7 @@ import inspect import json import re +from pathlib import Path from typing import Any, Callable from iac_code.i18n import _ @@ -13,10 +14,18 @@ class MCPTool(Tool): - def __init__(self, *, manager: Any, record: MCPToolRecord, session_id: str) -> None: + def __init__( + self, + *, + manager: Any, + record: MCPToolRecord, + session_id: str, + session_dir: Path | str | None = None, + ) -> None: self._manager = manager self._record = record self._session_id = session_id + self._session_dir = session_dir @property def name(self) -> str: @@ -70,6 +79,7 @@ async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> server_name=self._record.server_name, tool_name=self._record.tool_name, session_id=self._session_id, + session_dir=self._session_dir, ) def _build_progress_callback(self, context: ToolContext): @@ -141,9 +151,10 @@ class ReadMCPResourceTool(Tool): "additionalProperties": False, } - def __init__(self, *, manager: Any, session_id: str) -> None: + def __init__(self, *, manager: Any, session_id: str, session_dir: Path | str | None = None) -> None: self._manager = manager self._session_id = session_id + self._session_dir = session_dir def is_read_only(self, input: dict | None = None) -> bool: return True @@ -157,6 +168,7 @@ async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> server_name=server_name, tool_name="read_resource", session_id=self._session_id, + session_dir=self._session_dir, ) diff --git a/src/iac_code/pipeline/__init__.py b/src/iac_code/pipeline/__init__.py index e34a3533..29e4741c 100644 --- a/src/iac_code/pipeline/__init__.py +++ b/src/iac_code/pipeline/__init__.py @@ -34,6 +34,7 @@ def create_pipeline( auto_trigger_skills: list[Any] | None = None, resume_from_sidecar: bool = False, surface: str = "repl", + backup_service: Any | None = None, ) -> PipelineRunner: """Factory: create a pipeline runner by name. @@ -62,6 +63,7 @@ def create_pipeline( auto_trigger_skills=auto_trigger_skills, resume_from_sidecar=resume_from_sidecar, surface=surface, + backup_service=backup_service, ) diff --git a/src/iac_code/pipeline/engine/events.py b/src/iac_code/pipeline/engine/events.py index 8999fc78..09da9496 100644 --- a/src/iac_code/pipeline/engine/events.py +++ b/src/iac_code/pipeline/engine/events.py @@ -2,14 +2,18 @@ from __future__ import annotations +import time from dataclasses import dataclass from enum import Enum +from iac_code.utils.public_errors import sanitize_public_text + class PipelineEventType(str, Enum): PIPELINE_STARTED = "pipeline_started" PIPELINE_COMPLETED = "pipeline_completed" PIPELINE_RESUMED = "pipeline_resumed" + BACKUP_BLOCKED = "backup_blocked" PIPELINE_ERROR = "pipeline_error" PIPELINE_WARNING = "pipeline_warning" @@ -42,3 +46,17 @@ class PipelineEvent: step_id: str | None timestamp: float data: dict + + +def backup_blocked_event(step_id: str | None, reason: object, error: object) -> PipelineEvent: + reason_text = getattr(reason, "value", reason) + return PipelineEvent( + type=PipelineEventType.BACKUP_BLOCKED, + step_id=step_id, + timestamp=time.time(), + data={ + "reason": sanitize_public_text(str(reason_text)), + "error": sanitize_public_text(str(error)), + "recoverable": True, + }, + ) diff --git a/src/iac_code/pipeline/engine/observability.py b/src/iac_code/pipeline/engine/observability.py index 7a89c7ff..c64fff02 100644 --- a/src/iac_code/pipeline/engine/observability.py +++ b/src/iac_code/pipeline/engine/observability.py @@ -30,11 +30,15 @@ "all_failed", "candidate_index", "candidate_count_bucket", + "critical", "error_type", "from_step", "input_length_bucket", "parent_step_id", + "persisted", "reason", + "recoverable", + "retry_count", "rollback_scope", "status", "step_attempt", @@ -499,6 +503,54 @@ def pipeline_user_aborted( ) self._event(Events.PIPELINE_USER_ABORTED, attrs) + def backup_succeeded( + self, + *, + reason: str, + critical: bool, + step_id: str | None = None, + retry_count: int = 0, + ) -> None: + attrs = self.base_attrs(step_id=step_id, reason=reason, critical=critical, retry_count=retry_count) + self._event(Events.PIPELINE_BACKUP_SUCCEEDED, attrs) + self._metric( + Metrics.PIPELINE_BACKUP_SUCCEEDED_COUNT, + 1, + self.metric_attrs(step_id=step_id, reason=reason, critical=critical, retry_count=retry_count), + ) + + def backup_failed( + self, + *, + reason: str, + critical: bool, + step_id: str | None = None, + retry_count: int = 0, + ) -> None: + attrs = self.base_attrs(step_id=step_id, reason=reason, critical=critical, retry_count=retry_count) + self._event(Events.PIPELINE_BACKUP_FAILED, attrs) + self._metric( + Metrics.PIPELINE_BACKUP_FAILED_COUNT, + 1, + self.metric_attrs(step_id=step_id, reason=reason, critical=critical, retry_count=retry_count), + ) + + def backup_blocked( + self, + *, + reason: str, + step_id: str | None = None, + recoverable: bool = True, + persisted: bool = True, + ) -> None: + attrs = self.base_attrs(step_id=step_id, reason=reason, recoverable=recoverable, persisted=persisted) + self._event(Events.PIPELINE_BACKUP_BLOCKED, attrs) + self._metric( + Metrics.PIPELINE_BACKUP_BLOCKED_COUNT, + 1, + self.metric_attrs(step_id=step_id, reason=reason, recoverable=recoverable, persisted=persisted), + ) + def step_started(self, **attrs: Any) -> None: self._event(Events.PIPELINE_STEP_STARTED, self.base_attrs(**attrs)) diff --git a/src/iac_code/pipeline/engine/pipeline_runner.py b/src/iac_code/pipeline/engine/pipeline_runner.py index 5e8d7a6d..cd04a2ef 100644 --- a/src/iac_code/pipeline/engine/pipeline_runner.py +++ b/src/iac_code/pipeline/engine/pipeline_runner.py @@ -8,11 +8,13 @@ import json import logging import os +import stat import time from collections.abc import AsyncGenerator, Awaitable, Callable from dataclasses import dataclass, replace from pathlib import Path from typing import Any, cast +from unittest.mock import Mock from iac_code.agent.message import ContentBlock, Message, ToolResultBlock from iac_code.i18n import _ @@ -24,7 +26,7 @@ ) from iac_code.pipeline.engine.context import PipelineContext from iac_code.pipeline.engine.display_replay import DISPLAY_TRANSCRIPT_FILENAME -from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType +from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType, backup_blocked_event from iac_code.pipeline.engine.handoff import build_handoff_summary, terminal_outcome_from_completed_event from iac_code.pipeline.engine.interrupt import InterruptController, InterruptVerdict from iac_code.pipeline.engine.loader import load_pipeline_dir @@ -43,6 +45,8 @@ PipelineUserInput, normalize_pipeline_user_input, ) +from iac_code.services.session_backup import BackupReason, SessionBackupBlocked, SessionBackupService +from iac_code.services.session_metadata import SESSION_JSONL_FILENAME, SESSION_METADATA_FILENAME from iac_code.types.stream_events import ResourceObservedEvent, StreamEvent from iac_code.utils.public_errors import sanitize_public_text @@ -64,6 +68,94 @@ "corrupt_context", "invalid_context", } +_SIDECAR_ROOT_DIRS = {"a2a", "image-cache", "pipeline", "tool-results"} +_SIDECAR_ROOT_FILES = { + ".backup-state.json", + ".backup-lock", + ".permission-audit.jsonl.lock", + "permission-audit.jsonl", + ".usage.jsonl.lock", + "usage.jsonl", +} + + +def _entry_exists_no_follow(path: Path) -> bool: + try: + path.stat(follow_symlinks=False) + except OSError: + return False + return True + + +def _is_reparse_point(path: Path) -> bool: + try: + attributes = getattr(path.stat(follow_symlinks=False), "st_file_attributes", 0) + except OSError: + return False + return bool(attributes & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)) + + +def _is_directory_entry(path: Path) -> bool: + if path.is_symlink() or _is_reparse_point(path): + return False + try: + return stat.S_ISDIR(path.stat(follow_symlinks=False).st_mode) + except OSError: + return False + + +def _is_regular_file_entry(path: Path) -> bool: + if path.is_symlink() or _is_reparse_point(path): + return False + try: + return stat.S_ISREG(path.stat(follow_symlinks=False).st_mode) + except OSError: + return False + + +def _is_allowed_sidecar_file_name(name: str) -> bool: + if name in _SIDECAR_ROOT_FILES: + return True + prefix = "permission-audit.jsonl." + suffix = name[len(prefix) :] if name.startswith(prefix) else "" + return suffix.isdecimal() + + +def _is_safe_sidecar_directory_tree(path: Path) -> bool: + if not _is_directory_entry(path): + return False + stack = [path] + while stack: + current = stack.pop() + try: + children = list(current.iterdir()) + except OSError: + return False + for child in children: + if child.is_symlink() or _is_reparse_point(child): + return False + try: + mode = child.stat(follow_symlinks=False).st_mode + except OSError: + return False + if stat.S_ISDIR(mode): + stack.append(child) + elif not stat.S_ISREG(mode): + return False + return True + + +def _is_safe_sidecar_root_child(child: Path) -> bool: + if _is_allowed_sidecar_file_name(child.name): + return _is_regular_file_entry(child) + return child.name in _SIDECAR_ROOT_DIRS and _is_safe_sidecar_directory_tree(child) + + +def _storage_method(storage: Any, name: str) -> Any | None: + method = getattr(storage, name, None) + if isinstance(method, Mock) and method.side_effect is None and isinstance(method.return_value, Mock): + return None + return method class PipelineStatePersistenceError(RuntimeError): @@ -328,10 +420,12 @@ def __init__( auto_trigger_skills: list[Any] | None = None, resume_from_sidecar: bool = False, surface: str = "repl", + backup_service: Any | None = None, ) -> None: self._session_storage = session_storage self._session_id = session_id self._cwd = cwd or os.getcwd() + self._backup_service = backup_service or self._default_backup_service(session_storage) self._permission_context_getter = permission_context_getter self._memory_content_getter = memory_content_getter self._auto_trigger_skills = auto_trigger_skills or [] @@ -369,14 +463,22 @@ def __init__( # rather than the legacy sibling .pipeline/ that pre-dated # the directory format. SessionStorage.session_dir is the canonical # accessor for ///. - if hasattr(session_storage, "session_dir"): - raw_session_dir = session_storage.session_dir(self._cwd, session_id) - if isinstance(raw_session_dir, (str, Path)): - self.session = PipelineSession(Path(raw_session_dir) / "pipeline") - else: - self.session = None - else: - self.session = None + v2_session_dir = _storage_method(session_storage, "v2_session_dir") + session_dir = _storage_method(session_storage, "session_dir") + raw_session_dir = None + ensure_v2_session_dir = _storage_method(session_storage, "ensure_v2_session_dir_for_new_session") + if callable(ensure_v2_session_dir): + raw_session_dir = ensure_v2_session_dir(self._cwd, session_id) + if raw_session_dir is None: + if callable(v2_session_dir): + raw_session_dir = v2_session_dir(self._cwd, session_id) + if raw_session_dir is None and callable(session_dir): + raw_session_dir = self._writable_pipeline_sidecar_session_dir(session_dir(self._cwd, session_id)) + elif callable(session_dir): + raw_session_dir = session_dir(self._cwd, session_id) + self.session = ( + PipelineSession(Path(raw_session_dir) / "pipeline") if isinstance(raw_session_dir, (str, Path)) else None + ) self._attempts: dict[str, Any] = {"next_attempt_number": 1, "items": {}} self._execution: dict[str, Any] = {} @@ -393,6 +495,7 @@ def __init__( pipeline=self._loaded, pipeline_dir=pipeline_dir, session_storage=self._transcript_storage or session_storage, + root_session_storage=session_storage, cwd=self._cwd, pause_event=self._agent_pause_event, permission_context_getter=self._permission_context_getter, @@ -431,6 +534,12 @@ def __init__( # /status aggregator (problem 6). self._current_sub_executor_list: list[SubPipelineExecutor] | None = None + @staticmethod + def _default_backup_service(session_storage: Any) -> SessionBackupService: + if callable(getattr(session_storage, "session_dir", None)): + return SessionBackupService(session_storage=session_storage) + return SessionBackupService() + @property def pipeline_name(self) -> str: return self._loaded.name @@ -666,6 +775,38 @@ def _build_pipeline_identity(self, pipeline_dir: Path) -> PipelineIdentity: pipeline_fingerprint=digest, ) + @staticmethod + def _existing_pipeline_sidecar_session_dir(raw_session_dir: str | Path) -> Path | None: + session_dir = PipelineRunner._writable_pipeline_sidecar_session_dir(raw_session_dir) + if session_dir is None: + return None + pipeline_dir = session_dir / "pipeline" + if not _is_directory_entry(pipeline_dir): + return None + for name in ("meta.yaml", "context.yaml", "events.jsonl"): + if _is_regular_file_entry(pipeline_dir / name): + return session_dir + return None + + @staticmethod + def _writable_pipeline_sidecar_session_dir(raw_session_dir: str | Path) -> Path | None: + session_dir = Path(raw_session_dir) + if not _entry_exists_no_follow(session_dir): + return session_dir + if not _is_directory_entry(session_dir): + return None + if _entry_exists_no_follow(session_dir / SESSION_JSONL_FILENAME) or _entry_exists_no_follow( + session_dir / SESSION_METADATA_FILENAME + ): + return None + try: + children = list(session_dir.iterdir()) + except OSError: + return None + if not all(_is_safe_sidecar_root_child(child) for child in children): + return None + return session_dir + def should_switch_to_normal(self, completed_event_data: dict) -> bool: policy = self.on_complete_policy if policy is None or policy.action != "switch_to_normal": @@ -791,10 +932,38 @@ async def restore_from_sidecar(self) -> RestoreResult: def continue_from_sidecar( self, user_input: str | list[ContentBlock] | PipelineUserInput | None = None ) -> AsyncGenerator[StreamEvent | PipelineEvent | StepResult, None]: + if self.sidecar_status == "backup_blocked": + return self._continue_from_backup_blocked(user_input) if not user_input: return self._continue_from_current(resume_running_step=True) return self._continue_from_sidecar_with_input(user_input) + async def _continue_from_backup_blocked( + self, + user_input: str | list[ContentBlock] | PipelineUserInput | None = None, + ) -> AsyncGenerator[StreamEvent | PipelineEvent | StepResult, None]: + reason = self._backup_blocked_restore_reason() + blocked_event = await self._critical_backup_blocked_event(self._terminal_current_step_id() or None, reason) + if blocked_event is not None: + yield blocked_event + return + if self._sidecar_status == "backup_blocked": + self._sidecar_status = "running" + if not user_input: + async for event in self._continue_from_current(resume_running_step=True): + yield event + return + async for event in self._continue_from_sidecar_with_input(user_input): + yield event + + def _backup_blocked_restore_reason(self) -> BackupReason: + result = self._sidecar_restore_result + raw_reason = getattr(result, "reason", None) + try: + return BackupReason(str(raw_reason)) + except ValueError: + return BackupReason.PIPELINE_STEP_COMPLETED + async def _continue_from_sidecar_with_input( self, user_input: str | list[ContentBlock] | PipelineUserInput ) -> AsyncGenerator[StreamEvent | PipelineEvent | StepResult, None]: @@ -900,6 +1069,13 @@ async def _continue_after_sidecar_hard_interrupt( return if self.sidecar_status == "failed": current_step = getattr(self.state_machine, "current_step", None) + blocked_event = await self._critical_backup_blocked_event( + getattr(current_step, "step_id", None), + BackupReason.TERMINAL, + ) + if blocked_event is not None: + yield blocked_event + return yield PipelineEvent( type=PipelineEventType.PIPELINE_COMPLETED, step_id=getattr(current_step, "step_id", None), @@ -930,6 +1106,9 @@ async def _save_and_emit_interrupt_pause(self, verdict: InterruptVerdict) -> Pip ) self._set_pending_input_kind(_PIPELINE_PAUSE_CONFIRMATION_KIND) await self._save_waiting_input(step_id) + blocked_event = await self._critical_backup_blocked_event(step_id, BackupReason.INPUT_REQUIRED) + if blocked_event is not None: + return blocked_event if step_id: self._waiting_input_started_at[step_id] = self._observability.now() self._waiting_input_options_by_step[step_id] = [] @@ -1325,18 +1504,31 @@ def _mark_active_attempt_failed_preserve_execution(self) -> None: attempt["status"] = "failed" def _load_repaired_resume_messages(self, transcript_id: str | None) -> list | None: - if self._transcript_storage is None or not transcript_id: + if not transcript_id: + return None + if self._transcript_storage is not None: + loaded = self._transcript_storage.load(self._cwd, transcript_id) + if isinstance(loaded, list) and loaded: + return self._transcript_storage.repair_interrupted(loaded) + if self._session_storage is None: return None - loaded = self._transcript_storage.load(self._cwd, transcript_id) - return self._transcript_storage.repair_interrupted(loaded) + loaded = self._session_storage.load(self._cwd, transcript_id) + return self._session_storage.repair_interrupted(loaded) if isinstance(loaded, list) and loaded else None def _attempt_has_resume_transcript(self, attempt: dict[str, Any] | None) -> bool: - if self._transcript_storage is None or not attempt: + if not attempt: return False transcript_id = attempt.get("transcript_id") if not isinstance(transcript_id, str): return False - return bool(self._transcript_storage.load(self._cwd, transcript_id)) + if self._transcript_storage is not None: + loaded = self._transcript_storage.load(self._cwd, transcript_id) + if isinstance(loaded, list) and loaded: + return True + if self._session_storage is not None: + loaded = self._session_storage.load(self._cwd, transcript_id) + return isinstance(loaded, list) and bool(loaded) + return False def _record_sidecar_save_failure(self, status: str, operation: str, exc: Exception) -> None: self._sidecar_status = None @@ -1397,6 +1589,110 @@ def _is_persistence_failure_event(event: object) -> bool: error_details = event.data.get("error_details", {}) return isinstance(error_details, dict) and error_details.get("type") == "PipelineStatePersistenceError" + @staticmethod + def _is_backup_blocked_event(event: object) -> bool: + return isinstance(event, PipelineEvent) and event.type == PipelineEventType.BACKUP_BLOCKED + + @staticmethod + def _backup_retry_count(value: object) -> int: + retry_count = getattr(value, "retry_count", 0) + return retry_count if isinstance(retry_count, int) and retry_count >= 0 else 0 + + async def _backup_critical(self, reason: BackupReason, *, step_id: str | None = None) -> None: + result = await asyncio.to_thread( + self._backup_service.backup_session, + self._cwd, + self._session_id, + reason=reason, + critical=True, + ) + retry_count = self._backup_retry_count(result) + if getattr(result, "enabled", True) and not getattr(result, "succeeded", True): + message = getattr(result, "error", None) or _("Session backup failed.") + raise SessionBackupBlocked(str(message), retry_count=retry_count, result=result) + if getattr(result, "enabled", False): + self._observability.backup_succeeded( + step_id=step_id, + reason=reason.value, + critical=True, + retry_count=retry_count, + ) + + async def _critical_backup_blocked_event( + self, + step_id: str | None, + reason: BackupReason, + ) -> PipelineEvent | None: + try: + await self._backup_critical(reason, step_id=step_id) + except SessionBackupBlocked as exc: + return await self._backup_blocked_event_from_exception(step_id, reason, exc) + return None + + async def _backup_blocked_event_from_exception( + self, + step_id: str | None, + reason: BackupReason, + exc: SessionBackupBlocked, + ) -> PipelineEvent: + self._observability.backup_failed( + step_id=step_id, + reason=reason.value, + critical=True, + retry_count=self._backup_retry_count(exc), + ) + if not await self._save_backup_blocked_sidecar(step_id, reason): + self._observability.backup_blocked( + step_id=step_id, + reason=reason.value, + recoverable=False, + persisted=False, + ) + return self._persistence_failure_event( + PipelineStatePersistenceError( + _("Pipeline state persistence failed."), + step_id=step_id, + ) + ) + self._observability.backup_blocked( + step_id=step_id, + reason=reason.value, + recoverable=True, + persisted=True, + ) + return backup_blocked_event(step_id, reason, exc) + + async def _save_backup_blocked_sidecar(self, step_id: str | None, reason: BackupReason) -> bool: + if not self.session: + return False + session = self.session + current_step = step_id or self._terminal_current_step_id() + metadata_kwargs: dict[str, Any] = {"attempts": dict(self._attempts)} + if self._execution: + metadata_kwargs["execution"] = dict(self._execution) + + async def save() -> None: + await session.save_backup_blocked( + current_step, + self._state_machine_snapshot_for_sidecar(), + self.context.to_snapshot(), + self._pipeline_identity, + reason=reason.value, + **metadata_kwargs, + ) + + try: + await save() + except Exception as exc: + logger.warning( + "Failed to persist pipeline backup_blocked sidecar state (pipeline=%s, error_type=%s)", + self.pipeline_name, + type(exc).__name__, + ) + return False + self._sidecar_status = "backup_blocked" + return True + async def _try_save_sidecar( self, status: str, @@ -1653,7 +1949,6 @@ async def _save_after_advance(self, completed_step_id: str) -> None: self._set_current_step_user_input(None) try: if self.state_machine.is_complete: - await self._save_completed(completed_step_id, reason="pipeline completed") return await self._save_running( self.state_machine.current_step.step_id, @@ -1769,6 +2064,7 @@ def _restored_parallel_prompt_contexts(self, current_step: StepSpec) -> list[Pro pipeline=self._loaded, pipeline_dir=self._pipeline_dir, session_storage=self._transcript_storage or self._session_storage, + root_session_storage=self._session_storage, cwd=self._cwd, pause_event=self._agent_pause_event, permission_context_getter=self._permission_context_getter, @@ -1812,6 +2108,7 @@ def _restored_parallel_prompt_contexts(self, current_step: StepSpec) -> list[Pro pipeline=self._loaded, pipeline_dir=self._pipeline_dir, session_storage=self._transcript_storage or self._session_storage, + root_session_storage=self._session_storage, cwd=self._cwd, pause_event=self._agent_pause_event, permission_context_getter=self._permission_context_getter, @@ -2918,6 +3215,7 @@ async def _continue_from_current( ) -> AsyncGenerator[StreamEvent | PipelineEvent | StepResult, None]: is_first_step = True terminal_pipeline_telemetry_emitted = False + terminal_backup_completed = False step_result: StepResult | None = None restored_step_user_input = self._consume_restored_current_step_user_input() if user_input is None else None restored_resume_messages, restored_precompleted_tools = ( @@ -3036,6 +3334,15 @@ def emit_pipeline_completed(*, failed: bool, early_exit: bool) -> None: yield event if self._is_persistence_failure_event(event): return + if self._is_backup_blocked_event(event): + return + except SessionBackupBlocked as exc: + yield await self._backup_blocked_event_from_exception( + step.step_id, + BackupReason.PIPELINE_STEP_COMPLETED, + exc, + ) + return except Exception as exc: failure = public_error( message=str(exc) or type(exc).__name__, @@ -3047,6 +3354,11 @@ def emit_pipeline_completed(*, failed: bool, early_exit: bool) -> None: except PipelineStatePersistenceError as persistence_exc: yield self._persistence_failure_event(persistence_exc) return + blocked_event = await self._critical_backup_blocked_event(step.step_id, BackupReason.TERMINAL) + if blocked_event is not None: + yield blocked_event + return + terminal_backup_completed = True self._observability.step_failed( step_id=step.step_id, duration_ms=self._observability.duration_ms(step_started_at), @@ -3089,6 +3401,13 @@ def emit_pipeline_completed(*, failed: bool, early_exit: bool) -> None: except PipelineStatePersistenceError as exc: yield self._persistence_failure_event(exc) return + blocked_event = await self._critical_backup_blocked_event( + completed_step_id, + BackupReason.PIPELINE_STEP_COMPLETED, + ) + if blocked_event is not None: + yield blocked_event + return self._observability.step_completed( step_id=step.step_id, duration_ms=duration_ms, @@ -3108,8 +3427,24 @@ def emit_pipeline_completed(*, failed: bool, early_exit: bool) -> None: ui_mode=step.ui_mode, duration_ms=duration_ms, ) - if step.forward is None: + pipeline_completed_event: PipelineEvent | None = None + if self.state_machine.is_complete: + try: + await self._save_completed(completed_step_id, reason="pipeline completed") + except PipelineStatePersistenceError as exc: + yield self._persistence_failure_event(exc) + return + blocked_event = await self._critical_backup_blocked_event(None, BackupReason.TERMINAL) + if blocked_event is not None: + yield blocked_event + return emit_pipeline_completed(failed=False, early_exit=False) + pipeline_completed_event = PipelineEvent( + type=PipelineEventType.PIPELINE_COMPLETED, + step_id=None, + timestamp=time.time(), + data={"total_steps": self.state_machine.total_steps}, + ) parallel_result = self.context.get_conclusion(step.conclusion_field) candidates_count = len(parallel_result) if isinstance(parallel_result, list) else 0 yield PipelineEvent( @@ -3123,6 +3458,9 @@ def emit_pipeline_completed(*, failed: bool, early_exit: bool) -> None: "conclusion": parallel_result, }, ) + if pipeline_completed_event is not None: + yield pipeline_completed_event + return continue with self._observability.step_span( @@ -3214,6 +3552,11 @@ def emit_pipeline_completed(*, failed: bool, early_exit: bool) -> None: except PipelineStatePersistenceError as exc: yield self._persistence_failure_event(exc) return + blocked_event = await self._critical_backup_blocked_event(step.step_id, BackupReason.TERMINAL) + if blocked_event is not None: + yield blocked_event + return + terminal_backup_completed = True self._observability.step_failed( step_id=step.step_id, duration_ms=self._observability.duration_ms(step_started_at), @@ -3250,8 +3593,19 @@ def emit_pipeline_completed(*, failed: bool, early_exit: bool) -> None: duration_ms = self._observability.duration_ms(step_started_at) elapsed = duration_ms / 1000 + step_success_meta_appended = False step_success_observed = False + def append_step_success_meta() -> None: + nonlocal step_success_meta_appended + if step_success_meta_appended: + return + self._append_pipeline_session_meta_best_effort( + {"type": "pipeline_step_complete", "step_id": step.step_id}, + operation="pipeline_step_complete", + ) + step_success_meta_appended = True + def emit_step_success_observability(funnel_status: str | None = "completed") -> None: nonlocal step_success_observed if step_success_observed: @@ -3276,10 +3630,6 @@ def emit_step_success_observability(funnel_status: str | None = "completed") -> ui_mode=step.ui_mode, duration_ms=duration_ms, ) - self._append_pipeline_session_meta_best_effort( - {"type": "pipeline_step_complete", "step_id": step.step_id}, - operation="pipeline_step_complete", - ) step_success_observed = True step_completed_event = PipelineEvent( @@ -3300,11 +3650,23 @@ def emit_step_success_observability(funnel_status: str | None = "completed") -> matched = actual is ec_value if isinstance(ec_value, bool) else actual == ec_value if matched: logger.info("Exit condition met for step %s: %s=%r", step.step_id, ec_field, ec_value) + append_step_success_meta() + blocked_event = await self._critical_backup_blocked_event( + step.step_id, + BackupReason.PIPELINE_STEP_COMPLETED, + ) + if blocked_event is not None: + yield blocked_event + return try: await self._save_completed(step.step_id, reason="exit condition met") except PipelineStatePersistenceError as exc: yield self._persistence_failure_event(exc) return + blocked_event = await self._critical_backup_blocked_event(step.step_id, BackupReason.TERMINAL) + if blocked_event is not None: + yield blocked_event + return emit_step_success_observability() emit_pipeline_completed(failed=False, early_exit=True) yield step_completed_event @@ -3344,6 +3706,11 @@ def emit_step_success_observability(funnel_status: str | None = "completed") -> except PipelineStatePersistenceError as persistence_exc: yield self._persistence_failure_event(persistence_exc) return + blocked_event = await self._critical_backup_blocked_event(step.step_id, BackupReason.TERMINAL) + if blocked_event is not None: + yield blocked_event + return + terminal_backup_completed = True self._observability.step_failed( step_id=step.step_id, duration_ms=self._observability.duration_ms(step_started_at), @@ -3377,12 +3744,6 @@ def emit_step_success_observability(funnel_status: str | None = "completed") -> "error_details": failure.details, }, ) - # Emit terminal event so consumers (e.g. renderer teardown) - # see a clean pipeline end. Mirrors the post-loop - # PIPELINE_COMPLETED(failed=True) emission for genuine - # step failures, which we can't reuse here because - # step_result.status is COMPLETED (the step itself succeeded; - # only the rollback target was invalid). yield PipelineEvent( type=PipelineEventType.PIPELINE_COMPLETED, step_id=step.step_id, @@ -3411,7 +3772,18 @@ def emit_step_success_observability(funnel_status: str | None = "completed") -> except PipelineStatePersistenceError as exc: yield self._persistence_failure_event(exc) return - emit_step_success_observability() + append_step_success_meta() + self._append_pipeline_session_meta_best_effort( + {"type": "pipeline_rollback", "from": step.step_id, "to": target, "reason": reason}, + operation="pipeline_rollback", + ) + blocked_event = await self._critical_backup_blocked_event( + step.step_id, + BackupReason.PIPELINE_STEP_COMPLETED, + ) + if blocked_event is not None: + yield blocked_event + return self._observability.rollback( from_step=step.step_id, to_step=target, @@ -3419,10 +3791,7 @@ def emit_step_success_observability(funnel_status: str | None = "completed") -> rollback_scope="parent", stale_fields=stale, ) - self._append_pipeline_session_meta_best_effort( - {"type": "pipeline_rollback", "from": step.step_id, "to": target, "reason": reason}, - operation="pipeline_rollback", - ) + emit_step_success_observability() yield step_completed_event yield PipelineEvent( type=PipelineEventType.ROLLBACK_TRIGGERED, @@ -3445,10 +3814,39 @@ def emit_step_success_observability(funnel_status: str | None = "completed") -> except PipelineStatePersistenceError as exc: yield self._persistence_failure_event(exc) return - emit_step_success_observability() - if step.forward is None: + append_step_success_meta() + blocked_event = await self._critical_backup_blocked_event( + completed_step_id, + BackupReason.PIPELINE_STEP_COMPLETED, + ) + if blocked_event is not None: + yield blocked_event + return + pipeline_completed_event = None + if self.state_machine.is_complete: + try: + await self._save_completed(completed_step_id, reason="pipeline completed") + except PipelineStatePersistenceError as exc: + yield self._persistence_failure_event(exc) + return + blocked_event = await self._critical_backup_blocked_event(None, BackupReason.TERMINAL) + if blocked_event is not None: + yield blocked_event + return + emit_step_success_observability() emit_pipeline_completed(failed=False, early_exit=False) + pipeline_completed_event = PipelineEvent( + type=PipelineEventType.PIPELINE_COMPLETED, + step_id=None, + timestamp=time.time(), + data={"total_steps": self.state_machine.total_steps}, + ) + else: + emit_step_success_observability() yield step_completed_event + if pipeline_completed_event is not None: + yield pipeline_completed_event + return else: self._set_current_step_user_input(None) try: @@ -3456,6 +3854,14 @@ def emit_step_success_observability(funnel_status: str | None = "completed") -> except PipelineStatePersistenceError as exc: yield self._persistence_failure_event(exc) return + append_step_success_meta() + blocked_event = await self._critical_backup_blocked_event( + step.step_id, + BackupReason.PIPELINE_STEP_COMPLETED, + ) + if blocked_event is not None: + yield blocked_event + return emit_step_success_observability(funnel_status=None) yield step_completed_event conclusion = step_result.conclusion or {} @@ -3463,6 +3869,10 @@ def emit_step_success_observability(funnel_status: str | None = "completed") -> options = conclusion.get("options", []) if not isinstance(options, list): options = [] + blocked_event = await self._critical_backup_blocked_event(step.step_id, BackupReason.INPUT_REQUIRED) + if blocked_event is not None: + yield blocked_event + return self._waiting_input_started_at[step.step_id] = self._observability.now() self._waiting_input_options_by_step[step.step_id] = options self._observability.user_input_required( @@ -3498,6 +3908,20 @@ def emit_step_success_observability(funnel_status: str | None = "completed") -> return if self.state_machine.is_complete: + completed_step = locals().get("step") + completed_step_id = getattr(completed_step, "step_id", None) + if not isinstance(completed_step_id, str) and self._loaded.steps: + completed_step_id = self._loaded.steps[-1].step_id + if isinstance(completed_step_id, str): + try: + await self._save_completed(completed_step_id, reason="pipeline completed") + except PipelineStatePersistenceError as exc: + yield self._persistence_failure_event(exc) + return + blocked_event = await self._critical_backup_blocked_event(None, BackupReason.TERMINAL) + if blocked_event is not None: + yield blocked_event + return emit_pipeline_completed(failed=False, early_exit=False) yield PipelineEvent( type=PipelineEventType.PIPELINE_COMPLETED, @@ -3506,6 +3930,11 @@ def emit_step_success_observability(funnel_status: str | None = "completed") -> data={"total_steps": self.state_machine.total_steps}, ) elif step_result is None or step_result.status == StepStatus.FAILED: + if not terminal_backup_completed: + blocked_event = await self._critical_backup_blocked_event(step.step_id, BackupReason.TERMINAL) + if blocked_event is not None: + yield blocked_event + return emit_pipeline_completed(failed=True, early_exit=False) yield PipelineEvent( type=PipelineEventType.PIPELINE_COMPLETED, @@ -3549,12 +3978,14 @@ async def _execute_parallel_sub_pipeline( pipeline=self._loaded, pipeline_dir=self._step_executor._pipeline_dir, session_storage=self._transcript_storage or self._session_storage, + root_session_storage=self._session_storage, cwd=self._cwd, pause_event=self._agent_pause_event, permission_context_getter=self._permission_context_getter, memory_content_getter=self._memory_content_getter, auto_trigger_skills=self._auto_trigger_skills, surface=self._surface, + backup_service=self._backup_service, ) for _ in candidates ] @@ -3807,6 +4238,8 @@ async def record_sub_step_state(payload: dict[str, Any]) -> None: logger.debug("Candidate %d cancelled", i) except PipelineStatePersistenceError as exc: await event_queue.put(exc) + except SessionBackupBlocked as exc: + await event_queue.put(exc) except Exception as exc: failure = public_error_from_exception(exc) error_summary = failure.summary @@ -3920,6 +4353,13 @@ async def record_sub_step_state(payload: dict[str, Any]) -> None: if isinstance(event, PipelineStatePersistenceError): yield self._persistence_failure_event(event) return + if isinstance(event, SessionBackupBlocked): + yield await self._backup_blocked_event_from_exception( + step.step_id, + BackupReason.PIPELINE_STEP_COMPLETED, + event, + ) + return if isinstance(event, CandidateSentinel): idx = event.candidate_index if idx in self._pending_candidate_restarts: diff --git a/src/iac_code/pipeline/engine/session.py b/src/iac_code/pipeline/engine/session.py index 961a7e7c..790ec040 100644 --- a/src/iac_code/pipeline/engine/session.py +++ b/src/iac_code/pipeline/engine/session.py @@ -14,8 +14,16 @@ from iac_code.pipeline.engine.types import StepStatus from iac_code.utils.state_io import atomic_write_text -PipelineStatus = Literal["running", "waiting_input", "completed", "user_aborted", "failed", "discarded"] -RESUMABLE_STATUSES: set[PipelineStatus] = {"running", "waiting_input"} +PipelineStatus = Literal[ + "running", + "waiting_input", + "completed", + "user_aborted", + "failed", + "discarded", + "backup_blocked", +] +RESUMABLE_STATUSES: set[PipelineStatus] = {"running", "waiting_input", "backup_blocked"} SKIP_RESTORE_STATUSES: set[PipelineStatus] = {"completed", "user_aborted", "failed", "discarded"} _ALL_STATUSES = RESUMABLE_STATUSES | SKIP_RESTORE_STATUSES @@ -266,6 +274,53 @@ async def save_failed( normal_handoff=normal_handoff, ) + def save_backup_blocked_sync( + self, + step_id: str, + state_machine_snapshot: dict, + context_snapshot: dict, + identity: PipelineIdentity | dict, + reason: str | None = None, + *, + execution: _MetadataValue = _PRESERVE_METADATA, + attempts: _MetadataValue = _PRESERVE_METADATA, + normal_handoff: _MetadataValue = _PRESERVE_METADATA, + ) -> None: + self._save_snapshot_sync( + "backup_blocked", + step_id, + state_machine_snapshot, + context_snapshot, + identity, + reason=reason, + execution=execution, + attempts=attempts, + normal_handoff=normal_handoff, + ) + + async def save_backup_blocked( + self, + step_id: str, + state_machine_snapshot: dict, + context_snapshot: dict, + identity: PipelineIdentity | dict, + reason: str | None = None, + *, + execution: _MetadataValue = _PRESERVE_METADATA, + attempts: _MetadataValue = _PRESERVE_METADATA, + normal_handoff: _MetadataValue = _PRESERVE_METADATA, + ) -> None: + self.save_backup_blocked_sync( + step_id, + state_machine_snapshot, + context_snapshot, + identity, + reason=reason, + execution=execution, + attempts=attempts, + normal_handoff=normal_handoff, + ) + def save_user_aborted_sync( self, step_id: str, @@ -600,6 +655,7 @@ def _restore_result(self, identity: PipelineIdentity | dict | None) -> RestoreRe return self._restore_failure("invalid_context", status=status) return RestoreResult( ok=True, + reason=meta.get("reason"), status=status, state_machine_snapshot=state_machine, context_snapshot=context_data, diff --git a/src/iac_code/pipeline/engine/step_executor.py b/src/iac_code/pipeline/engine/step_executor.py index 64e78055..b7abb46e 100644 --- a/src/iac_code/pipeline/engine/step_executor.py +++ b/src/iac_code/pipeline/engine/step_executor.py @@ -25,6 +25,14 @@ from iac_code.pipeline.engine.recovery import last_successful_tool_input, reconstruct_completion_guard_state from iac_code.pipeline.engine.step_spec import IncludeExcludeConfig, LoadedPipeline, StepSpec, render_prompt from iac_code.pipeline.engine.types import StepConfig, StepResult, StepStatus +from iac_code.services.session_layout import ( + SESSION_LAYOUT_VERSION_V2, + SessionPaths, + ensure_session_owned_dir, + ensure_session_owned_parent, + require_supported_session_layout, +) +from iac_code.services.session_usage import SessionUsageStore from iac_code.tools.base import ToolRegistry from iac_code.types.stream_events import StreamEvent, ToolResultEvent, ToolUseEndEvent, ToolUseStartEvent @@ -67,6 +75,7 @@ def __init__( pipeline: LoadedPipeline, pipeline_dir: Path, session_storage: Any = None, + root_session_storage: Any = None, cwd: str | None = None, pause_event: asyncio.Event | None = None, permission_context_getter: Callable[[], Any] | None = None, @@ -79,6 +88,7 @@ def __init__( self._pipeline = pipeline self._pipeline_dir = pipeline_dir self._session_storage = session_storage + self._root_session_storage = root_session_storage or session_storage self._cwd = cwd self._pause_event = pause_event self._permission_context_getter = permission_context_getter @@ -377,6 +387,28 @@ def build_agent_loop_context( from iac_code.agent.agent_loop import AgentLoop agent_session_id = transcript_id or session_id + session_usage_store = None + result_storage_dir = None + audit_log_path = None + if ( + transcript_id + and self._root_session_storage is not None + and hasattr(self._root_session_storage, "session_dir") + ): + root_session_dir = Path(self._root_session_storage.session_dir(self._cwd or "", session_id)) + if require_supported_session_layout(root_session_dir) == SESSION_LAYOUT_VERSION_V2: + session_paths = SessionPaths.require_supported(root_session_dir) + transcript_dir = ensure_session_owned_dir(root_session_dir, session_paths.transcript_dir(transcript_id)) + result_storage_dir = ensure_session_owned_dir( + root_session_dir, + session_paths.transcript_tool_results_dir(transcript_id), + ) + audit_log_path = session_paths.transcript_permission_audit_path(transcript_id) + usage_path = session_paths.transcript_usage_path(transcript_id) + ensure_session_owned_parent(root_session_dir, audit_log_path) + ensure_session_owned_parent(root_session_dir, usage_path) + assert transcript_dir == session_paths.transcript_dir(transcript_id) + session_usage_store = SessionUsageStore(path_provider=lambda _cwd, _sid, path=usage_path: path) step_skill_roots = self._resolve_step_skill_roots(step) agent_loop = AgentLoop( provider_manager=self._provider_manager, @@ -384,7 +416,12 @@ def build_agent_loop_context( tool_registry=tool_registry, max_turns=step.max_agent_turns, session_storage=self._session_storage, + session_usage_store=session_usage_store, session_id=agent_session_id, + root_session_id=session_id if transcript_id else None, + transcript_id=transcript_id, + result_storage_dir=result_storage_dir, + audit_log_path=audit_log_path, resume_messages=repaired_messages or None, cwd=self._cwd, pause_event=self._pause_event, diff --git a/src/iac_code/pipeline/engine/sub_pipeline_executor.py b/src/iac_code/pipeline/engine/sub_pipeline_executor.py index bbeab523..d720dad7 100644 --- a/src/iac_code/pipeline/engine/sub_pipeline_executor.py +++ b/src/iac_code/pipeline/engine/sub_pipeline_executor.py @@ -23,6 +23,7 @@ from iac_code.pipeline.engine.step_executor import StepExecutor from iac_code.pipeline.engine.step_spec import LoadedPipeline, SubPipelineSpec from iac_code.pipeline.engine.types import StepResult, StepStatus +from iac_code.services.session_backup import SessionBackupBlocked from iac_code.types.stream_events import SubPipelineStreamEvent logger = logging.getLogger(__name__) @@ -67,18 +68,21 @@ def __init__( pipeline: LoadedPipeline, pipeline_dir: Path, session_storage: Any = None, + root_session_storage: Any = None, cwd: str | None = None, pause_event: asyncio.Event | None = None, permission_context_getter: Callable[[], Any] | None = None, memory_content_getter: Callable[[], str] | None = None, auto_trigger_skills: list[Any] | None = None, surface: str = "repl", + backup_service: Any | None = None, ) -> None: self._provider_manager = provider_manager self._base_tool_registry = base_tool_registry self._pipeline = pipeline self._pipeline_dir = pipeline_dir self._session_storage = session_storage + self._root_session_storage = root_session_storage or session_storage self._cwd = cwd self._pause_event = pause_event self._permission_context_getter = permission_context_getter @@ -157,6 +161,7 @@ async def execute( pipeline=self._pipeline, pipeline_dir=self._pipeline_dir, session_storage=self._session_storage, + root_session_storage=self._root_session_storage, cwd=self._cwd, pause_event=self._pause_event, permission_context_getter=self._permission_context_getter, @@ -289,6 +294,8 @@ async def execute( state_machine.advance() + except SessionBackupBlocked: + raise except Exception as e: failure = public_error_from_exception(e) return SubPipelineResult( @@ -331,6 +338,7 @@ def _make_step_executor(self) -> StepExecutor: pipeline=self._pipeline, pipeline_dir=self._pipeline_dir, session_storage=self._session_storage, + root_session_storage=self._root_session_storage, cwd=self._cwd, pause_event=self._pause_event, permission_context_getter=self._permission_context_getter, @@ -703,6 +711,13 @@ def sub_step_attrs_for_current(step, step_index: int) -> dict[str, Any]: duration_ms=self._observability.duration_ms(sub_step_started_at), **current_step_attrs, ) + self._mark_rolled_back_fields_stale(sub_context, sub_spec, target) + await publish_sub_step_state( + status="running", + attempt_status="rolled_back", + current_sub_step=target, + attempt_info=attempt_info, + ) yield PipelineEvent( type=PipelineEventType.SUB_STEP_COMPLETED, step_id=step.step_id, @@ -715,13 +730,6 @@ def sub_step_attrs_for_current(step, step_index: int) -> dict[str, Any]: }, ), ) - self._mark_rolled_back_fields_stale(sub_context, sub_spec, target) - await publish_sub_step_state( - status="running", - attempt_status="rolled_back", - current_sub_step=target, - attempt_info=attempt_info, - ) except ValueError as e: # P-I9: emit SUB_STEP_FAILED for symmetry with parent runner's STEP_FAILED # (P-C3). Provides a structured failure event for logging/judge state; @@ -814,6 +822,8 @@ def sub_step_attrs_for_current(step, step_index: int) -> dict[str, Any]: ), ) + except SessionBackupBlocked: + raise except Exception as e: # Keep public failure events structured without exposing traceback text. failure = public_error_from_exception(e) diff --git a/src/iac_code/pipeline/engine/transcript_storage.py b/src/iac_code/pipeline/engine/transcript_storage.py index 9bd7d580..0a62aaf8 100644 --- a/src/iac_code/pipeline/engine/transcript_storage.py +++ b/src/iac_code/pipeline/engine/transcript_storage.py @@ -4,14 +4,17 @@ import json import re +import stat from pathlib import Path from typing import Any from iac_code import __version__ from iac_code.agent.message import Message -from iac_code.services.session_metadata import SESSION_JSONL_FILENAME +from iac_code.services.session_layout import ensure_session_owned_parent, require_supported_session_layout +from iac_code.services.session_metadata import SESSION_JSONL_FILENAME, session_metadata_entry_exists from iac_code.services.session_storage import SessionStorage from iac_code.utils.file_security import ensure_private_dir, ensure_private_file +from iac_code.utils.state_io import append_jsonl_locked, open_text_no_follow, write_text_no_follow _SAFE_TRANSCRIPT_ID = re.compile(r"^[A-Za-z0-9_.-]+$") @@ -22,6 +25,7 @@ class PipelineTranscriptStorage: def __init__(self, sidecar_dir: Path | str) -> None: self._sidecar_dir = Path(sidecar_dir) self._transcripts_dir = self._sidecar_dir / "transcripts" + self._session_root = self._infer_session_root(self._sidecar_dir) def _validate_transcript_id(self, transcript_id: str) -> str: if not transcript_id or transcript_id in {".", ".."}: @@ -57,21 +61,19 @@ def append( git_branch: str | None = None, ) -> None: path = self.session_path(cwd, session_id) - ensure_private_dir(path.parent) + self._ensure_transcript_parent(path) data = self._stamp(message.to_dict(), cwd, session_id, git_branch) - with path.open("a", encoding="utf-8") as f: - f.write(json.dumps(data, ensure_ascii=False) + "\n") + append_jsonl_locked(path, [data]) ensure_private_file(path) def append_meta(self, cwd: str, session_id: str, meta_entry: dict[str, Any]) -> None: if "type" not in meta_entry: raise ValueError("meta_entry must include a 'type' field") path = self.session_path(cwd, session_id) - ensure_private_dir(path.parent) + self._ensure_transcript_parent(path) entry = dict(meta_entry) entry["session_id"] = session_id - with path.open("a", encoding="utf-8") as f: - f.write(json.dumps(entry, ensure_ascii=False) + "\n") + append_jsonl_locked(path, [entry]) ensure_private_file(path) def save( @@ -83,48 +85,93 @@ def save( git_branch: str | None = None, ) -> None: path = self.session_path(cwd, session_id) - ensure_private_dir(path.parent) - with path.open("w", encoding="utf-8") as f: - for message in messages: - data = self._stamp(message.to_dict(), cwd, session_id, git_branch) - f.write(json.dumps(data, ensure_ascii=False) + "\n") + self._ensure_transcript_parent(path) + content = "".join( + json.dumps(self._stamp(message.to_dict(), cwd, session_id, git_branch), ensure_ascii=False) + "\n" + for message in messages + ) + write_text_no_follow(path, content, encoding="utf-8") ensure_private_file(path) def load(self, cwd: str, session_id: str) -> list[Message]: path = self.session_path(cwd, session_id) - if not path.exists(): + if not _is_regular_file_entry(path): return [] messages: list[Message] = [] - with path.open(encoding="utf-8") as f: - for line in f: - line = line.strip() - if not line: - continue - try: - value = json.loads(line) - except json.JSONDecodeError: - continue - if not isinstance(value, dict) or "role" not in value: - continue - try: - messages.append(Message.from_dict(value)) - except Exception: - continue + try: + with open_text_no_follow(path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + value = json.loads(line) + except json.JSONDecodeError: + continue + if not isinstance(value, dict) or "role" not in value: + continue + try: + messages.append(Message.from_dict(value)) + except Exception: + continue + except OSError: + if _is_regular_file_entry(path): + raise + return [] return messages def exists(self, cwd: str, session_id: str) -> bool: - return self.session_path(cwd, session_id).exists() + return _is_regular_file_entry(self.session_path(cwd, session_id)) def list_transcript_ids(self) -> list[str]: """Return existing sidecar transcript ids, ignoring unsafe directory names.""" - if not self._transcripts_dir.exists(): + if not _is_directory_entry(self._transcripts_dir): return [] return sorted( path.name for path in self._transcripts_dir.iterdir() - if path.is_dir() and _SAFE_TRANSCRIPT_ID.fullmatch(path.name) + if _is_directory_entry(path) and _SAFE_TRANSCRIPT_ID.fullmatch(path.name) ) @staticmethod def repair_interrupted(messages: list[Message]) -> list[Message]: return SessionStorage.repair_interrupted(messages) + + def _ensure_transcript_parent(self, path: Path) -> None: + if self._session_root is not None and require_supported_session_layout(self._session_root) is not None: + ensure_session_owned_parent(self._session_root, path) + return + ensure_private_dir(path.parent) + + @staticmethod + def _infer_session_root(sidecar_dir: Path) -> Path | None: + session_root = sidecar_dir.parent + if sidecar_dir.name == "pipeline" and session_metadata_entry_exists(session_root): + return session_root + return None + + +def _is_directory_entry(path: Path) -> bool: + if path.is_symlink() or _is_reparse_point(path): + return False + try: + return stat.S_ISDIR(path.stat(follow_symlinks=False).st_mode) + except OSError: + return False + + +def _is_regular_file_entry(path: Path) -> bool: + if path.is_symlink() or _is_reparse_point(path): + return False + try: + return stat.S_ISREG(path.stat(follow_symlinks=False).st_mode) + except OSError: + return False + + +def _is_reparse_point(path: Path) -> bool: + try: + attributes = getattr(path.stat(follow_symlinks=False), "st_file_attributes", 0) + except OSError: + return False + return bool(attributes & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)) diff --git a/src/iac_code/services/agent_factory.py b/src/iac_code/services/agent_factory.py index c8fc0f58..00ff0ccf 100644 --- a/src/iac_code/services/agent_factory.py +++ b/src/iac_code/services/agent_factory.py @@ -126,6 +126,7 @@ def create_agent_runtime(options: AgentFactoryOptions) -> AgentRuntime: register_cloud_tools(tool_registry, CloudCredentials()) session_storage = SessionStorage() + session_dir = _prepare_session_dir_for_artifacts(session_storage, cwd, session_id) memory_runtime = ProjectMemoryRuntime(cwd) memory_manager = memory_runtime.memory_manager @@ -224,6 +225,7 @@ def build_base_system_prompt() -> str: mcp_manager, session_id, registered_mcp_tool_names, + session_dir=session_dir, ) registered_mcp_command_names, command_warnings = _run_async_blocking( _sync_mcp_command_registry(command_registry, mcp_manager, registered_mcp_command_names) @@ -247,6 +249,7 @@ async def on_mcp_changed(server_name: str, capability: str) -> None: mcp_manager, session_id, registered_mcp_tool_names, + session_dir=session_dir, ) if capability in {"prompts", "resources"}: registered_mcp_command_names, warnings = await _sync_mcp_command_registry( @@ -274,7 +277,9 @@ async def on_mcp_changed(server_name: str, capability: str) -> None: cli_disallowed=options.cli_disallowed_tools, cli_mode=options.cli_permission_mode, ) - permission_context.trusted_read_directories.extend(build_session_trusted_read_directories(session_id)) + permission_context.trusted_read_directories.extend( + build_session_trusted_read_directories(session_id, session_dir=session_dir) + ) if hasattr(tool_registry, "get"): agent_tool = tool_registry.get("agent") @@ -304,6 +309,7 @@ def build_agent_system_prompt() -> str: auto_trigger_skills=command_registry.get_model_invocable_skills(), memory_recall_service=memory_recall_service, system_prompt_refresher=build_agent_system_prompt, + result_storage_dir=_result_storage_dir_for_session(session_dir), ) if mcp_manager is not None: add_change_listener = getattr(mcp_manager, "add_change_listener", None) @@ -344,6 +350,69 @@ def _session_mcp_configs(configs: list[dict[str, Any]] | None) -> dict[str, dict return normalized +def _prepare_session_dir_for_artifacts(session_storage: Any, cwd: str, session_id: str) -> Path | None: + ensure_v2_session = getattr(session_storage, "ensure_v2_session_dir_for_new_session", None) + if callable(ensure_v2_session): + try: + ensure_v2_session(cwd, session_id) + except (AttributeError, TypeError): + pass + return _session_dir_for_artifacts(session_storage, cwd, session_id) + + +def _session_dir_for_artifacts(session_storage: Any, cwd: str, session_id: str) -> Path | None: + v2_session_dir_factory = getattr(session_storage, "v2_session_dir", None) + used_v2_session_dir = callable(v2_session_dir_factory) + if not used_v2_session_dir: + v2_session_dir_factory = getattr(session_storage, "session_dir", None) + if not callable(v2_session_dir_factory): + return None + try: + raw_session_dir = v2_session_dir_factory(cwd, session_id) + except (AttributeError, TypeError): + return None + if raw_session_dir is None: + return None + needs_marker_check = not used_v2_session_dir + if isinstance(raw_session_dir, Path): + session_dir = raw_session_dir + elif isinstance(raw_session_dir, str): + session_dir = Path(raw_session_dir) + else: + if not used_v2_session_dir: + return None + session_dir_factory = getattr(session_storage, "session_dir", None) + if not callable(session_dir_factory): + return None + try: + fallback_session_dir = session_dir_factory(cwd, session_id) + except (AttributeError, TypeError): + return None + if isinstance(fallback_session_dir, Path): + session_dir = fallback_session_dir + elif isinstance(fallback_session_dir, str): + session_dir = Path(fallback_session_dir) + else: + return None + needs_marker_check = True + if needs_marker_check: + from iac_code.services.session_layout import SESSION_LAYOUT_VERSION_V2, require_supported_session_layout + + if require_supported_session_layout(session_dir) != SESSION_LAYOUT_VERSION_V2: + return None + return session_dir + + +def _result_storage_dir_for_session(session_dir: Path | None) -> Path | None: + if session_dir is None: + return None + from iac_code.services.session_layout import SessionPaths, session_layout_version + + if session_layout_version(session_dir) is None: + return None + return SessionPaths.require_supported(session_dir).tool_results_dir + + def _mcp_connection_warnings(mcp_manager: Any) -> list[Any]: from iac_code.i18n import _ from iac_code.mcp.types import MCPConfigWarning, MCPConnectionState @@ -459,18 +528,21 @@ def _sync_mcp_tool_registry( mcp_manager: Any, session_id: str, registered_names: set[str], + *, + session_dir: Path | str | None = None, ) -> set[str]: from iac_code.mcp.tools import ListMCPResourcesTool, MCPTool, ReadMCPResourceTool records = {record.public_name: record for record in mcp_manager.list_tools()} for record in records.values(): - tool_registry.register(MCPTool(manager=mcp_manager, record=record, session_id=session_id)) + tool_registry.register( + MCPTool(manager=mcp_manager, record=record, session_id=session_id, session_dir=session_dir) + ) desired = set(records) if mcp_manager.list_resources(): if tool_registry.get("list_mcp_resources") is None: tool_registry.register(ListMCPResourcesTool(manager=mcp_manager)) - if tool_registry.get("read_mcp_resource") is None: - tool_registry.register(ReadMCPResourceTool(manager=mcp_manager, session_id=session_id)) + tool_registry.register(ReadMCPResourceTool(manager=mcp_manager, session_id=session_id, session_dir=session_dir)) desired.update({"list_mcp_resources", "read_mcp_resource"}) for name in registered_names - desired: tool_registry.unregister(name) diff --git a/src/iac_code/services/permissions/audit.py b/src/iac_code/services/permissions/audit.py index 8c7a6001..2a74327a 100644 --- a/src/iac_code/services/permissions/audit.py +++ b/src/iac_code/services/permissions/audit.py @@ -12,11 +12,17 @@ from loguru import logger +from iac_code.services.session_layout import ( + SESSION_LAYOUT_VERSION_V2, + ensure_session_owned_dir, + ensure_session_owned_parent, + require_supported_session_layout, +) from iac_code.services.session_storage import SessionStorage from iac_code.services.telemetry import log_event from iac_code.services.telemetry.names import Events from iac_code.types.permissions import MAX_PERMISSION_AUDIT_FILES, PermissionAuditSettings -from iac_code.utils.file_security import ensure_private_dir, ensure_private_file +from iac_code.utils.file_security import ensure_private_file from iac_code.utils.public_errors import sanitize_public_text from iac_code.utils.state_io import append_jsonl_rotating_locked @@ -191,6 +197,7 @@ class PermissionAuditRecord: operation: dict[str, Any] = field(default_factory=dict) input_summary: dict[str, Any] = field(default_factory=dict) tool_input_redacted: dict[str, Any] | None = None + audit_log_path: str | None = None timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) @@ -560,6 +567,7 @@ def emit_permission_boundary_audit( operation=permission_audit_operation(metadata), input_summary=build_input_summary(event.tool_name, event.tool_input), tool_input_redacted=redacted_tool_input_for_settings(event.tool_input, settings), + audit_log_path=_permission_audit_log_path(event), ), settings=settings, ) @@ -611,6 +619,7 @@ def emit_auto_permission_audit( operation=permission_audit_operation(metadata), input_summary=build_input_summary(event.tool_name, event.tool_input), tool_input_redacted=redacted_tool_input_for_settings(event.tool_input, settings), + audit_log_path=_permission_audit_log_path(event), ), settings=settings, ) @@ -651,6 +660,13 @@ def _permission_audit_cwd(event: Any) -> str: return cwd if isinstance(cwd, str) else "" +def _permission_audit_log_path(event: Any) -> str | None: + audit_log_path = _permission_audit_context(event).get("audit_log_path") + if isinstance(audit_log_path, Path): + return str(audit_log_path) + return audit_log_path if isinstance(audit_log_path, str) else None + + def emit_permission_audit(record: PermissionAuditRecord, settings: PermissionAuditSettings | None = None) -> bool: """Write a permission audit row and emit a telemetry event.""" @@ -673,7 +689,7 @@ def emit_permission_audit(record: PermissionAuditRecord, settings: PermissionAud _ensure_audit_log_files_private(log_path, max_files=max_files) log_written = True except Exception as exc: # pragma: no cover - defensive logging only - logger.warning("Failed to write permission audit log: {}", exc) + logger.warning("Failed to write permission audit log (error_type={})", type(exc).__name__) if log_written or record.decision == "deny": try: @@ -1049,12 +1065,40 @@ def _event_name(record: PermissionAuditRecord) -> str: def _log_path(record: PermissionAuditRecord) -> Path: + if record.audit_log_path: + return _validated_direct_audit_log_path(Path(record.audit_log_path)) cwd = record.cwd.strip() or os.getcwd() session_id = record.session_id.strip() or "unknown-session" - session_dir = ensure_private_dir(SessionStorage().session_dir(cwd, session_id)) + session_dir = SessionStorage().session_dir(cwd, session_id) + if session_dir.exists(): + require_supported_session_layout(session_dir) + session_dir = ensure_session_owned_dir(session_dir, session_dir) return session_dir / "permission-audit.jsonl" +def _validated_direct_audit_log_path(path: Path) -> Path: + if path.name != "permission-audit.jsonl": + raise ValueError("invalid permission audit log path") + session_dir = _session_dir_from_direct_audit_log_path(path) + if session_dir is None: + raise ValueError("invalid permission audit log path") + if require_supported_session_layout(session_dir) != SESSION_LAYOUT_VERSION_V2: + raise ValueError("invalid permission audit log path") + ensure_session_owned_parent(session_dir, path) + return path + + +def _session_dir_from_direct_audit_log_path(path: Path) -> Path | None: + parent = path.parent + if ( + parent.name.startswith("transcript_") + and parent.parent.name == "transcripts" + and parent.parent.parent.name == "pipeline" + ): + return parent.parent.parent.parent + return parent + + def _ensure_audit_log_files_private(path: Path, *, max_files: int) -> None: ensure_private_file(path) for index in range(1, max_files + 1): diff --git a/src/iac_code/services/permissions/trusted_roots.py b/src/iac_code/services/permissions/trusted_roots.py index 1c46ca9d..91175aca 100644 --- a/src/iac_code/services/permissions/trusted_roots.py +++ b/src/iac_code/services/permissions/trusted_roots.py @@ -2,7 +2,13 @@ from __future__ import annotations +import logging +from pathlib import Path + from iac_code import config +from iac_code.services.session_layout import SessionPaths, UnsupportedSessionLayoutError, ensure_session_owned_dir + +logger = logging.getLogger(__name__) def _validate_session_id(session_id: str) -> None: @@ -10,12 +16,24 @@ def _validate_session_id(session_id: str) -> None: raise ValueError(f"invalid session_id: {session_id!r}") -def build_session_trusted_read_directories(session_id: str | None) -> list[str]: +def build_session_trusted_read_directories( + session_id: str | None, + *, + session_dir: Path | str | None = None, +) -> list[str]: if not session_id: return [] _validate_session_id(session_id) config_dir = config.get_config_dir() - return [ + roots = [ str(config_dir / "tool-results" / session_id), str(config_dir / "image-cache" / session_id), ] + if session_dir is not None: + session_paths = SessionPaths.from_session_dir(session_dir) + for path in (session_paths.tool_results_dir, session_paths.image_cache_dir): + try: + roots.append(str(ensure_session_owned_dir(session_paths.session_dir, path))) + except UnsupportedSessionLayoutError as exc: + logger.debug("Skipping unsafe session trusted read root error_type=%s", type(exc).__name__) + return roots diff --git a/src/iac_code/services/session_backup.py b/src/iac_code/services/session_backup.py new file mode 100644 index 00000000..a3e899c3 --- /dev/null +++ b/src/iac_code/services/session_backup.py @@ -0,0 +1,985 @@ +"""Session directory backup mirroring.""" + +from __future__ import annotations + +import os +import re +import shutil +import stat +import tempfile +import time +from contextlib import contextmanager, suppress +from dataclasses import dataclass, replace +from datetime import datetime, timezone +from enum import Enum +from pathlib import Path, PureWindowsPath +from typing import Any, Iterable + +from iac_code.i18n import _ +from iac_code.services.session_layout import UnsupportedSessionLayoutError +from iac_code.services.session_metadata import SESSION_METADATA_FILENAME +from iac_code.services.session_storage import SessionStorage +from iac_code.utils.file_security import ensure_private_dir, ensure_private_file +from iac_code.utils.path_components import is_unsafe_windows_path_component +from iac_code.utils.public_errors import sanitize_public_text +from iac_code.utils.state_io import atomic_write_json, fsync_parent_dir, safe_replace + +BACKUP_ENV_VAR = "IAC_CODE_CONFIG_BACKUP_DIR" +BACKUP_STATE_FILENAME = ".backup-state.json" +BACKUP_LOCK_FILENAME = ".backup-lock" +_SAFE_SESSION_ID_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+$") +_PUBLIC_BACKUP_PATH_PATTERN = re.compile( + r"""(?x) + (? None: + super().__init__(message) + self.retry_count = retry_count if retry_count >= 0 else 0 + self.result = result + + +class SessionBackupService: + def __init__( + self, + session_storage: SessionStorage | None = None, + retry_delays: Iterable[float] = (0.25, 1.0), + ) -> None: + self._session_storage = session_storage or SessionStorage() + self._retry_delays = tuple(retry_delays) + + def backup_session( + self, + cwd: str, + session_id: str, + *, + reason: BackupReason, + critical: bool, + ) -> BackupResult: + if not self._backup_enabled(): + return BackupResult(enabled=False) + self._validate_session_id(session_id) + + try: + source = self._source_for_backup(cwd, session_id) + except (UnsupportedSessionLayoutError, SessionBackupError) as exc: + public_error = self._public_error_text(exc) + if critical: + raise SessionBackupBlocked(public_error, retry_count=0) from None + return BackupResult(enabled=True, succeeded=False, error=public_error) + if source is None: + if critical: + raise SessionBackupBlocked(_("Session backup requires a supported session layout."), retry_count=0) + return BackupResult(enabled=False) + delays = (0, *self._retry_delays) + last_error: Exception | None = None + last_public_error: str | None = None + destination: Path | None = None + + for attempt, delay in enumerate(delays): + if attempt: + time.sleep(delay) + failed_marker_written = False + source_verified = False + try: + self._resolve_real_source(source) + source_verified = True + with self._session_backup_lock(source): + try: + backup_root = self._backup_root() + if backup_root is None: + return BackupResult(enabled=False) + destination = backup_root / "projects" / source.parent.name / session_id + self._validate_mirror_paths(source, destination, backup_root) + self._ensure_private_dir(backup_root) + result = self._mirror(source, destination) + self._write_marker(source, reason=reason, status="succeeded", error=None) + except Exception as exc: + failed_marker_written = True + public_error = self._public_error_text(exc) + self._try_write_failed_marker( + source, + reason=reason, + error=public_error, + attempt=attempt + 1, + retry_count=attempt, + exhausted=attempt == len(delays) - 1, + ) + raise + except Exception as exc: + last_error = exc + last_public_error = self._public_error_text(exc) + if source_verified and not failed_marker_written: + self._try_write_failed_marker( + source, + reason=reason, + error=last_public_error, + attempt=attempt + 1, + retry_count=attempt, + exhausted=attempt == len(delays) - 1, + ) + continue + return replace(result, retry_count=attempt) + + failure_result = BackupResult( + enabled=True, + source=source, + destination=destination, + succeeded=False, + error=last_public_error, + retry_count=max(0, len(delays) - 1), + ) + if critical and last_error is not None: + raise SessionBackupBlocked( + last_public_error or self._public_error_text(last_error), + retry_count=failure_result.retry_count, + result=failure_result, + ) from None + return failure_result + + def _source_for_backup(self, cwd: str, session_id: str) -> Path | None: + v2_session_dir = getattr(self._session_storage, "v2_session_dir", None) + if callable(v2_session_dir): + explicit_source = v2_session_dir(cwd, session_id) + if explicit_source is not None: + return Path(explicit_source) + fallback_source = Path(self._session_storage.session_dir(cwd, session_id)) + if ( + fallback_source.is_symlink() + or self._is_reparse_point(fallback_source) + or (fallback_source.exists() and not fallback_source.is_dir()) + ): + self._resolve_real_source(fallback_source) + return None + + legacy_session_path = getattr(self._session_storage, "legacy_session_path", None) + if callable(legacy_session_path) and Path(legacy_session_path(cwd, session_id)).exists(): + return None + + source = self._session_storage.session_dir(cwd, session_id) + if (source / SESSION_METADATA_FILENAME).exists(): + return None + return source + + def _backup_root(self) -> Path | None: + raw = os.environ.get(BACKUP_ENV_VAR, "").strip() + if not raw: + return None + backup_root = Path(os.path.expandvars(os.path.expanduser(raw))) + self._validate_backup_root(raw, backup_root) + return backup_root + + def _validate_backup_root(self, raw: str, backup_root: Path) -> None: + if not backup_root.is_absolute(): + raise SessionBackupError(_("backup root must be an absolute directory: {path}").format(path=raw)) + if backup_root == Path(backup_root.anchor): + raise SessionBackupError(_("backup root must not be a filesystem root: {path}").format(path=raw)) + windows_path = PureWindowsPath(raw) + if windows_path.drive and not windows_path.is_absolute(): + raise SessionBackupError(_("backup root must not be drive-relative: {path}").format(path=raw)) + if windows_path.anchor and windows_path == PureWindowsPath(windows_path.anchor): + raise SessionBackupError(_("backup root must not be a filesystem root: {path}").format(path=raw)) + + def _mirror(self, source: Path, destination: Path) -> BackupResult: + self._resolve_real_source(source) + if ( + destination.is_symlink() + or self._is_reparse_point(destination) + or (destination.exists() and not destination.is_dir()) + ): + self._unlink(destination) + self._fsync_parent_dir(destination) + self._ensure_private_dir(destination) + copied_files = 0 + deleted_files = 0 + included_files: set[Path] = set() + + for path in self._iter_tree(source): + if path.is_symlink(): + continue + relative = path.relative_to(source) + if not self._included(relative): + continue + target = destination / relative + if path.is_dir(): + self._remove_conflicting_file(destination, target) + self._ensure_private_dir(target) + continue + if not self._is_regular_file(path): + continue + included_files.add(relative) + self._remove_conflicting_non_file(destination, target) + if self._needs_copy(path, target): + self._copy_file(path, target) + copied_files += 1 + + stale_paths = sorted( + ( + p + for p in self._iter_tree(destination, include_reparse=True) + if p.is_symlink() or self._is_reparse_point(p) or not p.is_dir() + ), + reverse=True, + ) + for path in stale_paths: + relative = path.relative_to(destination) + if relative not in included_files: + self._unlink(path) + self._fsync_parent_dir(path) + deleted_files += 1 + + self._prune_empty_dirs(destination) + return BackupResult( + enabled=True, + source=source, + destination=destination, + copied_files=copied_files, + deleted_files=deleted_files, + ) + + @staticmethod + def _backup_enabled() -> bool: + return bool(os.environ.get(BACKUP_ENV_VAR, "").strip()) + + @staticmethod + def _validate_session_id(session_id: str) -> None: + if ( + not session_id + or session_id in {".", ".."} + or Path(session_id).is_absolute() + or "/" in session_id + or "\\" in session_id + or ".." in session_id + or _SAFE_SESSION_ID_PATTERN.fullmatch(session_id) is None + or is_unsafe_windows_path_component(session_id) + ): + raise SessionBackupError(_("unsafe session_id: {session_id!r}").format(session_id=session_id)) + + def _validate_mirror_paths(self, source: Path, destination: Path, backup_root: Path) -> None: + source_resolved = self._resolve_real_source(source) + backup_root_resolved = self._resolve_backup_root(backup_root) + self._reject_symlinked_destination_ancestry(backup_root, destination) + destination_resolved = self._resolve_planned_child(destination.parent, destination.name) + + if ( + self._path_equal_or_under(backup_root_resolved, source_resolved) + or self._path_equal_or_under( + source_resolved, + backup_root_resolved, + ) + or self._path_equal_or_under( + destination_resolved, + source_resolved, + ) + or self._path_equal_or_under( + source_resolved, + destination_resolved, + ) + ): + raise SessionBackupError( + _("backup destination overlaps session source: {destination}").format(destination=destination) + ) + if not self._path_equal_or_under(destination_resolved, backup_root_resolved): + raise SessionBackupError( + _("backup destination resolves outside backup root: {destination}").format(destination=destination) + ) + self._validate_physical_mirror_paths(source, destination, backup_root) + + def _validate_physical_mirror_paths(self, source: Path, destination: Path, backup_root: Path) -> None: + if os.name != "nt": + return + source_physical = self._physical_path_text(source) + backup_root_physical = self._physical_path_text(backup_root) + destination_physical = self._physical_path_text(destination) + if ( + self._path_text_equal_or_under(backup_root_physical, source_physical) + or self._path_text_equal_or_under(source_physical, backup_root_physical) + or self._path_text_equal_or_under(destination_physical, source_physical) + or self._path_text_equal_or_under(source_physical, destination_physical) + ): + raise SessionBackupError( + _("backup destination overlaps session source: {destination}").format(destination=destination) + ) + + def _physical_path_text(self, path: Path) -> str: + if os.name != "nt": + return self._canonical_windows_path_text(path.resolve(strict=False)) + return self._windows_physical_path_text(path) + + def _windows_physical_path_text(self, path: Path) -> str: + missing_parts: list[str] = [] + current = path + while not current.exists(): + parent = current.parent + if parent == current: + return self._canonical_windows_path_text(path.resolve(strict=False)) + missing_parts.append(current.name) + current = parent + + base = self._windows_existing_physical_path_text(current) + for part in reversed(missing_parts): + base = base.rstrip("\\/") + "\\" + part + return self._canonical_windows_path_text(base) + + @staticmethod + def _windows_existing_physical_path_text(path: Path) -> str: + try: + import ctypes + except ImportError: + return str(path.resolve(strict=False)) + + windll_factory: Any = getattr(ctypes, "WinDLL", None) + if windll_factory is None: + return str(path.resolve(strict=False)) + kernel32 = windll_factory("kernel32", use_last_error=True) + from ctypes import wintypes + + file_read_attributes = 0x0080 + file_share_all = 0x00000001 | 0x00000002 | 0x00000004 + open_existing = 3 + file_flag_backup_semantics = 0x02000000 + kernel32.CreateFileW.argtypes = [ + wintypes.LPCWSTR, + wintypes.DWORD, + wintypes.DWORD, + wintypes.LPVOID, + wintypes.DWORD, + wintypes.DWORD, + wintypes.HANDLE, + ] + kernel32.CreateFileW.restype = wintypes.HANDLE + kernel32.GetFinalPathNameByHandleW.argtypes = [ + wintypes.HANDLE, + wintypes.LPWSTR, + wintypes.DWORD, + wintypes.DWORD, + ] + kernel32.GetFinalPathNameByHandleW.restype = wintypes.DWORD + kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + kernel32.CloseHandle.restype = wintypes.BOOL + invalid_handle = ctypes.c_void_p(-1).value + handle = kernel32.CreateFileW( + str(path), + file_read_attributes, + file_share_all, + None, + open_existing, + file_flag_backup_semantics, + None, + ) + if handle == invalid_handle: + return str(path.resolve(strict=False)) + try: + buffer = ctypes.create_unicode_buffer(32768) + length = kernel32.GetFinalPathNameByHandleW(handle, buffer, len(buffer), 0) + if 0 < length < len(buffer): + return buffer.value + return str(path.resolve(strict=False)) + finally: + kernel32.CloseHandle(handle) + + @staticmethod + def _canonical_windows_path_text(path: Path | str) -> str: + text = str(path).replace("/", "\\").rstrip("\\") + text_casefold = text.casefold() + unc_prefix = "\\\\?\\unc\\" + if text_casefold.startswith(unc_prefix): + text = "\\\\" + text[len(unc_prefix) :] + elif text_casefold.startswith("\\\\?\\"): + text = text[4:] + return text.casefold() + + @staticmethod + def _path_text_equal_or_under(path: str, root: str) -> bool: + if not path or not root: + return False + return path == root or path.startswith(root + "\\") + + def _reject_symlinked_destination_ancestry(self, backup_root: Path, destination: Path) -> None: + try: + relative_parent = destination.parent.relative_to(backup_root) + except ValueError as exc: + raise SessionBackupError( + _("backup destination is not under backup root: {destination}").format(destination=destination) + ) from exc + + current = backup_root + for part in relative_parent.parts: + current /= part + if current.is_symlink() or self._is_reparse_point(current): + raise SessionBackupError(_("backup destination ancestry contains symlink: {path}").format(path=current)) + + def _reject_existing_symlink_ancestry(self, path: Path, *, label: str, include_leaf: bool) -> None: + current = path if include_leaf else path.parent + candidates: list[Path] = [] + while True: + candidates.append(current) + parent = current.parent + if parent == current: + break + current = parent + + for candidate in reversed(candidates): + if candidate.is_symlink() or self._is_reparse_point(candidate): + raise SessionBackupError( + _("{label} ancestry contains symlink: {path}").format(label=label, path=candidate) + ) + + def _resolve_real_source(self, source: Path) -> Path: + self._reject_session_source_ancestry(source) + if source.is_symlink() or self._is_reparse_point(source): + raise SessionBackupError(_("session source is a symlink: {source}").format(source=source)) + if not source.is_dir(): + raise SessionBackupError(_("session source is not a directory: {source}").format(source=source)) + try: + return source.resolve(strict=True) + except (OSError, RuntimeError) as exc: + raise SessionBackupError(_("session source cannot be resolved: {source}").format(source=source)) from exc + + def _reject_session_source_ancestry(self, source: Path) -> None: + candidates = [source] + project_dir = source.parent + if project_dir != source: + candidates.append(project_dir) + projects_dir = project_dir.parent + if projects_dir != project_dir: + candidates.append(projects_dir) + for candidate in reversed(candidates): + if candidate.is_symlink() or self._is_reparse_point(candidate): + raise SessionBackupError( + _("{label} ancestry contains symlink: {path}").format(label=_("session source"), path=candidate) + ) + + @staticmethod + def _resolve_real_dir(path: Path, label: str) -> Path: + if path.is_symlink(): + raise SessionBackupError(_("{label} is a symlink: {path}").format(label=label, path=path)) + if not path.is_dir(): + raise SessionBackupError(_("{label} is not a directory: {path}").format(label=label, path=path)) + try: + return path.resolve(strict=True) + except (OSError, RuntimeError) as exc: + raise SessionBackupError(_("{label} cannot be resolved: {path}").format(label=label, path=path)) from exc + + @staticmethod + def _resolve_planned_child(parent: Path, child_name: str) -> Path: + try: + return parent.resolve(strict=False) / child_name + except (OSError, RuntimeError) as exc: + raise SessionBackupError( + _("backup destination cannot be resolved: {destination}").format(destination=parent / child_name) + ) from exc + + @staticmethod + def _resolve_backup_root(path: Path) -> Path: + try: + return path.resolve(strict=False) + except (OSError, RuntimeError) as exc: + raise SessionBackupError( + _("{label} cannot be resolved: {path}").format(label=_("backup root"), path=path) + ) from exc + + @staticmethod + def _path_equal_or_under(path: Path, root: Path) -> bool: + try: + path.relative_to(root) + except ValueError: + return False + return True + + @contextmanager + def _session_backup_lock(self, source: Path): + if source.is_symlink() or self._is_reparse_point(source): + raise SessionBackupError(_("session source is a symlink: {source}").format(source=source)) + if not source.is_dir(): + yield + return + + lock_path = source / BACKUP_LOCK_FILENAME + if lock_path.is_symlink() or self._is_reparse_point(lock_path): + raise SessionBackupError(_("backup lock is a symlink: {path}").format(path=lock_path)) + if lock_path.exists() and not lock_path.is_file(): + raise SessionBackupError(_("backup lock is not a regular file: {path}").format(path=BACKUP_LOCK_FILENAME)) + with self._open_backup_lock_file(lock_path) as lock_file: + ensure_private_file(lock_path) + if os.name == "nt": + import msvcrt + + try: + lock_file.seek(0) + msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1) + except OSError as exc: + raise SessionBackupError( + _("could not acquire backup lock for {source}").format(source=source) + ) from exc + try: + yield + finally: + with suppress(OSError): + lock_file.seek(0) + msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + + try: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) + except OSError as exc: + raise SessionBackupError( + _("could not acquire backup lock for {source}").format(source=source) + ) from exc + try: + yield + finally: + with suppress(OSError): + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) + + def _open_backup_lock_file(self, lock_path: Path): + if lock_path.is_symlink() or self._is_reparse_point(lock_path): + raise SessionBackupError(_("backup lock is a symlink: {path}").format(path=lock_path)) + fd = self._open_no_follow_fd(lock_path, os.O_RDWR | os.O_CREAT, 0o600) + return os.fdopen(fd, "a+b") + + def _copy_file(self, src: Path, dst: Path) -> None: + self._ensure_private_dir(dst.parent) + handle = tempfile.NamedTemporaryFile( + prefix=".iacbk.", + suffix=".tmp", + dir=dst.parent, + delete=False, + ) + tmp = Path(handle.name) + handle.close() + try: + self._copy_regular_file_no_follow(src, tmp) + self._fsync_file(tmp) + ensure_private_file(tmp) + self._make_writable(dst) + safe_replace(tmp, dst) + self._fsync_parent_dir(dst) + ensure_private_file(dst) + finally: + if tmp.exists(): + self._unlink(tmp) + self._fsync_parent_dir(tmp) + + def _copy_regular_file_no_follow(self, src: Path, dst: Path) -> None: + with self._open_source_file_no_follow(src) as source_file: + with dst.open("wb") as target_file: + shutil.copyfileobj(source_file, target_file) + target_file.flush() + with suppress(OSError): + shutil.copystat(src, dst, follow_symlinks=False) + + def _open_source_file_no_follow(self, src: Path): + if src.is_symlink() or self._is_reparse_point(src): + raise SessionBackupError(_("session source entry is not a regular file: {source}").format(source=src)) + fd = self._open_no_follow_fd(src, os.O_RDONLY, 0) + return os.fdopen(fd, "rb") + + def _open_no_follow_fd(self, path: Path, flags: int, mode: int) -> int: + if path.is_symlink() or self._is_reparse_point(path): + raise SessionBackupError(_("session source entry is not a regular file: {source}").format(source=path)) + if os.name == "nt": + return self._open_windows_no_follow_fd(path, flags, mode) + nofollow = getattr(os, "O_NOFOLLOW", 0) + try: + fd = os.open(path, flags | nofollow, mode) + except OSError as exc: + raise SessionBackupError( + _("session source entry is not a regular file: {source}").format(source=path) + ) from exc + try: + if not stat.S_ISREG(os.fstat(fd).st_mode): + raise SessionBackupError(_("session source entry is not a regular file: {source}").format(source=path)) + return fd + except Exception: + os.close(fd) + raise + + def _open_windows_no_follow_fd(self, path: Path, flags: int, _mode: int) -> int: + try: + import ctypes + import msvcrt + from ctypes import wintypes + except ImportError as exc: + raise SessionBackupError( + _("session source entry is not a regular file: {source}").format(source=path) + ) from exc + + windll_factory: Any = getattr(ctypes, "WinDLL", None) + if windll_factory is None: + raise SessionBackupError(_("session source entry is not a regular file: {source}").format(source=path)) + kernel32 = windll_factory("kernel32", use_last_error=True) + kernel32.CreateFileW.argtypes = [ + wintypes.LPCWSTR, + wintypes.DWORD, + wintypes.DWORD, + wintypes.LPVOID, + wintypes.DWORD, + wintypes.DWORD, + wintypes.HANDLE, + ] + kernel32.CreateFileW.restype = wintypes.HANDLE + + class _ByHandleFileInformation(ctypes.Structure): + _fields_ = [ + ("dwFileAttributes", wintypes.DWORD), + ("ftCreationTime", wintypes.FILETIME), + ("ftLastAccessTime", wintypes.FILETIME), + ("ftLastWriteTime", wintypes.FILETIME), + ("dwVolumeSerialNumber", wintypes.DWORD), + ("nFileSizeHigh", wintypes.DWORD), + ("nFileSizeLow", wintypes.DWORD), + ("nNumberOfLinks", wintypes.DWORD), + ("nFileIndexHigh", wintypes.DWORD), + ("nFileIndexLow", wintypes.DWORD), + ] + + kernel32.GetFileInformationByHandle.argtypes = [ + wintypes.HANDLE, + ctypes.POINTER(_ByHandleFileInformation), + ] + kernel32.GetFileInformationByHandle.restype = wintypes.BOOL + kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + kernel32.CloseHandle.restype = wintypes.BOOL + + generic_read = 0x80000000 + generic_write = 0x40000000 + file_share_all = 0x00000001 | 0x00000002 | 0x00000004 + create_new = 1 + open_existing = 3 + open_always = 4 + file_attribute_normal = 0x00000080 + file_attribute_reparse_point = 0x00000400 + file_flag_open_reparse_point = 0x00200000 + + if flags & os.O_RDWR: + desired_access = generic_read | generic_write + fd_flags = os.O_RDWR + elif flags & os.O_WRONLY: + desired_access = generic_write + fd_flags = os.O_WRONLY + else: + desired_access = generic_read + fd_flags = os.O_RDONLY + if flags & os.O_APPEND: + fd_flags |= os.O_APPEND + fd_flags |= getattr(os, "O_BINARY", 0) + creation_disposition = open_existing + if flags & os.O_CREAT: + creation_disposition = create_new if flags & os.O_EXCL else open_always + + handle = kernel32.CreateFileW( + str(path), + desired_access, + file_share_all, + None, + creation_disposition, + file_attribute_normal | file_flag_open_reparse_point, + None, + ) + invalid_handle = ctypes.c_void_p(-1).value + if handle == invalid_handle: + raise SessionBackupError(_("session source entry is not a regular file: {source}").format(source=path)) + try: + file_info = _ByHandleFileInformation() + if ( + not kernel32.GetFileInformationByHandle(handle, ctypes.byref(file_info)) + or file_info.dwFileAttributes & file_attribute_reparse_point + ): + raise SessionBackupError(_("session source entry is not a regular file: {source}").format(source=path)) + open_osfhandle: Any = getattr(msvcrt, "open_osfhandle", None) + if open_osfhandle is None: + raise OSError(_("could not open file without following reparse point: {path}").format(path=path)) + fd = open_osfhandle(handle, fd_flags) + handle = None + try: + if not stat.S_ISREG(os.fstat(fd).st_mode): + raise SessionBackupError( + _("session source entry is not a regular file: {source}").format(source=path) + ) + return fd + except Exception: + os.close(fd) + raise + finally: + if handle is not None: + kernel32.CloseHandle(handle) + + @staticmethod + def _fsync_file(path: Path) -> None: + with suppress(OSError): + with path.open("rb") as handle: + os.fsync(handle.fileno()) + + def _ensure_private_dir(self, path: Path) -> Path: + missing_dirs = self._missing_directory_chain(path) + ensure_private_dir(path) + if missing_dirs: + for created_dir in reversed(missing_dirs): + self._fsync_parent_dir(created_dir) + else: + self._fsync_parent_dir(path) + return path + + @staticmethod + def _missing_directory_chain(path: Path) -> list[Path]: + missing_dirs: list[Path] = [] + current = path + while not current.exists(): + missing_dirs.append(current) + parent = current.parent + if parent == current: + break + current = parent + return missing_dirs + + @staticmethod + def _fsync_parent_dir(path: Path) -> None: + with suppress(OSError): + fsync_parent_dir(path) + + def _remove_conflicting_file(self, root: Path, path: Path) -> None: + self._require_under_root(root, path) + if path.is_symlink() or self._is_reparse_point(path) or (path.exists() and not path.is_dir()): + self._unlink(path) + self._fsync_parent_dir(path) + + def _remove_conflicting_non_file(self, root: Path, path: Path) -> None: + self._require_under_root(root, path) + if path.is_symlink() or self._is_reparse_point(path): + self._unlink(path) + self._fsync_parent_dir(path) + elif path.is_dir(): + self._rmtree(path) + self._fsync_parent_dir(path) + elif path.exists() and not path.is_file(): + self._unlink(path) + self._fsync_parent_dir(path) + + @staticmethod + def _require_under_root(root: Path, path: Path) -> None: + path.relative_to(root) + + def _included(self, path: Path) -> bool: + return not any( + part in {BACKUP_STATE_FILENAME, BACKUP_LOCK_FILENAME} or self._is_hidden_lock_file(part) + for part in path.parts + ) + + @staticmethod + def _is_hidden_lock_file(name: str) -> bool: + return name.startswith(".") and name.endswith(".lock") + + def _try_write_failed_marker( + self, + source: Path, + *, + reason: BackupReason, + error: str, + attempt: int | None = None, + retry_count: int | None = None, + exhausted: bool | None = None, + ) -> None: + if not self._marker_source_is_safe(source): + return + try: + self._write_marker( + source, + reason=reason, + status="failed", + error=error, + attempt=attempt, + retry_count=retry_count, + exhausted=exhausted, + ) + except Exception: + return + + def _write_marker( + self, + source: Path, + *, + reason: BackupReason, + status: str, + error: str | None, + attempt: int | None = None, + retry_count: int | None = None, + exhausted: bool | None = None, + ) -> None: + if not self._marker_source_is_safe(source): + raise SessionBackupError(_("session source is not a directory: {source}").format(source=source)) + marker = source / BACKUP_STATE_FILENAME + payload: dict[str, Any] = { + "reason": reason.value, + "status": status, + "updated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + } + if status == "failed": + if attempt is not None: + payload["attempt"] = attempt + if retry_count is not None: + payload["retry_count"] = retry_count + if exhausted is not None: + payload["exhausted"] = exhausted + if error is not None: + payload["error"] = sanitize_public_text(error)[:500] + atomic_write_json(marker, payload, durable=True) + ensure_private_file(marker) + + @staticmethod + def _public_error_text(exc: BaseException) -> str: + return _PUBLIC_BACKUP_PATH_PATTERN.sub("[PATH]", sanitize_public_text(str(exc))) + + def _marker_source_is_safe(self, source: Path) -> bool: + if source.is_symlink() or self._is_reparse_point(source) or not source.is_dir(): + return False + try: + self._reject_session_source_ancestry(source) + except SessionBackupError: + return False + return True + + def _prune_empty_dirs(self, root: Path) -> None: + for path in sorted((p for p in self._iter_tree(root, include_reparse=True) if p.is_dir()), reverse=True): + try: + if self._is_reparse_point(path): + continue + self._make_writable(path) + path.rmdir() + except OSError: + continue + self._fsync_parent_dir(path) + + @staticmethod + def _needs_copy(src: Path, dst: Path) -> bool: + if not dst.exists(): + return True + src_stat = src.stat(follow_symlinks=False) + dst_stat = dst.stat(follow_symlinks=False) + return src_stat.st_size != dst_stat.st_size or src_stat.st_mtime_ns != dst_stat.st_mtime_ns + + def _is_regular_file(self, path: Path) -> bool: + if path.is_symlink() or self._is_reparse_point(path): + return False + try: + return stat.S_ISREG(path.stat(follow_symlinks=False).st_mode) + except OSError: + return False + + def _iter_tree(self, root: Path, *, include_reparse: bool = False) -> Iterable[Path]: + stack = [root] + while stack: + current = stack.pop() + for child in current.iterdir(): + if self._is_reparse_point(child): + if include_reparse: + yield child + continue + if child.is_symlink(): + yield child + continue + yield child + if child.is_dir(): + stack.append(child) + + @staticmethod + def _is_reparse_point(path: Path) -> bool: + try: + attributes = getattr(path.stat(follow_symlinks=False), "st_file_attributes", 0) + except OSError: + return False + return bool(attributes & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)) + + def _unlink(self, path: Path) -> None: + if path.is_symlink(): + if os.name == "nt" and self._is_windows_directory_entry(path): + path.rmdir() + else: + path.unlink() + return + if self._is_reparse_point(path): + if self._is_windows_directory_entry(path): + path.rmdir() + else: + path.unlink() + return + self._make_writable(path) + path.unlink() + + def _rmtree(self, path: Path) -> None: + shutil.rmtree(path, onerror=self._rmtree_onerror) + + def _rmtree_onerror(self, func, path: str, _exc_info) -> None: + retry_path = Path(path) + self._make_writable(retry_path) + func(path) + + @staticmethod + def _is_windows_directory_entry(path: Path) -> bool: + if os.name != "nt": + return path.is_dir() + try: + attributes = getattr(path.stat(follow_symlinks=False), "st_file_attributes", 0) + if attributes & getattr(stat, "FILE_ATTRIBUTE_DIRECTORY", 0x10): + return True + except (AttributeError, OSError, TypeError): + pass + is_junction = getattr(path, "is_junction", None) + if callable(is_junction): + with suppress(OSError): + if is_junction(): + return True + return path.is_dir() + + @staticmethod + def _make_writable(path: Path) -> None: + if path.is_symlink() or SessionBackupService._is_reparse_point(path): + return + if os.name != "nt" or not path.exists(): + return + with suppress(OSError): + mode = path.stat().st_mode + os.chmod(path, mode | stat.S_IWRITE | stat.S_IREAD) diff --git a/src/iac_code/services/session_index.py b/src/iac_code/services/session_index.py index a639aa2c..20dade31 100644 --- a/src/iac_code/services/session_index.py +++ b/src/iac_code/services/session_index.py @@ -16,12 +16,15 @@ from iac_code.agent.message import RECALLED_MEMORY_MARKER, RECALLED_MEMORY_METADATA_TYPE from iac_code.pipeline.constants import CLEANUP_PROMPT_METADATA_TYPE -from iac_code.services.session_metadata import SESSION_JSONL_FILENAME, read_session_metadata +from iac_code.services.session_layout import ( + UnsupportedSessionLayoutError, + is_supported_session_dir_for_id, +) +from iac_code.services.session_metadata import SESSION_JSONL_FILENAME, SESSION_METADATA_FILENAME, read_session_metadata from iac_code.utils.project_paths import ( - get_project_dir, get_projects_dir, is_conversation_session_file, - sanitize_path, + project_dir_candidates, ) LITE_READ_BUF_SIZE = 64 * 1024 @@ -274,7 +277,7 @@ def _trim_title(text: str, max_len: int = 200) -> str: return flat[:max_len].rstrip() + "…" -def _iter_session_files(project_dir: Path) -> list[tuple[Path, str]]: +def _iter_session_files(project_dir: Path, *, projects_dir: Path | None = None) -> list[tuple[Path, str]]: files_by_session_id = { jsonl.stem: jsonl for jsonl in project_dir.glob("*.jsonl") if is_conversation_session_file(jsonl) } @@ -283,18 +286,108 @@ def _iter_session_files(project_dir: Path) -> list[tuple[Path, str]]: continue jsonl = session_dir / SESSION_JSONL_FILENAME if jsonl.exists(): + if not _is_indexable_session_dir(session_dir, session_dir.name): + continue files_by_session_id[session_dir.name] = jsonl + continue + metadata = session_dir / SESSION_METADATA_FILENAME + if metadata.exists(): + if not _is_indexable_session_dir(session_dir, session_dir.name): + continue + session_metadata = read_session_metadata(session_dir) + if session_metadata is None: + continue + if _metadata_only_shadowed_by_legacy_session( + project_dir, + projects_dir or project_dir.parent, + session_dir.name, + session_metadata, + ): + continue + if session_dir.name in files_by_session_id: + continue + files_by_session_id[session_dir.name] = metadata return [(jsonl, session_id) for session_id, jsonl in files_by_session_id.items()] +def _is_indexable_session_dir(session_dir: Path, session_id: str) -> bool: + try: + return is_supported_session_dir_for_id(session_dir, session_id) + except UnsupportedSessionLayoutError: + return False + + +def _metadata_only_shadowed_by_legacy_session( + project_dir: Path, + projects_dir: Path, + session_id: str, + metadata: object, +) -> bool: + for candidate in _metadata_shadow_project_dirs(project_dir, projects_dir, metadata): + legacy_path = candidate / f"{session_id}.jsonl" + if legacy_path.exists() and is_conversation_session_file(legacy_path): + return True + return False + + +def _metadata_shadow_project_dirs(project_dir: Path, projects_dir: Path, metadata: object) -> tuple[Path, ...]: + project_dirs: list[Path] = [] + seen: set[Path] = set() + + def add(candidate: Path) -> None: + if candidate not in seen: + project_dirs.append(candidate) + seen.add(candidate) + + cwd = getattr(metadata, "cwd", None) + if isinstance(cwd, str) and cwd: + for candidate in project_dir_candidates(cwd, projects_dir): + add(candidate) + add(project_dir) + suffix = _long_project_dir_hash_suffix(project_dir) + if suffix is not None and projects_dir.exists(): + for candidate in projects_dir.iterdir(): + if candidate.is_dir() and _long_project_dir_hash_suffix(candidate) == suffix: + add(candidate) + return tuple(project_dirs) + + +def _long_project_dir_hash_suffix(project_dir: Path) -> str | None: + name = project_dir.name + if len(name) < 14 or name[-13] != "-": + return None + suffix = name[-12:] + try: + int(suffix, 16) + except ValueError: + return None + return suffix + + +def _session_file_priority(path: Path) -> int: + if path.name == SESSION_JSONL_FILENAME: + return 3 + if path.name == SESSION_METADATA_FILENAME: + return 1 + return 2 + + +def _project_dirs_for_cwd(cwd: str, projects_dir: Path) -> tuple[Path, ...]: + candidates = project_dir_candidates(cwd, projects_dir) + return tuple(candidate for candidate in candidates if candidate.exists()) + + def _build_entry(path: Path, fallback_cwd: str, session_id: str | None = None) -> SessionEntry | None: try: stat = path.stat() except OSError: return None - lite_meta = read_lite_metadata(path) + metadata_only = path.name == SESSION_METADATA_FILENAME + lite_meta = LiteMetadata() if metadata_only else read_lite_metadata(path) path_session_id = session_id or path.stem - directory_metadata = read_session_metadata(path.parent) if path.name == SESSION_JSONL_FILENAME else None + directory_metadata = ( + read_session_metadata(path.parent) if path.name in {SESSION_JSONL_FILENAME, SESSION_METADATA_FILENAME} else None + ) if directory_metadata and directory_metadata.session_id != path_session_id: directory_metadata = None name = directory_metadata.name if directory_metadata else None @@ -309,10 +402,10 @@ def _build_entry(path: Path, fallback_cwd: str, session_id: str | None = None) - git_branch=(directory_metadata.git_branch if directory_metadata else None) or lite_meta.git_branch, title=title, mtime=stat.st_mtime, - size_bytes=stat.st_size, + size_bytes=0 if metadata_only else stat.st_size, name=name, auto_title=auto_title, - is_legacy=path.name != SESSION_JSONL_FILENAME, + is_legacy=path.name not in {SESSION_JSONL_FILENAME, SESSION_METADATA_FILENAME}, ) @@ -328,17 +421,22 @@ def __init__(self, projects_dir: Path | None = None) -> None: def list_for_cwd(self, cwd: str) -> list[SessionEntry]: """List entries that belong to ``cwd``, mtime-descending.""" - if self._projects_dir == get_projects_dir(): - project_dir = get_project_dir(cwd) - else: - project_dir = self._projects_dir / sanitize_path(cwd) - if not project_dir.exists(): + project_dirs = _project_dirs_for_cwd(cwd, self._projects_dir) + if not project_dirs: return [] - entries: list[SessionEntry] = [] - for jsonl, session_id in _iter_session_files(project_dir): - entry = _build_entry(jsonl, fallback_cwd=cwd, session_id=session_id) - if entry is not None: - entries.append(entry) + entries_by_session_id: dict[str, SessionEntry] = {} + priorities: dict[str, int] = {} + for project_dir in project_dirs: + for jsonl, session_id in _iter_session_files(project_dir, projects_dir=self._projects_dir): + entry = _build_entry(jsonl, fallback_cwd=cwd, session_id=session_id) + if entry is None: + continue + priority = _session_file_priority(jsonl) + if priority <= priorities.get(session_id, -1): + continue + entries_by_session_id[session_id] = entry + priorities[session_id] = priority + entries = list(entries_by_session_id.values()) entries.sort(key=lambda e: e.mtime, reverse=True) return entries @@ -346,14 +444,22 @@ def list_all_projects(self) -> list[SessionEntry]: """List entries across every known project, mtime-descending.""" if not self._projects_dir.exists(): return [] - entries: list[SessionEntry] = [] + entries_by_project_session: dict[tuple[str, str], SessionEntry] = {} + priorities: dict[tuple[str, str], int] = {} for proj_dir in self._projects_dir.iterdir(): if not proj_dir.is_dir(): continue - for jsonl, session_id in _iter_session_files(proj_dir): + for jsonl, session_id in _iter_session_files(proj_dir, projects_dir=self._projects_dir): entry = _build_entry(jsonl, fallback_cwd="", session_id=session_id) - if entry is not None: - entries.append(entry) + if entry is None: + continue + priority = _session_file_priority(jsonl) + key = (entry.cwd, session_id) + if priority <= priorities.get(key, -1): + continue + entries_by_project_session[key] = entry + priorities[key] = priority + entries = list(entries_by_project_session.values()) entries.sort(key=lambda e: e.mtime, reverse=True) return entries diff --git a/src/iac_code/services/session_layout.py b/src/iac_code/services/session_layout.py new file mode 100644 index 00000000..e7cfd8be --- /dev/null +++ b/src/iac_code/services/session_layout.py @@ -0,0 +1,200 @@ +"""Layout-aware paths for session-owned runtime data.""" + +from __future__ import annotations + +import re +import stat +from dataclasses import dataclass +from pathlib import Path + +from iac_code.i18n import _ +from iac_code.services.session_metadata import ( + SESSION_LAYOUT_VERSION_V2, + SUPPORTED_SESSION_LAYOUT_VERSIONS, + is_session_metadata_file_entry, + read_session_layout_version, + read_session_metadata, + session_metadata_entry_exists, +) +from iac_code.utils.path_components import is_unsafe_windows_path_component + +_SAFE_TRANSCRIPT_ID = re.compile(r"^[A-Za-z0-9_.-]+$") + +__all__ = [ + "SESSION_LAYOUT_VERSION_V2", + "SessionPaths", + "UnsupportedSessionLayoutError", + "ensure_session_owned_dir", + "ensure_session_owned_parent", + "is_supported_session_dir_for_id", + "require_supported_session_layout", + "session_layout_version", +] + + +class UnsupportedSessionLayoutError(RuntimeError): + """Raised when a session metadata layout version is unknown to this build.""" + + +def session_layout_version(session_dir: Path) -> int | None: + return read_session_layout_version(session_dir) + + +def require_supported_session_layout(session_dir: Path) -> int | None: + try: + version = session_layout_version(session_dir) + except ValueError as exc: + raise UnsupportedSessionLayoutError(_("Unsupported session metadata: {path}").format(path=session_dir)) from exc + if version is None or version in SUPPORTED_SESSION_LAYOUT_VERSIONS: + return version + raise UnsupportedSessionLayoutError(_("Unsupported session layout version: {version}").format(version=version)) + + +def is_supported_session_dir_for_id(session_dir: Path | str, session_id: str) -> bool: + """Return whether a supported session directory belongs to ``session_id``. + + A metadata ``session_id`` mismatch is treated as a non-match so callers can + ignore shadow directories. Invalid or unsupported metadata for the same + directory is still surfaced as an unsupported layout. + """ + + path = Path(session_dir) + if not session_metadata_entry_exists(path): + require_supported_session_layout(path) + return True + if not is_session_metadata_file_entry(path): + raise UnsupportedSessionLayoutError(_("Unsupported session metadata: {path}").format(path=path)) + metadata = read_session_metadata(path) + if metadata is not None and metadata.session_id != session_id: + return False + require_supported_session_layout(path) + if metadata is None: + raise UnsupportedSessionLayoutError(_("Unsupported session metadata: {path}").format(path=path)) + return True + + +def ensure_session_owned_dir(session_dir: Path | str, path: Path | str) -> Path: + session_root = Path(session_dir) + target = Path(path) + require_supported_session_layout(session_root) + relative = _session_owned_relative_path(session_root, target) + _ensure_directory_entry_is_safe(session_root) + current = session_root + for part in relative.parts: + current = current / part + _ensure_directory_entry_is_safe(current) + return target + + +def ensure_session_owned_parent(session_dir: Path | str, path: Path | str) -> Path: + return ensure_session_owned_dir(session_dir, Path(path).parent) + + +def _session_owned_relative_path(session_dir: Path, path: Path) -> Path: + try: + relative = path.relative_to(session_dir) + except ValueError as exc: + raise UnsupportedSessionLayoutError(_("Path is outside session directory: {path}").format(path=path)) from exc + if not relative.parts: + return Path() + if any(part in {"", ".", ".."} for part in relative.parts): + raise UnsupportedSessionLayoutError(_("Unsafe session-owned path: {path}").format(path=path)) + return relative + + +def _ensure_directory_entry_is_safe(path: Path) -> None: + if path.is_symlink() or _is_reparse_point(path): + raise UnsupportedSessionLayoutError(_("Unsafe session-owned path: {path}").format(path=path)) + if not path.exists(): + from iac_code.utils.file_security import ensure_private_dir + + ensure_private_dir(path) + try: + mode = path.stat(follow_symlinks=False).st_mode + except OSError as exc: + raise UnsupportedSessionLayoutError(_("Unsafe session-owned path: {path}").format(path=path)) from exc + if not stat.S_ISDIR(mode): + raise UnsupportedSessionLayoutError(_("Unsafe session-owned path: {path}").format(path=path)) + + +def _is_reparse_point(path: Path) -> bool: + try: + attributes = getattr(path.stat(follow_symlinks=False), "st_file_attributes", 0) + except OSError: + return False + return bool(attributes & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)) + + +def _validate_transcript_id(transcript_id: str) -> str: + if not transcript_id or transcript_id in {".", ".."}: + raise ValueError(_("unsafe transcript id")) + if "/" in transcript_id or "\\" in transcript_id or ".." in transcript_id: + raise ValueError(_("unsafe transcript id")) + if not _SAFE_TRANSCRIPT_ID.fullmatch(transcript_id): + raise ValueError(_("unsafe transcript id")) + if is_unsafe_windows_path_component(transcript_id): + raise ValueError(_("unsafe transcript id")) + return transcript_id + + +@dataclass(frozen=True) +class SessionPaths: + session_dir: Path + + @classmethod + def from_session_dir(cls, session_dir: Path | str) -> SessionPaths: + return cls(Path(session_dir)) + + @classmethod + def require_supported(cls, session_dir: Path | str) -> SessionPaths: + path = Path(session_dir) + require_supported_session_layout(path) + return cls(path) + + @property + def session_jsonl_path(self) -> Path: + return self.session_dir / "session.jsonl" + + @property + def usage_path(self) -> Path: + return self.session_dir / "usage.jsonl" + + @property + def permission_audit_path(self) -> Path: + return self.session_dir / "permission-audit.jsonl" + + @property + def image_cache_dir(self) -> Path: + return self.session_dir / "image-cache" + + @property + def tool_results_dir(self) -> Path: + return self.session_dir / "tool-results" + + @property + def a2a_dir(self) -> Path: + return self.session_dir / "a2a" + + @property + def a2a_task_path(self) -> Path: + return self.a2a_dir / "task.json" + + @property + def a2a_context_path(self) -> Path: + return self.a2a_dir / "context.json" + + @property + def a2a_artifacts_dir(self) -> Path: + return self.a2a_dir / "artifacts" + + def transcript_dir(self, transcript_id: str) -> Path: + return self.session_dir / "pipeline" / "transcripts" / _validate_transcript_id(transcript_id) + + def transcript_usage_path(self, transcript_id: str) -> Path: + return self.transcript_dir(transcript_id) / "usage.jsonl" + + def transcript_permission_audit_path(self, transcript_id: str) -> Path: + return self.transcript_dir(transcript_id) / "permission-audit.jsonl" + + def transcript_tool_results_dir(self, transcript_id: str) -> Path: + return self.transcript_dir(transcript_id) / "tool-results" diff --git a/src/iac_code/services/session_metadata.py b/src/iac_code/services/session_metadata.py index fb034f08..b5cd4e0d 100644 --- a/src/iac_code/services/session_metadata.py +++ b/src/iac_code/services/session_metadata.py @@ -4,18 +4,22 @@ import json import re +import stat from dataclasses import asdict, dataclass from pathlib import Path from typing import Any from iac_code.i18n import _ from iac_code.utils.file_security import ensure_private_dir, ensure_private_file +from iac_code.utils.state_io import atomic_write_text SESSION_JSONL_FILENAME = "session.jsonl" SESSION_METADATA_FILENAME = "metadata.json" SESSION_NAME_PATTERN_TEXT = r"^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$" SESSION_NAME_PATTERN = re.compile(SESSION_NAME_PATTERN_TEXT) SESSION_METADATA_SCHEMA_VERSION = 1 +SESSION_LAYOUT_VERSION_V2 = 2 +SUPPORTED_SESSION_LAYOUT_VERSIONS = {SESSION_LAYOUT_VERSION_V2} @dataclass(frozen=True) @@ -27,6 +31,7 @@ class SessionMetadata: created_at: str | None = None updated_at: str | None = None schema_version: int = SESSION_METADATA_SCHEMA_VERSION + layout_version: int | None = None @classmethod def from_dict(cls, data: dict[str, Any]) -> SessionMetadata | None: @@ -36,6 +41,7 @@ def from_dict(cls, data: dict[str, Any]) -> SessionMetadata | None: name = data.get("name") schema_version = data.get("schema_version") + layout_version = data.get("layout_version") return cls( session_id=session_id, name=name if isinstance(name, str) and name else None, @@ -44,8 +50,12 @@ def from_dict(cls, data: dict[str, Any]) -> SessionMetadata | None: created_at=_string_or_none(data.get("created_at")), updated_at=_string_or_none(data.get("updated_at")), schema_version=schema_version if type(schema_version) is int else SESSION_METADATA_SCHEMA_VERSION, + layout_version=layout_version if type(layout_version) is int else None, ) + def to_dict(self) -> dict[str, Any]: + return {key: value for key, value in asdict(self).items() if value is not None} + def validate_session_name(name: str) -> str: if not SESSION_NAME_PATTERN.fullmatch(name): @@ -58,8 +68,11 @@ def normalize_session_name(name: str) -> str: def read_session_metadata(session_dir: Path) -> SessionMetadata | None: + metadata_path = session_dir / SESSION_METADATA_FILENAME + if not session_metadata_entry_exists(session_dir) or not is_session_metadata_file_entry(session_dir): + return None try: - data = json.loads((session_dir / SESSION_METADATA_FILENAME).read_text(encoding="utf-8")) + data = json.loads(metadata_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return None if not isinstance(data, dict): @@ -67,12 +80,60 @@ def read_session_metadata(session_dir: Path) -> SessionMetadata | None: return SessionMetadata.from_dict(data) +def read_session_layout_version(session_dir: Path) -> int | None: + metadata_path = session_dir / SESSION_METADATA_FILENAME + if not session_metadata_entry_exists(session_dir): + return None + if not is_session_metadata_file_entry(session_dir): + raise ValueError(_("invalid session metadata")) + try: + data = json.loads(metadata_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(_("invalid session metadata")) from exc + if not isinstance(data, dict): + raise ValueError(_("invalid session metadata")) + if "layout_version" not in data: + return None + layout_version = data.get("layout_version") + if type(layout_version) is not int: + raise ValueError(_("invalid session layout version")) + if SessionMetadata.from_dict(data) is None: + raise ValueError(_("invalid session metadata")) + return layout_version + + def write_session_metadata(session_dir: Path, metadata: SessionMetadata) -> None: ensure_private_dir(session_dir) path = session_dir / SESSION_METADATA_FILENAME - path.write_text(json.dumps(asdict(metadata), ensure_ascii=False) + "\n", encoding="utf-8") + atomic_write_text(path, json.dumps(metadata.to_dict(), ensure_ascii=False) + "\n", encoding="utf-8") ensure_private_file(path) +def session_metadata_entry_exists(session_dir: Path) -> bool: + try: + (session_dir / SESSION_METADATA_FILENAME).stat(follow_symlinks=False) + except OSError: + return False + return True + + +def is_session_metadata_file_entry(session_dir: Path) -> bool: + metadata_path = session_dir / SESSION_METADATA_FILENAME + if metadata_path.is_symlink() or _is_reparse_point(metadata_path): + return False + try: + return stat.S_ISREG(metadata_path.stat(follow_symlinks=False).st_mode) + except OSError: + return False + + +def _is_reparse_point(path: Path) -> bool: + try: + attributes = getattr(path.stat(follow_symlinks=False), "st_file_attributes", 0) + except OSError: + return False + return bool(attributes & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)) + + def _string_or_none(value: object) -> str | None: return value if isinstance(value, str) else None diff --git a/src/iac_code/services/session_storage.py b/src/iac_code/services/session_storage.py index a8e3555d..c907a5d0 100644 --- a/src/iac_code/services/session_storage.py +++ b/src/iac_code/services/session_storage.py @@ -23,6 +23,7 @@ from __future__ import annotations import json +import stat from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -31,19 +32,26 @@ from iac_code.agent.message import ContentBlock, Message, ToolResultBlock from iac_code.i18n import _ from iac_code.pipeline.constants import CLEANUP_PROMPT_METADATA_TYPE +from iac_code.services.session_layout import ( + UnsupportedSessionLayoutError, + is_supported_session_dir_for_id, + require_supported_session_layout, +) from iac_code.services.session_metadata import ( SESSION_JSONL_FILENAME, + SESSION_LAYOUT_VERSION_V2, + SESSION_METADATA_FILENAME, SessionMetadata, normalize_session_name, read_session_metadata, + session_metadata_entry_exists, write_session_metadata, ) from iac_code.utils.file_security import ensure_private_dir, ensure_private_file from iac_code.utils.project_paths import ( - get_project_dir, get_projects_dir, - get_session_path, is_conversation_session_file, + project_dir_candidates, ) from iac_code.utils.state_io import append_jsonl_locked, atomic_write_text, safe_replace @@ -52,6 +60,18 @@ def _utc_now() -> str: return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") +def _long_project_dir_hash_suffix(project_dir: Path) -> str | None: + name = project_dir.name + if len(name) < 14 or name[-13] != "-": + return None + suffix = name[-12:] + try: + int(suffix, 16) + except ValueError: + return None + return suffix + + def _cleanup_prompt_identity(message: Message) -> str: metadata = message.metadata if metadata.get("type") == CLEANUP_PROMPT_METADATA_TYPE: @@ -81,34 +101,155 @@ def __init__(self, projects_dir: Path | str | None = None) -> None: # Internal path helpers # ------------------------------------------------------------------ - def _legacy_session_path(self, cwd: str, session_id: str) -> Path: - if self._projects_dir == get_projects_dir(): - return get_session_path(cwd, session_id) - from iac_code.utils.project_paths import sanitize_path + def _project_write_dir_for(self, cwd: str) -> Path: + return project_dir_candidates(cwd, self._projects_dir)[0] - return self._projects_dir / sanitize_path(cwd) / f"{session_id}.jsonl" + def _project_read_dirs_for(self, cwd: str) -> tuple[Path, ...]: + candidates = project_dir_candidates(cwd, self._projects_dir) + existing = tuple(candidate for candidate in candidates if candidate.exists()) + return existing or (candidates[0],) + + def _legacy_session_paths(self, cwd: str, session_id: str) -> tuple[Path, ...]: + return tuple(project_dir / f"{session_id}.jsonl" for project_dir in self._project_read_dirs_for(cwd)) + + def _legacy_session_path(self, cwd: str, session_id: str) -> Path: + for path in self._legacy_session_paths(cwd, session_id): + if path.exists(): + return path + return self._project_write_dir_for(cwd) / f"{session_id}.jsonl" + + def _existing_legacy_session_path(self, cwd: str, session_id: str) -> Path | None: + for path in self._legacy_session_paths(cwd, session_id): + if path.exists(): + return path + return None def _project_dir_for(self, cwd: str) -> Path: - if self._projects_dir == get_projects_dir(): - return get_project_dir(cwd) - from iac_code.utils.project_paths import sanitize_path + return self._project_write_dir_for(cwd) + + def _session_write_dir(self, cwd: str, session_id: str) -> Path: + return self._project_write_dir_for(cwd) / session_id - return self._projects_dir / sanitize_path(cwd) + def _legacy_sidecar_placeholder_dir(self, legacy_path: Path) -> Path: + return legacy_path.with_name(f"{legacy_path.stem}.legacy-sidecars") + + def _safe_legacy_sidecar_placeholder_dir(self, legacy_path: Path) -> Path: + placeholder_dir = self._legacy_sidecar_placeholder_dir(legacy_path) + if self._entry_exists_no_follow(placeholder_dir) and not self._is_directory_entry(placeholder_dir): + return self._conflicting_sidecar_placeholder_dir(placeholder_dir) + return placeholder_dir + + def _conflicting_sidecar_placeholder_dir(self, session_dir: Path) -> Path: + return session_dir.with_name(f"{session_dir.name}.conflict-sidecars") + + def _conflicting_session_path(self, session_dir: Path) -> Path: + return self._conflicting_sidecar_placeholder_dir(session_dir) / SESSION_JSONL_FILENAME + + def _session_dirs_for(self, cwd: str, session_id: str) -> tuple[Path, ...]: + return tuple(project_dir / session_id for project_dir in self._project_read_dirs_for(cwd)) def _session_dir(self, cwd: str, session_id: str) -> Path: - return self._project_dir_for(cwd) / session_id + existing = self._existing_session_dir(cwd, session_id) + if existing is not None: + return existing + legacy_path = self._existing_legacy_session_path(cwd, session_id) + if legacy_path is not None: + return self._safe_legacy_sidecar_placeholder_dir(legacy_path) + conflicting_metadata_dir = self._conflicting_metadata_session_dir(cwd, session_id) + if conflicting_metadata_dir is not None: + return self._conflicting_sidecar_placeholder_dir(conflicting_metadata_dir) + return self._session_write_dir(cwd, session_id) def _directory_session_path(self, cwd: str, session_id: str) -> Path: return self._session_dir(cwd, session_id) / SESSION_JSONL_FILENAME + @staticmethod + def _directory_session_path_for_dir(session_dir: Path) -> Path: + return session_dir / SESSION_JSONL_FILENAME + + def _existing_session_dir(self, cwd: str, session_id: str) -> Path | None: + directory_session_dir = self._directory_session_dir(cwd, session_id) + if directory_session_dir is not None: + return directory_session_dir + legacy_path = self._existing_legacy_session_path(cwd, session_id) + if legacy_path is not None: + for session_dir in self._session_dirs_for(cwd, session_id): + if self._is_reusable_legacy_sidecar_session_dir(session_dir): + return session_dir + return None + sidecar_session_dir = self._sidecar_only_session_dir(cwd, session_id) + if sidecar_session_dir is not None: + return sidecar_session_dir + metadata_session_dir = self._metadata_only_session_dir(cwd, session_id) + if metadata_session_dir is not None: + return metadata_session_dir + return None + + def _directory_session_dir(self, cwd: str, session_id: str) -> Path | None: + for session_dir in self._session_dirs_for(cwd, session_id): + if not self._is_directory_entry(session_dir): + continue + if not self._is_regular_file_entry(self._directory_session_path_for_dir(session_dir)): + continue + if not is_supported_session_dir_for_id(session_dir, session_id): + continue + return session_dir + return None + + def _conflicting_metadata_session_dir(self, cwd: str, session_id: str) -> Path | None: + for session_dir in self._session_dirs_for(cwd, session_id): + if not self._is_directory_entry(session_dir): + continue + if not session_metadata_entry_exists(session_dir): + continue + if not self._is_regular_file_entry(session_dir / SESSION_METADATA_FILENAME): + return session_dir + metadata = read_session_metadata(session_dir) + if metadata is None or metadata.session_id != session_id: + return session_dir + return None + + def _metadata_only_session_dir(self, cwd: str, session_id: str) -> Path | None: + metadata_candidates: list[Path] = [] + for session_dir in self._session_dirs_for(cwd, session_id): + if not self._is_directory_entry(session_dir): + continue + metadata_path = session_dir / SESSION_METADATA_FILENAME + if not self._is_regular_file_entry(metadata_path) or self._entry_exists_no_follow( + self._directory_session_path_for_dir(session_dir) + ): + continue + if not is_supported_session_dir_for_id(session_dir, session_id): + continue + if self._has_runtime_sidecar_state(session_dir): + return session_dir + metadata_candidates.append(session_dir) + return metadata_candidates[0] if metadata_candidates else None + + def _sidecar_only_session_dir(self, cwd: str, session_id: str, *, include_empty: bool = False) -> Path | None: + empty_candidates: list[Path] = [] + for session_dir in self._session_dirs_for(cwd, session_id): + if not self._is_sidecar_only_session_dir(session_dir): + continue + if self._has_runtime_sidecar_state(session_dir): + return session_dir + empty_candidates.append(session_dir) + return empty_candidates[0] if include_empty and empty_candidates else None + def _session_path(self, cwd: str, session_id: str) -> Path: - directory_path = self._directory_session_path(cwd, session_id) - legacy_path = self._legacy_session_path(cwd, session_id) - if directory_path.exists(): - return directory_path - if legacy_path.exists(): + directory_session_dir = self._directory_session_dir(cwd, session_id) + if directory_session_dir is not None: + return directory_session_dir / SESSION_JSONL_FILENAME + legacy_path = self._existing_legacy_session_path(cwd, session_id) + if legacy_path is not None: return legacy_path - return directory_path + metadata_only_session_dir = self._metadata_only_session_dir(cwd, session_id) + if metadata_only_session_dir is not None: + return metadata_only_session_dir / SESSION_JSONL_FILENAME + conflicting_metadata_dir = self._conflicting_metadata_session_dir(cwd, session_id) + if conflicting_metadata_dir is not None: + return self._conflicting_session_path(conflicting_metadata_dir) + return self._session_write_dir(cwd, session_id) / SESSION_JSONL_FILENAME def session_path(self, cwd: str, session_id: str) -> Path: """Public accessor for the on-disk JSONL path of a session.""" @@ -121,7 +262,300 @@ def session_dir(self, cwd: str, session_id: str) -> Path: return self._session_dir(cwd, session_id) def read_metadata(self, cwd: str, session_id: str) -> SessionMetadata | None: - return read_session_metadata(self._session_dir(cwd, session_id)) + if self._legacy_file_wins_over_metadata_only_dir(cwd, session_id): + return None + session_dir = self._existing_session_dir(cwd, session_id) + if session_dir is None: + return None + metadata = read_session_metadata(session_dir) + if metadata is not None and metadata.session_id != session_id: + return None + return metadata + + def v2_session_dir(self, cwd: str, session_id: str) -> Path | None: + """Return the session directory only when it is explicitly layout v2.""" + if self._legacy_file_wins_over_metadata_only_dir(cwd, session_id): + return None + directory_session_dir = self._directory_session_dir(cwd, session_id) + if directory_session_dir is not None: + version = require_supported_session_layout(directory_session_dir) + return directory_session_dir if version == SESSION_LAYOUT_VERSION_V2 else None + if self._sidecar_only_session_dir(cwd, session_id) is not None: + return None + session_dir = self._metadata_only_session_dir(cwd, session_id) + if session_dir is None: + return None + version = require_supported_session_layout(session_dir) + return session_dir if version == SESSION_LAYOUT_VERSION_V2 else None + + def ensure_v2_session_dir_for_new_session( + self, + cwd: str, + session_id: str, + *, + git_branch: str | None = None, + ) -> Path | None: + """Create v2 metadata for a brand-new session, leaving legacy state untouched.""" + if self._existing_legacy_session_path(cwd, session_id) is not None: + return None + directory_session_dir = self._directory_session_dir(cwd, session_id) + if directory_session_dir is not None: + version = require_supported_session_layout(directory_session_dir) + return directory_session_dir if version == SESSION_LAYOUT_VERSION_V2 else None + sidecar_session_dir = self._sidecar_only_session_dir(cwd, session_id, include_empty=True) + if sidecar_session_dir is not None: + now = _utc_now() + write_session_metadata( + sidecar_session_dir, + SessionMetadata( + session_id=session_id, + cwd=cwd, + git_branch=git_branch, + created_at=now, + updated_at=now, + layout_version=SESSION_LAYOUT_VERSION_V2, + ), + ) + return sidecar_session_dir + metadata_session_dir = self._metadata_only_session_dir(cwd, session_id) + if metadata_session_dir is not None: + version = require_supported_session_layout(metadata_session_dir) + return metadata_session_dir if version == SESSION_LAYOUT_VERSION_V2 else None + if self._has_directory_session_state(cwd, session_id): + return None + session_dir = self._session_write_dir(cwd, session_id) + if session_dir.is_symlink() or self._is_reparse_point(session_dir): + return None + if session_dir.exists() and not self._is_directory_entry(session_dir): + return None + if session_dir.exists() and not self._is_sidecar_only_session_dir(session_dir): + return None + now = _utc_now() + write_session_metadata( + session_dir, + SessionMetadata( + session_id=session_id, + cwd=cwd, + git_branch=git_branch, + created_at=now, + updated_at=now, + layout_version=SESSION_LAYOUT_VERSION_V2, + ), + ) + return session_dir + + def _has_directory_session_state(self, cwd: str, session_id: str) -> bool: + return any( + self._entry_exists_no_follow(self._directory_session_path_for_dir(session_dir)) + or session_metadata_entry_exists(session_dir) + for session_dir in self._session_dirs_for(cwd, session_id) + ) + + def _legacy_file_wins_over_metadata_only_dir(self, cwd: str, session_id: str) -> bool: + return ( + self._existing_legacy_session_path(cwd, session_id) is not None + and self._directory_session_dir(cwd, session_id) is None + ) + + @staticmethod + def _is_sidecar_only_session_dir(session_dir: Path) -> bool: + if not SessionStorage._is_directory_entry(session_dir): + return False + try: + children = list(session_dir.iterdir()) + except OSError: + return False + return all(SessionStorage._is_allowed_sidecar_child(child) for child in children) + + @staticmethod + def _is_reusable_legacy_sidecar_session_dir(session_dir: Path) -> bool: + return SessionStorage._is_sidecar_only_session_dir(session_dir) and SessionStorage._has_pipeline_sidecar_marker( + session_dir + ) + + @staticmethod + def _has_pipeline_sidecar_marker(session_dir: Path) -> bool: + pipeline_dir = session_dir / "pipeline" + if SessionStorage._is_directory_entry(pipeline_dir) and any( + SessionStorage._is_regular_file_entry(pipeline_dir / name) + for name in ("meta.yaml", "context.yaml", "events.jsonl") + ): + return True + a2a_pipeline_dir = session_dir / "a2a" / "pipeline" + if not SessionStorage._is_directory_entry(a2a_pipeline_dir): + return False + return any( + SessionStorage._is_regular_file_entry(a2a_pipeline_dir / name) + for name in ("a2a-events.jsonl", "a2a-snapshot.json") + ) + + @staticmethod + def _is_allowed_sidecar_child(child: Path) -> bool: + allowed_dirs = { + "a2a", + "image-cache", + "pipeline", + "tool-results", + } + if SessionStorage._is_allowed_sidecar_file_name(child.name): + return SessionStorage._is_regular_file_entry(child) + return child.name in allowed_dirs and SessionStorage._is_safe_sidecar_directory_tree(child) + + @staticmethod + def _is_allowed_sidecar_file_name(name: str) -> bool: + allowed_files = { + ".backup-state.json", + ".backup-lock", + ".permission-audit.jsonl.lock", + "permission-audit.jsonl", + ".usage.jsonl.lock", + "usage.jsonl", + } + if name in allowed_files: + return True + prefix = "permission-audit.jsonl." + suffix = name[len(prefix) :] if name.startswith(prefix) else "" + return suffix.isdecimal() + + @staticmethod + def _entry_exists_no_follow(path: Path) -> bool: + try: + path.stat(follow_symlinks=False) + except OSError: + return False + return True + + @staticmethod + def _has_runtime_sidecar_state(session_dir: Path) -> bool: + if not SessionStorage._is_directory_entry(session_dir): + return False + try: + children = list(session_dir.iterdir()) + except OSError: + return False + return any( + child.name != SESSION_METADATA_FILENAME and SessionStorage._is_allowed_sidecar_child(child) + for child in children + ) + + @staticmethod + def _is_safe_sidecar_directory_tree(path: Path) -> bool: + if not SessionStorage._is_directory_entry(path): + return False + stack = [path] + while stack: + current = stack.pop() + try: + children = list(current.iterdir()) + except OSError: + return False + for child in children: + if child.is_symlink() or SessionStorage._is_reparse_point(child): + return False + try: + mode = child.stat(follow_symlinks=False).st_mode + except OSError: + return False + if stat.S_ISDIR(mode): + stack.append(child) + elif not stat.S_ISREG(mode): + return False + return True + + @staticmethod + def _is_regular_file_entry(path: Path) -> bool: + if path.is_symlink() or SessionStorage._is_reparse_point(path): + return False + try: + return stat.S_ISREG(path.stat(follow_symlinks=False).st_mode) + except OSError: + return False + + @staticmethod + def _is_directory_entry(path: Path) -> bool: + if path.is_symlink() or SessionStorage._is_reparse_point(path): + return False + try: + return stat.S_ISDIR(path.stat(follow_symlinks=False).st_mode) + except OSError: + return False + + @staticmethod + def _is_reparse_point(path: Path) -> bool: + try: + attributes = getattr(path.stat(follow_symlinks=False), "st_file_attributes", 0) + except OSError: + return False + return bool(attributes & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)) + + def _is_new_directory_session(self, cwd: str, session_id: str) -> bool: + return ( + not self._has_directory_session_state(cwd, session_id) + and self._existing_legacy_session_path(cwd, session_id) is None + ) + + def _prepare_session_write(self, cwd: str, session_id: str) -> tuple[Path, bool]: + was_new = self._is_new_directory_session(cwd, session_id) + directory_session_dir = self._directory_session_dir(cwd, session_id) + legacy_path = self._existing_legacy_session_path(cwd, session_id) + metadata_only_session_dir = ( + None if legacy_path is not None else self._metadata_only_session_dir(cwd, session_id) + ) + path = self._session_path(cwd, session_id) + conflicting_metadata_dir = self._conflicting_metadata_session_dir(cwd, session_id) + if ( + conflicting_metadata_dir is not None + and directory_session_dir is None + and metadata_only_session_dir is None + and legacy_path is None + ): + raise UnsupportedSessionLayoutError( + _("Unsupported session metadata: {path}").format(path=conflicting_metadata_dir) + ) + if directory_session_dir is not None and path == self._directory_session_path_for_dir(directory_session_dir): + require_supported_session_layout(directory_session_dir) + return path, was_new + + def _ensure_new_session_metadata( + self, + cwd: str, + session_id: str, + *, + git_branch: str | None, + was_new: bool, + ) -> None: + if self._legacy_file_wins_over_metadata_only_dir(cwd, session_id): + return + session_dir = self._session_dir(cwd, session_id) + if was_new: + now = _utc_now() + write_session_metadata( + session_dir, + SessionMetadata( + session_id=session_id, + cwd=cwd, + git_branch=git_branch, + created_at=now, + updated_at=now, + layout_version=SESSION_LAYOUT_VERSION_V2, + ), + ) + return + current = read_session_metadata(session_dir) + if current and current.layout_version == SESSION_LAYOUT_VERSION_V2 and git_branch and not current.git_branch: + write_session_metadata( + session_dir, + SessionMetadata( + session_id=current.session_id, + name=current.name, + cwd=current.cwd, + git_branch=git_branch, + created_at=current.created_at, + updated_at=_utc_now(), + schema_version=current.schema_version, + layout_version=current.layout_version, + ), + ) # ------------------------------------------------------------------ # Stamp helpers @@ -149,22 +583,24 @@ def append( git_branch: str | None = None, ) -> None: """Append a single message (real-time persistence).""" - path = self._session_path(cwd, session_id) + path, was_new = self._prepare_session_write(cwd, session_id) ensure_private_dir(path.parent) data = self._stamp(message.to_dict(), cwd, session_id, git_branch) append_jsonl_locked(path, [data]) ensure_private_file(path) + self._ensure_new_session_metadata(cwd, session_id, git_branch=git_branch, was_new=was_new) def append_meta(self, cwd: str, session_id: str, meta_entry: dict[str, Any]) -> None: """Append a lite-meta row (no ``role``, distinguished by ``type``).""" if "type" not in meta_entry: raise ValueError("meta_entry must include a 'type' field") - path = self._session_path(cwd, session_id) + path, was_new = self._prepare_session_write(cwd, session_id) ensure_private_dir(path.parent) entry = dict(meta_entry) entry["session_id"] = session_id append_jsonl_locked(path, [entry]) ensure_private_file(path) + self._ensure_new_session_metadata(cwd, session_id, git_branch=None, was_new=was_new) def save( self, @@ -176,9 +612,9 @@ def save( preserve_cleanup_prompts: bool = False, ) -> None: """Overwrite the session file with the given messages.""" + path, was_new = self._prepare_session_write(cwd, session_id) if preserve_cleanup_prompts: messages = self._merge_preserved_cleanup_prompts(cwd, session_id, messages) - path = self._session_path(cwd, session_id) ensure_private_dir(path.parent) lines = [] for msg in messages: @@ -186,6 +622,7 @@ def save( lines.append(json.dumps(data, ensure_ascii=False) + "\n") atomic_write_text(path, "".join(lines), durable=True) ensure_private_file(path) + self._ensure_new_session_metadata(cwd, session_id, git_branch=git_branch, was_new=was_new) def _merge_preserved_cleanup_prompts( self, @@ -242,30 +679,45 @@ def load(self, cwd: str, session_id: str) -> list[Message]: return messages def exists(self, cwd: str, session_id: str) -> bool: - return self._session_path(cwd, session_id).exists() + path = self._session_path(cwd, session_id) + if path.exists(): + return True + if self._directory_session_dir(cwd, session_id) is not None: + return True + return self._metadata_only_session_dir(cwd, session_id) is not None # ------------------------------------------------------------------ # Rename / migration # ------------------------------------------------------------------ def _iter_project_session_dirs(self, cwd: str) -> list[Path]: - project_dir = self._project_dir_for(cwd) - if not project_dir.exists(): - return [] - return [p for p in project_dir.iterdir() if p.is_dir() and (p / SESSION_JSONL_FILENAME).exists()] + session_dirs: list[Path] = [] + for project_dir in self._project_read_dirs_for(cwd): + if not project_dir.exists(): + continue + session_dirs.extend( + p for p in project_dir.iterdir() if p.is_dir() and (p / SESSION_JSONL_FILENAME).exists() + ) + return session_dirs def _name_owner_in_project(self, cwd: str, name: str) -> str | None: for session_dir in self._iter_project_session_dirs(cwd): + try: + if not is_supported_session_dir_for_id(session_dir, session_dir.name): + continue + except UnsupportedSessionLayoutError: + continue metadata = read_session_metadata(session_dir) - if metadata and metadata.name == name: + if metadata and metadata.session_id == session_dir.name and metadata.name == name: return metadata.session_id return None def _ensure_directory_format(self, cwd: str, session_id: str) -> Path: - session_dir = self._session_dir(cwd, session_id) + existing_directory_dir = self._directory_session_dir(cwd, session_id) + if existing_directory_dir is not None: + return existing_directory_dir + session_dir = self._session_write_dir(cwd, session_id) directory_path = session_dir / SESSION_JSONL_FILENAME - if directory_path.exists(): - return session_dir legacy_path = self._legacy_session_path(cwd, session_id) if not legacy_path.exists(): ensure_private_dir(session_dir) @@ -273,13 +725,46 @@ def _ensure_directory_format(self, cwd: str, session_id: str) -> Path: ensure_private_file(directory_path) return session_dir ensure_private_dir(session_dir) + self._merge_legacy_sidecar_placeholder(legacy_path, session_dir) safe_replace(str(legacy_path), str(directory_path)) ensure_private_file(directory_path) return session_dir + def _merge_legacy_sidecar_placeholder(self, legacy_path: Path, session_dir: Path) -> None: + placeholder_dir = self._legacy_sidecar_placeholder_dir(legacy_path) + if not self._entry_exists_no_follow(placeholder_dir) or not self._is_directory_entry(placeholder_dir): + return + for child in list(placeholder_dir.iterdir()): + if not self._is_allowed_sidecar_child(child): + continue + destination = session_dir / child.name + if destination.exists(): + continue + try: + child.replace(destination) + except OSError: + continue + try: + placeholder_dir.rmdir() + except OSError: + pass + def rename_session(self, cwd: str, session_id: str, name: str, *, git_branch: str | None = None) -> str: normalized = normalize_session_name(name) - current = self.read_metadata(cwd, session_id) + was_new = self._is_new_directory_session(cwd, session_id) + legacy_file_wins = self._legacy_file_wins_over_metadata_only_dir(cwd, session_id) + if not legacy_file_wins: + conflicting_metadata_dir = self._conflicting_metadata_session_dir(cwd, session_id) + if conflicting_metadata_dir is not None: + raise UnsupportedSessionLayoutError( + _("Unsupported session metadata: {path}").format(path=conflicting_metadata_dir) + ) + session_dir = ( + self._session_write_dir(cwd, session_id) if legacy_file_wins else self._session_dir(cwd, session_id) + ) + if self._has_directory_session_state(cwd, session_id) and not legacy_file_wins: + require_supported_session_layout(session_dir) + current = None if legacy_file_wins else self.read_metadata(cwd, session_id) if current and current.name == normalized: return "unchanged" owner = self._name_owner_in_project(cwd, normalized) @@ -294,6 +779,11 @@ def rename_session(self, cwd: str, session_id: str, name: str, *, git_branch: st git_branch=git_branch, created_at=current.created_at if current else now, updated_at=now, + layout_version=SESSION_LAYOUT_VERSION_V2 + if was_new or legacy_file_wins + else current.layout_version + if current + else None, ) write_session_metadata(session_dir, metadata) return "renamed" @@ -316,8 +806,19 @@ def find_session_anywhere(self, session_id: str) -> tuple[str, Path] | None: continue candidate = proj_dir / session_id / SESSION_JSONL_FILENAME if candidate.exists(): - cwd = self._read_cwd_from_file(candidate) or "" + if not is_supported_session_dir_for_id(candidate.parent, session_id): + continue + cwd = self._read_cwd_from_directory_session(candidate.parent, candidate) or "" return cwd, candidate + metadata_candidate = proj_dir / session_id / SESSION_METADATA_FILENAME + if metadata_candidate.exists() and not (proj_dir / f"{session_id}.jsonl").exists(): + if not is_supported_session_dir_for_id(metadata_candidate.parent, session_id): + continue + metadata = read_session_metadata(metadata_candidate.parent) + if self._metadata_only_shadowed_by_legacy_session(session_id, metadata, scanned_project_dir=proj_dir): + continue + cwd = metadata.cwd if metadata and metadata.cwd else "" + return cwd, metadata_candidate candidate = proj_dir / f"{session_id}.jsonl" if candidate.exists() and is_conversation_session_file(candidate): cwd = self._read_cwd_from_file(candidate) or "" @@ -329,6 +830,7 @@ def get_latest_session_anywhere(self) -> tuple[str, str] | None: if not self._projects_dir.exists(): return None latest: tuple[float, Path] | None = None + latest_unsupported: tuple[float, UnsupportedSessionLayoutError] | None = None for proj_dir in self._projects_dir.iterdir(): if not proj_dir.is_dir(): continue @@ -338,8 +840,37 @@ def get_latest_session_anywhere(self) -> tuple[str, str] | None: jsonl = session_dir / SESSION_JSONL_FILENAME if jsonl.exists(): mtime = jsonl.stat().st_mtime + try: + if not is_supported_session_dir_for_id(session_dir, session_dir.name): + continue + except UnsupportedSessionLayoutError as exc: + if latest is None or mtime > latest[0]: + latest_unsupported = (mtime, exc) + continue if latest is None or mtime > latest[0]: latest = (mtime, jsonl) + continue + metadata = session_dir / SESSION_METADATA_FILENAME + if metadata.exists(): + if (proj_dir / f"{session_dir.name}.jsonl").exists(): + continue + try: + if not is_supported_session_dir_for_id(session_dir, session_dir.name): + continue + except UnsupportedSessionLayoutError: + continue + session_metadata = read_session_metadata(session_dir) + if session_metadata is None: + continue + if self._metadata_only_shadowed_by_legacy_session( + session_dir.name, + session_metadata, + scanned_project_dir=proj_dir, + ): + continue + mtime = metadata.stat().st_mtime + if latest is None or mtime > latest[0]: + latest = (mtime, metadata) for jsonl in proj_dir.glob("*.jsonl"): if not is_conversation_session_file(jsonl): continue @@ -347,12 +878,70 @@ def get_latest_session_anywhere(self) -> tuple[str, str] | None: if latest is None or mtime > latest[0]: latest = (mtime, jsonl) if latest is None: + if latest_unsupported is not None: + raise latest_unsupported[1] return None + if latest_unsupported is not None and latest_unsupported[0] > latest[0]: + raise latest_unsupported[1] path = latest[1] - cwd = self._read_cwd_from_file(path) or "" - session_id = path.parent.name if path.name == SESSION_JSONL_FILENAME else path.stem + if path.name == SESSION_JSONL_FILENAME: + is_supported_session_dir_for_id(path.parent, path.parent.name) + cwd = self._read_cwd_from_directory_session(path.parent, path) or "" + session_id = path.parent.name + elif path.name == SESSION_METADATA_FILENAME: + metadata = read_session_metadata(path.parent) + cwd = metadata.cwd if metadata and metadata.cwd else "" + session_id = path.parent.name + else: + cwd = self._read_cwd_from_file(path) or "" + session_id = path.stem return cwd, session_id + def _metadata_only_shadowed_by_legacy_session( + self, + session_id: str, + metadata: SessionMetadata | None, + *, + scanned_project_dir: Path | None = None, + ) -> bool: + for project_dir in self._metadata_shadow_project_dirs(metadata, scanned_project_dir): + legacy_path = project_dir / f"{session_id}.jsonl" + if legacy_path.exists() and is_conversation_session_file(legacy_path): + return True + return False + + def _metadata_shadow_project_dirs( + self, + metadata: SessionMetadata | None, + scanned_project_dir: Path | None, + ) -> tuple[Path, ...]: + project_dirs: list[Path] = [] + seen: set[Path] = set() + + def add(project_dir: Path) -> None: + if project_dir not in seen: + project_dirs.append(project_dir) + seen.add(project_dir) + + if metadata is not None and metadata.cwd: + for project_dir in project_dir_candidates(metadata.cwd, self._projects_dir): + add(project_dir) + if scanned_project_dir is not None: + add(scanned_project_dir) + suffix = _long_project_dir_hash_suffix(scanned_project_dir) + if suffix is not None and self._projects_dir.exists(): + for project_dir in self._projects_dir.iterdir(): + if project_dir.is_dir() and _long_project_dir_hash_suffix(project_dir) == suffix: + add(project_dir) + return tuple(project_dirs) + + @staticmethod + def _read_cwd_from_directory_session(session_dir: Path, session_path: Path) -> str | None: + metadata = read_session_metadata(session_dir) + if metadata and metadata.session_id == session_dir.name and metadata.cwd: + return metadata.cwd + return SessionStorage._read_cwd_from_file(session_path) + @staticmethod def _read_cwd_from_file(path: Path) -> str | None: """Read the first message-row's ``cwd`` stamp from a session file.""" diff --git a/src/iac_code/services/session_usage.py b/src/iac_code/services/session_usage.py index be98982a..820ec7b9 100644 --- a/src/iac_code/services/session_usage.py +++ b/src/iac_code/services/session_usage.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path @@ -10,11 +11,25 @@ from loguru import logger +from iac_code.i18n import _ +from iac_code.services.session_layout import ( + SESSION_LAYOUT_VERSION_V2, + UnsupportedSessionLayoutError, + ensure_session_owned_parent, + is_supported_session_dir_for_id, + require_supported_session_layout, +) +from iac_code.services.session_metadata import ( + SESSION_JSONL_FILENAME, + session_metadata_entry_exists, +) from iac_code.types.stream_events import Usage from iac_code.utils.file_security import ensure_private_dir, ensure_private_file -from iac_code.utils.project_paths import get_project_dir, get_projects_dir, sanitize_path +from iac_code.utils.project_paths import get_projects_dir, project_dir_candidates +from iac_code.utils.state_io import open_text_no_follow USAGE_JSONL_FILENAME = "usage.jsonl" +UsagePathProvider = Callable[[str, str], Path] @dataclass @@ -59,11 +74,23 @@ def copy(self) -> SessionUsageTotals: class SessionUsageStore: """Persist cumulative API usage as a sidecar JSONL file.""" - def __init__(self, projects_dir: Path | str | None = None) -> None: + def __init__( + self, + projects_dir: Path | str | None = None, + *, + path_provider: UsagePathProvider | None = None, + ) -> None: self._projects_dir = Path(projects_dir) if projects_dir is not None else get_projects_dir() + self._path_provider = path_provider def path_for(self, cwd: str, session_id: str) -> Path: - return self._project_dir_for(cwd) / session_id / USAGE_JSONL_FILENAME + if self._path_provider is not None: + return Path(self._path_provider(cwd, session_id)) + return self._usage_path_for_write(cwd, session_id) + + @property + def uses_direct_path_provider(self) -> bool: + return self._path_provider is not None def legacy_path_for(self, cwd: str, session_id: str) -> Path: return self._project_dir_for(cwd) / f"{session_id}.usage.jsonl" @@ -83,9 +110,9 @@ def append( return False path = self.path_for(cwd, session_id) - ensure_private_dir(path.parent) + _ensure_usage_parent(path) row = _usage_to_row(usage, provider=provider, model=model, created_at=created_at) - with open(path, "a", encoding="utf-8") as f: + with open_text_no_follow(path, "a", encoding="utf-8") as f: f.write(json.dumps(row, ensure_ascii=False) + "\n") ensure_private_file(path) return True @@ -93,16 +120,36 @@ def append( def load(self, cwd: str, session_id: str) -> SessionUsageTotals: """Load cumulative usage totals, skipping corrupt or unrelated rows.""" totals = SessionUsageTotals() - for path in (self.path_for(cwd, session_id), self.legacy_path_for(cwd, session_id)): + if self._path_provider is not None: + path = self.path_for(cwd, session_id) + if _can_read_direct_usage_path(path): + self._load_path(path, totals) + return totals + seen: set[Path] = set() + paths = [] + legacy_session_path = self._existing_legacy_session_path(cwd, session_id) + for project_dir in self._project_read_dirs_for(cwd): + session_dir = project_dir / session_id + if self._can_read_directory_usage( + session_dir, + session_id, + legacy_session_exists=legacy_session_path is not None, + ): + paths.append(session_dir / USAGE_JSONL_FILENAME) + paths.append(project_dir / f"{session_id}.usage.jsonl") + for path in paths: + if path in seen: + continue + seen.add(path) self._load_path(path, totals) return totals def _load_path(self, path: Path, totals: SessionUsageTotals) -> None: - if not path.exists(): + if not _entry_exists_no_follow(path): return try: - with open(path, encoding="utf-8") as f: + with open_text_no_follow(path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: @@ -119,9 +166,106 @@ def _load_path(self, path: Path, totals: SessionUsageTotals) -> None: logger.debug("Failed to load usage sidecar {}: {}", path, exc) def _project_dir_for(self, cwd: str) -> Path: - if self._projects_dir == get_projects_dir(): - return get_project_dir(cwd) - return self._projects_dir / sanitize_path(cwd) + return project_dir_candidates(cwd, self._projects_dir)[0] + + def _project_read_dirs_for(self, cwd: str) -> tuple[Path, ...]: + candidates = project_dir_candidates(cwd, self._projects_dir) + existing = tuple(candidate for candidate in candidates if candidate.exists()) + return existing or (candidates[0],) + + def _usage_path_for_write(self, cwd: str, session_id: str) -> Path: + existing_legacy_session_path = self._existing_legacy_session_path(cwd, session_id) + for project_dir in self._project_read_dirs_for(cwd): + session_dir = project_dir / session_id + if _entry_exists_no_follow(session_dir / SESSION_JSONL_FILENAME): + if is_supported_session_dir_for_id(session_dir, session_id): + return session_dir / USAGE_JSONL_FILENAME + if existing_legacy_session_path is not None: + return _legacy_usage_path(existing_legacy_session_path) + _raise_unsupported_session_metadata(session_dir) + project_legacy_session_path = project_dir / f"{session_id}.jsonl" + if project_legacy_session_path.exists(): + return _legacy_usage_path(project_legacy_session_path) + if session_metadata_entry_exists(session_dir): + if existing_legacy_session_path is not None: + continue + if is_supported_session_dir_for_id(session_dir, session_id): + return session_dir / USAGE_JSONL_FILENAME + _raise_unsupported_session_metadata(session_dir) + if existing_legacy_session_path is not None: + return _legacy_usage_path(existing_legacy_session_path) + return self._project_dir_for(cwd) / session_id / USAGE_JSONL_FILENAME + + @staticmethod + def _can_read_directory_usage(session_dir: Path, session_id: str, *, legacy_session_exists: bool) -> bool: + if not _entry_exists_no_follow(session_dir): + return True + has_session_jsonl = _entry_exists_no_follow(session_dir / SESSION_JSONL_FILENAME) + has_metadata = session_metadata_entry_exists(session_dir) + if legacy_session_exists and has_metadata and not has_session_jsonl: + return False + if not has_session_jsonl and not has_metadata: + return True + try: + return is_supported_session_dir_for_id(session_dir, session_id) + except UnsupportedSessionLayoutError: + return False + + def _existing_legacy_session_path(self, cwd: str, session_id: str) -> Path | None: + for project_dir in self._project_read_dirs_for(cwd): + legacy_path = project_dir / f"{session_id}.jsonl" + if legacy_path.exists(): + return legacy_path + return None + + +def _ensure_usage_parent(path: Path) -> None: + session_dir = _session_dir_from_usage_path(path) + if session_dir is not None and session_metadata_entry_exists(session_dir): + if require_supported_session_layout(session_dir) == SESSION_LAYOUT_VERSION_V2: + ensure_session_owned_parent(session_dir, path) + return + ensure_private_dir(path.parent) + + +def _session_dir_from_usage_path(path: Path) -> Path | None: + if path.name != USAGE_JSONL_FILENAME: + return None + parent = path.parent + if ( + parent.name.startswith("transcript_") + and parent.parent.name == "transcripts" + and parent.parent.parent.name == "pipeline" + ): + return parent.parent.parent.parent + return parent + + +def _legacy_usage_path(legacy_session_path: Path) -> Path: + return legacy_session_path.with_name(f"{legacy_session_path.stem}.usage.jsonl") + + +def _can_read_direct_usage_path(path: Path) -> bool: + session_dir = _session_dir_from_usage_path(path) + if session_dir is None or not session_metadata_entry_exists(session_dir): + return True + try: + require_supported_session_layout(session_dir) + except UnsupportedSessionLayoutError: + return False + return True + + +def _entry_exists_no_follow(path: Path) -> bool: + try: + path.stat(follow_symlinks=False) + except OSError: + return False + return True + + +def _raise_unsupported_session_metadata(session_dir: Path) -> None: + raise UnsupportedSessionLayoutError(_("Unsupported session metadata: {path}").format(path=session_dir)) def _usage_is_zero(usage: Usage) -> bool: diff --git a/src/iac_code/services/telemetry/metrics.py b/src/iac_code/services/telemetry/metrics.py index dce9edb3..8c95c228 100644 --- a/src/iac_code/services/telemetry/metrics.py +++ b/src/iac_code/services/telemetry/metrics.py @@ -35,6 +35,9 @@ M.PIPELINE_CANDIDATE_SUCCESS_COUNT, M.PIPELINE_CANDIDATE_FAILED_COUNT, M.PIPELINE_FUNNEL_STEP_COUNT, + M.PIPELINE_BACKUP_SUCCEEDED_COUNT, + M.PIPELINE_BACKUP_FAILED_COUNT, + M.PIPELINE_BACKUP_BLOCKED_COUNT, ) _HISTOGRAM_NAMES: frozenset[str] = frozenset( diff --git a/src/iac_code/services/telemetry/names.py b/src/iac_code/services/telemetry/names.py index 30543619..1e16fa62 100644 --- a/src/iac_code/services/telemetry/names.py +++ b/src/iac_code/services/telemetry/names.py @@ -186,6 +186,9 @@ class Events: PIPELINE_STEP_NUDGED = "iac.pipeline.step.nudged" PIPELINE_HARD_INTERRUPT = "iac.pipeline.hard_interrupt" PIPELINE_SIDECAR_FAILED = "iac.pipeline.sidecar.failed" + PIPELINE_BACKUP_SUCCEEDED = "iac.pipeline.backup.succeeded" + PIPELINE_BACKUP_FAILED = "iac.pipeline.backup.failed" + PIPELINE_BACKUP_BLOCKED = "iac.pipeline.backup.blocked" PIPELINE_USER_INPUT_REQUIRED = "iac.pipeline.user_input.required" PIPELINE_USER_INPUT_RECEIVED = "iac.pipeline.user_input.received" PIPELINE_SELECTION_MADE = "iac.pipeline.selection.made" @@ -228,6 +231,9 @@ class Metrics: PIPELINE_CANDIDATE_SUCCESS_COUNT = "iac.pipeline.candidate.success.count" PIPELINE_CANDIDATE_FAILED_COUNT = "iac.pipeline.candidate.failed.count" PIPELINE_FUNNEL_STEP_COUNT = "iac.pipeline.funnel.step.count" + PIPELINE_BACKUP_SUCCEEDED_COUNT = "iac.pipeline.backup.succeeded.count" + PIPELINE_BACKUP_FAILED_COUNT = "iac.pipeline.backup.failed.count" + PIPELINE_BACKUP_BLOCKED_COUNT = "iac.pipeline.backup.blocked.count" class Spans: diff --git a/src/iac_code/skills/processor.py b/src/iac_code/skills/processor.py index eb8894e9..59b732c9 100644 --- a/src/iac_code/skills/processor.py +++ b/src/iac_code/skills/processor.py @@ -57,9 +57,10 @@ async def process_prompt_command( allowed_tools = skill.allowed_tools model_override = skill.model_override effort_override = skill.effort_override + skill_root = skill.skill_root or "" context_modifier = None - if allowed_tools or (model_override and model_override != "inherit") or effort_override: + if allowed_tools or (model_override and model_override != "inherit") or effort_override or skill_root: def context_modifier(ctx: dict) -> dict: modified = {**ctx} @@ -70,6 +71,15 @@ def context_modifier(ctx: dict) -> dict: modified["model_override"] = model_override if effort_override: modified["effort_override"] = effort_override + if skill_root: + trusted_roots = list(modified.get("tool_context_trusted_read_directories", []) or []) + relative_roots = list(modified.get("tool_context_relative_read_directories", []) or []) + if skill_root not in trusted_roots: + trusted_roots.append(skill_root) + if skill_root not in relative_roots: + relative_roots.append(skill_root) + modified["tool_context_trusted_read_directories"] = trusted_roots + modified["tool_context_relative_read_directories"] = relative_roots return modified return ProcessedSkillResult( diff --git a/src/iac_code/tools/result_storage.py b/src/iac_code/tools/result_storage.py index 75ec4608..ee997372 100644 --- a/src/iac_code/tools/result_storage.py +++ b/src/iac_code/tools/result_storage.py @@ -7,7 +7,10 @@ from hashlib import blake2b from pathlib import Path +from iac_code.services.session_layout import ensure_session_owned_dir +from iac_code.services.session_metadata import session_metadata_entry_exists from iac_code.utils.file_security import ensure_private_dir, ensure_private_file +from iac_code.utils.state_io import write_text_no_follow DEFAULT_MAX_INLINE_CHARS = 50_000 DEFAULT_PREVIEW_CHARS = 2_000 @@ -52,13 +55,24 @@ def process(self, tool_use_id: str, content: str) -> ProcessedResult: if len(content) <= self._max_inline_chars: return ProcessedResult(content=content) storage_path = Path(self._storage_dir) - if storage_path.parent.name == "tool-results": + session_root = _session_root_for_storage_path(storage_path) + if session_root is not None: + storage_dir = ensure_session_owned_dir(session_root, storage_path) + elif storage_path.parent.name == "tool-results": ensure_private_dir(storage_path.parent) - storage_dir = ensure_private_dir(storage_path) + storage_dir = ensure_private_dir(storage_path) + else: + storage_dir = ensure_private_dir(storage_path) file_path = storage_dir / _result_filename(tool_use_id) - with file_path.open("w", encoding="utf-8") as f: - f.write(content) + write_text_no_follow(file_path, content, encoding="utf-8") ensure_private_file(file_path) preview = content[: self._preview_chars] preview += f"\n\n... [truncated — full output ({len(content)} chars) saved to {file_path}]" return ProcessedResult(content=preview, is_externalized=True, file_path=str(file_path)) + + +def _session_root_for_storage_path(storage_path: Path) -> Path | None: + for candidate in (storage_path, *storage_path.parents): + if session_metadata_entry_exists(candidate): + return candidate + return None diff --git a/src/iac_code/ui/repl.py b/src/iac_code/ui/repl.py index 58b04090..a578ec10 100644 --- a/src/iac_code/ui/repl.py +++ b/src/iac_code/ui/repl.py @@ -50,6 +50,7 @@ permission_audit_operation, redacted_tool_input_for_settings, ) +from iac_code.services.session_backup import BackupReason, SessionBackupService from iac_code.services.session_index import SessionIndex from iac_code.services.session_metadata import normalize_session_name from iac_code.services.session_resolver import ResolutionStatus, resolve_session_argument @@ -154,6 +155,7 @@ def __init__( cli_allowed_tools: list[str] | None = None, cli_disallowed_tools: list[str] | None = None, cli_permission_mode: str | None = None, + backup_service: SessionBackupService | None = None, ) -> None: self.console = Console() # Lock the working directory for the lifetime of this REPL. All session @@ -184,13 +186,19 @@ def __init__( base_url_override=self._base_url_override, ) self._session_storage = SessionStorage() + self._backup_service = ( + backup_service + if backup_service is not None + else SessionBackupService(session_storage=self._session_storage) + ) self.session_index = SessionIndex() self._session_id = self._resolve_session_id(resume_session_id) - self._was_resumed = resume_session_id is not None + self._was_resumed = bool(getattr(self, "_resolved_existing_session", False)) self._runtime_mode = self._resolve_initial_runtime_mode(resume_session_id) - from iac_code.utils.image.store import ImageStore + if not self._was_resumed: + self._prepare_new_session_layout(self._session_id) - self._image_store = ImageStore(session_id=self._session_id) + self._set_image_store_for_session(self._session_id) self._resume_messages = self._load_resume_messages(resume_session_id) self._session_name = self._load_current_session_name() self._task_manager = TaskManager() @@ -254,7 +262,12 @@ def __init__( cli_disallowed=cli_disallowed_tools, cli_mode=cli_permission_mode, ) - permission_context.trusted_read_directories.extend(build_session_trusted_read_directories(self._session_id)) + permission_context.trusted_read_directories.extend( + build_session_trusted_read_directories( + self._session_id, + session_dir=self._session_dir_for_artifacts(), + ) + ) self.store.set_state(permission_context=permission_context) agent_tool = self.tool_registry.get("agent") @@ -279,18 +292,20 @@ def __init__( auto_trigger_skills=skill_commands, memory_recall_service=self._memory_recall_service, system_prompt_refresher=self._build_current_system_prompt, + result_storage_dir=self._result_storage_dir_for_session(), ) self.renderer = Renderer( self.console, self.tool_registry, status_callback=self._status_text, app_state_store=self.store, - image_path_resolver=self._image_store.get_path, - image_block_path_resolver=self._image_store.store_block, + image_path_resolver=lambda image_id: self._image_store.get_path(image_id), + image_block_path_resolver=lambda block: self._image_store.store_block(block), ) self._pipeline: PipelineRunner | None = None self._pipeline_waiting_input: bool = False + self._pipeline_backup_blocked: bool = False self._pipeline_restored_status: str | None = None self._pipeline_display_recorder = None self._pipeline_display_current_step_id: str | None = None @@ -344,6 +359,114 @@ def locked_skill_names(self): """Return skill names that cannot be disabled.""" return getattr(self, "_locked_skill_names", set()) + def _session_dir_for_artifacts(self, session_id: str | None = None): + from pathlib import Path + + selected_session_id = session_id or self._session_id + if not selected_session_id: + return None + session_dir_factory = getattr(self._session_storage, "v2_session_dir", None) + used_v2_session_dir = callable(session_dir_factory) + if not used_v2_session_dir: + session_dir_factory = getattr(self._session_storage, "session_dir", None) + if not callable(session_dir_factory): + return None + try: + raw_session_dir = session_dir_factory(self._original_cwd, selected_session_id) + except (AttributeError, TypeError): + return None + if raw_session_dir is None: + return None + needs_marker_check = not used_v2_session_dir + if isinstance(raw_session_dir, Path): + session_dir = Path(raw_session_dir) + elif isinstance(raw_session_dir, str): + session_dir = Path(raw_session_dir) + else: + if not used_v2_session_dir: + return None + raw_session_dir = self._raw_session_dir_for_trusted_roots(selected_session_id) + if raw_session_dir is None: + return None + session_dir = raw_session_dir + needs_marker_check = True + if needs_marker_check: + from iac_code.services.session_layout import SESSION_LAYOUT_VERSION_V2, require_supported_session_layout + + if require_supported_session_layout(session_dir) != SESSION_LAYOUT_VERSION_V2: + return None + return session_dir + + def _raw_session_dir_for_trusted_roots(self, session_id: str | None = None): + from pathlib import Path + + selected_session_id = session_id or self._session_id + if not selected_session_id: + return None + session_dir_factory = getattr(self._session_storage, "session_dir", None) + if not callable(session_dir_factory): + return None + try: + raw_session_dir = session_dir_factory(self._original_cwd, selected_session_id) + except (AttributeError, TypeError): + return None + if isinstance(raw_session_dir, Path): + return raw_session_dir + if isinstance(raw_session_dir, (str, os.PathLike)): + return Path(raw_session_dir) + return None + + def _prepare_new_session_layout(self, session_id: str) -> None: + ensure_v2_session = getattr(self._session_storage, "ensure_v2_session_dir_for_new_session", None) + if not callable(ensure_v2_session): + return + try: + ensure_v2_session(self._original_cwd, session_id, git_branch=self.current_git_branch()) + except (AttributeError, TypeError): + return + + def _prepare_pipeline_session_layout(self, pipeline_cwd: str, session_id: str) -> None: + ensure_v2_session = getattr(self._session_storage, "ensure_v2_session_dir_for_new_session", None) + if not callable(ensure_v2_session): + return + try: + ensure_v2_session(pipeline_cwd, session_id, git_branch=self.current_git_branch()) + except (AttributeError, TypeError): + return + + def _result_storage_dir_for_session(self, session_id: str | None = None): + from iac_code.services.session_layout import SessionPaths, session_layout_version + + session_dir = self._session_dir_for_artifacts(session_id) + if session_dir is None: + return None + if session_layout_version(session_dir) is None: + return None + return SessionPaths.require_supported(session_dir).tool_results_dir + + def _set_image_store_for_session(self, session_id: str) -> None: + from iac_code.utils.image.store import ImageStore + + self._image_store = ImageStore(session_id=session_id, session_root=self._session_dir_for_artifacts(session_id)) + prompt_input = getattr(self, "_prompt_input", None) + if prompt_input is not None: + setattr(prompt_input, "_image_store", self._image_store) + + def _refresh_mcp_tool_wrappers_for_session(self) -> None: + from iac_code.services.agent_factory import _sync_mcp_tool_registry + + mcp_manager = getattr(self, "_mcp_manager", None) + tool_registry = getattr(self, "tool_registry", None) + if mcp_manager is None or tool_registry is None: + return + self._registered_mcp_tool_names = _sync_mcp_tool_registry( + tool_registry, + mcp_manager, + self._session_id, + getattr(self, "_registered_mcp_tool_names", set()), + session_dir=self._session_dir_for_artifacts(), + ) + def _register_mcp_integrations(self) -> None: from pathlib import Path @@ -366,6 +489,7 @@ def _register_mcp_integrations(self) -> None: return self._mcp_manager = MCPManager(load_result.servers, roots=[mcp_workspace_root], session_id=self._session_id) + session_dir = self._session_dir_for_artifacts() try: _run_async_blocking(self._mcp_manager.connect_all()) self.mcp_config_warnings.extend(_mcp_connection_warnings(self._mcp_manager)) @@ -384,6 +508,7 @@ def _register_mcp_integrations(self) -> None: self._mcp_manager, self._session_id, self._registered_mcp_tool_names, + session_dir=session_dir, ) self._registered_mcp_command_names, command_warnings = _run_async_blocking( _sync_mcp_command_registry(self.command_registry, self._mcp_manager, self._registered_mcp_command_names) @@ -407,6 +532,7 @@ async def on_mcp_changed(server_name: str, capability: str) -> None: self._mcp_manager, self._session_id, self._registered_mcp_tool_names, + session_dir=self._session_dir_for_artifacts(), ) if capability in {"prompts", "resources"}: self._registered_mcp_command_names, warnings = await _sync_mcp_command_registry( @@ -1543,8 +1669,8 @@ def _render_pipeline_display_transcript_window(self, messages: list[Message]) -> self.tool_registry, status_callback=self._status_text, app_state_store=self.store, - image_path_resolver=self._image_store.get_path, - image_block_path_resolver=self._image_store.store_block, + image_path_resolver=lambda image_id: self._image_store.get_path(image_id), + image_block_path_resolver=lambda block: self._image_store.store_block(block), ) temp_renderer.replay_history(messages) rendered = stream.getvalue().rstrip() @@ -1574,6 +1700,35 @@ def _get_runtime_mode(self) -> RunMode: def _set_runtime_mode(self, mode: RunMode) -> None: self._runtime_mode = mode + async def _backup_normal_turn_end(self) -> None: + backup_service = getattr(self, "_backup_service", None) + original_cwd = getattr(self, "_original_cwd", None) + session_id = getattr(self, "_session_id", None) + if backup_service is None or not isinstance(original_cwd, str) or not isinstance(session_id, str): + return + try: + result = await asyncio.to_thread( + backup_service.backup_session, + original_cwd, + session_id, + reason=BackupReason.NORMAL_TURN_END, + critical=False, + ) + if getattr(result, "enabled", False) and not getattr(result, "succeeded", True): + logger.warning( + "Normal REPL session backup failed (reason={}, retry_count={}): {}", + BackupReason.NORMAL_TURN_END.value, + getattr(result, "retry_count", 0), + getattr(result, "error", None) or "unknown", + ) + except Exception as exc: + logger.warning( + "Normal REPL session backup failed (reason={}, retry_count={}, error_type={})", + BackupReason.NORMAL_TURN_END.value, + getattr(exc, "retry_count", 0), + type(exc).__name__, + ) + async def _handle_chat_continue(self) -> list[str]: """Continue the agent loop after injecting messages (e.g., skill prompt). @@ -1591,6 +1746,7 @@ async def _handle_chat_continue(self) -> list[str]: return [] self.store.set_state(is_busy=True) + backup_turn_end = False try: streaming_input = StreamingInputBuffer() events = self._wrap_cleanup_observer( @@ -1612,10 +1768,13 @@ async def _handle_chat_continue(self) -> list[str]: msg_count = len(self._agent_loop.context_manager.get_messages()) for err in self.renderer._last_streaming_errors: self._streaming_error_log.append((err, msg_count)) + backup_turn_end = not bool(getattr(result, "interrupted", False)) return queued_inputs finally: self._prune_cleanup_prompts_if_no_pending_cleanup() self.store.set_state(is_busy=False) + if backup_turn_end: + await self._backup_normal_turn_end() def _cleanup_ledger_for_pipeline(self, pipeline: object | None): if pipeline is None: @@ -2217,6 +2376,7 @@ async def _start_pipeline_cleanup_from_ledger(self, ledger) -> bool: return False self.store.set_state(is_busy=True) + backup_turn_end = False try: streaming_input = StreamingInputBuffer() events = self._wrap_cleanup_observer(self._agent_loop.continue_streaming(), ledger=ledger) @@ -2235,9 +2395,12 @@ async def _start_pipeline_cleanup_from_ledger(self, ledger) -> bool: msg_count = len(self._agent_loop.context_manager.get_messages()) for err in self.renderer._last_streaming_errors: self._streaming_error_log.append((err, msg_count)) + backup_turn_end = not bool(getattr(result, "interrupted", False)) finally: self._prune_cleanup_prompts_if_no_pending_cleanup(ledger) self.store.set_state(is_busy=False) + if backup_turn_end: + await self._backup_normal_turn_end() return True def _cleanup_prompt_messages_from_context(self): @@ -2412,6 +2575,7 @@ async def _handle_chat(self, user_input: PromptInputResult | str) -> list[str]: self.store.set_state(is_busy=True) self.renderer.record_user_turn(record_text) + backup_turn_end = False try: streaming_input = StreamingInputBuffer() events = self._wrap_cleanup_observer( @@ -2433,10 +2597,13 @@ async def _handle_chat(self, user_input: PromptInputResult | str) -> list[str]: msg_count = len(self._agent_loop.context_manager.get_messages()) for err in self.renderer._last_streaming_errors: self._streaming_error_log.append((err, msg_count)) + backup_turn_end = not bool(getattr(result, "interrupted", False)) return queued_inputs finally: self._prune_cleanup_prompts_if_no_pending_cleanup() self.store.set_state(is_busy=False) + if backup_turn_end: + await self._backup_normal_turn_end() async def _flush_pipeline_telemetry(self) -> None: from iac_code.services.telemetry import flush_telemetry @@ -2463,8 +2630,14 @@ def _refresh_pipeline_display_recorder(self) -> None: self._pipeline_display_recorder = None def _record_pipeline_display_event(self, event) -> None: + from iac_code.pipeline.engine.events import PipelineEventType + if self._is_pipeline_state_persistence_failure_event(event): self._pipeline_state_persistence_failed = True + if getattr(event, "type", None) == PipelineEventType.BACKUP_BLOCKED: + self._pipeline_backup_blocked = True + elif getattr(event, "type", None) == PipelineEventType.PIPELINE_STARTED: + self._pipeline_backup_blocked = False recorder = getattr(self, "_pipeline_display_recorder", None) if recorder is None: return @@ -2585,11 +2758,13 @@ async def ensure_pipeline_restored_for_prompt(self) -> bool: ) self._pipeline = None self._pipeline_waiting_input = False + self._pipeline_backup_blocked = False self._pipeline_restored_status = None return False self._pipeline_restored_status = restored.status self._pipeline_waiting_input = restored.status == "waiting_input" + self._pipeline_backup_blocked = False return True def _pipeline_user_input_from_repl_input( @@ -2636,6 +2811,7 @@ async def _handle_pipeline_chat(self, user_input: str | "PipelineUserInput") -> if self._pipeline is None: pipeline_cwd = get_working_directory() or self._original_cwd + self._prepare_pipeline_session_layout(pipeline_cwd, self._session_id) self._pipeline = create_pipeline( name=get_pipeline_name(), provider_manager=self._provider_manager, @@ -2648,6 +2824,7 @@ async def _handle_pipeline_chat(self, user_input: str | "PipelineUserInput") -> auto_trigger_skills=self.command_registry.get_model_invocable_skills(), ) self._refresh_pipeline_display_recorder() + self._pipeline_backup_blocked = False restored = None if self._detect_pipeline_session(pipeline_cwd, self._session_id): restored = await self._pipeline.restore_from_sidecar() @@ -2666,7 +2843,7 @@ async def _handle_pipeline_chat(self, user_input: str | "PipelineUserInput") -> resume_waiting_candidate_selection = True else: event_stream = cast(Any, self._pipeline).resume(pipeline_input) - elif restored and restored.ok and restored.status == "running": + elif restored and restored.ok and restored.status in {"running", "backup_blocked"}: self._pipeline_waiting_input = False event_stream = cast(Any, self._pipeline).continue_from_sidecar(user_input=pipeline_input) else: @@ -2675,11 +2852,12 @@ async def _handle_pipeline_chat(self, user_input: str | "PipelineUserInput") -> else: self._refresh_pipeline_display_recorder() self._pipeline_waiting_input = False + self._pipeline_backup_blocked = False restored_status = getattr(self, "_pipeline_restored_status", None) self._pipeline_restored_status = None resume_waiting_candidate_selection = False event_stream = None - if restored_status == "running": + if restored_status in {"running", "backup_blocked"}: event_stream = cast(Any, self._pipeline).continue_from_sidecar(user_input=pipeline_input) elif restored_status == "waiting_input": if self._pipeline_current_step_is_candidate_selection() is True: @@ -2897,6 +3075,7 @@ async def restored_selection_stream(): def _clear_pipeline_runtime_state(self) -> None: self._pipeline = None self._pipeline_waiting_input = False + self._pipeline_backup_blocked = False self._pipeline_restored_status = None self._pipeline_display_recorder = None self._pipeline_display_current_step_id = None @@ -2914,6 +3093,11 @@ def _finalize_pipeline_after_render(self, terminal_event: PipelineEvent | None) self._pipeline_waiting_input = False self._warn_pipeline_state_persistence_failed_once() return + from iac_code.pipeline.engine.events import PipelineEventType + + if getattr(terminal_event, "type", None) == PipelineEventType.BACKUP_BLOCKED: + self._pipeline_backup_blocked = True + return handoff_result = self._handoff_pipeline_to_normal(terminal_event) if handoff_result == "persistence_failed": if self._pipeline is not None: @@ -2928,7 +3112,11 @@ def _finalize_pipeline_after_render(self, terminal_event: PipelineEvent | None) self._clear_pipeline_runtime_state() elif self._pipeline is not None and self._pipeline.state_machine.is_complete: self._clear_pipeline_runtime_state() - elif self._pipeline is not None and not self._pipeline_waiting_input: + elif ( + self._pipeline is not None + and not self._pipeline_waiting_input + and not getattr(self, "_pipeline_backup_blocked", False) + ): from iac_code.pipeline.engine.pipeline_runner import PipelineStatePersistenceError try: @@ -3377,6 +3565,7 @@ async def _stop_renderer() -> bool: teardown_events = ( PipelineEventType.STEP_STARTED, PipelineEventType.USER_INPUT_REQUIRED, + PipelineEventType.BACKUP_BLOCKED, PipelineEventType.PIPELINE_COMPLETED, ) if event.type in teardown_events: @@ -3401,9 +3590,9 @@ async def _stop_renderer() -> bool: selection_result = await self._render_candidate_selection_tabs( event_stream, progress_bar_fn=_make_header_fn() ) - if ( - isinstance(selection_result, PipelineEvent) - and selection_result.type == PipelineEventType.PIPELINE_COMPLETED + if isinstance(selection_result, PipelineEvent) and selection_result.type in ( + PipelineEventType.PIPELINE_COMPLETED, + PipelineEventType.BACKUP_BLOCKED, ): return selection_result if isinstance( @@ -3433,10 +3622,12 @@ async def _stop_renderer() -> bool: tabs_interrupted = await self._render_parallel_tabs( event_stream, progress_bar_fn=_make_header_fn() ) - if ( - isinstance(tabs_interrupted, PipelineEvent) - and tabs_interrupted.type == PipelineEventType.PIPELINE_COMPLETED + if isinstance(tabs_interrupted, PipelineEvent) and tabs_interrupted.type in ( + PipelineEventType.PIPELINE_COMPLETED, + PipelineEventType.BACKUP_BLOCKED, ): + if tabs_interrupted.type == PipelineEventType.BACKUP_BLOCKED: + self._render_pipeline_event(tabs_interrupted) return tabs_interrupted if isinstance( tabs_interrupted, PipelineEvent @@ -3456,7 +3647,7 @@ async def _stop_renderer() -> bool: self._update_pipeline_state_from_event(event) self._render_pipeline_event(event) - if event.type == PipelineEventType.PIPELINE_COMPLETED: + if event.type in (PipelineEventType.PIPELINE_COMPLETED, PipelineEventType.BACKUP_BLOCKED): return event if event.type == PipelineEventType.PIPELINE_STARTED: @@ -3581,6 +3772,7 @@ async def _render_candidate_selection_tabs( interrupted = False interrupt_feedback = "" terminal_event: PipelineEvent | None = None + render_terminal_event_after_live = False detail_tool_ids: set[str] = set() detail_accumulated: dict[str, str] = {} @@ -3761,12 +3953,15 @@ async def _stop_key_reader() -> None: PipelineEventType.STEP_FAILED, PipelineEventType.PIPELINE_COMPLETED, PipelineEventType.ROLLBACK_TRIGGERED, + PipelineEventType.BACKUP_BLOCKED, ): if ( event.type == PipelineEventType.PIPELINE_COMPLETED + or event.type == PipelineEventType.BACKUP_BLOCKED or self._is_pipeline_state_persistence_failure_event(event) ): terminal_event = event + render_terminal_event_after_live = event.type == PipelineEventType.BACKUP_BLOCKED break elif isinstance(event, DiagramEvent): @@ -3842,6 +4037,8 @@ async def _stop_key_reader() -> None: return True if terminal_event is not None: + if render_terminal_event_after_live: + self._render_pipeline_event(terminal_event) return terminal_event if selected and self._pipeline is not None: @@ -4139,9 +4336,11 @@ async def _prompt_child_permission(sub_id: str, inner: PermissionRequestEvent) - PipelineEventType.STEP_STARTED, PipelineEventType.PIPELINE_COMPLETED, PipelineEventType.STEP_FAILED, + PipelineEventType.BACKUP_BLOCKED, ): if ( event.type == PipelineEventType.PIPELINE_COMPLETED + or event.type == PipelineEventType.BACKUP_BLOCKED or self._is_pipeline_state_persistence_failure_event(event) ): terminal_event = event @@ -4223,10 +4422,13 @@ def _update_pipeline_state_from_event(self, event): if self._is_pipeline_state_persistence_failure_event(event): self._pipeline_state_persistence_failed = True + if event.type == PipelineEventType.BACKUP_BLOCKED: + self._pipeline_backup_blocked = True if event.type == PipelineEventType.PIPELINE_STARTED: self._pipeline_step_names = event.data.get("step_names", []) self._pipeline_start_time = time.time() self._pipeline_completed_indices = set() + self._pipeline_backup_blocked = False @staticmethod def _is_pipeline_state_persistence_failure_event(event) -> bool: @@ -4272,6 +4474,14 @@ def _render_pipeline_event(self, event): reason = str(event.data.get("reason") or "warning") message = str(event.data.get("message") or _("Pipeline warning: {reason}").format(reason=reason)) con.print(f" [yellow]⚠[/] [yellow]{message}[/]") + case PipelineEventType.BACKUP_BLOCKED: + error = str(event.data.get("error") or _("backup unavailable")) + con.print( + " [yellow]⚠[/] " + + _("Pipeline backup is blocked; the pipeline is paused and recoverable: {error}").format( + error=error + ) + ) case PipelineEventType.USER_INPUT_REQUIRED: options = event.data.get("options", []) prompt_text = event.data.get("prompt", "") @@ -4488,6 +4698,7 @@ def _resolve_session_id(self, resume: str | bool | None) -> str: """ import uuid + self._resolved_existing_session = False if resume is True: latest = self._session_storage.get_latest_session_anywhere() if latest is None: @@ -4495,6 +4706,7 @@ def _resolve_session_id(self, resume: str | bool | None) -> str: cwd, sid = latest if cwd and not same_project_path(cwd, self._original_cwd): raise ValueError(self._cross_project_message(cwd, sid)) + self._resolved_existing_session = True return sid if isinstance(resume, str) and resume: resolution = resolve_session_argument(self.session_index, self._original_cwd, resume) @@ -4506,6 +4718,7 @@ def _resolve_session_id(self, resume: str | bool | None) -> str: raise ValueError(_("Session not found: {session_id}").format(session_id=resume)) if resolution.entry.cwd and not same_project_path(resolution.entry.cwd, self._original_cwd): raise ValueError(self._cross_project_message(resolution.entry.cwd, resolution.entry.session_id)) + self._resolved_existing_session = True return resolution.entry.session_id return str(uuid.uuid4()) @@ -4901,6 +5114,8 @@ def swap_session(self, new_session_id: str) -> None: new_messages = self._with_terminal_pipeline_abort_notice(new_messages, pipeline_cwd, new_session_id) self._agent_loop.replace_session(new_session_id, new_messages or None) self._session_id = new_session_id + self._set_image_store_for_session(new_session_id) + self._refresh_mcp_tool_wrappers_for_session() self._clear_pipeline_cleanup_ledger_path() self._was_resumed = True self._session_name = self._load_current_session_name() @@ -4908,11 +5123,21 @@ def swap_session(self, new_session_id: str) -> None: state = self.store.get_state() permission_context = getattr(state, "permission_context", None) if permission_context is not None: - old_roots = set(build_session_trusted_read_directories(old_session_id)) + old_roots = set( + build_session_trusted_read_directories( + old_session_id, + session_dir=self._raw_session_dir_for_trusted_roots(old_session_id), + ) + ) permission_context.trusted_read_directories = [ root for root in permission_context.trusted_read_directories if root not in old_roots ] - permission_context.trusted_read_directories.extend(build_session_trusted_read_directories(new_session_id)) + permission_context.trusted_read_directories.extend( + build_session_trusted_read_directories( + new_session_id, + session_dir=self._session_dir_for_artifacts(new_session_id), + ) + ) # Clear screen + scrollback, redraw banner, replay history. self.console.file.write("\033[H\033[2J\033[3J") @@ -4944,6 +5169,7 @@ async def swap_session_async(self, new_session_id: str) -> None: # session's sidecar after the id rotation in step 2. self._pipeline = None self._pipeline_waiting_input = False + self._pipeline_backup_blocked = False self._pipeline_restored_status = None # Step 2: delegate to existing sync swap (handles message reload, clear, @@ -5000,6 +5226,7 @@ async def swap_session_async(self, new_session_id: str) -> None: ) self._pipeline = None self._pipeline_waiting_input = False + self._pipeline_backup_blocked = False self._pipeline_restored_status = None return self._pipeline_restored_status = restored.status diff --git a/src/iac_code/utils/image/store.py b/src/iac_code/utils/image/store.py index c9fe89da..718e0ec1 100644 --- a/src/iac_code/utils/image/store.py +++ b/src/iac_code/utils/image/store.py @@ -2,15 +2,17 @@ import base64 import hashlib -import os import shutil +import stat import time from collections import OrderedDict from pathlib import Path from iac_code.config import get_config_dir +from iac_code.services.session_layout import SessionPaths, UnsupportedSessionLayoutError, ensure_session_owned_dir from iac_code.utils.file_security import ensure_private_dir from iac_code.utils.image.pasted_content import PastedContent +from iac_code.utils.state_io import atomic_write_bytes IMAGE_STORE_DIR_NAME = "image-cache" MAX_STORED_IMAGE_PATHS = 200 @@ -31,29 +33,73 @@ def _validate_session_id(session_id: str) -> None: raise ValueError(f"invalid session_id: {session_id!r}") +def _is_reparse_point(path: Path) -> bool: + try: + attributes = getattr(path.stat(follow_symlinks=False), "st_file_attributes", 0) + except OSError: + return False + return bool(attributes & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)) + + +def _is_regular_file_entry(path: Path) -> bool: + if path.is_symlink() or _is_reparse_point(path): + return False + try: + return stat.S_ISREG(path.stat(follow_symlinks=False).st_mode) + except OSError: + return False + + class ImageStore: - def __init__(self, session_id: str) -> None: + def __init__(self, session_id: str, *, session_root: Path | str | None = None) -> None: _validate_session_id(session_id) self._session_id = session_id + self._session_root = Path(session_root) if session_root is not None else None self._paths: OrderedDict[int, str] = OrderedDict() def _session_dir(self) -> Path: + if self._session_root is not None: + return SessionPaths.require_supported(self._session_root).image_cache_dir + return self._legacy_session_dir() + + def _legacy_session_dir(self) -> Path: return _get_base_dir() / self._session_id + def _session_dir_for_read(self) -> Path | None: + session_dir = self._session_dir() + if self._session_root is None: + return session_dir + if not session_dir.exists(): + return session_dir + try: + return ensure_session_owned_dir(self._session_root, session_dir) + except UnsupportedSessionLayoutError: + return None + + def _read_session_dirs(self) -> list[Path]: + session_dir = self._session_dir_for_read() + dirs = [session_dir] if session_dir is not None else [] + if self._session_root is not None: + legacy_dir = self._legacy_session_dir() + if legacy_dir not in dirs: + dirs.append(legacy_dir) + return dirs + + def _ensure_session_dir(self) -> Path: + if self._session_root is not None: + return ensure_session_owned_dir(self._session_root, self._session_dir()) + ensure_private_dir(_get_base_dir()) + return ensure_private_dir(self._session_dir()) + def store(self, pc: PastedContent) -> str | None: if not pc.is_valid_image(): return None - ensure_private_dir(_get_base_dir()) - d = ensure_private_dir(self._session_dir()) + d = self._ensure_session_dir() ext = (pc.media_type or "image/png").split("/")[-1] path = d / f"{pc.id}.{ext}" try: data = base64.b64decode(pc.content) - fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) - try: - os.write(fd, data) - finally: - os.close(fd) + atomic_write_bytes(path, data) except Exception: return None self.cache_path(pc.id, str(path)) @@ -63,23 +109,16 @@ def store_block(self, block: object) -> str | None: data = getattr(block, "data", "") if not data: return None - ensure_private_dir(_get_base_dir()) - d = ensure_private_dir(self._session_dir()) + d = self._ensure_session_dir() media_type = getattr(block, "media_type", None) or "image/png" ext = media_type.split("/")[-1] digest = hashlib.sha256(str(data).encode()).hexdigest()[:32] path = d / f"block-{digest}.{ext}" - if path.is_file(): + if _is_regular_file_entry(path): return str(path) try: decoded = base64.b64decode(data) - fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) - try: - os.write(fd, decoded) - finally: - os.close(fd) - except FileExistsError: - return str(path) + atomic_write_bytes(path, decoded) except Exception: return None return str(path) @@ -101,24 +140,25 @@ def get_path(self, image_id: int) -> str | None: return discovered def _discover_cached_path(self, image_id: int) -> str | None: - session_dir = self._session_dir() - if not session_dir.exists(): - return None - for suffix in KNOWN_IMAGE_SUFFIXES: - path = session_dir / f"{image_id}{suffix}" - if path.is_file(): - return str(path) - for path in sorted(session_dir.glob(f"{image_id}.*")): - if path.is_file(): - return str(path) + for session_dir in self._read_session_dirs(): + if not session_dir.exists(): + continue + for suffix in KNOWN_IMAGE_SUFFIXES: + path = session_dir / f"{image_id}{suffix}" + if _is_regular_file_entry(path): + return str(path) + for path in sorted(session_dir.glob(f"{image_id}.*")): + if _is_regular_file_entry(path): + return str(path) return None def next_image_id(self) -> int: image_ids = [image_id for image_id in self._paths if image_id > 0] - session_dir = self._session_dir() - if session_dir.exists(): + for session_dir in self._read_session_dirs(): + if not session_dir.exists(): + continue for path in session_dir.iterdir(): - if not path.is_file(): + if not _is_regular_file_entry(path): continue try: image_ids.append(int(path.stem)) diff --git a/src/iac_code/utils/path_components.py b/src/iac_code/utils/path_components.py new file mode 100644 index 00000000..8a9db710 --- /dev/null +++ b/src/iac_code/utils/path_components.py @@ -0,0 +1,26 @@ +"""Path component validation helpers.""" + +from __future__ import annotations + +_WINDOWS_RESERVED_BASENAMES = { + "CON", + "PRN", + "AUX", + "NUL", + *(f"COM{index}" for index in range(1, 10)), + *(f"LPT{index}" for index in range(1, 10)), +} +_WINDOWS_RESERVED_DIGIT_TRANSLATION = str.maketrans( + { + "\N{SUPERSCRIPT ONE}": "1", + "\N{SUPERSCRIPT TWO}": "2", + "\N{SUPERSCRIPT THREE}": "3", + } +) + + +def is_unsafe_windows_path_component(component: str) -> bool: + if component.endswith((" ", ".")): + return True + basename = component.split(".", 1)[0].translate(_WINDOWS_RESERVED_DIGIT_TRANSLATION).upper() + return basename in _WINDOWS_RESERVED_BASENAMES diff --git a/src/iac_code/utils/project_paths.py b/src/iac_code/utils/project_paths.py index 688781ee..c93e4d78 100644 --- a/src/iac_code/utils/project_paths.py +++ b/src/iac_code/utils/project_paths.py @@ -20,6 +20,9 @@ from iac_code.config import get_config_dir MAX_SANITIZED_LENGTH = 200 +_HASH_LENGTH = 12 +_HASH_SUFFIX_LENGTH = 1 + _HASH_LENGTH +_SANITIZED_PREFIX_LENGTH = MAX_SANITIZED_LENGTH - _HASH_SUFFIX_LENGTH _NON_ALNUM = re.compile(r"[^a-zA-Z0-9]") _WINDOWS_DRIVE_PATH = re.compile(r"^[a-zA-Z]:[\\/]") @@ -37,6 +40,18 @@ def sanitize_path(name: str) -> str: return f"{sanitized[:MAX_SANITIZED_LENGTH]}-{digest}" +def _legacy_sanitize_path(name: str) -> str: + return sanitize_path(name) + + +def _session_project_sanitize_path(name: str) -> str: + sanitized = _NON_ALNUM.sub("-", name) + if len(sanitized) <= MAX_SANITIZED_LENGTH: + return sanitized + digest = blake2b(name.encode("utf-8"), digest_size=6).hexdigest() + return f"{sanitized[:_SANITIZED_PREFIX_LENGTH]}-{digest}" + + def get_projects_dir() -> Path: """Root directory holding all per-project session folders.""" return get_config_dir() / "projects" @@ -44,7 +59,20 @@ def get_projects_dir() -> Path: def get_project_dir(cwd: str) -> Path: """Directory holding session files for a specific working directory.""" - return get_projects_dir() / sanitize_path(cwd) + candidates = project_dir_candidates(cwd) + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def project_dir_candidates(cwd: str, projects_dir: Path | None = None) -> tuple[Path, ...]: + root = projects_dir or get_projects_dir() + current = root / _session_project_sanitize_path(cwd) + legacy = root / _legacy_sanitize_path(cwd) + if current == legacy: + return (current,) + return (current, legacy) def get_session_path(cwd: str, session_id: str) -> Path: diff --git a/src/iac_code/utils/state_io.py b/src/iac_code/utils/state_io.py index 81d7d239..3e557999 100644 --- a/src/iac_code/utils/state_io.py +++ b/src/iac_code/utils/state_io.py @@ -6,24 +6,220 @@ import logging import os import shutil +import stat import sys import tempfile import time from collections.abc import Callable, Iterable from contextlib import contextmanager, suppress from pathlib import Path -from typing import Any, Iterator +from typing import Any, Iterator, TextIO, cast +from iac_code.i18n import _ from iac_code.utils.path_locks import PathLockRegistry logger = logging.getLogger(__name__) _PATH_LOCKS = PathLockRegistry() +_FILE_OPEN = os.open def _path_lock(path: Path): return _PATH_LOCKS.lock_for(path) +def _is_reparse_point(path: Path) -> bool: + try: + attributes = getattr(path.stat(follow_symlinks=False), "st_file_attributes", 0) + except OSError: + return False + return bool(attributes & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)) + + +def _reject_symlink_or_reparse_leaf(path: Path) -> None: + if path.is_symlink() or _is_reparse_point(path): + raise OSError(_("refusing to follow symlink or reparse point: {path}").format(path=path)) + + +def _open_no_follow(path: Path, flags: int, mode: int): + _reject_symlink_or_reparse_leaf(path) + if os.name == "nt": + return _open_windows_no_follow(path, flags, mode) + nofollow = getattr(os, "O_NOFOLLOW", 0) + fd = _FILE_OPEN(path, flags | nofollow, mode) + try: + fd_stat = os.fstat(fd) + if not stat.S_ISREG(fd_stat.st_mode): + raise OSError(_("refusing to open non-regular file: {path}").format(path=path)) + return fd + except Exception: + os.close(fd) + raise + + +def _open_windows_no_follow(path: Path, flags: int, _mode: int): + try: + import ctypes + import msvcrt + from ctypes import wintypes + except ImportError as exc: + raise OSError(_("could not open file without following reparse point: {path}").format(path=path)) from exc + + windll_factory: Any = getattr(ctypes, "WinDLL", None) + if windll_factory is None: + raise OSError(_("could not open file without following reparse point: {path}").format(path=path)) + kernel32 = windll_factory("kernel32", use_last_error=True) + kernel32.CreateFileW.argtypes = [ + wintypes.LPCWSTR, + wintypes.DWORD, + wintypes.DWORD, + wintypes.LPVOID, + wintypes.DWORD, + wintypes.DWORD, + wintypes.HANDLE, + ] + kernel32.CreateFileW.restype = wintypes.HANDLE + + class _ByHandleFileInformation(ctypes.Structure): + _fields_ = [ + ("dwFileAttributes", wintypes.DWORD), + ("ftCreationTime", wintypes.FILETIME), + ("ftLastAccessTime", wintypes.FILETIME), + ("ftLastWriteTime", wintypes.FILETIME), + ("dwVolumeSerialNumber", wintypes.DWORD), + ("nFileSizeHigh", wintypes.DWORD), + ("nFileSizeLow", wintypes.DWORD), + ("nNumberOfLinks", wintypes.DWORD), + ("nFileIndexHigh", wintypes.DWORD), + ("nFileIndexLow", wintypes.DWORD), + ] + + kernel32.GetFileInformationByHandle.argtypes = [ + wintypes.HANDLE, + ctypes.POINTER(_ByHandleFileInformation), + ] + kernel32.GetFileInformationByHandle.restype = wintypes.BOOL + kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + kernel32.CloseHandle.restype = wintypes.BOOL + + generic_read = 0x80000000 + generic_write = 0x40000000 + file_share_all = 0x00000001 | 0x00000002 | 0x00000004 + create_new = 1 + open_existing = 3 + open_always = 4 + file_attribute_normal = 0x00000080 + file_attribute_reparse_point = 0x00000400 + file_flag_open_reparse_point = 0x00200000 + + if flags & os.O_RDWR: + desired_access = generic_read | generic_write + fd_flags = os.O_RDWR + elif flags & os.O_WRONLY: + desired_access = generic_write + fd_flags = os.O_WRONLY + else: + desired_access = generic_read + fd_flags = os.O_RDONLY + if flags & os.O_APPEND: + fd_flags |= os.O_APPEND + fd_flags |= getattr(os, "O_BINARY", 0) + creation_disposition = open_existing + if flags & os.O_CREAT: + creation_disposition = create_new if flags & os.O_EXCL else open_always + + handle = kernel32.CreateFileW( + str(path), + desired_access, + file_share_all, + None, + creation_disposition, + file_attribute_normal | file_flag_open_reparse_point, + None, + ) + invalid_handle = ctypes.c_void_p(-1).value + if handle == invalid_handle: + raise OSError(_("could not open file without following reparse point: {path}").format(path=path)) + try: + file_info = _ByHandleFileInformation() + if ( + not kernel32.GetFileInformationByHandle(handle, ctypes.byref(file_info)) + or file_info.dwFileAttributes & file_attribute_reparse_point + ): + raise OSError(_("refusing to follow symlink or reparse point: {path}").format(path=path)) + open_osfhandle: Any = getattr(msvcrt, "open_osfhandle", None) + if open_osfhandle is None: + raise OSError(_("could not open file without following reparse point: {path}").format(path=path)) + fd = open_osfhandle(handle, fd_flags) + handle = None + try: + fd_stat = os.fstat(fd) + if not stat.S_ISREG(fd_stat.st_mode): + raise OSError(_("refusing to open non-regular file: {path}").format(path=path)) + if flags & os.O_TRUNC: + os.ftruncate(fd, 0) + return fd + except Exception: + os.close(fd) + raise + finally: + if handle is not None: + kernel32.CloseHandle(handle) + + +def _open_append_binary(path: Path, *, create_mode: int | None = None): + mode = 0o666 if create_mode is None else create_mode + fd = _open_no_follow(path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, mode) + return os.fdopen(fd, "ab") + + +@contextmanager +def open_text_no_follow( + path: str | Path, + mode: str, + *, + encoding: str = "utf-8", + create_mode: int = 0o600, +) -> Iterator[TextIO]: + target = Path(path) + if mode == "r": + flags = os.O_RDONLY + elif mode == "a": + flags = os.O_WRONLY | os.O_APPEND | os.O_CREAT + target.parent.mkdir(parents=True, exist_ok=True) + elif mode == "w": + flags = os.O_WRONLY | os.O_TRUNC | os.O_CREAT + target.parent.mkdir(parents=True, exist_ok=True) + else: + raise ValueError("mode must be one of 'r', 'a', or 'w'") + + fd = _open_no_follow(target, flags, create_mode) + try: + handle = cast(TextIO, os.fdopen(fd, mode, encoding=encoding)) + except Exception: + os.close(fd) + raise + with handle: + yield handle + + +def write_text_no_follow( + path: str | Path, + content: str, + *, + encoding: str = "utf-8", + create_mode: int = 0o600, +) -> None: + with _path_lock(Path(path)): + with open_text_no_follow(path, "w", encoding=encoding, create_mode=create_mode) as handle: + handle.write(content) + + +def _open_lock_binary(path: Path): + _reject_symlink_or_reparse_leaf(path) + fd = _open_no_follow(path, os.O_RDWR | os.O_CREAT, 0o600) + return os.fdopen(fd, "a+b") + + def safe_replace(src: str | Path, dst: str | Path, *, attempts: int = 3, delay: float = 0.05) -> None: if attempts < 1: raise ValueError("attempts must be >= 1") @@ -146,10 +342,10 @@ def atomic_write_json( @contextmanager -def _cross_process_append_lock(path: Path) -> Iterator[None]: +def cross_process_append_lock(path: Path) -> Iterator[None]: lock_path = path.with_name(f".{path.name}.lock") lock_path.parent.mkdir(parents=True, exist_ok=True) - with lock_path.open("a+b") as lock_file: + with _open_lock_binary(lock_path) as lock_file: if sys.platform == "win32": import msvcrt @@ -176,6 +372,9 @@ def _cross_process_append_lock(path: Path) -> Iterator[None]: fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) +_cross_process_append_lock = cross_process_append_lock + + def append_jsonl_locked( path: str | Path, records: Iterable[dict[str, Any]], @@ -190,9 +389,9 @@ def append_jsonl_locked( if not lines: return with _path_lock(target): - with _cross_process_append_lock(target): + with cross_process_append_lock(target): created = not target.exists() - with target.open("ab") as handle: + with _open_append_binary(target) as handle: for line in lines: handle.write(line.encode("utf-8")) handle.flush() @@ -240,7 +439,7 @@ def append_jsonl_rotating_locked( if not lines: return with _path_lock(target): - with _cross_process_append_lock(target): + with cross_process_append_lock(target): try: pending_bytes = sum(len(line.encode("utf-8")) for line in lines) _rotate_jsonl_files( @@ -260,10 +459,3 @@ def append_jsonl_rotating_locked( os.fsync(handle.fileno()) if durable and created: fsync_parent_dir(target) - - -def _open_append_binary(path: Path, *, create_mode: int | None = None): - if create_mode is None: - return path.open("ab") - fd = os.open(path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, create_mode) - return os.fdopen(fd, "ab") diff --git a/tests/a2a/test_app.py b/tests/a2a/test_app.py index df1bbef5..2189a331 100644 --- a/tests/a2a/test_app.py +++ b/tests/a2a/test_app.py @@ -1,6 +1,7 @@ import asyncio import builtins import json +import threading from base64 import b64encode from pathlib import Path from types import SimpleNamespace @@ -33,11 +34,15 @@ resolve_token, run_server, ) +from iac_code.a2a.metrics import NoOpA2AMetrics from iac_code.a2a.persistence import A2AContextSnapshot, A2APersistenceStore, A2ATaskSnapshot +from iac_code.a2a.pipeline_executor import recoverable_task_id_from_sidecar from iac_code.a2a.pipeline_journal import A2APipelineJournal from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore, reduce_pipeline_events +from iac_code.a2a.task_store import A2ATaskStore from iac_code.a2a.transports.dispatcher import create_runtime_components from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType +from iac_code.services.session_backup import BackupReason, SessionBackupBlocked from iac_code.services.session_storage import SessionStorage from iac_code.types.stream_events import TextDeltaEvent, ToolResultEvent @@ -97,6 +102,36 @@ def test_health_route() -> None: assert response.json() == {"status": "healthy"} +@pytest.mark.asyncio +async def test_a2a_task_store_writes_session_snapshots_and_global_indexes(monkeypatch, tmp_path) -> None: + config_dir = tmp_path / "config" + cwd = tmp_path / "workspace" + cwd.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) + persistence = A2APersistenceStore(config_dir / "a2a") + store = A2ATaskStore(metrics=NoOpA2AMetrics(), persistence=persistence) + + ctx = await store.get_or_create_context( + context_id="ctx-1", + cwd=str(cwd), + runtime_factory=lambda _session_id: FakeRuntime(), + ) + task = await store.get_or_create_task(task_id="task-1", context_id=ctx.context_id) + task.state = "working" + store.mirror_task(task) + + session_dir = SessionStorage().session_dir(str(cwd), ctx.session_id) + session_task = json.loads((session_dir / "a2a" / "task.json").read_text(encoding="utf-8")) + session_context = json.loads((session_dir / "a2a" / "context.json").read_text(encoding="utf-8")) + + assert session_task["task_id"] == "task-1" + assert session_task["context_id"] == "ctx-1" + assert session_context["context_id"] == "ctx-1" + assert session_context["session_id"] == ctx.session_id + assert (config_dir / "a2a" / "tasks" / "task-1.json").exists() + assert (config_dir / "a2a" / "contexts" / "ctx-1.json").exists() + + def test_run_server_reports_aligned_missing_uvicorn_hint(monkeypatch, tmp_path) -> None: real_import = builtins.__import__ @@ -809,6 +844,7 @@ def test_streaming_v03_active_sidecar_mismatch_preserves_recoverable_error_data( persistence.save_context(A2AContextSnapshot(context_id="ctx-1", session_id=session_id, cwd=str(tmp_path))) persistence.save_task(A2ATaskSnapshot(task_id="task-owner", context_id="ctx-1", state="working")) persistence.save_task(A2ATaskSnapshot(task_id="task-new", context_id="ctx-1", state="input-required")) + SessionStorage().ensure_v2_session_dir_for_new_session(str(tmp_path), session_id) pipeline_dir = SessionStorage().session_dir(str(tmp_path), session_id) / "a2a" / "pipeline" owner_event = _pipeline_event(1, "evt-owner") @@ -1605,6 +1641,7 @@ def test_send_message_routes_context_only_pending_pipeline_input_after_restart(m persistence = A2APersistenceStore(persistence_dir) persistence.save_context(A2AContextSnapshot(context_id="ctx-1", session_id=session_id, cwd=str(tmp_path))) persistence.save_task(A2ATaskSnapshot(task_id="task-1", context_id="ctx-1", state="input-required")) + SessionStorage().ensure_v2_session_dir_for_new_session(str(tmp_path), session_id) pipeline_dir = SessionStorage().session_dir(str(tmp_path), session_id) / "a2a" / "pipeline" pending = _pipeline_pending_ask_event() @@ -1855,6 +1892,7 @@ async def test_cancel_input_required_pipeline_task_after_restart_marks_canceled( persistence = A2APersistenceStore(persistence_dir) persistence.save_context(A2AContextSnapshot(context_id="ctx-1", session_id=session_id, cwd=str(tmp_path))) persistence.save_task(A2ATaskSnapshot(task_id="task-1", context_id="ctx-1", state="input-required")) + SessionStorage().ensure_v2_session_dir_for_new_session(str(tmp_path), session_id) pipeline_dir = SessionStorage().session_dir(str(tmp_path), session_id) / "a2a" / "pipeline" pending = _pipeline_pending_ask_event() @@ -1882,7 +1920,169 @@ async def test_cancel_input_required_pipeline_task_after_restart_marks_canceled( assert snapshot["normalHandoff"]["outcome"] == "canceled" assert "Outcome: canceled" in snapshot["normalHandoff"]["summary"] events = A2APipelineJournal(pipeline_dir).read_all_repairing_tail() - assert [event["eventType"] for event in events[-2:]] == ["pipeline_canceled", "pipeline_handoff_ready"] + assert [event["eventType"] for event in events[-4:]] == [ + "pipeline_canceled", + "pipeline_handoff_ready", + "backup_committed", + "backup_committed", + ] + assert [event["data"]["committedEventType"] for event in events[-2:]] == [ + "pipeline_canceled", + "pipeline_handoff_ready", + ] + finally: + await components.aclose() + + +@pytest.mark.asyncio +async def test_cancel_input_required_pipeline_task_backup_blocked_returns_input_required(tmp_path: Path) -> None: + persistence_dir = tmp_path / "a2a" + session_id = "session-ctx-1" + persistence = A2APersistenceStore(persistence_dir) + persistence.save_context(A2AContextSnapshot(context_id="ctx-1", session_id=session_id, cwd=str(tmp_path))) + persistence.save_task(A2ATaskSnapshot(task_id="task-1", context_id="ctx-1", state="input-required")) + SessionStorage().ensure_v2_session_dir_for_new_session(str(tmp_path), session_id) + + pipeline_dir = SessionStorage().session_dir(str(tmp_path), session_id) / "a2a" / "pipeline" + pending = _pipeline_pending_ask_event() + A2APipelineJournal(pipeline_dir).append(pending) + A2APipelineSnapshotStore(pipeline_dir).save(reduce_pipeline_events([pending])) + + class BlockingBackupService: + def __init__(self) -> None: + self.calls: list[tuple[str, str, BackupReason, bool]] = [] + self.thread_ids: list[int] = [] + + def backup_session(self, cwd: str, session_id: str, *, reason: BackupReason, critical: bool) -> None: + self.calls.append((cwd, session_id, reason, critical)) + self.thread_ids.append(threading.get_ident()) + session_dir = SessionStorage().session_dir(cwd, session_id) + task_snapshot = json.loads((session_dir / "a2a" / "task.json").read_text(encoding="utf-8")) + context_snapshot = json.loads((session_dir / "a2a" / "context.json").read_text(encoding="utf-8")) + pipeline_snapshot = A2APipelineSnapshotStore(pipeline_dir).load() + assert task_snapshot["state"] == "input-required" + assert context_snapshot["session_id"] == session_id + assert context_snapshot["active_task_id"] is None + assert pipeline_snapshot is not None + assert pipeline_snapshot["status"] == "waiting_input" + assert pipeline_snapshot["pendingTerminal"]["eventType"] == "pipeline_canceled" + assert pipeline_snapshot["normalHandoff"] is None + assert pipeline_snapshot["pendingNormalHandoff"]["outcome"] == "canceled" + raise SessionBackupBlocked("copy failed secret_token=tok-live at /tmp/iac-code/cancel") + + backup_service = BlockingBackupService() + event_loop_thread_id = threading.get_ident() + components = create_runtime_components( + model="qwen3.6-plus", + host="127.0.0.1", + port=41242, + persistence_dir=persistence_dir, + backup_service=backup_service, + ) + call_context = ServerCallContext() + + try: + task = await components.handler.on_cancel_task(CancelTaskRequest(id="task-1"), call_context) + + assert isinstance(task, Task) + assert task.status.state == TaskState.TASK_STATE_INPUT_REQUIRED + assert persistence.load_task("task-1").state == "input-required" + assert backup_service.calls == [(str(tmp_path), session_id, BackupReason.TERMINAL, True)] + assert backup_service.thread_ids and backup_service.thread_ids[0] != event_loop_thread_id + snapshot = A2APipelineSnapshotStore(pipeline_dir).load() + assert snapshot is not None + assert snapshot["status"] == "waiting_input" + assert snapshot["pendingInput"]["kind"] == "ask_user_question" + assert snapshot["normalHandoff"] is None + assert snapshot["pendingNormalHandoff"] is None + assert snapshot["pendingTerminal"] is None + assert ( + recoverable_task_id_from_sidecar(cwd=str(tmp_path), session_id=session_id, context_id="ctx-1") == "task-1" + ) + assert ( + recoverable_task_id_from_sidecar( + cwd=str(tmp_path), + session_id=session_id, + context_id="ctx-1", + include_running=False, + ) + == "task-1" + ) + assert not await components.handler.agent_executor._should_route_pipeline_handoff_to_normal( + context_id="ctx-1", + cwd=str(tmp_path), + ) + events = A2APipelineJournal(pipeline_dir).read_all_repairing_tail() + assert events[-1]["eventType"] == "backup_blocked" + assert events[-1]["status"] == "input_required" + assert events[-1]["data"]["reason"] == "terminal" + assert events[-1]["data"]["recoverable"] is True + assert "tok-live" not in events[-1]["data"]["error"] + assert "/tmp/iac-code" not in events[-1]["data"]["error"] + finally: + await components.aclose() + + +@pytest.mark.asyncio +async def test_cancel_input_required_pipeline_task_backup_blocked_persist_failure_stays_input_required( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + persistence_dir = tmp_path / "a2a" + session_id = "session-ctx-1" + persistence = A2APersistenceStore(persistence_dir) + persistence.save_context(A2AContextSnapshot(context_id="ctx-1", session_id=session_id, cwd=str(tmp_path))) + persistence.save_task(A2ATaskSnapshot(task_id="task-1", context_id="ctx-1", state="input-required")) + + pipeline_dir = SessionStorage().session_dir(str(tmp_path), session_id) / "a2a" / "pipeline" + pending = _pipeline_pending_ask_event() + A2APipelineJournal(pipeline_dir).append(pending) + A2APipelineSnapshotStore(pipeline_dir).save(reduce_pipeline_events([pending])) + + class BlockingBackupService: + def backup_session(self, cwd: str, session_id: str, *, reason: BackupReason, critical: bool) -> None: + raise SessionBackupBlocked("copy failed secret_token=tok-live at /tmp/iac-code/cancel") + + original_append = A2APipelineJournal.append + + def fail_backup_blocked_append(self, event: dict, durable: bool = False): + if event.get("eventType") == "backup_blocked": + raise OSError("journal locked") + return original_append(self, event, durable=durable) + + monkeypatch.setattr(A2APipelineJournal, "append", fail_backup_blocked_append) + + components = create_runtime_components( + model="qwen3.6-plus", + host="127.0.0.1", + port=41242, + persistence_dir=persistence_dir, + backup_service=BlockingBackupService(), + ) + call_context = ServerCallContext() + + try: + task = await components.handler.on_cancel_task(CancelTaskRequest(id="task-1"), call_context) + + assert isinstance(task, Task) + assert task.status.state == TaskState.TASK_STATE_INPUT_REQUIRED + assert persistence.load_task("task-1").state == "input-required" + events = A2APipelineJournal(pipeline_dir).read_all_repairing_tail() + assert [event["eventType"] for event in events] == [ + "input_required", + "pipeline_canceled", + "pipeline_handoff_ready", + "pipeline_canceled", + "pipeline_handoff_ready", + "input_required", + ] + assert [event.get("visibility") for event in events[1:5]] == [ + "pending_backup", + "pending_backup", + "committed", + "committed", + ] + assert events[-1]["data"]["kind"] == "terminal_publication_unavailable" finally: await components.aclose() diff --git a/tests/a2a/test_artifacts.py b/tests/a2a/test_artifacts.py index 916e756c..feab773b 100644 --- a/tests/a2a/test_artifacts.py +++ b/tests/a2a/test_artifacts.py @@ -1,12 +1,24 @@ +from pathlib import Path + import pytest from iac_code.a2a.artifacts import ( A2AArtifactStore, UnsafeArtifactNameError, + artifact_store_for_session, sanitize_public_artifact_data, sanitize_public_artifact_text, sanitize_public_tool_output_data, ) +from iac_code.services.session_layout import UnsupportedSessionLayoutError +from iac_code.services.session_metadata import SESSION_LAYOUT_VERSION_V2, SessionMetadata, write_session_metadata + + +def _symlink_or_skip(target: Path, link: Path, *, target_is_directory: bool = False) -> None: + try: + link.symlink_to(target, target_is_directory=target_is_directory) + except (NotImplementedError, OSError) as exc: + pytest.skip(f"symlink creation unsupported: {exc}") def test_artifact_store_writes_text_and_metadata(tmp_path) -> None: @@ -26,6 +38,43 @@ def test_artifact_store_writes_text_and_metadata(tmp_path) -> None: assert store.path_for(metadata.artifact_id).read_text(encoding="utf-8").startswith("ROSTemplate") +def test_artifact_store_for_session_uses_session_a2a_artifacts_dir(tmp_path) -> None: + session_dir = tmp_path / "projects" / "p" / "session-1" + write_session_metadata( + session_dir, + SessionMetadata(session_id="session-1", cwd="/repo", layout_version=SESSION_LAYOUT_VERSION_V2), + ) + + store = artifact_store_for_session(session_dir) + + assert store.root == session_dir / "a2a" / "artifacts" + + +def test_artifact_store_for_session_rejects_symlink_artifacts_dir(tmp_path) -> None: + session_dir = tmp_path / "projects" / "p" / "session-1" + write_session_metadata( + session_dir, + SessionMetadata(session_id="session-1", cwd="/repo", layout_version=SESSION_LAYOUT_VERSION_V2), + ) + outside = tmp_path / "outside-artifacts" + outside.mkdir() + (session_dir / "a2a").mkdir() + _symlink_or_skip(outside, session_dir / "a2a" / "artifacts", target_is_directory=True) + + with pytest.raises(UnsupportedSessionLayoutError, match="session-owned path"): + artifact_store_for_session(session_dir) + + assert list(outside.iterdir()) == [] + + +def test_artifact_store_for_session_rejects_legacy_session_dir(tmp_path) -> None: + session_dir = tmp_path / "projects" / "p" / "session-1" + session_dir.mkdir(parents=True) + + with pytest.raises(UnsupportedSessionLayoutError): + artifact_store_for_session(session_dir) + + def test_artifact_store_writes_binary_and_metadata(tmp_path) -> None: store = A2AArtifactStore(tmp_path) diff --git a/tests/a2a/test_executor.py b/tests/a2a/test_executor.py index d5eb7b30..acf62639 100644 --- a/tests/a2a/test_executor.py +++ b/tests/a2a/test_executor.py @@ -1,5 +1,6 @@ import asyncio import base64 +import json import logging from pathlib import Path from types import SimpleNamespace @@ -9,15 +10,20 @@ from a2a.utils.errors import InvalidParamsError from google.protobuf.json_format import MessageToDict +from iac_code.a2a.backup import backup_session_async from iac_code.a2a.executor import IacCodeA2AExecutor from iac_code.a2a.exposure import A2AExposureType from iac_code.a2a.metrics import NoOpA2AMetrics from iac_code.a2a.persistence import A2AContextSnapshot, A2APersistenceStore, A2ATaskSnapshot +from iac_code.a2a.pipeline_executor import recoverable_task_id_from_sidecar from iac_code.a2a.pipeline_journal import A2APipelineJournal from iac_code.a2a.pipeline_paths import a2a_pipeline_dir_for_session +from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore from iac_code.a2a.task_store import A2ATaskStore from iac_code.agent.message import ImageBlock, TextBlock from iac_code.pipeline.engine.user_input import PipelineUserInput +from iac_code.services.session_backup import BackupReason, BackupResult, SessionBackupBlocked +from iac_code.services.session_storage import SessionStorage from iac_code.types.stream_events import PermissionRequestEvent, TextDeltaEvent, ToolResultEvent from .fakes import FakeAgentLoop, FakeEventQueue, FakeRequestContext, FakeRuntime, pending_future @@ -35,11 +41,139 @@ def _image_only_pipeline_input() -> PipelineUserInput: ) +def _ensure_v2_session(cwd: str, session_id: str) -> Path: + return SessionStorage().ensure_v2_session_dir_for_new_session(cwd, session_id) + + +class FailingBackupService: + def __init__(self) -> None: + self.calls: list[tuple[str, str, BackupReason, bool]] = [] + + def backup_session(self, cwd: str, session_id: str, *, reason: BackupReason, critical: bool) -> None: + self.calls.append((cwd, session_id, reason, critical)) + raise RuntimeError("backup destination unavailable") + + +class SnapshotReadingBackupService: + def __init__(self) -> None: + self.snapshots: list[tuple[BackupReason, dict, dict]] = [] + self.calls: list[tuple[str, str, BackupReason, bool]] = [] + + def backup_session(self, cwd: str, session_id: str, *, reason: BackupReason, critical: bool) -> None: + self.calls.append((cwd, session_id, reason, critical)) + session_dir = SessionStorage().session_dir(cwd, session_id) + task_snapshot = json.loads((session_dir / "a2a" / "task.json").read_text(encoding="utf-8")) + context_snapshot = json.loads((session_dir / "a2a" / "context.json").read_text(encoding="utf-8")) + self.snapshots.append((reason, task_snapshot, context_snapshot)) + + +class UnsuccessfulBackupService: + def __init__(self) -> None: + self.calls: list[tuple[str, str, BackupReason, bool]] = [] + + def backup_session(self, cwd: str, session_id: str, *, reason: BackupReason, critical: bool) -> BackupResult: + self.calls.append((cwd, session_id, reason, critical)) + return BackupResult(enabled=True, succeeded=False, error="backup destination unavailable") + + +class UnsuccessfulBackupServiceWithoutError: + def backup_session(self, cwd: str, session_id: str, *, reason: BackupReason, critical: bool) -> BackupResult: + return BackupResult(enabled=True, succeeded=False, error=None) + + +class BlockedBackupServiceWithRetries: + def backup_session(self, cwd: str, session_id: str, *, reason: BackupReason, critical: bool) -> BackupResult: + raise SessionBackupBlocked("backup destination unavailable", retry_count=2) + + +class BackupBlockedMetrics(NoOpA2AMetrics): + def __init__(self) -> None: + self.backup_blocked: list[tuple[str, bool]] = [] + self.backup_failed: list[tuple[str, bool, int]] = [] + self.backup_succeeded: list[tuple[str, bool, int]] = [] + self.executor_error = 0 + self.task_canceled = 0 + self.task_failed = 0 + + def record_backup_blocked(self, *, reason: str, recoverable: bool) -> None: + self.backup_blocked.append((reason, recoverable)) + + def record_backup_failed(self, *, reason: str, critical: bool, retry_count: int) -> None: + self.backup_failed.append((reason, critical, retry_count)) + + def record_backup_succeeded(self, *, reason: str, critical: bool, retry_count: int) -> None: + self.backup_succeeded.append((reason, critical, retry_count)) + + def record_executor_error(self) -> None: + self.executor_error += 1 + + def record_task_canceled(self) -> None: + self.task_canceled += 1 + + def record_task_failed(self) -> None: + self.task_failed += 1 + + +class ExplodingBackupMetrics(NoOpA2AMetrics): + def record_backup_blocked(self, *, reason: str, recoverable: bool) -> None: + raise RuntimeError("metrics sink failed with token=abc123") + + def record_backup_failed(self, *, reason: str, critical: bool, retry_count: int) -> None: + raise RuntimeError("metrics sink failed with token=abc123") + + def record_backup_succeeded(self, *, reason: str, critical: bool, retry_count: int) -> None: + raise RuntimeError("metrics sink failed with token=abc123") + + @pytest.fixture(autouse=True) def default_normal_mode(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("IAC_CODE_MODE", raising=False) +@pytest.mark.asyncio +async def test_backup_session_async_uses_translatable_fallback_for_missing_error() -> None: + with pytest.raises(SessionBackupBlocked, match="Session backup failed"): + await backup_session_async( + UnsuccessfulBackupServiceWithoutError(), + "/repo", + "session-1", + reason=BackupReason.TERMINAL, + critical=True, + ) + + +@pytest.mark.asyncio +async def test_backup_session_async_records_retry_count_from_blocked_exception() -> None: + metrics = BackupBlockedMetrics() + + with pytest.raises(SessionBackupBlocked): + await backup_session_async( + BlockedBackupServiceWithRetries(), + "/repo", + "session-1", + reason=BackupReason.TERMINAL, + critical=True, + metrics=metrics, + ) + + assert metrics.backup_failed == [(BackupReason.TERMINAL.value, True, 2)] + + +@pytest.mark.asyncio +async def test_backup_session_async_swallows_metrics_errors_for_noncritical_failure() -> None: + result = await backup_session_async( + UnsuccessfulBackupService(), + "/repo", + "session-1", + reason=BackupReason.NORMAL_TURN_END, + critical=False, + metrics=ExplodingBackupMetrics(), + ) + + assert result is not None + assert result.succeeded is False + + @pytest.mark.asyncio async def test_executor_runs_prompt_and_finishes_input_required( monkeypatch: pytest.MonkeyPatch, tmp_path: Path @@ -50,7 +184,8 @@ async def test_executor_runs_prompt_and_finishes_input_required( monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) store = A2ATaskStore(metrics=NoOpA2AMetrics()) - executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + backup_service = SnapshotReadingBackupService() + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus", backup_service=backup_service) queue = FakeEventQueue() context = FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}) @@ -65,6 +200,271 @@ async def test_executor_runs_prompt_and_finishes_input_required( assert "".join(record.output_text) == "hi" +@pytest.mark.asyncio +async def test_executor_runs_noncritical_backup_after_normal_turn_without_failing_task( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + loop = FakeAgentLoop([TextDeltaEvent(text="hi")]) + runtime = FakeRuntime(agent_loop=loop, session_id="session-1") + backup_service = FailingBackupService() + metrics = BackupBlockedMetrics() + + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor( + task_store=store, + model="qwen3.6-plus", + metrics=metrics, + backup_service=backup_service, + ) + queue = FakeEventQueue() + + await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), queue) + + assert [(reason, critical) for *_ids, reason, critical in backup_service.calls] == [ + (BackupReason.NORMAL_TURN_END, False) + ] + assert dump(queue.events[-1])["status"]["state"] == "TASK_STATE_INPUT_REQUIRED" + record = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + assert record.state == "input-required" + assert "A2A session backup failed" in caplog.text + assert "reason=normal_turn_end" in caplog.text + assert "retry_count=0" in caplog.text + assert metrics.backup_failed == [(BackupReason.NORMAL_TURN_END.value, False, 0)] + + +@pytest.mark.asyncio +async def test_executor_mirrors_input_required_before_normal_turn_backup( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + loop = FakeAgentLoop([TextDeltaEvent(text="hi")]) + runtime = FakeRuntime(agent_loop=loop, session_id="session-1") + backup_service = SnapshotReadingBackupService() + + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + + executor = IacCodeA2AExecutor( + task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), + model="qwen3.6-plus", + backup_service=backup_service, + ) + + await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), FakeEventQueue()) + + assert len(backup_service.snapshots) == 1 + reason, task_snapshot, context_snapshot = backup_service.snapshots[0] + assert reason == BackupReason.NORMAL_TURN_END + assert task_snapshot["state"] == "input-required" + assert context_snapshot["active_task_id"] is None + + +@pytest.mark.asyncio +async def test_executor_mirrors_failed_terminal_before_terminal_backup( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + class ExplodingLoop: + async def run_streaming(self, prompt: str): + raise RuntimeError("boom") + yield TextDeltaEvent(text="never") + + runtime = FakeRuntime(agent_loop=ExplodingLoop(), session_id="session-1") + backup_service = SnapshotReadingBackupService() + + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + + executor = IacCodeA2AExecutor( + task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), + model="qwen3.6-plus", + backup_service=backup_service, + ) + + await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), FakeEventQueue()) + + assert len(backup_service.snapshots) == 1 + reason, task_snapshot, context_snapshot = backup_service.snapshots[0] + assert reason == BackupReason.TERMINAL + assert task_snapshot["state"] == "failed" + assert context_snapshot["active_task_id"] is None + assert [(reason, critical) for *_ids, reason, critical in backup_service.calls] == [(BackupReason.TERMINAL, True)] + record = await executor._task_store.get_or_create_task(task_id="task-1", context_id="ctx-1") + assert record.state == "failed" + + +@pytest.mark.asyncio +async def test_executor_failed_terminal_backup_result_blocks_terminal_status( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + class ExplodingLoop: + async def run_streaming(self, prompt: str): + raise RuntimeError("boom") + yield TextDeltaEvent(text="never") + + runtime = FakeRuntime(agent_loop=ExplodingLoop(), session_id="session-1") + backup_service = UnsuccessfulBackupService() + metrics = BackupBlockedMetrics() + + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor( + task_store=store, + model="qwen3.6-plus", + metrics=metrics, + backup_service=backup_service, + ) + queue = FakeEventQueue() + + await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), queue) + + assert [(reason, critical) for *_ids, reason, critical in backup_service.calls] == [(BackupReason.TERMINAL, True)] + states = [dump(event)["status"]["state"] for event in queue.events if isinstance(event, TaskStatusUpdateEvent)] + assert states[-1] == "TASK_STATE_INPUT_REQUIRED" + assert "TASK_STATE_FAILED" not in states + record = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + assert record.state == "input-required" + terminal_update = dump(queue.events[-1]) + backup_metadata = terminal_update["metadata"]["iac_code"]["backupBlocked"] + assert backup_metadata["reason"] == BackupReason.TERMINAL.value + assert backup_metadata["blockedTerminalState"] == "failed" + assert backup_metadata["recoverable"] is True + assert metrics.backup_blocked == [(BackupReason.TERMINAL.value, True)] + assert metrics.backup_failed == [(BackupReason.TERMINAL.value, True, 0)] + assert metrics.executor_error == 1 + assert metrics.task_failed == 1 + assert metrics.task_canceled == 0 + + +@pytest.mark.asyncio +async def test_executor_terminal_backup_blocked_ignores_metrics_sink_failure( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + class ExplodingLoop: + async def run_streaming(self, prompt: str): + raise RuntimeError("boom") + yield TextDeltaEvent(text="never") + + runtime = FakeRuntime(agent_loop=ExplodingLoop(), session_id="session-1") + backup_service = UnsuccessfulBackupService() + + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + + executor = IacCodeA2AExecutor( + task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), + model="qwen3.6-plus", + metrics=ExplodingBackupMetrics(), + backup_service=backup_service, + ) + queue = FakeEventQueue() + + await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), queue) + + states = [dump(event)["status"]["state"] for event in queue.events if isinstance(event, TaskStatusUpdateEvent)] + assert states[-1] == "TASK_STATE_INPUT_REQUIRED" + terminal_update = dump(queue.events[-1]) + backup_metadata = terminal_update["metadata"]["iac_code"]["backupBlocked"] + assert backup_metadata["reason"] == BackupReason.TERMINAL.value + + +@pytest.mark.asyncio +async def test_executor_mirrors_canceled_terminal_before_terminal_backup( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + started = asyncio.Event() + + class BlockingLoop: + async def run_streaming(self, prompt: str): + started.set() + await asyncio.Future() + yield TextDeltaEvent(text="never") + + runtime = FakeRuntime(agent_loop=BlockingLoop(), session_id="session-1") + backup_service = SnapshotReadingBackupService() + + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + + executor = IacCodeA2AExecutor( + task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), + model="qwen3.6-plus", + backup_service=backup_service, + ) + execute_task = asyncio.create_task( + executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), FakeEventQueue()) + ) + await started.wait() + + execute_task.cancel() + await execute_task + + assert len(backup_service.snapshots) == 1 + reason, task_snapshot, context_snapshot = backup_service.snapshots[0] + assert reason == BackupReason.TERMINAL + assert task_snapshot["state"] == "canceled" + assert context_snapshot["active_task_id"] is None + assert [(reason, critical) for *_ids, reason, critical in backup_service.calls] == [(BackupReason.TERMINAL, True)] + record = await executor._task_store.get_or_create_task(task_id="task-1", context_id="ctx-1") + assert record.state == "canceled" + + +@pytest.mark.asyncio +async def test_executor_canceled_terminal_backup_blocked_records_cancel_intent( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + started = asyncio.Event() + + class BlockingLoop: + async def run_streaming(self, prompt: str): + started.set() + await asyncio.Future() + yield TextDeltaEvent(text="never") + + runtime = FakeRuntime(agent_loop=BlockingLoop(), session_id="session-1") + backup_service = UnsuccessfulBackupService() + metrics = BackupBlockedMetrics() + + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor( + task_store=store, + model="qwen3.6-plus", + metrics=metrics, + backup_service=backup_service, + ) + queue = FakeEventQueue() + execute_task = asyncio.create_task( + executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), queue) + ) + await started.wait() + + execute_task.cancel() + await execute_task + + assert [(reason, critical) for *_ids, reason, critical in backup_service.calls] == [(BackupReason.TERMINAL, True)] + states = [dump(event)["status"]["state"] for event in queue.events if isinstance(event, TaskStatusUpdateEvent)] + assert states[-1] == "TASK_STATE_INPUT_REQUIRED" + assert "TASK_STATE_CANCELED" not in states + terminal_update = dump(queue.events[-1]) + backup_metadata = terminal_update["metadata"]["iac_code"]["backupBlocked"] + assert backup_metadata["reason"] == BackupReason.TERMINAL.value + assert backup_metadata["blockedTerminalState"] == "canceled" + record = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + assert record.state == "input-required" + assert metrics.backup_blocked == [(BackupReason.TERMINAL.value, True)] + assert metrics.backup_failed == [(BackupReason.TERMINAL.value, True, 0)] + assert metrics.task_canceled == 1 + assert metrics.executor_error == 0 + assert metrics.task_failed == 0 + + @pytest.mark.asyncio async def test_executor_publishes_mcp_warnings(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: runtime = FakeRuntime( @@ -77,7 +477,8 @@ async def test_executor_publishes_mcp_warnings(monkeypatch: pytest.MonkeyPatch, monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) store = A2ATaskStore(metrics=NoOpA2AMetrics()) - executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + backup_service = SnapshotReadingBackupService() + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus", backup_service=backup_service) queue = FakeEventQueue() await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), queue) @@ -137,7 +538,8 @@ def factory(options): monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", factory) store = A2ATaskStore(metrics=NoOpA2AMetrics()) - executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + backup_service = SnapshotReadingBackupService() + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus", backup_service=backup_service) queue = FakeEventQueue() await executor.execute( @@ -557,6 +959,7 @@ async def test_executor_hydrates_running_pipeline_task_id_from_sidecar( persistence = A2APersistenceStore(tmp_path / "a2a-state") persistence.save_context(A2AContextSnapshot(context_id="ctx-1", session_id="session-1", cwd=str(tmp_path))) persistence.save_task(A2ATaskSnapshot(task_id="task-1", context_id="ctx-1", state="working")) + _ensure_v2_session(str(tmp_path), "session-1") journal = A2APipelineJournal(a2a_pipeline_dir_for_session(cwd=str(tmp_path), session_id="session-1")) journal.append( { @@ -1141,6 +1544,7 @@ async def test_pipeline_handoff_context_routes_followup_to_normal_after_restart( context_id = "ctx-handoff" persistence = A2APersistenceStore(tmp_path / "a2a") persistence.save_context(A2AContextSnapshot(context_id=context_id, session_id=session_id, cwd=str(cwd))) + _ensure_v2_session(str(cwd), session_id) A2APipelineSnapshotStore(a2a_pipeline_dir_for_session(cwd=str(cwd), session_id=session_id)).save( { "normalHandoff": { @@ -1185,6 +1589,105 @@ async def execute(self, **kwargs) -> None: assert any(getattr(message, "content", "") == "[Pipeline Handoff Context]" for message in seen_resume[0]) +@pytest.mark.asyncio +async def test_pipeline_handoff_route_replays_newer_backup_blocked_journal( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + + cwd = tmp_path / "ws" + cwd.mkdir() + session_id = "session-stale-blocked" + context_id = "ctx-stale-blocked" + task_id = "task-stale-blocked" + persistence = A2APersistenceStore(tmp_path / "a2a") + persistence.save_context(A2AContextSnapshot(context_id=context_id, session_id=session_id, cwd=str(cwd))) + _ensure_v2_session(str(cwd), session_id) + + def event(sequence: int, event_type: str, status: str, *, data: dict | None = None) -> dict: + return { + "schemaVersion": "1.0", + "eventId": f"evt-{sequence}", + "sequence": sequence, + "eventType": event_type, + "scope": "pipeline", + "pipelineRunId": context_id, + "taskId": task_id, + "contextId": context_id, + "pipelineName": "selling", + "status": status, + "data": data or {}, + } + + pending_input = { + "inputId": "ask-ask-1", + "kind": "ask_user_question", + "toolUseId": "ask-1", + "question": "请选择部署目标", + "options": [{"id": "nginx", "label": "Nginx 网站"}], + "allowFreeText": True, + } + input_required = event(1, "input_required", "input_required", data={"kind": "ask_user_question"}) + input_required["input"] = pending_input + canceled = event(2, "pipeline_canceled", "canceled", data={"source": "a2a_cancel"}) + handoff = event( + 3, + "pipeline_handoff_ready", + "canceled", + data={ + "action": "switch_to_normal", + "targetMode": "normal", + "outcome": "canceled", + "summary": "[Pipeline Handoff Context]\nOutcome: canceled", + }, + ) + backup_blocked = event( + 4, + "backup_blocked", + "input_required", + data={"reason": "terminal", "error": "Backup blocked.", "recoverable": True}, + ) + backup_blocked["input"] = pending_input + + pipeline_dir = a2a_pipeline_dir_for_session(cwd=str(cwd), session_id=session_id) + journal = A2APipelineJournal(pipeline_dir) + journal.append_many([input_required, canceled, handoff, backup_blocked], durable=True) + snapshot_store = A2APipelineSnapshotStore(pipeline_dir) + snapshot_store.save( + { + "schemaVersion": "1.1", + "pipelineRunId": context_id, + "taskId": task_id, + "contextId": context_id, + "pipelineName": "selling", + "status": "canceled", + "lastSequence": 3, + "pendingInput": None, + "normalHandoff": { + "action": "switch_to_normal", + "targetMode": "normal", + "outcome": "canceled", + "summary": "[Pipeline Handoff Context]\nOutcome: canceled", + }, + } + ) + + executor = IacCodeA2AExecutor( + task_store=A2ATaskStore(metrics=NoOpA2AMetrics(), persistence=persistence), + model="qwen3.6-plus", + ) + + assert not await executor._should_route_pipeline_handoff_to_normal(context_id=context_id, cwd=str(cwd)) + repaired = snapshot_store.load() + assert repaired is not None + assert repaired["status"] == "waiting_input" + assert repaired["lastSequence"] == 4 + assert repaired["pendingInput"]["kind"] == "ask_user_question" + assert repaired["normalHandoff"] is None + assert recoverable_task_id_from_sidecar(cwd=str(cwd), session_id=session_id, context_id=context_id) == task_id + + @pytest.mark.asyncio async def test_pipeline_handoff_image_request_passes_image_blocks_to_normal_agent( monkeypatch: pytest.MonkeyPatch, @@ -1212,6 +1715,7 @@ async def test_pipeline_handoff_image_request_passes_image_blocks_to_normal_agen context_id = "ctx-handoff" persistence = A2APersistenceStore(tmp_path / "a2a") persistence.save_context(A2AContextSnapshot(context_id=context_id, session_id=session_id, cwd=str(cwd))) + _ensure_v2_session(str(cwd), session_id) A2APipelineSnapshotStore(a2a_pipeline_dir_for_session(cwd=str(cwd), session_id=session_id)).save( {"normalHandoff": {"action": "switch_to_normal", "targetMode": "normal", "summary": "handoff"}} ) @@ -1283,6 +1787,7 @@ async def test_pipeline_handoff_context_is_backfilled_from_snapshot_when_session summary = "[Pipeline Handoff Context]\nPipeline: selling" persistence = A2APersistenceStore(tmp_path / "a2a") persistence.save_context(A2AContextSnapshot(context_id=context_id, session_id=session_id, cwd=str(cwd))) + _ensure_v2_session(str(cwd), session_id) A2APipelineSnapshotStore(a2a_pipeline_dir_for_session(cwd=str(cwd), session_id=session_id)).save( { "normalHandoff": { @@ -1352,6 +1857,7 @@ async def test_pipeline_handoff_context_routes_and_backfills_public_summary_from cleanup_prompt = "cleanup prompt for stack-123" persistence = A2APersistenceStore(tmp_path / "a2a") persistence.save_context(A2AContextSnapshot(context_id=context_id, session_id=session_id, cwd=str(cwd))) + _ensure_v2_session(str(cwd), session_id) pipeline_dir = a2a_pipeline_dir_for_session(cwd=str(cwd), session_id=session_id) pipeline_dir.mkdir(parents=True, exist_ok=True) (pipeline_dir / "a2a-snapshot.json").write_text("{broken", encoding="utf-8") @@ -1453,8 +1959,7 @@ def raise_auth_error(options): dumped = dump(queue.events[-1]) assert dumped["status"]["state"] == "TASK_STATE_FAILED" assert ( - dumped["status"]["message"]["parts"][0]["text"] - == "Authentication required. Please configure your API credentials." + dumped["status"]["message"]["parts"][0]["text"] == "Authentication required. Configure credentials and retry." ) @@ -1469,7 +1974,8 @@ async def run_streaming(self, prompt: str): monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) store = A2ATaskStore(metrics=NoOpA2AMetrics()) - executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + backup_service = SnapshotReadingBackupService() + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus", backup_service=backup_service) queue = FakeEventQueue() context = FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}) @@ -1478,6 +1984,13 @@ async def run_streaming(self, prompt: str): dumped = dump(queue.events[-1]) assert dumped["status"]["state"] == "TASK_STATE_INPUT_REQUIRED" assert dumped["status"]["message"]["parts"][0]["text"] == "A temporary error occurred. Please retry." + assert [(reason, critical) for *_ids, reason, critical in backup_service.calls] == [ + (BackupReason.INPUT_REQUIRED, False) + ] + reason, task_snapshot, context_snapshot = backup_service.snapshots[0] + assert reason == BackupReason.INPUT_REQUIRED + assert task_snapshot["state"] == "input-required" + assert context_snapshot["active_task_id"] is None @pytest.mark.asyncio @@ -1625,8 +2138,7 @@ async def run_streaming(self, prompt: str): dumped = dump(queue.events[-1]) assert dumped["status"]["state"] == "TASK_STATE_FAILED" assert ( - dumped["status"]["message"]["parts"][0]["text"] - == "Authentication required. Please configure your API credentials." + dumped["status"]["message"]["parts"][0]["text"] == "Authentication required. Configure credentials and retry." ) diff --git a/tests/a2a/test_jsonrpc_passthrough.py b/tests/a2a/test_jsonrpc_passthrough.py index 0d5f0fbe..38803b6f 100644 --- a/tests/a2a/test_jsonrpc_passthrough.py +++ b/tests/a2a/test_jsonrpc_passthrough.py @@ -17,6 +17,9 @@ def sentinel_build_error_response(request_id: str | int | None, error: Any) -> d import iac_code.a2a.pipeline_executor as pipeline_executor importlib.reload(pipeline_executor) + import iac_code.a2a.executor as executor_module + + executor_module.IacCodeA2APipelineExecutor = pipeline_executor.IacCodeA2APipelineExecutor assert response_helpers.build_error_response is sentinel_build_error_response assert jsonrpc_dispatcher.build_error_response is sentinel_build_error_response diff --git a/tests/a2a/test_metrics.py b/tests/a2a/test_metrics.py index 8142b049..cda518de 100644 --- a/tests/a2a/test_metrics.py +++ b/tests/a2a/test_metrics.py @@ -10,6 +10,9 @@ def test_noop_metrics_records_events() -> None: metrics.record_task_failed() metrics.record_context_evicted() metrics.record_executor_error() + metrics.record_backup_blocked(reason="terminal", recoverable=True) + metrics.record_backup_succeeded(reason="normal_turn_end", critical=False, retry_count=0) + metrics.record_backup_failed(reason="terminal", critical=True, retry_count=2) def test_noop_metrics_support_push_delivery_hooks() -> None: diff --git a/tests/a2a/test_pipeline_events.py b/tests/a2a/test_pipeline_events.py index 5c4886be..a182a2ef 100644 --- a/tests/a2a/test_pipeline_events.py +++ b/tests/a2a/test_pipeline_events.py @@ -118,6 +118,32 @@ def test_pipeline_warning_translates_to_non_terminal_envelope() -> None: assert "load_error" not in envelope["data"] +def test_backup_blocked_translates_to_recoverable_envelope() -> None: + translator = PipelineEventTranslator(_ctx()) + + [envelope] = translator.translate( + PipelineEvent( + type=PipelineEventType.BACKUP_BLOCKED, + step_id="confirm_and_select", + timestamp=1717821600.0, + data={ + "reason": "pipeline_step_completed", + "error": "backup unavailable", + "recoverable": True, + }, + ) + ) + + assert envelope["eventType"] == "backup_blocked" + assert envelope["scope"] == "pipeline" + assert envelope["status"] == "input_required" + assert envelope["data"] == { + "reason": "pipeline_step_completed", + "error": "backup unavailable", + "recoverable": True, + } + + def test_manual_cleanup_event_normalizes_cleanup_data_keys() -> None: translator = PipelineEventTranslator(_ctx()) diff --git a/tests/a2a/test_pipeline_executor.py b/tests/a2a/test_pipeline_executor.py index 480867a6..2e517d54 100644 --- a/tests/a2a/test_pipeline_executor.py +++ b/tests/a2a/test_pipeline_executor.py @@ -3,7 +3,9 @@ import asyncio import contextlib import json +import os import shutil +import threading from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock @@ -12,6 +14,7 @@ from a2a.types import TaskStatusUpdateEvent from google.protobuf.json_format import MessageToDict +from iac_code.a2a.artifacts import A2AArtifactStore from iac_code.a2a.executor import IacCodeA2AExecutor from iac_code.a2a.metrics import NoOpA2AMetrics from iac_code.a2a.persistence import A2APersistenceStore @@ -22,7 +25,11 @@ from iac_code.pipeline.engine.cleanup import CleanupLedger, CleanupResource from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType from iac_code.pipeline.engine.interrupt import InterruptVerdict -from iac_code.pipeline.engine.user_input import PipelineUserInput +from iac_code.pipeline.engine.user_input import PipelineUserInput, normalize_pipeline_user_input +from iac_code.services.session_backup import BackupReason, BackupResult, SessionBackupBlocked +from iac_code.services.session_layout import UnsupportedSessionLayoutError +from iac_code.services.session_metadata import SESSION_LAYOUT_VERSION_V2, SessionMetadata, write_session_metadata +from iac_code.services.session_storage import SessionStorage from iac_code.types.stream_events import AskUserQuestionEvent, TextDeltaEvent from .fakes import FakeEventQueue, FakeRequestContext @@ -72,6 +79,70 @@ def test_active_sidecar_mismatch_error_serializes_raw_jsonrpc_data() -> None: } +@pytest.mark.asyncio +async def test_sync_backup_blocked_sidecar_warning_omits_mounted_paths(monkeypatch: pytest.MonkeyPatch) -> None: + from iac_code.a2a import pipeline_executor as module + + warning_calls = [] + + class FailingPipeline: + def _save_backup_blocked_sidecar(self, step_id, reason): + raise OSError("failed at /mnt/oss/customer-bucket/sensitive-session-id/pipeline/meta.yaml") + + monkeypatch.setattr(module.logger, "warning", lambda *args, **kwargs: warning_calls.append(args)) + + result = await module._sync_pipeline_backup_blocked_sidecar( + FailingPipeline(), + reason=BackupReason.PIPELINE_STEP_COMPLETED, + step_id="step-1", + ) + + assert result is False + assert warning_calls + logged = " ".join(str(arg) for arg in warning_calls[0]) + assert "OSError" in logged + assert "/mnt/oss" not in logged + assert "customer-bucket" not in logged + assert "sensitive-session-id" not in logged + + +def test_sidecar_matches_backup_blocked_pending_input(tmp_path: Path) -> None: + from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator + from iac_code.a2a.pipeline_executor import _sidecar_matches_task + from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher + + pipeline_dir = tmp_path / "pipeline" + context = PipelineA2AContext( + pipeline_run_id="ctx-1", + task_id="task-1", + context_id="ctx-1", + pipeline_name="selling", + ) + translator = PipelineEventTranslator(context) + backup_blocked = translator.manual_event( + "backup_blocked", + "pipeline", + status="input_required", + data={"reason": "terminal", "error": "copy failed", "recoverable": True}, + ) + journal = A2APipelineJournal(pipeline_dir) + journal.append(backup_blocked, durable=True) + A2APipelineSnapshotStore(pipeline_dir).save(reduce_pipeline_events([backup_blocked])) + publisher = PipelineA2AEventPublisher( + event_queue=FakeEventQueue(), + translator=translator, + journal=journal, + snapshot_store=A2APipelineSnapshotStore(pipeline_dir), + ) + + assert _sidecar_matches_task( + publisher, + task_id="task-1", + context_id="ctx-1", + sidecar_status="backup_blocked", + ) + + def dump(event): return MessageToDict(event, preserving_proto_field_name=False) @@ -133,6 +204,143 @@ def build_normal_handoff_summary(self, data: dict) -> str: return self.handoff_summary +@pytest.mark.asyncio +@pytest.mark.parametrize("legacy_sidecar_status", ["waiting_input", "completed", "failed"]) +async def test_select_stream_promotes_backup_blocked_pending_input_for_legacy_sidecar( + tmp_path: Path, + legacy_sidecar_status: str, +) -> None: + from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator + from iac_code.a2a.pipeline_executor import IacCodeA2APipelineExecutor + from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher + + pipeline_dir = tmp_path / "pipeline" + context = PipelineA2AContext( + pipeline_run_id="ctx-1", + task_id="task-1", + context_id="ctx-1", + pipeline_name="selling", + ) + translator = PipelineEventTranslator(context) + backup_blocked = translator.manual_event( + "backup_blocked", + "pipeline", + status="input_required", + data={"reason": "terminal", "error": "copy failed", "recoverable": True}, + ) + journal = A2APipelineJournal(pipeline_dir) + journal.append(backup_blocked, durable=True) + snapshot_store = A2APipelineSnapshotStore(pipeline_dir) + snapshot_store.save(reduce_pipeline_events([backup_blocked])) + publisher = PipelineA2AEventPublisher( + event_queue=FakeEventQueue(), + translator=translator, + journal=journal, + snapshot_store=snapshot_store, + ) + pipeline = FakePipeline([], session_dir=pipeline_dir) + pipeline.sidecar_status = legacy_sidecar_status + executor = IacCodeA2APipelineExecutor( + task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), + model="qwen3.6-plus", + metrics=NoOpA2AMetrics(), + artifact_store=None, + push_notifier=None, + permission_resolver=None, + auto_approve_permissions=False, + thinking_exposure_types=None, + ) + + selected = await executor._select_stream( + pipeline, + "retry", + pipeline_input=normalize_pipeline_user_input("retry"), + publisher=publisher, + task_id="task-1", + context_id="ctx-1", + fresh_pipeline_factory=lambda: FakePipeline([], session_dir=pipeline_dir), + ) + + assert selected.pipeline is pipeline + assert pipeline.sidecar_status == "backup_blocked" + assert pipeline.continue_calls == 1 + assert pipeline.continue_inputs == ["retry"] + + +@pytest.mark.asyncio +async def test_select_stream_rejects_when_backup_blocked_sidecar_resync_fails(tmp_path: Path) -> None: + from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator + from iac_code.a2a.pipeline_executor import IacCodeA2APipelineExecutor, RecoverablePipelineInvalidParamsError + from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher + + class FailingSyncPipeline(FakePipeline): + def _save_backup_blocked_sidecar(self, step_id, reason): + return False + + pipeline_dir = tmp_path / "pipeline" + context = PipelineA2AContext( + pipeline_run_id="ctx-1", + task_id="task-1", + context_id="ctx-1", + pipeline_name="selling", + ) + translator = PipelineEventTranslator(context) + backup_blocked = translator.manual_event( + "backup_blocked", + "pipeline", + status="input_required", + data={"reason": "terminal", "error": "copy failed", "recoverable": True}, + ) + journal = A2APipelineJournal(pipeline_dir) + journal.append(backup_blocked, durable=True) + snapshot_store = A2APipelineSnapshotStore(pipeline_dir) + snapshot_store.save(reduce_pipeline_events([backup_blocked])) + publisher = PipelineA2AEventPublisher( + event_queue=FakeEventQueue(), + translator=translator, + journal=journal, + snapshot_store=snapshot_store, + ) + pipeline = FailingSyncPipeline([], session_dir=pipeline_dir) + pipeline.sidecar_status = "waiting_input" + executor = IacCodeA2APipelineExecutor( + task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), + model="qwen3.6-plus", + metrics=NoOpA2AMetrics(), + artifact_store=None, + push_notifier=None, + permission_resolver=None, + auto_approve_permissions=False, + thinking_exposure_types=None, + ) + + with pytest.raises(RecoverablePipelineInvalidParamsError) as exc_info: + await executor._select_stream( + pipeline, + "retry", + pipeline_input=normalize_pipeline_user_input("retry"), + publisher=publisher, + task_id="task-1", + context_id="ctx-1", + fresh_pipeline_factory=lambda: FakePipeline([], session_dir=pipeline_dir), + ) + + assert exc_info.value.data["recoverableTaskId"] == "task-1" + assert exc_info.value.data["sidecarStatus"] == "backup_blocked" + assert pipeline.continue_calls == 0 + + +class TerminalSidecarAfterCompletionPipeline(FakePipeline): + async def run(self, prompt: str): + self.run_prompts.append(_display_text(prompt)) + for event in self.events: + if isinstance(event, BaseException): + raise event + yield event + if isinstance(event, PipelineEvent) and event.type == PipelineEventType.PIPELINE_COMPLETED: + self.sidecar_status = "completed" + + class CloseableEventStream: def __init__(self, events, *, wait_until_closed: bool = True) -> None: self.events = list(events) @@ -166,6 +374,77 @@ def unregister(self, tool_name: str) -> None: pass +class RecordingBackupService: + def __init__( + self, + *, + block_reasons: set[BackupReason] | None = None, + expected_task_states: dict[BackupReason, str] | None = None, + expected_handoff_summaries: dict[BackupReason, str] | None = None, + pipeline_snapshot_dir: Path | None = None, + on_backup=None, + ) -> None: + self.block_reasons = block_reasons or set() + self.expected_task_states = expected_task_states or {} + self.expected_handoff_summaries = expected_handoff_summaries or {} + self.pipeline_snapshot_dir = pipeline_snapshot_dir + self.on_backup = on_backup + self.calls: list[tuple[str, str, BackupReason, bool]] = [] + self.session_snapshots: list[tuple[BackupReason, dict, dict]] = [] + self.pipeline_snapshots: list[tuple[BackupReason, dict]] = [] + + def backup_session(self, cwd: str, session_id: str, *, reason: BackupReason, critical: bool) -> None: + self.calls.append((cwd, session_id, reason, critical)) + session_dir = SessionStorage().session_dir(cwd, session_id) + task_snapshot = json.loads((session_dir / "a2a" / "task.json").read_text(encoding="utf-8")) + context_snapshot = json.loads((session_dir / "a2a" / "context.json").read_text(encoding="utf-8")) + self.session_snapshots.append((reason, task_snapshot, context_snapshot)) + expected_state = self.expected_task_states.get(reason) + if expected_state is not None: + assert task_snapshot["state"] == expected_state + assert context_snapshot["session_id"] == session_id + expected_handoff_summary = self.expected_handoff_summaries.get(reason) + if expected_handoff_summary is not None: + assert self.pipeline_snapshot_dir is not None + pipeline_snapshot = A2APipelineSnapshotStore(self.pipeline_snapshot_dir).load() + assert pipeline_snapshot is not None + self.pipeline_snapshots.append((reason, pipeline_snapshot)) + handoff_snapshot = pipeline_snapshot.get("normalHandoff") or pipeline_snapshot.get("pendingNormalHandoff") + assert handoff_snapshot["summary"] == expected_handoff_summary + if self.on_backup is not None: + self.on_backup(reason) + if reason in self.block_reasons: + raise SessionBackupBlocked(f"backup failed for secret_token=tok-live at /tmp/iac-code/{reason.value}") + + +class SpyMetrics(NoOpA2AMetrics): + def __init__(self) -> None: + self.task_failed = 0 + self.turn_completed = 0 + self.executor_error = 0 + self.backup_blocked: list[tuple[str, bool]] = [] + self.backup_failed: list[tuple[str, bool, int]] = [] + self.backup_succeeded: list[tuple[str, bool, int]] = [] + + def record_task_failed(self) -> None: + self.task_failed += 1 + + def record_turn_completed(self) -> None: + self.turn_completed += 1 + + def record_executor_error(self) -> None: + self.executor_error += 1 + + def record_backup_blocked(self, *, reason: str, recoverable: bool) -> None: + self.backup_blocked.append((reason, recoverable)) + + def record_backup_failed(self, *, reason: str, critical: bool, retry_count: int) -> None: + self.backup_failed.append((reason, critical, retry_count)) + + def record_backup_succeeded(self, *, reason: str, critical: bool, retry_count: int) -> None: + self.backup_succeeded.append((reason, critical, retry_count)) + + def _fake_runtime(): return SimpleNamespace(provider_manager=object(), tool_registry=FakeToolRegistry()) @@ -174,6 +453,279 @@ def _status_events(queue: FakeEventQueue) -> list[dict]: return [dump(event) for event in queue.events if isinstance(event, TaskStatusUpdateEvent)] +def _pipeline_status_events(queue: FakeEventQueue) -> list[dict]: + return [ + dumped["metadata"]["iac_code"]["pipeline"] + for event in queue.events + if isinstance(event, TaskStatusUpdateEvent) + for dumped in [dump(event)] + if "pipeline" in dumped.get("metadata", {}).get("iac_code", {}) + ] + + +def test_a2a_pipeline_dir_for_session_rejects_legacy_session( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from iac_code.a2a.pipeline_paths import a2a_pipeline_dir_for_session + + config_dir = tmp_path / "config" + cwd = tmp_path / "workspace" + cwd.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) + session_id = "legacy-session" + legacy_session_dir = SessionStorage().session_dir(str(cwd), session_id) + legacy_session_dir.mkdir(parents=True) + (legacy_session_dir / "session.jsonl").write_text("", encoding="utf-8") + + with pytest.raises(UnsupportedSessionLayoutError): + a2a_pipeline_dir_for_session(cwd=str(cwd), session_id=session_id) + + +def test_a2a_pipeline_dir_for_session_uses_long_cwd_legacy_sidecar_over_metadata_shadow( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from iac_code.a2a.pipeline_paths import a2a_pipeline_dir_for_session + from iac_code.utils import project_paths + + config_dir = tmp_path / "config" + cwd = "x" * (project_paths.MAX_SANITIZED_LENGTH + 50) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) + current_project_dir, legacy_project_dir = project_paths.project_dir_candidates(cwd, config_dir / "projects") + session_id = "legacy-a2a-sidecar-shadow" + write_session_metadata( + current_project_dir / session_id, + SessionMetadata(session_id=session_id, cwd=cwd, layout_version=SESSION_LAYOUT_VERSION_V2), + ) + legacy_a2a_pipeline_dir = legacy_project_dir / session_id / "a2a" / "pipeline" + legacy_a2a_pipeline_dir.mkdir(parents=True) + (legacy_a2a_pipeline_dir / "a2a-events.jsonl").write_text("", encoding="utf-8") + + assert a2a_pipeline_dir_for_session(cwd=cwd, session_id=session_id) == legacy_a2a_pipeline_dir + + +def test_a2a_pipeline_dir_for_session_reuses_legacy_flat_direct_a2a_sidecar( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from iac_code.a2a.pipeline_paths import a2a_pipeline_dir_for_session, existing_a2a_pipeline_dir_for_session + + config_dir = tmp_path / "config" + cwd = tmp_path / "workspace" + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) + storage = SessionStorage() + legacy_path = storage.legacy_session_path(str(cwd), "legacy-a2a") + legacy_path.parent.mkdir(parents=True) + legacy_path.write_text('{"role":"user","content":"legacy"}\n', encoding="utf-8") + a2a_pipeline_dir = legacy_path.parent / "legacy-a2a" / "a2a" / "pipeline" + a2a_pipeline_dir.mkdir(parents=True) + (a2a_pipeline_dir / "a2a-events.jsonl").write_text("", encoding="utf-8") + + assert a2a_pipeline_dir_for_session(cwd=str(cwd), session_id="legacy-a2a") == a2a_pipeline_dir + assert existing_a2a_pipeline_dir_for_session(cwd=str(cwd), session_id="legacy-a2a") == a2a_pipeline_dir + + +def test_pipeline_publisher_prefers_session_artifact_store_when_session_available( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from iac_code.a2a.pipeline_executor import IacCodeA2APipelineExecutor + + config_dir = tmp_path / "config" + cwd = tmp_path / "workspace" + cwd.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) + session_id = "session-1" + session_dir = SessionStorage().session_dir(str(cwd), session_id) + write_session_metadata( + session_dir, + SessionMetadata(session_id=session_id, cwd=str(cwd), layout_version=SESSION_LAYOUT_VERSION_V2), + ) + global_store = A2AArtifactStore(config_dir / "a2a" / "artifacts") + executor = IacCodeA2APipelineExecutor( + task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), + model="qwen3.6-plus", + metrics=NoOpA2AMetrics(), + artifact_store=global_store, + push_notifier=None, + permission_resolver=None, + auto_approve_permissions=False, + thinking_exposure_types=None, + ) + + publisher = executor._publisher( + event_queue=FakeEventQueue(), + pipeline=FakePipeline([], session_dir=session_dir / "pipeline"), + task_id="task-1", + context_id="ctx-1", + session_id=session_id, + cwd=str(cwd), + ) + + assert publisher.artifact_store is not None + assert publisher.artifact_store.root == session_dir / "a2a" / "artifacts" + assert publisher.artifact_store.root != global_store.root + + +def test_pipeline_artifact_store_uses_global_store_for_legacy_session( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from iac_code.a2a.pipeline_executor import IacCodeA2APipelineExecutor + + config_dir = tmp_path / "config" + cwd = tmp_path / "workspace" + cwd.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) + session_id = "legacy-session" + SessionStorage().session_dir(str(cwd), session_id).mkdir(parents=True) + global_store = A2AArtifactStore(config_dir / "a2a" / "artifacts") + executor = IacCodeA2APipelineExecutor( + task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), + model="qwen3.6-plus", + metrics=NoOpA2AMetrics(), + artifact_store=global_store, + push_notifier=None, + permission_resolver=None, + auto_approve_permissions=False, + thinking_exposure_types=None, + ) + + assert executor._artifact_store_for_session(cwd=str(cwd), session_id=session_id) is global_store + + +def test_pipeline_artifact_store_rejects_unsupported_session_layout( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from iac_code.a2a.pipeline_executor import IacCodeA2APipelineExecutor + from iac_code.services.session_layout import UnsupportedSessionLayoutError + + config_dir = tmp_path / "config" + cwd = tmp_path / "workspace" + cwd.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) + session_id = "session-1" + session_dir = SessionStorage().session_dir(str(cwd), session_id) + write_session_metadata(session_dir, SessionMetadata(session_id=session_id, cwd=str(cwd), layout_version=99)) + global_store = A2AArtifactStore(config_dir / "a2a" / "artifacts") + executor = IacCodeA2APipelineExecutor( + task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), + model="qwen3.6-plus", + metrics=NoOpA2AMetrics(), + artifact_store=global_store, + push_notifier=None, + permission_resolver=None, + auto_approve_permissions=False, + thinking_exposure_types=None, + ) + + with pytest.raises(UnsupportedSessionLayoutError): + executor._artifact_store_for_session(cwd=str(cwd), session_id=session_id) + + +def test_existing_a2a_pipeline_dir_rejects_unsupported_session_layout( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from iac_code.a2a.pipeline_paths import existing_a2a_pipeline_dir_for_session + + config_dir = tmp_path / "config" + cwd = tmp_path / "workspace" + cwd.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) + session_id = "session-1" + session_dir = SessionStorage().session_dir(str(cwd), session_id) + write_session_metadata(session_dir, SessionMetadata(session_id=session_id, cwd=str(cwd), layout_version=99)) + preferred = session_dir / "a2a" / "pipeline" + preferred.mkdir(parents=True) + (preferred / "a2a-events.jsonl").write_text("", encoding="utf-8") + + with pytest.raises(UnsupportedSessionLayoutError, match="Unsupported session layout version"): + existing_a2a_pipeline_dir_for_session(cwd=str(cwd), session_id=session_id) + + +def test_existing_a2a_pipeline_dir_ignores_symlinked_legacy_sidecar_placeholder( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from iac_code.a2a.pipeline_paths import existing_a2a_pipeline_dir_for_session + + config_dir = tmp_path / "config" + cwd = tmp_path / "workspace" + cwd.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) + session_id = "legacy-session" + storage = SessionStorage() + legacy_path = storage.legacy_session_path(str(cwd), session_id) + legacy_path.parent.mkdir(parents=True, exist_ok=True) + legacy_path.write_text('{"role":"user","content":"legacy"}\n', encoding="utf-8") + external_dir = tmp_path / "external-sidecars" + external_a2a_pipeline = external_dir / "a2a" / "pipeline" + external_a2a_pipeline.mkdir(parents=True) + (external_a2a_pipeline / "a2a-events.jsonl").write_text("", encoding="utf-8") + placeholder_dir = legacy_path.with_name(f"{legacy_path.stem}.legacy-sidecars") + try: + placeholder_dir.symlink_to(external_dir, target_is_directory=True) + except (NotImplementedError, OSError) as exc: + pytest.skip(f"symlink creation unsupported: {exc}") + + pipeline_dir = existing_a2a_pipeline_dir_for_session(cwd=str(cwd), session_id=session_id) + + assert pipeline_dir != external_a2a_pipeline + assert pipeline_dir.parent.parent.name == f"{placeholder_dir.name}.conflict-sidecars" + + +def test_pipeline_sidecar_dir_uses_existing_legacy_a2a_sidecar_without_v2_comparison( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from iac_code.a2a.pipeline_executor import _pipeline_sidecar_dir + + config_dir = tmp_path / "config" + cwd = tmp_path / "workspace" + cwd.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) + session_id = "legacy-session" + session_dir = SessionStorage().session_dir(str(cwd), session_id) + legacy_sidecar = session_dir / "pipeline" + legacy_sidecar.mkdir(parents=True) + (legacy_sidecar / "a2a-events.jsonl").write_text("", encoding="utf-8") + pipeline = FakePipeline([], session_dir=legacy_sidecar) + + assert _pipeline_sidecar_dir(pipeline, str(cwd), session_id) == legacy_sidecar + + +def test_pipeline_artifact_store_propagates_unexpected_session_helper_errors( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from iac_code.a2a.pipeline_executor import IacCodeA2APipelineExecutor + + cwd = tmp_path / "workspace" + cwd.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + SessionStorage().ensure_v2_session_dir_for_new_session(str(cwd), "session-1") + executor = IacCodeA2APipelineExecutor( + task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), + model="qwen3.6-plus", + metrics=NoOpA2AMetrics(), + artifact_store=A2AArtifactStore(tmp_path / "global-artifacts"), + push_notifier=None, + permission_resolver=None, + auto_approve_permissions=False, + thinking_exposure_types=None, + ) + monkeypatch.setattr( + "iac_code.a2a.pipeline_executor.artifact_store_for_session", + lambda _session_dir: (_ for _ in ()).throw(RuntimeError("helper failed")), + ) + + with pytest.raises(RuntimeError, match="helper failed"): + executor._artifact_store_for_session(cwd=str(cwd), session_id="session-1") + + @pytest.mark.asyncio async def test_pipeline_executor_applies_aliyun_metadata_while_creating_runtime( monkeypatch: pytest.MonkeyPatch, @@ -468,7 +1020,7 @@ async def test_executor_runs_pipeline_mode(monkeypatch: pytest.MonkeyPatch, tmp_ if isinstance(event, TaskStatusUpdateEvent) and "pipeline" in dump(event).get("metadata", {}).get("iac_code", {}) ] - assert event_types == ["pipeline_started", "text_delta", "pipeline_completed"] + assert event_types == ["pipeline_started", "text_delta", "pipeline_completed", "backup_committed"] record = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") assert record.state == "completed" assert "".join(record.output_text) == "pipeline output" @@ -554,8 +1106,17 @@ def fake_create_pipeline(*args, **kwargs): if isinstance(event, TaskStatusUpdateEvent) and "pipeline" in dump(event).get("metadata", {}).get("iac_code", {}) ] - assert [event["eventType"] for event in pipeline_events] == ["pipeline_completed", "pipeline_handoff_ready"] - handoff = pipeline_events[1] + assert [event["eventType"] for event in pipeline_events] == [ + "pipeline_completed", + "backup_committed", + "pipeline_handoff_ready", + "backup_committed", + ] + assert [event["data"]["committedEventType"] for event in pipeline_events[1::2]] == [ + "pipeline_completed", + "pipeline_handoff_ready", + ] + handoff = pipeline_events[2] assert handoff["status"] == "completed" assert handoff["data"] == { "action": "switch_to_normal", @@ -635,7 +1196,7 @@ def fake_create_pipeline(*args, **kwargs): if isinstance(event, TaskStatusUpdateEvent) and "pipeline" in dump(event).get("metadata", {}).get("iac_code", {}) ] - handoff = pipeline_events[-1] + handoff = next(event for event in reversed(pipeline_events) if event["eventType"] == "pipeline_handoff_ready") cleanup = handoff["data"]["cleanup"] assert cleanup["status"] == "pending" assert cleanup["resourceCount"] == 1 @@ -698,7 +1259,8 @@ def fake_create_pipeline(*args, **kwargs): monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) store = A2ATaskStore(metrics=NoOpA2AMetrics()) - executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + backup_service = RecordingBackupService() + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus", backup_service=backup_service) await executor.execute( FakeRequestContext(task_id="task-1", context_id="ctx-1", metadata={"iac_code": {"cwd": str(tmp_path)}}), @@ -711,6 +1273,7 @@ def fake_create_pipeline(*args, **kwargs): pipeline_run_id="ctx-1", ) assert create_pipeline_kwargs["surface"] == "a2a" + assert create_pipeline_kwargs["backup_service"] is backup_service @pytest.mark.asyncio @@ -755,8 +1318,17 @@ def fake_create_pipeline(*args, **kwargs): if isinstance(event, TaskStatusUpdateEvent) and "pipeline" in dump(event).get("metadata", {}).get("iac_code", {}) ] - assert [event["eventType"] for event in pipeline_events] == ["pipeline_failed", "pipeline_handoff_ready"] - handoff = pipeline_events[1] + assert [event["eventType"] for event in pipeline_events] == [ + "pipeline_failed", + "backup_committed", + "pipeline_handoff_ready", + "backup_committed", + ] + assert [event["data"]["committedEventType"] for event in pipeline_events[1::2]] == [ + "pipeline_failed", + "pipeline_handoff_ready", + ] + handoff = pipeline_events[2] assert handoff["status"] == "failed" assert handoff["data"]["outcome"] == "failed" assert handoff["data"]["summary"] == "[Pipeline Handoff Context]\nOutcome: failed" @@ -768,7 +1340,7 @@ def fake_create_pipeline(*args, **kwargs): @pytest.mark.asyncio -async def test_executor_candidate_started_includes_steps_from_loaded_sub_pipeline( +async def test_pipeline_executor_runs_critical_backup_before_input_required_publication( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -776,746 +1348,979 @@ async def test_executor_candidate_started_includes_steps_from_loaded_sub_pipelin fake_pipeline = FakePipeline( [ PipelineEvent( - type=PipelineEventType.SUB_PIPELINE_STARTED, - step_id=None, - timestamp=1717821600.0, - data={ - "parent_step_id": "evaluate_candidates", - "sub_pipeline_id": "evaluate_candidate_candidate_0", - "sub_pipeline_name": "evaluate_candidate", - "candidate_index": 0, - "candidate_name": "轻量应用服务器方案", - "total_steps": 2, - }, - ), - PipelineEvent( - type=PipelineEventType.PIPELINE_COMPLETED, - step_id=None, + type=PipelineEventType.USER_INPUT_REQUIRED, + step_id="confirm", timestamp=1717821601.0, - data={}, + data={"prompt": "请选择方案"}, ), ], session_dir=tmp_path / "sidecar", ) - fake_pipeline._loaded = SimpleNamespace( - steps=[ - SimpleNamespace(step_id="intent_parsing"), - SimpleNamespace( - step_id="evaluate_candidates", - step_type="parallel_sub_pipeline", - sub_pipeline_name="evaluate_candidate", - ), - SimpleNamespace(step_id="confirm_and_select"), - ], - sub_pipelines={ - "evaluate_candidate": SimpleNamespace( - steps=[ - SimpleNamespace(step_id="template_generating"), - SimpleNamespace(step_id="cost_estimating"), - ], - ) - }, - ) monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + backup_service = RecordingBackupService( + expected_task_states={BackupReason.INPUT_REQUIRED: "input-required"}, + ) store = A2ATaskStore(metrics=NoOpA2AMetrics()) - executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus", backup_service=backup_service) queue = FakeEventQueue() await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), queue) - candidate_started = next( - dump(event)["metadata"]["iac_code"]["pipeline"] - for event in queue.events - if isinstance(event, TaskStatusUpdateEvent) - and dump(event).get("metadata", {}).get("iac_code", {}).get("pipeline", {}).get("eventType") - == "candidate_started" - ) - assert [step["id"] for step in candidate_started["candidate"]["steps"]] == [ - "template_generating", - "cost_estimating", + assert [(reason, critical) for *_ids, reason, critical in backup_service.calls] == [ + (BackupReason.INPUT_REQUIRED, True) ] + assert _pipeline_status_events(queue)[-1]["eventType"] == "input_required" + assert _status_events(queue)[-1]["status"]["state"] == "TASK_STATE_INPUT_REQUIRED" @pytest.mark.asyncio -async def test_executor_hydrates_translator_step_attempts_before_resuming_waiting_input( +async def test_pipeline_executor_runs_critical_backups_for_terminal_and_handoff_ready( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: monkeypatch.setenv("IAC_CODE_MODE", "pipeline") session_dir = tmp_path / "sidecar" - waiting_event = { - "schemaVersion": "1.0", - "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", - "eventId": "evt-waiting", - "sequence": 42, - "createdAt": "2026-06-11T06:15:55Z", - "eventType": "input_required", - "scope": "step", - "pipelineRunId": "ctx-1", - "taskId": "task-1", - "contextId": "ctx-1", - "pipelineName": "selling", - "status": "input_required", - "step": {"id": "confirm_and_select", "runId": "step-confirm_and_select-2", "attempt": 2}, - "data": {"prompt": "请选择要部署的方案:"}, - } - journal = A2APipelineJournal(session_dir) - journal.append(waiting_event) - A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([waiting_event])) fake_pipeline = FakePipeline( [ - PipelineEvent( - type=PipelineEventType.USER_INPUT_RECEIVED, - step_id="confirm_and_select", - timestamp=1717821601.0, - data={"selected_value": "已有VPC下新建VSwitch"}, - ), PipelineEvent( type=PipelineEventType.PIPELINE_COMPLETED, step_id=None, - timestamp=1717821602.0, - data={"total_steps": 5}, + timestamp=1717821601.0, + data={"total_steps": 1}, ), ], session_dir=session_dir, ) - fake_pipeline.sidecar_status = "waiting_input" + fake_pipeline.handoff_enabled = True + fake_pipeline.handoff_summary = "[Pipeline Handoff Context]\nPipeline: selling" monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + def assert_committed_publication_before_backup(reason: BackupReason) -> None: + events = A2APipelineJournal(session_dir).read_all() + snapshot = A2APipelineSnapshotStore(session_dir).load() + assert snapshot is not None + if reason == BackupReason.TERMINAL: + terminal_events = [event for event in events if event["eventType"] == "pipeline_completed"] + ack_events = [event for event in events if event["eventType"] == "backup_committed"] + assert [event.get("visibility") for event in terminal_events] == [ + "pending_backup", + "committed", + ] + assert ack_events == [] + assert snapshot["pendingTerminal"]["eventType"] == "pipeline_completed" + elif reason == BackupReason.HANDOFF_READY: + terminal_events = [event for event in events if event["eventType"] == "pipeline_completed"] + handoff_events = [event for event in events if event["eventType"] == "pipeline_handoff_ready"] + ack_events = [event for event in events if event["eventType"] == "backup_committed"] + assert terminal_events[-1].get("visibility") == "committed" + assert handoff_events[-1].get("visibility") == "committed" + assert ack_events == [] + assert snapshot["pendingNormalHandoff"]["summary"] == "[Pipeline Handoff Context]\nPipeline: selling" + + backup_service = RecordingBackupService( + expected_task_states={ + BackupReason.TERMINAL: "working", + BackupReason.HANDOFF_READY: "working", + }, + expected_handoff_summaries={ + BackupReason.HANDOFF_READY: "[Pipeline Handoff Context]\nPipeline: selling", + }, + pipeline_snapshot_dir=session_dir, + on_backup=assert_committed_publication_before_backup, + ) + store = A2ATaskStore(metrics=NoOpA2AMetrics()) - executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus", backup_service=backup_service) queue = FakeEventQueue() - await executor.execute( - FakeRequestContext( - task_id="task-1", - context_id="ctx-1", - text="已有VPC下新建VSwitch", - metadata={"iac_code": {"cwd": str(tmp_path)}}, - ), - queue, - ) + await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), queue) - input_received = next( - dump(event)["metadata"]["iac_code"]["pipeline"] - for event in queue.events - if isinstance(event, TaskStatusUpdateEvent) - and dump(event).get("metadata", {}).get("iac_code", {}).get("pipeline", {}).get("eventType") == "input_received" - ) - assert fake_pipeline.resume_prompts == ["已有VPC下新建VSwitch"] - assert input_received["step"]["runId"] == "step-confirm_and_select-2" - assert input_received["step"]["attempt"] == 2 + assert [(reason, critical) for *_ids, reason, critical in backup_service.calls] == [ + (BackupReason.TERMINAL, True), + (BackupReason.HANDOFF_READY, True), + ] + pipeline_events = _pipeline_status_events(queue) + assert [event["eventType"] for event in pipeline_events] == [ + "pipeline_completed", + "backup_committed", + "pipeline_handoff_ready", + "backup_committed", + ] + assert [event["data"]["committedEventType"] for event in pipeline_events[1::2]] == [ + "pipeline_completed", + "pipeline_handoff_ready", + ] + committed_events = [event for event in pipeline_events if event["eventType"] != "backup_committed"] + assert [event["visibility"] for event in committed_events] == ["committed", "committed"] + assert [reason for reason, _snapshot in backup_service.pipeline_snapshots] == [BackupReason.HANDOFF_READY] + handoff_backup_snapshot = backup_service.pipeline_snapshots[0][1] + assert handoff_backup_snapshot["status"] == "working" + assert handoff_backup_snapshot["normalHandoff"] is None + assert handoff_backup_snapshot["pendingNormalHandoff"]["summary"] == "[Pipeline Handoff Context]\nPipeline: selling" + final_pipeline_snapshot = A2APipelineSnapshotStore(session_dir).load() + assert final_pipeline_snapshot is not None + assert final_pipeline_snapshot["status"] == "completed" + assert final_pipeline_snapshot["pendingNormalHandoff"] is None + assert final_pipeline_snapshot["normalHandoff"]["summary"] == "[Pipeline Handoff Context]\nPipeline: selling" + record = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + assert record.state == "completed" + context_record = store._contexts["ctx-1"] + root_session_dir = SessionStorage().session_dir(str(tmp_path), context_record.session_id) + task_snapshot = json.loads((root_session_dir / "a2a" / "task.json").read_text(encoding="utf-8")) + assert task_snapshot["state"] == "completed" @pytest.mark.asyncio -async def test_executor_returns_input_required_for_retryable_stream_error( +async def test_pipeline_backup_blocked_before_input_required_publishes_recoverable_state( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: monkeypatch.setenv("IAC_CODE_MODE", "pipeline") - fake_pipeline = FakePipeline([TimeoutError("upstream timed out")], session_dir=tmp_path / "sidecar") + session_dir = tmp_path / "sidecar" + fake_pipeline = FakePipeline( + [ + PipelineEvent( + type=PipelineEventType.USER_INPUT_REQUIRED, + step_id="confirm", + timestamp=1717821601.0, + data={"prompt": "请选择方案"}, + ), + ], + session_dir=session_dir, + ) monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + metrics = SpyMetrics() + backup_service = RecordingBackupService(block_reasons={BackupReason.INPUT_REQUIRED}) store = A2ATaskStore(metrics=NoOpA2AMetrics()) - executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + executor = IacCodeA2AExecutor( + task_store=store, + model="qwen3.6-plus", + metrics=metrics, + backup_service=backup_service, + ) queue = FakeEventQueue() await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), queue) - final_status = _status_events(queue)[-1]["status"] - assert final_status["state"] == "TASK_STATE_INPUT_REQUIRED" - assert final_status["message"]["parts"][0]["text"] == RETRY_TEXT + pipeline_events = _pipeline_status_events(queue) + assert [event["eventType"] for event in pipeline_events] == ["backup_blocked"] + blocked = pipeline_events[0] + assert blocked["status"] == "input_required" + assert blocked["data"]["reason"] == "input_required" + assert blocked["data"]["recoverable"] is True + assert "tok-live" not in blocked["data"]["error"] + assert "/tmp/iac-code" not in blocked["data"]["error"] + assert _status_events(queue)[-1]["status"]["state"] == "TASK_STATE_INPUT_REQUIRED" record = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") assert record.state == "input-required" + assert fake_pipeline.sidecar_status == "backup_blocked" + assert metrics.task_failed == 0 + assert metrics.backup_blocked == [("input_required", True)] + assert [event["eventType"] for event in A2APipelineJournal(session_dir).read_all()] == [ + "input_required", + "backup_blocked", + ] + snapshot = A2APipelineSnapshotStore(session_dir).load() + assert snapshot is not None + assert snapshot["status"] == "waiting_input" @pytest.mark.asyncio -async def test_executor_returns_input_required_for_retryable_pipeline_creation_error( +async def test_pipeline_backup_blocked_sidecar_persist_failure_is_not_reported_recoverable( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - monkeypatch.setenv("IAC_CODE_MODE", "pipeline") - - def raise_timeout(*args, **kwargs): - raise TimeoutError("pipeline setup timed out") + class FailingBackupBlockedSidecarPipeline(FakePipeline): + def _save_backup_blocked_sidecar(self, step_id, reason): + return False - monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", raise_timeout) + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + session_dir = tmp_path / "sidecar" + fake_pipeline = FailingBackupBlockedSidecarPipeline( + [ + PipelineEvent( + type=PipelineEventType.USER_INPUT_REQUIRED, + step_id="confirm", + timestamp=1717821601.0, + data={"prompt": "请选择方案"}, + ), + ], + session_dir=session_dir, + ) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + metrics = SpyMetrics() + backup_service = RecordingBackupService(block_reasons={BackupReason.INPUT_REQUIRED}) store = A2ATaskStore(metrics=NoOpA2AMetrics()) - executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + executor = IacCodeA2AExecutor( + task_store=store, + model="qwen3.6-plus", + metrics=metrics, + backup_service=backup_service, + ) queue = FakeEventQueue() await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), queue) - final_status = _status_events(queue)[-1]["status"] - assert final_status["state"] == "TASK_STATE_INPUT_REQUIRED" - assert final_status["message"]["parts"][0]["text"] == RETRY_TEXT + assert [event["eventType"] for event in _pipeline_status_events(queue)] == [] record = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") assert record.state == "input-required" + assert fake_pipeline.sidecar_status is None + assert metrics.backup_blocked == [("input_required", False)] + events = A2APipelineJournal(session_dir).read_all() + assert "backup_blocked" not in [event["eventType"] for event in events] + assert events[-1]["eventType"] == "input_required" + assert events[-1]["data"]["kind"] == "terminal_publication_unavailable" + assert events[-1]["data"]["reason"] == "backup_blocked_sidecar_persist_failed" + snapshot = A2APipelineSnapshotStore(session_dir).load() + assert snapshot is not None + assert snapshot["status"] == "waiting_input" + assert snapshot["pendingInput"]["kind"] == "terminal_publication_unavailable" @pytest.mark.asyncio -async def test_executor_returns_input_required_for_retryable_runtime_creation_error( +async def test_pipeline_backup_blocked_before_terminal_suppresses_terminal_publication( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: monkeypatch.setenv("IAC_CODE_MODE", "pipeline") - - def raise_timeout(options): - raise TimeoutError("runtime setup timed out") - - monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", raise_timeout) + session_dir = tmp_path / "sidecar" + fake_pipeline = FakePipeline( + [ + PipelineEvent( + type=PipelineEventType.PIPELINE_COMPLETED, + step_id=None, + timestamp=1717821601.0, + data={"total_steps": 1}, + ), + ], + session_dir=session_dir, + ) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + metrics = SpyMetrics() + backup_service = RecordingBackupService( + block_reasons={BackupReason.TERMINAL}, + expected_task_states={BackupReason.TERMINAL: "working"}, + ) store = A2ATaskStore(metrics=NoOpA2AMetrics()) - executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + executor = IacCodeA2AExecutor( + task_store=store, + model="qwen3.6-plus", + metrics=metrics, + backup_service=backup_service, + ) queue = FakeEventQueue() await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), queue) - final_status = _status_events(queue)[-1]["status"] - assert final_status["state"] == "TASK_STATE_INPUT_REQUIRED" - assert final_status["message"]["parts"][0]["text"] == RETRY_TEXT + pipeline_events = _pipeline_status_events(queue) + assert [event["eventType"] for event in pipeline_events] == ["backup_blocked"] + assert pipeline_events[0]["data"]["reason"] == "terminal" + assert _status_events(queue)[-1]["status"]["state"] == "TASK_STATE_INPUT_REQUIRED" record = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") assert record.state == "input-required" + assert fake_pipeline.sidecar_status == "backup_blocked" + assert metrics.task_failed == 0 + assert metrics.turn_completed == 0 + assert metrics.executor_error == 1 + assert metrics.backup_blocked == [("terminal", True)] + journal_events = A2APipelineJournal(session_dir).read_all() + assert [event["eventType"] for event in journal_events] == [ + "pipeline_completed", + "pipeline_completed", + "backup_blocked", + ] + terminal_events = [event for event in journal_events if event["eventType"] == "pipeline_completed"] + assert [event.get("visibility") for event in terminal_events] == ["pending_backup", "committed"] + snapshot = A2APipelineSnapshotStore(session_dir).load() + assert snapshot is not None + assert snapshot["status"] == "waiting_input" + assert snapshot.get("pendingTerminal") is None @pytest.mark.asyncio -async def test_executor_sanitizes_auth_looking_pipeline_error( +async def test_pipeline_backup_blocked_publication_requires_durable_metadata( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: + from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") fake_pipeline = FakePipeline( - [ValueError("missing API key: secret-internal-detail")], + [ + PipelineEvent( + type=PipelineEventType.PIPELINE_COMPLETED, + step_id=None, + timestamp=1717821601.0, + data={"total_steps": 1}, + ), + ], session_dir=tmp_path / "sidecar", ) monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + original_publish_manual = PipelineA2AEventPublisher.publish_manual + durable_flags: list[bool | None] = [] - store = A2ATaskStore(metrics=NoOpA2AMetrics()) - executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") - queue = FakeEventQueue() + async def recording_publish_manual(self, event_type, *args, **kwargs): + if event_type == "backup_blocked": + durable_flags.append(kwargs.get("require_durable_metadata")) + return await original_publish_manual(self, event_type, *args, **kwargs) - await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), queue) + monkeypatch.setattr(PipelineA2AEventPublisher, "publish_manual", recording_publish_manual) + executor = IacCodeA2AExecutor( + task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), + model="qwen3.6-plus", + backup_service=RecordingBackupService(block_reasons={BackupReason.TERMINAL}), + ) - final_status = _status_events(queue)[-1]["status"] - assert final_status["state"] == "TASK_STATE_FAILED" - assert final_status["message"]["parts"][0]["text"] == AUTH_TEXT + await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), FakeEventQueue()) + + assert durable_flags == [True] @pytest.mark.asyncio -async def test_executor_persists_pipeline_failed_event_for_nonretryable_stream_error( +async def test_pipeline_terminal_backup_success_commits_after_pending_publication( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: monkeypatch.setenv("IAC_CODE_MODE", "pipeline") session_dir = tmp_path / "sidecar" fake_pipeline = FakePipeline( - [ValueError("planner crashed INTERNAL_TOKEN=tok-live /tmp/iac-code/work.py")], + [ + PipelineEvent( + type=PipelineEventType.PIPELINE_COMPLETED, + step_id=None, + timestamp=1717821601.0, + data={"total_steps": 1}, + ), + ], session_dir=session_dir, ) monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + def assert_committed_terminal_before_backup(reason: BackupReason) -> None: + if reason == BackupReason.TERMINAL: + events = A2APipelineJournal(session_dir).read_all() + assert [event["eventType"] for event in events] == [ + "pipeline_completed", + "pipeline_completed", + ] + assert [event.get("visibility") for event in events[:2]] == ["pending_backup", "committed"] + snapshot = A2APipelineSnapshotStore(session_dir).load() + assert snapshot is not None + assert snapshot["pendingTerminal"]["eventType"] == "pipeline_completed" + + backup_service = RecordingBackupService( + expected_task_states={BackupReason.TERMINAL: "working"}, + on_backup=assert_committed_terminal_before_backup, + ) + store = A2ATaskStore(metrics=NoOpA2AMetrics()) - executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus", backup_service=backup_service) queue = FakeEventQueue() await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), queue) - events = A2APipelineJournal(session_dir).read_all() - assert events[-1]["eventType"] == "pipeline_failed" - assert events[-1]["status"] == "failed" - assert events[-1]["data"]["errorSummary"] == "ValueError: planner crashed INTERNAL_TOKEN=[REDACTED] [PATH]" - assert events[-1]["data"]["errorDetails"]["type"] == "ValueError" - assert events[-1]["data"]["errorDetails"]["errorId"] - assert events[-1]["data"]["errorDetails"]["traceback"] == "Stack trace omitted from public event; see error_id." - assert "tok-live" not in json.dumps(events[-1]) - assert "/tmp/iac-code" not in json.dumps(events[-1]) + assert [(reason, critical) for *_ids, reason, critical in backup_service.calls] == [(BackupReason.TERMINAL, True)] + pipeline_events = _pipeline_status_events(queue) + assert [event["eventType"] for event in pipeline_events] == ["pipeline_completed", "backup_committed"] + assert pipeline_events[0]["visibility"] == "committed" + assert pipeline_events[1]["data"]["committedEventType"] == "pipeline_completed" + journal_events = A2APipelineJournal(session_dir).read_all() + assert [event["eventType"] for event in journal_events] == [ + "pipeline_completed", + "pipeline_completed", + "backup_committed", + ] + assert [event.get("visibility") for event in journal_events[:2]] == ["pending_backup", "committed"] + assert journal_events[-1]["data"]["committedEventType"] == "pipeline_completed" snapshot = A2APipelineSnapshotStore(session_dir).load() assert snapshot is not None - assert snapshot["status"] == "failed" - assert _status_events(queue)[-1]["status"]["state"] == "TASK_STATE_FAILED" + assert snapshot["status"] == "completed" + assert snapshot.get("pendingTerminal") is None + record = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + assert record.state == "completed" + context_record = store._contexts["ctx-1"] + root_session_dir = SessionStorage().session_dir(str(tmp_path), context_record.session_id) + task_snapshot = json.loads((root_session_dir / "a2a" / "task.json").read_text(encoding="utf-8")) + context_snapshot = json.loads((root_session_dir / "a2a" / "context.json").read_text(encoding="utf-8")) + assert task_snapshot["state"] == "completed" + assert context_snapshot["active_task_id"] is None @pytest.mark.asyncio -@pytest.mark.parametrize( - ("sidecar_status", "expected_state", "expected_snapshot_status", "expected_event_type"), - [ - ("completed", "TASK_STATE_COMPLETED", "completed", "pipeline_completed"), - ("failed", "TASK_STATE_FAILED", "failed", "pipeline_failed"), - ("user_aborted", "TASK_STATE_CANCELED", "canceled", "pipeline_canceled"), - ("canceled", "TASK_STATE_CANCELED", "canceled", "pipeline_canceled"), - ], -) -async def test_executor_preserves_terminal_sidecar_recovery_state_on_followup( +async def test_pipeline_backup_blocked_before_handoff_suppresses_terminal_publication( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, - sidecar_status: str, - expected_state: str, - expected_snapshot_status: str, - expected_event_type: str, ) -> None: monkeypatch.setenv("IAC_CODE_MODE", "pipeline") session_dir = tmp_path / "sidecar" - A2APipelineJournal(session_dir).append( - { - "schemaVersion": "1.0", - "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", - "eventId": "evt-terminal", - "sequence": 1, - "createdAt": "2026-06-08T10:00:00Z", - "eventType": expected_event_type, - "scope": "pipeline", - "pipelineRunId": "ctx-1", - "taskId": "task-1", - "contextId": "ctx-1", - "pipelineName": "selling", - "status": expected_snapshot_status, - "data": {"sidecarStatus": sidecar_status}, - } + fake_pipeline = FakePipeline( + [ + PipelineEvent( + type=PipelineEventType.PIPELINE_COMPLETED, + step_id=None, + timestamp=1717821601.0, + data={"total_steps": 1}, + ), + ], + session_dir=session_dir, ) - fake_pipeline = FakePipeline([], session_dir=session_dir) - fake_pipeline.sidecar_status = sidecar_status + fake_pipeline.handoff_enabled = True monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + metrics = SpyMetrics() + backup_service = RecordingBackupService( + block_reasons={BackupReason.HANDOFF_READY}, + expected_task_states={ + BackupReason.TERMINAL: "working", + BackupReason.HANDOFF_READY: "working", + }, + ) store = A2ATaskStore(metrics=NoOpA2AMetrics()) - executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + executor = IacCodeA2AExecutor( + task_store=store, + model="qwen3.6-plus", + metrics=metrics, + backup_service=backup_service, + ) queue = FakeEventQueue() - await executor.execute( - FakeRequestContext( - task_id="task-1", - context_id="ctx-1", - text="new request", - metadata={"iac_code": {"cwd": str(tmp_path)}}, - ), - queue, - ) + await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), queue) - assert fake_pipeline.clear_sidecar_calls == 0 - assert fake_pipeline.run_prompts == [] - assert (session_dir / "a2a-events.jsonl").exists() - assert _status_events(queue)[-1]["status"]["state"] == expected_state + assert [(reason, critical) for *_ids, reason, critical in backup_service.calls] == [ + (BackupReason.TERMINAL, True), + (BackupReason.HANDOFF_READY, True), + ] + pipeline_events = _pipeline_status_events(queue) + assert [event["eventType"] for event in pipeline_events] == ["backup_blocked"] + assert pipeline_events[0]["status"] == "input_required" + assert pipeline_events[0]["data"]["reason"] == "handoff_ready" + assert _status_events(queue)[-1]["status"]["state"] == "TASK_STATE_INPUT_REQUIRED" + record = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + assert record.state == "input-required" + assert fake_pipeline.sidecar_status == "backup_blocked" + assert metrics.task_failed == 0 + assert metrics.turn_completed == 0 + assert metrics.executor_error == 1 + assert metrics.backup_blocked == [("handoff_ready", True)] + journal_events = A2APipelineJournal(session_dir).read_all() + assert [event["eventType"] for event in journal_events] == [ + "pipeline_completed", + "pipeline_handoff_ready", + "pipeline_completed", + "pipeline_handoff_ready", + "backup_blocked", + ] + assert [event.get("visibility") for event in journal_events[:4]] == [ + "pending_backup", + "pending_backup", + "committed", + "committed", + ] snapshot = A2APipelineSnapshotStore(session_dir).load() assert snapshot is not None - assert snapshot["status"] == expected_snapshot_status - last_event = A2APipelineJournal(session_dir).read_all()[-1] - assert last_event["eventType"] == expected_event_type - assert last_event["taskId"] == "task-1" - record = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") - assert record.state in {"completed", "failed", "canceled"} + assert snapshot["status"] == "waiting_input" + assert snapshot["normalHandoff"] is None + assert snapshot.get("pendingNormalHandoff") is None @pytest.mark.asyncio -async def test_executor_clears_previous_task_terminal_sidecar_and_runs_new_task( +async def test_pipeline_cancel_handoff_publication_unavailable_keeps_task_nonterminal( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: monkeypatch.setenv("IAC_CODE_MODE", "pipeline") session_dir = tmp_path / "sidecar" - journal = A2APipelineJournal(session_dir) - previous_terminal = { - "schemaVersion": "1.0", - "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", - "eventId": "evt-old-terminal", - "sequence": 1, - "createdAt": "2026-06-08T10:00:00Z", - "eventType": "pipeline_completed", - "scope": "pipeline", - "pipelineRunId": "ctx-1", - "taskId": "task-old", - "contextId": "ctx-1", - "pipelineName": "selling", - "status": "completed", - "data": {"sidecarStatus": "completed"}, - } - journal.append(previous_terminal) - A2APipelineSnapshotStore(session_dir).save( - { - "schemaVersion": "1.0", - "snapshotVersion": 1, - "pipelineRunId": "ctx-1", - "taskId": "task-old", - "contextId": "ctx-1", - "pipelineName": "selling", - "status": "completed", - "lastSequence": 1, - "steps": [], - "display": {"messages": [], "diagrams": [], "candidateDetails": [], "artifacts": []}, - "pendingInput": None, - "control": {"activeCandidateRunIds": [], "rollbackHistory": [], "candidateRestarts": []}, - "seenEventIds": ["evt-old-terminal"], - } - ) - fake_pipeline = FakePipeline( - [ - TextDeltaEvent(text="new output"), - PipelineEvent(type=PipelineEventType.PIPELINE_COMPLETED, step_id=None, timestamp=1717821601.0, data={}), - ], - session_dir=session_dir, - ) - fake_pipeline.sidecar_status = "completed" + fake_pipeline = FakePipeline([asyncio.CancelledError()], session_dir=session_dir) + fake_pipeline.handoff_enabled = True monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + async def block_publication(*args, **kwargs) -> bool: + return False + + monkeypatch.setattr( + "iac_code.a2a.pipeline_executor.IacCodeA2APipelineExecutor._backup_before_pipeline_publication", + block_publication, + ) + + metrics = SpyMetrics() store = A2ATaskStore(metrics=NoOpA2AMetrics()) - executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + executor = IacCodeA2AExecutor( + task_store=store, + model="qwen3.6-plus", + metrics=metrics, + ) queue = FakeEventQueue() - await executor.execute( - FakeRequestContext( - task_id="task-new", - context_id="ctx-1", - text="new request", - metadata={"iac_code": {"cwd": str(tmp_path)}}, - ), - queue, - ) + await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), queue) - assert fake_pipeline.clear_sidecar_calls == 1 - assert fake_pipeline.run_prompts == ["new request"] - event_types = [event["eventType"] for event in journal.read_all()] - assert event_types == ["pipeline_completed", "text_delta", "pipeline_completed"] - assert journal.read_all()[-1]["taskId"] == "task-new" - assert _status_events(queue)[-1]["status"]["state"] == "TASK_STATE_COMPLETED" + assert _pipeline_status_events(queue) == [] + assert _status_events(queue)[-1]["status"]["state"] == "TASK_STATE_INPUT_REQUIRED" + record = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + assert record.state == "input-required" + assert metrics.task_failed == 0 + journal_events = A2APipelineJournal(session_dir).read_all() + assert [event["eventType"] for event in journal_events[:5]] == [ + "pipeline_canceled", + "pipeline_handoff_ready", + "pipeline_canceled", + "pipeline_handoff_ready", + "input_required", + ] + assert [event.get("visibility") for event in journal_events[:4]] == [ + "pending_backup", + "pending_backup", + "committed", + "committed", + ] + assert any( + event["eventType"] == "input_required" + and isinstance(event.get("data"), dict) + and event["data"].get("kind") == "terminal_publication_unavailable" + for event in journal_events + ) + snapshot = A2APipelineSnapshotStore(session_dir).load() + assert snapshot is not None + assert snapshot["status"] not in {"completed", "failed", "canceled"} + assert snapshot["normalHandoff"] is None + assert await executor._should_route_pipeline_handoff_to_normal(context_id="ctx-1", cwd=str(tmp_path)) is False @pytest.mark.asyncio -@pytest.mark.parametrize( - ("sidecar_status", "event_type", "event_status"), - [ - ("completed", "pipeline_completed", "completed"), - ], -) -async def test_executor_replaces_terminal_restored_pipeline_when_sidecar_owner_mismatches( +async def test_pipeline_completed_handoff_persist_failure_blocks_terminal_sidecar_recovery( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, - sidecar_status: str, - event_type: str, - event_status: str, ) -> None: + from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") session_dir = tmp_path / "sidecar" - old_event = { - "schemaVersion": "1.0", - "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", - "eventId": "evt-old-sidecar", - "sequence": 1, - "createdAt": "2026-06-08T10:00:00Z", - "eventType": event_type, - "scope": "pipeline", - "pipelineRunId": "ctx-1", - "taskId": "task-old", - "contextId": "ctx-1", - "pipelineName": "selling", - "status": event_status, - "data": {"prompt": "old choice"} if event_type == "input_required" else {}, - } - A2APipelineJournal(session_dir).append(old_event) - A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([old_event])) - - class RestoredMemoryPipeline(FakePipeline): - async def run(self, prompt: str): - self.run_prompts.append(prompt) - yield TextDeltaEvent(text="stale restored output") - yield PipelineEvent( + fake_pipeline = TerminalSidecarAfterCompletionPipeline( + [ + PipelineEvent( type=PipelineEventType.PIPELINE_COMPLETED, step_id=None, timestamp=1717821601.0, - data={}, - ) - - restored_pipeline = RestoredMemoryPipeline([], session_dir=session_dir) - restored_pipeline.sidecar_status = sidecar_status - fresh_pipeline = FakePipeline( - [ - TextDeltaEvent(text="fresh output"), - PipelineEvent(type=PipelineEventType.PIPELINE_COMPLETED, step_id=None, timestamp=1717821601.0, data={}), + data={"total_steps": 1}, + ), ], session_dir=session_dir, ) - create_resume_flags: list[bool | None] = [] + fake_pipeline.handoff_enabled = True + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + original_persist_envelope = PipelineA2AEventPublisher.persist_envelope - def fake_create_pipeline(*args, **kwargs): - create_resume_flags.append(kwargs.get("resume_from_sidecar")) - return restored_pipeline if len(create_resume_flags) == 1 else fresh_pipeline + async def fail_pending_handoff_persist(self, envelope, *args, **kwargs): + if envelope.get("eventType") == "pipeline_handoff_ready" and envelope.get("visibility") == "pending_backup": + return None + return await original_persist_envelope(self, envelope, *args, **kwargs) - monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", fake_create_pipeline) - monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + monkeypatch.setattr(PipelineA2AEventPublisher, "persist_envelope", fail_pending_handoff_persist) + metrics = SpyMetrics() store = A2ATaskStore(metrics=NoOpA2AMetrics()) - executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") - - await executor.execute( - FakeRequestContext( - task_id="task-new", - context_id="ctx-1", - text="new request", - metadata={"iac_code": {"cwd": str(tmp_path)}}, - ), - FakeEventQueue(), + executor = IacCodeA2AExecutor( + task_store=store, + model="qwen3.6-plus", + metrics=metrics, ) + queue = FakeEventQueue() - assert create_resume_flags == [True, False] - assert restored_pipeline.clear_sidecar_calls == 1 - assert restored_pipeline.run_prompts == [] - assert fresh_pipeline.run_prompts == ["new request"] - record = await store.get_task_record("task-new") - assert "".join(record.output_text) == "fresh output" + await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), queue) + + assert not any(event["eventType"] == "pipeline_completed" for event in _pipeline_status_events(queue)) + record = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + assert record.state not in {"completed", "failed", "canceled"} + journal_events = A2APipelineJournal(session_dir).read_all() + assert not any( + event["eventType"] == "pipeline_completed" and event.get("visibility") == "committed" + for event in journal_events + ) + snapshot = A2APipelineSnapshotStore(session_dir).load() + assert snapshot is not None + assert snapshot["status"] not in {"completed", "failed", "canceled"} + assert snapshot["normalHandoff"] is None + assert await executor._should_route_pipeline_handoff_to_normal(context_id="ctx-1", cwd=str(tmp_path)) is False @pytest.mark.asyncio -@pytest.mark.parametrize( - ("sidecar_status", "event_type", "event_status"), - [ - ("waiting_input", "input_required", "waiting_input"), - ("running", "pipeline_started", "working"), - ], -) -async def test_executor_rejects_active_restored_pipeline_owner_mismatch_without_clearing( +async def test_pipeline_completed_handoff_terminal_enqueue_failure_does_not_commit_available_terminal( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, - sidecar_status: str, - event_type: str, - event_status: str, ) -> None: - from iac_code.a2a.pipeline_executor import ( - IacCodeA2APipelineExecutor, - RecoverablePipelineInvalidParamsError, - ) + from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator + from iac_code.a2a.pipeline_executor import _committed_terminal_status_for_task_context + from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher monkeypatch.setenv("IAC_CODE_MODE", "pipeline") session_dir = tmp_path / "sidecar" - owner_event = { - "schemaVersion": "1.0", - "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", - "eventId": "evt-owner", - "sequence": 1, - "createdAt": "2026-06-08T10:00:00Z", - "eventType": event_type, - "scope": "pipeline", - "pipelineRunId": "ctx-1", - "taskId": "task-owner", - "contextId": "ctx-1", - "pipelineName": "selling", - "status": event_status, - "data": {"prompt": "owner choice"} if event_type == "input_required" else {}, - } - journal = A2APipelineJournal(session_dir) - journal.append(owner_event) - A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([owner_event])) - restored_pipeline = FakePipeline( + fake_pipeline = TerminalSidecarAfterCompletionPipeline( [ - TextDeltaEvent(text="stale restored output"), - PipelineEvent(type=PipelineEventType.PIPELINE_COMPLETED, step_id=None, timestamp=1717821601.0, data={}), + PipelineEvent( + type=PipelineEventType.PIPELINE_COMPLETED, + step_id=None, + timestamp=1717821601.0, + data={"total_steps": 1}, + ), ], session_dir=session_dir, ) - restored_pipeline.sidecar_status = sidecar_status - created_pipelines: list[FakePipeline] = [] + fake_pipeline.handoff_enabled = True + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + original_enqueue_persisted = PipelineA2AEventPublisher.enqueue_persisted - def fake_create_pipeline(*args, **kwargs): - created_pipelines.append(restored_pipeline) - return restored_pipeline + async def fail_committed_terminal_enqueue(self, envelope, *args, **kwargs): + if envelope.get("eventType") == "pipeline_completed" and envelope.get("visibility") == "committed": + return False + return await original_enqueue_persisted(self, envelope, *args, **kwargs) - monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", fake_create_pipeline) - monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + monkeypatch.setattr(PipelineA2AEventPublisher, "enqueue_persisted", fail_committed_terminal_enqueue) + metrics = SpyMetrics() store = A2ATaskStore(metrics=NoOpA2AMetrics()) - executor = IacCodeA2APipelineExecutor( + executor = IacCodeA2AExecutor( task_store=store, model="qwen3.6-plus", - metrics=NoOpA2AMetrics(), - artifact_store=None, - push_notifier=None, - permission_resolver=None, - auto_approve_permissions=False, - thinking_exposure_types=None, + metrics=metrics, ) + queue = FakeEventQueue() - with pytest.raises(RecoverablePipelineInvalidParamsError) as exc_info: - await executor.execute( - context=FakeRequestContext( - task_id="task-new", + await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), queue) + + record = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + assert record.state not in {"completed", "failed", "canceled"} + snapshot = A2APipelineSnapshotStore(session_dir).load() + assert snapshot is not None + assert snapshot["status"] not in {"completed", "failed", "canceled"} + assert snapshot["normalHandoff"] is None + assert await executor._should_route_pipeline_handoff_to_normal(context_id="ctx-1", cwd=str(tmp_path)) is False + context_record = store._contexts["ctx-1"] + publisher = PipelineA2AEventPublisher( + event_queue=FakeEventQueue(), + translator=PipelineEventTranslator( + PipelineA2AContext( + pipeline_run_id="ctx-1", + task_id="task-1", context_id="ctx-1", - text="new request", - metadata={"iac_code": {"cwd": str(tmp_path)}}, - ), - event_queue=FakeEventQueue(), - task=await store.get_or_create_task(task_id="task-new", context_id="ctx-1"), - task_id="task-new", + pipeline_name="selling", + iac_code_session_id=context_record.session_id, + ) + ), + journal=A2APipelineJournal(session_dir), + snapshot_store=A2APipelineSnapshotStore(session_dir), + ) + assert ( + _committed_terminal_status_for_task_context( + publisher, + task_id="task-1", context_id="ctx-1", - cwd=str(tmp_path), - prompt="new request", ) - - assert exc_info.value.data == { - "recoverableTaskId": "task-owner", - "contextId": "ctx-1", - "sidecarStatus": sidecar_status, - } - assert len(created_pipelines) == 1 - assert restored_pipeline.clear_sidecar_calls == 0 - assert restored_pipeline.run_prompts == [] - assert journal.read_all() == [owner_event] + is None + ) @pytest.mark.asyncio -async def test_executor_keeps_a2a_metadata_when_mismatch_clears_pipeline_sidecar( +async def test_pipeline_completed_handoff_enqueue_failure_keeps_terminal_available( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - monkeypatch.setenv("IAC_CODE_MODE", "pipeline") - session_root = tmp_path / "session" - sidecar_dir = session_root / "pipeline" - a2a_dir = session_root / "a2a" / "pipeline" - sidecar_dir.mkdir(parents=True) - old_event = { - "schemaVersion": "1.0", - "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", - "eventId": "evt-old-sidecar", - "sequence": 1, - "createdAt": "2026-06-08T10:00:00Z", - "eventType": "pipeline_completed", - "scope": "pipeline", - "pipelineRunId": "ctx-1", - "taskId": "task-old", - "contextId": "ctx-1", - "pipelineName": "selling", - "status": "completed", - "data": {"sidecarStatus": "completed"}, - } - A2APipelineJournal(a2a_dir).append(old_event) - A2APipelineSnapshotStore(a2a_dir).save(reduce_pipeline_events([old_event])) - - class DeletingSidecarPipeline(FakePipeline): - def clear_sidecar(self) -> None: - super().clear_sidecar() - shutil.rmtree(self.session.session_dir, ignore_errors=True) + from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher - restored_pipeline = DeletingSidecarPipeline([], session_dir=sidecar_dir) - restored_pipeline.sidecar_status = "completed" - fresh_pipeline = FakePipeline( + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + session_dir = tmp_path / "sidecar" + fake_pipeline = TerminalSidecarAfterCompletionPipeline( [ - TextDeltaEvent(text="fresh output"), - PipelineEvent(type=PipelineEventType.PIPELINE_COMPLETED, step_id=None, timestamp=1717821601.0, data={}), + PipelineEvent( + type=PipelineEventType.PIPELINE_COMPLETED, + step_id=None, + timestamp=1717821601.0, + data={"total_steps": 1}, + ), ], - session_dir=sidecar_dir, + session_dir=session_dir, ) - create_resume_flags: list[bool | None] = [] + fake_pipeline.handoff_enabled = True + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + original_enqueue_persisted = PipelineA2AEventPublisher.enqueue_persisted - def fake_create_pipeline(*args, **kwargs): - create_resume_flags.append(kwargs.get("resume_from_sidecar")) - return restored_pipeline if len(create_resume_flags) == 1 else fresh_pipeline + async def fail_committed_handoff_enqueue(self, envelope, *args, **kwargs): + if envelope.get("eventType") == "pipeline_handoff_ready" and envelope.get("visibility") == "committed": + return False + return await original_enqueue_persisted(self, envelope, *args, **kwargs) - monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", fake_create_pipeline) - monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + monkeypatch.setattr(PipelineA2AEventPublisher, "enqueue_persisted", fail_committed_handoff_enqueue) - executor = IacCodeA2AExecutor(task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), model="qwen3.6-plus") + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + queue = FakeEventQueue() - await executor.execute( - FakeRequestContext( - task_id="task-new", + await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), queue) + + pipeline_events = _pipeline_status_events(queue) + assert [event["eventType"] for event in pipeline_events] == ["pipeline_completed", "backup_committed"] + assert _status_events(queue)[-1]["status"]["state"] == "TASK_STATE_COMPLETED" + record = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + assert record.state == "completed" + assert await executor._should_route_pipeline_handoff_to_normal(context_id="ctx-1", cwd=str(tmp_path)) is False + journal_events = A2APipelineJournal(session_dir).read_all() + unavailable_handoff = [ + event + for event in journal_events + if event["eventType"] == "pipeline_handoff_ready" + and isinstance(event.get("data"), dict) + and event["data"].get("action") == "switch_to_normal_unavailable" + ] + assert len(unavailable_handoff) == 1 + assert unavailable_handoff[0].get("visibility") is None + snapshot = A2APipelineSnapshotStore(session_dir).load() + assert snapshot is not None + assert snapshot["status"] == "completed" + assert snapshot["normalHandoff"]["action"] == "switch_to_normal_unavailable" + assert snapshot["pendingNormalHandoff"] is None + + +@pytest.mark.asyncio +async def test_pipeline_pending_handoff_is_not_routed_to_normal(tmp_path: Path) -> None: + from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + ctx = await store.get_or_create_context( + context_id="ctx-1", + cwd=str(tmp_path), + runtime_factory=lambda _session_id: _fake_runtime(), + ) + pipeline_dir = SessionStorage().session_dir(str(tmp_path), ctx.session_id) / "a2a" / "pipeline" + translator = PipelineEventTranslator( + PipelineA2AContext( + pipeline_run_id="ctx-1", + task_id="task-1", context_id="ctx-1", - text="new request", - metadata={"iac_code": {"cwd": str(tmp_path)}}, - ), - FakeEventQueue(), + pipeline_name="selling", + iac_code_session_id=ctx.session_id, + ) ) + terminal = translator.manual_event("pipeline_completed", "pipeline", status="completed", data={"totalSteps": 1}) + handoff = translator.manual_event( + "pipeline_handoff_ready", + "pipeline", + status="completed", + data={ + "action": "switch_to_normal", + "targetMode": "normal", + "outcome": "completed", + "summary": "[Pipeline Handoff Context]\nPipeline: selling", + }, + ) + handoff["visibility"] = "pending_backup" + journal = A2APipelineJournal(pipeline_dir) + journal.append_many([terminal, handoff], durable=True) + snapshot_store = A2APipelineSnapshotStore(pipeline_dir) + snapshot_store.save(reduce_pipeline_events(journal.read_all_repairing_tail())) - assert create_resume_flags == [True, False] - assert restored_pipeline.clear_sidecar_calls == 1 - events = A2APipelineJournal(a2a_dir).read_all() - assert [event["taskId"] for event in events] == ["task-old", "task-new", "task-new"] - assert not (sidecar_dir / "a2a-events.jsonl").exists() + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + + assert await executor._should_route_pipeline_handoff_to_normal(context_id="ctx-1", cwd=str(tmp_path)) is False + snapshot = snapshot_store.load() + assert snapshot is not None + assert snapshot["normalHandoff"] is None + assert snapshot["pendingNormalHandoff"]["summary"] == "[Pipeline Handoff Context]\nPipeline: selling" @pytest.mark.asyncio -async def test_executor_does_not_duplicate_existing_terminal_recovery_event_when_snapshot_missing( +async def test_committed_handoff_routes_to_normal_after_backup_visibility_commit(tmp_path: Path) -> None: + from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + ctx = await store.get_or_create_context( + context_id="ctx-1", + cwd=str(tmp_path), + runtime_factory=lambda _session_id: _fake_runtime(), + ) + pipeline_dir = SessionStorage().session_dir(str(tmp_path), ctx.session_id) / "a2a" / "pipeline" + translator = PipelineEventTranslator( + PipelineA2AContext( + pipeline_run_id="ctx-1", + task_id="task-1", + context_id="ctx-1", + pipeline_name="selling", + iac_code_session_id=ctx.session_id, + ) + ) + terminal = translator.manual_event("pipeline_completed", "pipeline", status="completed", data={"totalSteps": 1}) + pending = translator.manual_event( + "pipeline_handoff_ready", + "pipeline", + status="completed", + data={ + "action": "switch_to_normal", + "targetMode": "normal", + "outcome": "completed", + "summary": "[Pipeline Handoff Context]\nPipeline: selling", + }, + ) + pending["visibility"] = "pending_backup" + committed = translator.manual_event( + "pipeline_handoff_ready", + "pipeline", + status="completed", + data={ + "action": "switch_to_normal", + "targetMode": "normal", + "outcome": "completed", + "summary": "[Pipeline Handoff Context]\nPipeline: selling", + }, + ) + committed["visibility"] = "committed" + ack = translator.manual_event( + "backup_committed", + "pipeline", + data={ + "committedEventId": committed["eventId"], + "committedEventType": "pipeline_handoff_ready", + "committedSequence": committed["sequence"], + }, + ) + ack.pop("status", None) + journal = A2APipelineJournal(pipeline_dir) + journal.append_many([terminal, pending, committed, ack], durable=True) + snapshot_store = A2APipelineSnapshotStore(pipeline_dir) + snapshot_store.save(reduce_pipeline_events(journal.read_all_repairing_tail())) + + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + + assert await executor._should_route_pipeline_handoff_to_normal(context_id="ctx-1", cwd=str(tmp_path)) is True + snapshot = snapshot_store.load() + assert snapshot is not None + assert snapshot["pendingNormalHandoff"] is None + assert snapshot["normalHandoff"]["summary"] == "[Pipeline Handoff Context]\nPipeline: selling" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("publish_failure", ["none", "raise"]) +async def test_handoff_backup_blocked_publish_failure_does_not_expose_terminal_or_normal_handoff( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, + publish_failure: str, ) -> None: + from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") session_dir = tmp_path / "sidecar" - journal = A2APipelineJournal(session_dir) - journal.append( - { - "schemaVersion": "1.0", - "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", - "eventId": "evt-terminal", - "sequence": 1, - "createdAt": "2026-06-08T10:00:00Z", - "eventType": "pipeline_failed", - "scope": "pipeline", - "pipelineRunId": "ctx-1", - "taskId": "task-1", - "contextId": "ctx-1", - "pipelineName": "selling", - "status": "failed", - "data": {"sidecarStatus": "failed", "recovered": True}, - } + fake_pipeline = FakePipeline( + [ + PipelineEvent( + type=PipelineEventType.PIPELINE_COMPLETED, + step_id=None, + timestamp=1717821601.0, + data={"total_steps": 1}, + ), + ], + session_dir=session_dir, ) - fake_pipeline = FakePipeline([], session_dir=session_dir) - fake_pipeline.sidecar_status = "failed" + fake_pipeline.handoff_enabled = True monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + original_publish_manual = PipelineA2AEventPublisher.publish_manual + + async def fail_backup_blocked_publish(self, event_type, *args, **kwargs): + if event_type == "backup_blocked": + if publish_failure == "raise": + raise RuntimeError("publish failed") + return None + return await original_publish_manual(self, event_type, *args, **kwargs) + + monkeypatch.setattr(PipelineA2AEventPublisher, "publish_manual", fail_backup_blocked_publish) + metrics = SpyMetrics() + backup_service = RecordingBackupService(block_reasons={BackupReason.HANDOFF_READY}) store = A2ATaskStore(metrics=NoOpA2AMetrics()) - executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + executor = IacCodeA2AExecutor( + task_store=store, + model="qwen3.6-plus", + metrics=metrics, + backup_service=backup_service, + ) queue = FakeEventQueue() - await executor.execute( - FakeRequestContext( - task_id="task-1", - context_id="ctx-1", - text="new request", - metadata={"iac_code": {"cwd": str(tmp_path)}}, - ), - queue, - ) + await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), queue) - terminal_events = [event for event in journal.read_all() if event["eventType"] == "pipeline_failed"] - assert len(terminal_events) == 1 + assert _pipeline_status_events(queue) == [] + record = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + assert record.state == "input-required" + assert await executor._should_route_pipeline_handoff_to_normal(context_id="ctx-1", cwd=str(tmp_path)) is False snapshot = A2APipelineSnapshotStore(session_dir).load() assert snapshot is not None - assert snapshot["status"] == "failed" - assert _status_events(queue)[-1]["status"]["state"] == "TASK_STATE_FAILED" + assert snapshot["status"] not in {"completed", "failed", "canceled"} + assert snapshot["normalHandoff"] is None + assert metrics.backup_blocked == [(BackupReason.HANDOFF_READY.value, False)] + assert metrics.task_failed == 0 @pytest.mark.asyncio -async def test_executor_does_not_publish_conflicting_terminal_sidecar_recovery_event( +async def test_executor_candidate_started_includes_steps_from_loaded_sub_pipeline( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: monkeypatch.setenv("IAC_CODE_MODE", "pipeline") - session_dir = tmp_path / "sidecar" - failed_event = { - "schemaVersion": "1.0", - "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", - "eventId": "evt-terminal", - "sequence": 1, - "createdAt": "2026-06-08T10:00:00Z", - "eventType": "pipeline_failed", - "scope": "pipeline", - "pipelineRunId": "ctx-1", - "taskId": "task-1", - "contextId": "ctx-1", - "pipelineName": "selling", - "status": "failed", - "data": {"source": "executor"}, - } - journal = A2APipelineJournal(session_dir) - journal.append(failed_event) - A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([failed_event])) - fake_pipeline = FakePipeline([], session_dir=session_dir) - fake_pipeline.sidecar_status = "completed" + fake_pipeline = FakePipeline( + [ + PipelineEvent( + type=PipelineEventType.SUB_PIPELINE_STARTED, + step_id=None, + timestamp=1717821600.0, + data={ + "parent_step_id": "evaluate_candidates", + "sub_pipeline_id": "evaluate_candidate_candidate_0", + "sub_pipeline_name": "evaluate_candidate", + "candidate_index": 0, + "candidate_name": "轻量应用服务器方案", + "total_steps": 2, + }, + ), + PipelineEvent( + type=PipelineEventType.PIPELINE_COMPLETED, + step_id=None, + timestamp=1717821601.0, + data={}, + ), + ], + session_dir=tmp_path / "sidecar", + ) + fake_pipeline._loaded = SimpleNamespace( + steps=[ + SimpleNamespace(step_id="intent_parsing"), + SimpleNamespace( + step_id="evaluate_candidates", + step_type="parallel_sub_pipeline", + sub_pipeline_name="evaluate_candidate", + ), + SimpleNamespace(step_id="confirm_and_select"), + ], + sub_pipelines={ + "evaluate_candidate": SimpleNamespace( + steps=[ + SimpleNamespace(step_id="template_generating"), + SimpleNamespace(step_id="cost_estimating"), + ], + ) + }, + ) monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) @@ -1523,92 +2328,65 @@ async def test_executor_does_not_publish_conflicting_terminal_sidecar_recovery_e executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") queue = FakeEventQueue() - await executor.execute( - FakeRequestContext( - task_id="task-1", - context_id="ctx-1", - text="new request", - metadata={"iac_code": {"cwd": str(tmp_path)}}, - ), - queue, - ) + await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), queue) - events = journal.read_all() - assert [event["eventType"] for event in events] == ["pipeline_failed"] - snapshot = A2APipelineSnapshotStore(session_dir).load() - assert snapshot is not None - assert snapshot["status"] == "failed" - assert _status_events(queue)[-1]["status"]["state"] == "TASK_STATE_FAILED" + candidate_started = next( + dump(event)["metadata"]["iac_code"]["pipeline"] + for event in queue.events + if isinstance(event, TaskStatusUpdateEvent) + and dump(event).get("metadata", {}).get("iac_code", {}).get("pipeline", {}).get("eventType") + == "candidate_started" + ) + assert [step["id"] for step in candidate_started["candidate"]["steps"]] == [ + "template_generating", + "cost_estimating", + ] @pytest.mark.asyncio -async def test_executor_rebuilds_stale_snapshot_from_existing_terminal_recovery_event( +async def test_executor_hydrates_translator_step_attempts_before_resuming_waiting_input( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: monkeypatch.setenv("IAC_CODE_MODE", "pipeline") session_dir = tmp_path / "sidecar" - journal = A2APipelineJournal(session_dir) - working_event = { + waiting_event = { "schemaVersion": "1.0", "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", - "eventId": "evt-working", - "sequence": 1, - "createdAt": "2026-06-08T10:00:00Z", - "eventType": "pipeline_started", - "scope": "pipeline", + "eventId": "evt-waiting", + "sequence": 42, + "createdAt": "2026-06-11T06:15:55Z", + "eventType": "input_required", + "scope": "step", "pipelineRunId": "ctx-1", "taskId": "task-1", "contextId": "ctx-1", "pipelineName": "selling", - "status": "working", - "data": {}, + "status": "input_required", + "step": {"id": "confirm_and_select", "runId": "step-confirm_and_select-2", "attempt": 2}, + "data": {"prompt": "请选择要部署的方案:"}, } - terminal_event = dict(working_event) - terminal_event.update( - { - "eventId": "evt-terminal", - "sequence": 2, - "eventType": "pipeline_failed", - "status": "failed", - "data": {"sidecarStatus": "failed", "recovered": True}, - } - ) - other_context_terminal_event = dict(working_event) - other_context_terminal_event.update( - { - "eventId": "evt-other-terminal", - "sequence": 99, - "eventType": "pipeline_completed", - "pipelineRunId": "ctx-other", - "taskId": "task-other", - "contextId": "ctx-other", - "status": "completed", - "data": {"sidecarStatus": "completed", "recovered": True}, - } - ) - journal.append(working_event) - journal.append(terminal_event) - journal.append(other_context_terminal_event) - A2APipelineSnapshotStore(session_dir).save( - { - "schemaVersion": "1.0", - "snapshotVersion": 1, - "pipelineRunId": "ctx-1", - "taskId": "task-1", - "contextId": "ctx-1", - "pipelineName": "selling", - "status": "working", - "lastSequence": 1, - "steps": [], - "display": {"messages": [], "diagrams": [], "candidateDetails": [], "artifacts": []}, - "pendingInput": None, - "control": {"activeCandidateRunIds": [], "rollbackHistory": [], "candidateRestarts": []}, - "seenEventIds": ["evt-working"], - } + journal = A2APipelineJournal(session_dir) + journal.append(waiting_event) + A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([waiting_event])) + fake_pipeline = FakePipeline( + [ + PipelineEvent( + type=PipelineEventType.USER_INPUT_RECEIVED, + step_id="confirm_and_select", + timestamp=1717821601.0, + data={"selected_value": "已有VPC下新建VSwitch"}, + ), + PipelineEvent( + type=PipelineEventType.PIPELINE_COMPLETED, + step_id=None, + timestamp=1717821602.0, + data={"total_steps": 5}, + ), + ], + session_dir=session_dir, ) - fake_pipeline = FakePipeline([], session_dir=session_dir) - fake_pipeline.sidecar_status = "failed" + fake_pipeline.sidecar_status = "waiting_input" monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) @@ -1620,175 +2398,83 @@ async def test_executor_rebuilds_stale_snapshot_from_existing_terminal_recovery_ FakeRequestContext( task_id="task-1", context_id="ctx-1", - text="new request", + text="已有VPC下新建VSwitch", metadata={"iac_code": {"cwd": str(tmp_path)}}, ), queue, ) - terminal_events = [event for event in journal.read_all() if event["eventType"] == "pipeline_failed"] - assert len(terminal_events) == 1 - snapshot = A2APipelineSnapshotStore(session_dir).load() - assert snapshot is not None - assert snapshot["status"] == "failed" - assert snapshot["lastSequence"] == 2 - assert snapshot["taskId"] == "task-1" - assert snapshot["contextId"] == "ctx-1" - assert _status_events(queue)[-1]["status"]["state"] == "TASK_STATE_FAILED" + input_received = next( + dump(event)["metadata"]["iac_code"]["pipeline"] + for event in queue.events + if isinstance(event, TaskStatusUpdateEvent) + and dump(event).get("metadata", {}).get("iac_code", {}).get("pipeline", {}).get("eventType") == "input_received" + ) + assert fake_pipeline.resume_prompts == ["已有VPC下新建VSwitch"] + assert input_received["step"]["runId"] == "step-confirm_and_select-2" + assert input_received["step"]["attempt"] == 2 @pytest.mark.asyncio -async def test_executor_does_not_rebuild_terminal_snapshot_from_unrepairable_journal( +async def test_executor_returns_input_required_for_retryable_stream_error( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: monkeypatch.setenv("IAC_CODE_MODE", "pipeline") - session_dir = tmp_path / "sidecar" - working_event = { - "schemaVersion": "1.0", - "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", - "eventId": "evt-working", - "sequence": 1, - "createdAt": "2026-06-08T10:00:00Z", - "eventType": "pipeline_started", - "scope": "pipeline", - "pipelineRunId": "ctx-1", - "taskId": "task-1", - "contextId": "ctx-1", - "pipelineName": "selling", - "status": "working", - "data": {}, - } - terminal_event = dict(working_event) - terminal_event.update( - { - "eventId": "evt-terminal", - "sequence": 3, - "eventType": "pipeline_failed", - "status": "failed", - "data": {"sidecarStatus": "failed", "recovered": True}, - } - ) - journal = A2APipelineJournal(session_dir) - journal.append(working_event) - journal.path.write_text( - journal.path.read_text(encoding="utf-8") - + "not-json\n" - + json.dumps(terminal_event, ensure_ascii=False, separators=(",", ":")) - + "\n", - encoding="utf-8", - ) - A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([working_event])) - - class TerminalAfterRunPipeline(FakePipeline): - async def run(self, prompt: str): - self.run_prompts.append(prompt) - self.sidecar_status = "failed" - if False: - yield None - - fake_pipeline = TerminalAfterRunPipeline([], session_dir=session_dir) + fake_pipeline = FakePipeline([TimeoutError("upstream timed out")], session_dir=tmp_path / "sidecar") monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) - executor = IacCodeA2AExecutor(task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), model="qwen3.6-plus") + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") queue = FakeEventQueue() - await executor.execute( - FakeRequestContext( - task_id="task-1", - context_id="ctx-1", - text="resume", - metadata={"iac_code": {"cwd": str(tmp_path)}}, - ), - queue, - ) + await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), queue) - snapshot = A2APipelineSnapshotStore(session_dir).load() - assert snapshot is not None - assert snapshot["status"] == "working" - assert snapshot["lastSequence"] == 1 - assert _status_events(queue)[-1]["status"]["state"] == "TASK_STATE_FAILED" + final_status = _status_events(queue)[-1]["status"] + assert final_status["state"] == "TASK_STATE_INPUT_REQUIRED" + assert final_status["message"]["parts"][0]["text"] == RETRY_TEXT + record = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + assert record.state == "input-required" @pytest.mark.asyncio -async def test_executor_repairs_same_task_terminal_sidecar_with_nonterminal_snapshot_without_rerun( +async def test_executor_returns_input_required_for_retryable_pipeline_creation_error( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: monkeypatch.setenv("IAC_CODE_MODE", "pipeline") - session_dir = tmp_path / "sidecar" - working_event = { - "schemaVersion": "1.0", - "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", - "eventId": "evt-working", - "sequence": 1, - "createdAt": "2026-06-08T10:00:00Z", - "eventType": "pipeline_started", - "scope": "pipeline", - "pipelineRunId": "ctx-1", - "taskId": "task-1", - "contextId": "ctx-1", - "pipelineName": "selling", - "status": "working", - "data": {}, - } - journal = A2APipelineJournal(session_dir) - journal.append(working_event) - A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([working_event])) - fake_pipeline = FakePipeline( - [ - TextDeltaEvent(text="should not rerun"), - PipelineEvent(type=PipelineEventType.PIPELINE_COMPLETED, step_id=None, timestamp=1717821601.0, data={}), - ], - session_dir=session_dir, - ) - fake_pipeline.sidecar_status = "completed" - monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) + + def raise_timeout(*args, **kwargs): + raise TimeoutError("pipeline setup timed out") + + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", raise_timeout) monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) store = A2ATaskStore(metrics=NoOpA2AMetrics()) executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") queue = FakeEventQueue() - await executor.execute( - FakeRequestContext( - task_id="task-1", - context_id="ctx-1", - text="new request", - metadata={"iac_code": {"cwd": str(tmp_path)}}, - ), - queue, - ) + await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), queue) - assert fake_pipeline.clear_sidecar_calls == 0 - assert fake_pipeline.run_prompts == [] - events = journal.read_all() - assert [event["eventType"] for event in events] == ["pipeline_started", "pipeline_completed"] - assert events[-1]["data"]["recovered"] is True - snapshot = A2APipelineSnapshotStore(session_dir).load() - assert snapshot is not None - assert snapshot["status"] == "completed" - assert _status_events(queue)[-1]["status"]["state"] == "TASK_STATE_COMPLETED" + final_status = _status_events(queue)[-1]["status"] + assert final_status["state"] == "TASK_STATE_INPUT_REQUIRED" + assert final_status["message"]["parts"][0]["text"] == RETRY_TEXT + record = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + assert record.state == "input-required" @pytest.mark.asyncio -async def test_executor_repairs_terminal_sidecar_after_partial_nonterminal_stream( +async def test_executor_returns_input_required_for_retryable_runtime_creation_error( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: monkeypatch.setenv("IAC_CODE_MODE", "pipeline") - session_dir = tmp_path / "sidecar" - class PartialTerminalPipeline(FakePipeline): - async def run(self, prompt: str): - self.run_prompts.append(prompt) - yield TextDeltaEvent(text="partial output") - self.sidecar_status = "failed" + def raise_timeout(options): + raise TimeoutError("runtime setup timed out") - fake_pipeline = PartialTerminalPipeline([], session_dir=session_dir) - monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) - monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", raise_timeout) store = A2ATaskStore(metrics=NoOpA2AMetrics()) executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") @@ -1796,1581 +2482,2970 @@ async def run(self, prompt: str): await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), queue) - event_types = [event["eventType"] for event in A2APipelineJournal(session_dir).read_all()] - assert event_types == ["text_delta", "pipeline_failed"] - snapshot = A2APipelineSnapshotStore(session_dir).load() - assert snapshot is not None - assert snapshot["status"] == "failed" - assert _status_events(queue)[-1]["status"]["state"] == "TASK_STATE_FAILED" + final_status = _status_events(queue)[-1]["status"] + assert final_status["state"] == "TASK_STATE_INPUT_REQUIRED" + assert final_status["message"]["parts"][0]["text"] == RETRY_TEXT + record = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + assert record.state == "input-required" @pytest.mark.asyncio -async def test_executor_routes_waiting_sidecar_prompt_to_resume( +async def test_executor_sanitizes_auth_looking_pipeline_error( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: monkeypatch.setenv("IAC_CODE_MODE", "pipeline") fake_pipeline = FakePipeline( - [ - PipelineEvent( - type=PipelineEventType.PIPELINE_COMPLETED, - step_id=None, - timestamp=1717821601.0, - data={}, - ) - ], + [ValueError("missing API key: secret-internal-detail")], session_dir=tmp_path / "sidecar", ) - fake_pipeline.sidecar_status = "waiting_input" - input_event = { - "schemaVersion": "1.0", - "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", - "eventId": "evt-input", - "sequence": 1, - "createdAt": "2026-06-08T10:00:00Z", - "eventType": "input_required", - "scope": "pipeline", - "pipelineRunId": "ctx-1", - "taskId": "task-1", - "contextId": "ctx-1", - "pipelineName": "selling", - "status": "waiting_input", - "data": {"prompt": "choose"}, - } - A2APipelineJournal(tmp_path / "sidecar").append(input_event) - A2APipelineSnapshotStore(tmp_path / "sidecar").save(reduce_pipeline_events([input_event])) monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) - executor = IacCodeA2AExecutor(task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), model="qwen3.6-plus") + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + queue = FakeEventQueue() - await executor.execute( - FakeRequestContext(text="selected", metadata={"iac_code": {"cwd": str(tmp_path)}}), - FakeEventQueue(), - ) + await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), queue) - assert fake_pipeline.resume_prompts == ["selected"] - assert fake_pipeline.run_prompts == [] + final_status = _status_events(queue)[-1]["status"] + assert final_status["state"] == "TASK_STATE_FAILED" + assert final_status["message"]["parts"][0]["text"] == AUTH_TEXT @pytest.mark.asyncio -async def test_executor_does_not_resume_waiting_sidecar_when_restore_failed( +async def test_executor_persists_pipeline_failed_event_for_nonretryable_stream_error( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - from iac_code.pipeline.engine.session import RestoreResult - monkeypatch.setenv("IAC_CODE_MODE", "pipeline") session_dir = tmp_path / "sidecar" fake_pipeline = FakePipeline( - [ - PipelineEvent( - type=PipelineEventType.PIPELINE_COMPLETED, - step_id=None, - timestamp=1717821601.0, - data={}, - ) - ], + [ValueError("planner crashed INTERNAL_TOKEN=tok-live /tmp/iac-code/work.py")], session_dir=session_dir, ) - fake_pipeline.sidecar_status = "waiting_input" - fake_pipeline.sidecar_restore_result = RestoreResult(ok=False, status="waiting_input", reason="invalid_context") - input_event = { - "schemaVersion": "1.0", - "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", - "eventId": "evt-input", - "sequence": 1, - "createdAt": "2026-06-08T10:00:00Z", - "eventType": "input_required", - "scope": "pipeline", - "pipelineRunId": "ctx-1", - "taskId": "task-1", - "contextId": "ctx-1", - "pipelineName": "selling", - "status": "waiting_input", - "data": {"prompt": "choose"}, - } - A2APipelineJournal(session_dir).append(input_event) - A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([input_event])) monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) - executor = IacCodeA2AExecutor(task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), model="qwen3.6-plus") + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") queue = FakeEventQueue() - await executor.execute( - FakeRequestContext(text="selected", metadata={"iac_code": {"cwd": str(tmp_path)}}), - queue, - ) + await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), queue) - assert fake_pipeline.resume_prompts == [] - assert fake_pipeline.run_prompts == [] + events = A2APipelineJournal(session_dir).read_all() + assert events[-1]["eventType"] == "backup_committed" + assert events[-1]["data"]["committedEventType"] == "pipeline_failed" + terminal_event = next( + event + for event in reversed(events) + if event["eventType"] == "pipeline_failed" and event.get("visibility") == "committed" + ) + assert terminal_event["status"] == "failed" + assert terminal_event["data"]["errorSummary"] == "ValueError: planner crashed INTERNAL_TOKEN=[REDACTED] [PATH]" + assert terminal_event["data"]["errorDetails"]["type"] == "ValueError" + assert terminal_event["data"]["errorDetails"]["errorId"] + assert terminal_event["data"]["errorDetails"]["traceback"] == "Stack trace omitted from public event; see error_id." + assert "tok-live" not in json.dumps(terminal_event) + assert "/tmp/iac-code" not in json.dumps(terminal_event) + snapshot = A2APipelineSnapshotStore(session_dir).load() + assert snapshot is not None + assert snapshot["status"] == "failed" assert _status_events(queue)[-1]["status"]["state"] == "TASK_STATE_FAILED" @pytest.mark.asyncio -async def test_executor_resumes_matching_waiting_sidecar_when_journal_has_partial_tail( +@pytest.mark.parametrize( + ("sidecar_status", "expected_state", "expected_snapshot_status", "expected_event_type"), + [ + ("completed", "TASK_STATE_COMPLETED", "completed", "pipeline_completed"), + ("failed", "TASK_STATE_FAILED", "failed", "pipeline_failed"), + ("user_aborted", "TASK_STATE_CANCELED", "canceled", "pipeline_canceled"), + ("canceled", "TASK_STATE_CANCELED", "canceled", "pipeline_canceled"), + ], +) +async def test_executor_preserves_terminal_sidecar_recovery_state_on_followup( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, + sidecar_status: str, + expected_state: str, + expected_snapshot_status: str, + expected_event_type: str, ) -> None: monkeypatch.setenv("IAC_CODE_MODE", "pipeline") session_dir = tmp_path / "sidecar" - fake_pipeline = FakePipeline( - [ - PipelineEvent( - type=PipelineEventType.PIPELINE_COMPLETED, - step_id=None, - timestamp=1717821601.0, - data={}, - ) - ], - session_dir=session_dir, + A2APipelineJournal(session_dir).append( + { + "schemaVersion": "1.0", + "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", + "eventId": "evt-terminal", + "sequence": 1, + "createdAt": "2026-06-08T10:00:00Z", + "eventType": expected_event_type, + "scope": "pipeline", + "pipelineRunId": "ctx-1", + "taskId": "task-1", + "contextId": "ctx-1", + "pipelineName": "selling", + "status": expected_snapshot_status, + "data": {"sidecarStatus": sidecar_status}, + } ) - fake_pipeline.sidecar_status = "waiting_input" - input_event = { - "schemaVersion": "1.0", - "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", - "eventId": "evt-input", - "sequence": 1, - "createdAt": "2026-06-08T10:00:00Z", - "eventType": "input_required", - "scope": "pipeline", - "pipelineRunId": "ctx-1", - "taskId": "task-1", - "contextId": "ctx-1", - "pipelineName": "selling", - "status": "waiting_input", - "data": {"prompt": "choose"}, - } - journal = A2APipelineJournal(session_dir) - journal.append(input_event) - journal.path.write_text(journal.path.read_text(encoding="utf-8") + '{"eventId":"evt-partial"', encoding="utf-8") - A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([input_event])) + fake_pipeline = FakePipeline([], session_dir=session_dir) + fake_pipeline.sidecar_status = sidecar_status monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) - executor = IacCodeA2AExecutor(task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), model="qwen3.6-plus") + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + queue = FakeEventQueue() await executor.execute( - FakeRequestContext(text="selected", metadata={"iac_code": {"cwd": str(tmp_path)}}), - FakeEventQueue(), + FakeRequestContext( + task_id="task-1", + context_id="ctx-1", + text="new request", + metadata={"iac_code": {"cwd": str(tmp_path)}}, + ), + queue, ) assert fake_pipeline.clear_sidecar_calls == 0 - assert fake_pipeline.resume_prompts == ["selected"] assert fake_pipeline.run_prompts == [] + assert (session_dir / "a2a-events.jsonl").exists() + assert _status_events(queue)[-1]["status"]["state"] == expected_state + snapshot = A2APipelineSnapshotStore(session_dir).load() + assert snapshot is not None + assert snapshot["status"] == expected_snapshot_status + last_event = A2APipelineJournal(session_dir).read_all()[-1] + assert last_event["eventType"] == expected_event_type + assert last_event["taskId"] == "task-1" + record = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + assert record.state in {"completed", "failed", "canceled"} @pytest.mark.asyncio -async def test_executor_does_not_trust_snapshot_owner_when_journal_has_middle_corruption( +async def test_executor_clears_previous_task_terminal_sidecar_and_runs_new_task( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: monkeypatch.setenv("IAC_CODE_MODE", "pipeline") session_dir = tmp_path / "sidecar" - fake_pipeline = FakePipeline( - [ - PipelineEvent( - type=PipelineEventType.PIPELINE_COMPLETED, - step_id=None, - timestamp=1717821601.0, - data={}, - ) - ], - session_dir=session_dir, - ) - fake_pipeline.sidecar_status = "waiting_input" - old_input = { + journal = A2APipelineJournal(session_dir) + previous_terminal = { "schemaVersion": "1.0", "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", - "eventId": "evt-old-input", + "eventId": "evt-old-terminal", "sequence": 1, "createdAt": "2026-06-08T10:00:00Z", - "eventType": "input_required", + "eventType": "pipeline_completed", "scope": "pipeline", "pipelineRunId": "ctx-1", "taskId": "task-old", "contextId": "ctx-1", "pipelineName": "selling", - "status": "waiting_input", - "data": {"prompt": "old choice"}, + "status": "completed", + "data": {"sidecarStatus": "completed"}, } - new_event = dict(old_input) - new_event.update( + journal.append(previous_terminal) + A2APipelineSnapshotStore(session_dir).save( { - "eventId": "evt-new", - "sequence": 3, - "taskId": "task-new", - "status": "working", - "eventType": "pipeline_started", - "data": {}, + "schemaVersion": "1.0", + "snapshotVersion": 1, + "pipelineRunId": "ctx-1", + "taskId": "task-old", + "contextId": "ctx-1", + "pipelineName": "selling", + "status": "completed", + "lastSequence": 1, + "steps": [], + "display": {"messages": [], "diagrams": [], "candidateDetails": [], "artifacts": []}, + "pendingInput": None, + "control": {"activeCandidateRunIds": [], "rollbackHistory": [], "candidateRestarts": []}, + "seenEventIds": ["evt-old-terminal"], } ) - journal = A2APipelineJournal(session_dir) - journal.append(old_input) - journal.path.write_text( - journal.path.read_text(encoding="utf-8") - + "not-json\n" - + json.dumps(new_event, ensure_ascii=False, separators=(",", ":")) - + "\n", - encoding="utf-8", + fake_pipeline = FakePipeline( + [ + TextDeltaEvent(text="new output"), + PipelineEvent(type=PipelineEventType.PIPELINE_COMPLETED, step_id=None, timestamp=1717821601.0, data={}), + ], + session_dir=session_dir, ) - A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([old_input])) + fake_pipeline.sidecar_status = "completed" monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) - executor = IacCodeA2AExecutor(task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), model="qwen3.6-plus") + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") queue = FakeEventQueue() await executor.execute( FakeRequestContext( - task_id="task-old", + task_id="task-new", context_id="ctx-1", - text="old followup", + text="new request", metadata={"iac_code": {"cwd": str(tmp_path)}}, ), queue, ) - assert fake_pipeline.clear_sidecar_calls == 0 - assert fake_pipeline.resume_prompts == [] - assert fake_pipeline.run_prompts == [] - assert _status_events(queue)[-1]["status"]["state"] == "TASK_STATE_FAILED" + assert fake_pipeline.clear_sidecar_calls == 1 + assert fake_pipeline.run_prompts == ["new request"] + events = journal.read_all() + event_types = [event["eventType"] for event in events] + assert event_types == [ + "pipeline_completed", + "text_delta", + "pipeline_completed", + "pipeline_completed", + "backup_committed", + ] + assert [event.get("visibility") for event in events[-3:-1]] == ["pending_backup", "committed"] + assert events[-2]["taskId"] == "task-new" + assert events[-1]["data"]["committedEventType"] == "pipeline_completed" + assert _status_events(queue)[-1]["status"]["state"] == "TASK_STATE_COMPLETED" @pytest.mark.asyncio -@pytest.mark.parametrize("sidecar_status", ["waiting_input", "running"]) @pytest.mark.parametrize( - ("terminal_event_type", "terminal_status", "expected_state"), + ("sidecar_status", "event_type", "event_status"), [ - ("pipeline_completed", "completed", "TASK_STATE_COMPLETED"), - ("pipeline_failed", "failed", "TASK_STATE_FAILED"), - ("pipeline_canceled", "canceled", "TASK_STATE_CANCELED"), + ("completed", "pipeline_completed", "completed"), ], ) -async def test_executor_does_not_resume_nonterminal_sidecar_when_a2a_state_is_terminal( +async def test_executor_replaces_terminal_restored_pipeline_when_sidecar_owner_mismatches( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, sidecar_status: str, - terminal_event_type: str, - terminal_status: str, - expected_state: str, + event_type: str, + event_status: str, ) -> None: monkeypatch.setenv("IAC_CODE_MODE", "pipeline") session_dir = tmp_path / "sidecar" - terminal_event = { + old_event = { "schemaVersion": "1.0", "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", - "eventId": "evt-terminal", + "eventId": "evt-old-sidecar", "sequence": 1, "createdAt": "2026-06-08T10:00:00Z", - "eventType": terminal_event_type, + "eventType": event_type, "scope": "pipeline", "pipelineRunId": "ctx-1", - "taskId": "task-1", + "taskId": "task-old", "contextId": "ctx-1", "pipelineName": "selling", - "status": terminal_status, - "data": {}, + "status": event_status, + "data": {"prompt": "old choice"} if event_type == "input_required" else {}, } - A2APipelineJournal(session_dir).append(terminal_event) - A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([terminal_event])) - fake_pipeline = FakePipeline( + A2APipelineJournal(session_dir).append(old_event) + A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([old_event])) + + class RestoredMemoryPipeline(FakePipeline): + async def run(self, prompt: str): + self.run_prompts.append(prompt) + yield TextDeltaEvent(text="stale restored output") + yield PipelineEvent( + type=PipelineEventType.PIPELINE_COMPLETED, + step_id=None, + timestamp=1717821601.0, + data={}, + ) + + restored_pipeline = RestoredMemoryPipeline([], session_dir=session_dir) + restored_pipeline.sidecar_status = sidecar_status + fresh_pipeline = FakePipeline( [ TextDeltaEvent(text="fresh output"), PipelineEvent(type=PipelineEventType.PIPELINE_COMPLETED, step_id=None, timestamp=1717821601.0, data={}), ], session_dir=session_dir, ) - fake_pipeline.sidecar_status = sidecar_status - monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) + create_resume_flags: list[bool | None] = [] + + def fake_create_pipeline(*args, **kwargs): + create_resume_flags.append(kwargs.get("resume_from_sidecar")) + return restored_pipeline if len(create_resume_flags) == 1 else fresh_pipeline + + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", fake_create_pipeline) monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) - executor = IacCodeA2AExecutor(task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), model="qwen3.6-plus") - queue = FakeEventQueue() + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") await executor.execute( FakeRequestContext( - task_id="task-1", + task_id="task-new", context_id="ctx-1", - text="retry", + text="new request", metadata={"iac_code": {"cwd": str(tmp_path)}}, ), - queue, + FakeEventQueue(), ) - assert fake_pipeline.clear_sidecar_calls == 0 - assert fake_pipeline.resume_prompts == [] - assert fake_pipeline.continue_calls == 0 - assert fake_pipeline.run_prompts == [] - assert _status_events(queue)[-1]["status"]["state"] == expected_state - events = A2APipelineJournal(session_dir).read_all() - assert [event["eventType"] for event in events] == [terminal_event_type] - snapshot = A2APipelineSnapshotStore(session_dir).load() - assert snapshot is not None - assert snapshot["status"] == terminal_status + assert create_resume_flags == [True, False] + assert restored_pipeline.clear_sidecar_calls == 1 + assert restored_pipeline.run_prompts == [] + assert fresh_pipeline.run_prompts == ["new request"] + record = await store.get_task_record("task-new") + assert "".join(record.output_text) == "fresh output" @pytest.mark.asyncio -async def test_executor_rejects_previous_task_waiting_sidecar_without_starting_new_task( +@pytest.mark.parametrize( + ("sidecar_status", "event_type", "event_status"), + [ + ("waiting_input", "input_required", "waiting_input"), + ("running", "pipeline_started", "working"), + ], +) +async def test_executor_rejects_active_restored_pipeline_owner_mismatch_without_clearing( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, + sidecar_status: str, + event_type: str, + event_status: str, ) -> None: - from iac_code.a2a.pipeline_executor import RecoverablePipelineInvalidParamsError + from iac_code.a2a.pipeline_executor import ( + IacCodeA2APipelineExecutor, + RecoverablePipelineInvalidParamsError, + ) monkeypatch.setenv("IAC_CODE_MODE", "pipeline") session_dir = tmp_path / "sidecar" - old_input = { + owner_event = { "schemaVersion": "1.0", "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", - "eventId": "evt-old-input", + "eventId": "evt-owner", "sequence": 1, "createdAt": "2026-06-08T10:00:00Z", - "eventType": "input_required", + "eventType": event_type, "scope": "pipeline", "pipelineRunId": "ctx-1", - "taskId": "task-old", + "taskId": "task-owner", "contextId": "ctx-1", "pipelineName": "selling", - "status": "waiting_input", - "data": {"prompt": "old choice"}, + "status": event_status, + "data": {"prompt": "owner choice"} if event_type == "input_required" else {}, } - A2APipelineJournal(session_dir).append(old_input) - A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([old_input])) - fake_pipeline = FakePipeline( + journal = A2APipelineJournal(session_dir) + journal.append(owner_event) + A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([owner_event])) + restored_pipeline = FakePipeline( [ - TextDeltaEvent(text="new output"), + TextDeltaEvent(text="stale restored output"), PipelineEvent(type=PipelineEventType.PIPELINE_COMPLETED, step_id=None, timestamp=1717821601.0, data={}), ], session_dir=session_dir, ) - fake_pipeline.sidecar_status = "waiting_input" - monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) + restored_pipeline.sidecar_status = sidecar_status + created_pipelines: list[FakePipeline] = [] + + def fake_create_pipeline(*args, **kwargs): + created_pipelines.append(restored_pipeline) + return restored_pipeline + + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", fake_create_pipeline) monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) - executor = IacCodeA2AExecutor(task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), model="qwen3.6-plus") + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2APipelineExecutor( + task_store=store, + model="qwen3.6-plus", + metrics=NoOpA2AMetrics(), + artifact_store=None, + push_notifier=None, + permission_resolver=None, + auto_approve_permissions=False, + thinking_exposure_types=None, + ) with pytest.raises(RecoverablePipelineInvalidParamsError) as exc_info: await executor.execute( - FakeRequestContext( + context=FakeRequestContext( task_id="task-new", context_id="ctx-1", text="new request", metadata={"iac_code": {"cwd": str(tmp_path)}}, ), - FakeEventQueue(), + event_queue=FakeEventQueue(), + task=await store.get_or_create_task(task_id="task-new", context_id="ctx-1"), + task_id="task-new", + context_id="ctx-1", + cwd=str(tmp_path), + prompt="new request", ) assert exc_info.value.data == { - "recoverableTaskId": "task-old", + "recoverableTaskId": "task-owner", "contextId": "ctx-1", - "sidecarStatus": "waiting_input", + "sidecarStatus": sidecar_status, } - assert fake_pipeline.clear_sidecar_calls == 0 - assert fake_pipeline.resume_prompts == [] - assert fake_pipeline.run_prompts == [] + assert len(created_pipelines) == 1 + assert restored_pipeline.clear_sidecar_calls == 0 + assert restored_pipeline.run_prompts == [] + assert journal.read_all() == [owner_event] @pytest.mark.asyncio -@pytest.mark.parametrize( - ("sidecar_status", "event_type", "event_status"), - [ - ("waiting_input", "input_required", "waiting_input"), - ("running", "pipeline_started", "working"), - ], -) -async def test_executor_does_not_attach_current_sidecar_to_historical_task( +async def test_executor_keeps_a2a_metadata_when_mismatch_clears_pipeline_sidecar( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, - sidecar_status: str, - event_type: str, - event_status: str, ) -> None: - from iac_code.a2a.pipeline_executor import RecoverablePipelineInvalidParamsError - monkeypatch.setenv("IAC_CODE_MODE", "pipeline") - session_dir = tmp_path / "sidecar" - old_event = { + session_root = tmp_path / "session" + sidecar_dir = session_root / "pipeline" + a2a_dir = session_root / "a2a" / "pipeline" + sidecar_dir.mkdir(parents=True) + old_event = { "schemaVersion": "1.0", "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", - "eventId": "evt-old", + "eventId": "evt-old-sidecar", "sequence": 1, "createdAt": "2026-06-08T10:00:00Z", - "eventType": event_type, + "eventType": "pipeline_completed", "scope": "pipeline", "pipelineRunId": "ctx-1", "taskId": "task-old", "contextId": "ctx-1", "pipelineName": "selling", - "status": event_status, - "data": {"prompt": "old choice"} if event_type == "input_required" else {}, + "status": "completed", + "data": {"sidecarStatus": "completed"}, } - current_event = dict(old_event) - current_event.update( - { - "eventId": "evt-current", - "sequence": 2, - "taskId": "task-current", - "status": event_status, - "data": {"prompt": "current choice"} if event_type == "input_required" else {}, - } - ) - journal = A2APipelineJournal(session_dir) - journal.append(old_event) - journal.append(current_event) - A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([old_event, current_event])) - fake_pipeline = FakePipeline( + A2APipelineJournal(a2a_dir).append(old_event) + A2APipelineSnapshotStore(a2a_dir).save(reduce_pipeline_events([old_event])) + + class DeletingSidecarPipeline(FakePipeline): + def clear_sidecar(self) -> None: + super().clear_sidecar() + shutil.rmtree(self.session.session_dir, ignore_errors=True) + + restored_pipeline = DeletingSidecarPipeline([], session_dir=sidecar_dir) + restored_pipeline.sidecar_status = "completed" + fresh_pipeline = FakePipeline( [ - TextDeltaEvent(text="old followup output"), + TextDeltaEvent(text="fresh output"), PipelineEvent(type=PipelineEventType.PIPELINE_COMPLETED, step_id=None, timestamp=1717821601.0, data={}), ], - session_dir=session_dir, + session_dir=sidecar_dir, ) - fake_pipeline.sidecar_status = sidecar_status - monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) + create_resume_flags: list[bool | None] = [] + + def fake_create_pipeline(*args, **kwargs): + create_resume_flags.append(kwargs.get("resume_from_sidecar")) + return restored_pipeline if len(create_resume_flags) == 1 else fresh_pipeline + + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", fake_create_pipeline) monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) executor = IacCodeA2AExecutor(task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), model="qwen3.6-plus") - with pytest.raises(RecoverablePipelineInvalidParamsError) as exc_info: - await executor.execute( - FakeRequestContext( - task_id="task-old", - context_id="ctx-1", - text="old followup", - metadata={"iac_code": {"cwd": str(tmp_path)}}, - ), - FakeEventQueue(), - ) + await executor.execute( + FakeRequestContext( + task_id="task-new", + context_id="ctx-1", + text="new request", + metadata={"iac_code": {"cwd": str(tmp_path)}}, + ), + FakeEventQueue(), + ) - assert exc_info.value.data == { - "recoverableTaskId": "task-current", - "contextId": "ctx-1", - "sidecarStatus": sidecar_status, - } - assert fake_pipeline.clear_sidecar_calls == 0 - assert fake_pipeline.resume_prompts == [] - assert fake_pipeline.continue_calls == 0 - assert fake_pipeline.run_prompts == [] - assert journal.read_all()[-1]["taskId"] == "task-current" + assert create_resume_flags == [True, False] + assert restored_pipeline.clear_sidecar_calls == 1 + events = A2APipelineJournal(a2a_dir).read_all() + assert [event["taskId"] for event in events] == ["task-old", "task-new", "task-new", "task-new", "task-new"] + assert [event.get("visibility") for event in events[-3:-1]] == ["pending_backup", "committed"] + assert events[-1]["eventType"] == "backup_committed" + assert not (sidecar_dir / "a2a-events.jsonl").exists() @pytest.mark.asyncio -async def test_executor_routes_running_sidecar_to_continue(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: +async def test_executor_does_not_duplicate_existing_terminal_recovery_event_when_snapshot_missing( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: monkeypatch.setenv("IAC_CODE_MODE", "pipeline") - fake_pipeline = FakePipeline( - [ - PipelineEvent( - type=PipelineEventType.PIPELINE_COMPLETED, - step_id=None, - timestamp=1717821601.0, - data={}, - ) - ], - session_dir=tmp_path / "sidecar", + session_dir = tmp_path / "sidecar" + journal = A2APipelineJournal(session_dir) + journal.append( + { + "schemaVersion": "1.0", + "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", + "eventId": "evt-terminal", + "sequence": 1, + "createdAt": "2026-06-08T10:00:00Z", + "eventType": "pipeline_failed", + "scope": "pipeline", + "pipelineRunId": "ctx-1", + "taskId": "task-1", + "contextId": "ctx-1", + "pipelineName": "selling", + "status": "failed", + "data": {"sidecarStatus": "failed", "recovered": True}, + } ) - fake_pipeline.sidecar_status = "running" - running_event = { - "schemaVersion": "1.0", - "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", - "eventId": "evt-running", - "sequence": 1, - "createdAt": "2026-06-08T10:00:00Z", - "eventType": "pipeline_started", - "scope": "pipeline", - "pipelineRunId": "ctx-1", - "taskId": "task-1", - "contextId": "ctx-1", - "pipelineName": "selling", - "status": "working", - "data": {}, - } - A2APipelineJournal(tmp_path / "sidecar").append(running_event) - A2APipelineSnapshotStore(tmp_path / "sidecar").save(reduce_pipeline_events([running_event])) + fake_pipeline = FakePipeline([], session_dir=session_dir) + fake_pipeline.sidecar_status = "failed" monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) - executor = IacCodeA2AExecutor(task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), model="qwen3.6-plus") + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + queue = FakeEventQueue() await executor.execute( - FakeRequestContext(text="not fresh input", metadata={"iac_code": {"cwd": str(tmp_path)}}), - FakeEventQueue(), + FakeRequestContext( + task_id="task-1", + context_id="ctx-1", + text="new request", + metadata={"iac_code": {"cwd": str(tmp_path)}}, + ), + queue, ) - assert fake_pipeline.continue_calls == 1 - assert fake_pipeline.continue_inputs == ["not fresh input"] - assert fake_pipeline.run_prompts == ["not fresh input"] + terminal_events = [event for event in journal.read_all() if event["eventType"] == "pipeline_failed"] + assert len(terminal_events) == 1 + snapshot = A2APipelineSnapshotStore(session_dir).load() + assert snapshot is not None + assert snapshot["status"] == "failed" + assert _status_events(queue)[-1]["status"]["state"] == "TASK_STATE_FAILED" @pytest.mark.asyncio -async def test_executor_preserves_running_sidecar_pause_as_input_required( +async def test_executor_does_not_publish_conflicting_terminal_sidecar_recovery_event( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: monkeypatch.setenv("IAC_CODE_MODE", "pipeline") - pause_event = PipelineEvent( - type=PipelineEventType.USER_INPUT_REQUIRED, - step_id="deploying", - timestamp=1717821601.0, - data={ - "kind": "pipeline_pause_confirmation", - "prompt": "Pipeline paused.", - "reason": "judge failed: timeout after 90.0s", - "paused": True, - "options": [], - }, - ) - fake_pipeline = FakePipeline([pause_event], session_dir=tmp_path / "sidecar") - fake_pipeline.sidecar_status = "running" - running_event = { + session_dir = tmp_path / "sidecar" + failed_event = { "schemaVersion": "1.0", "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", - "eventId": "evt-running", + "eventId": "evt-terminal", "sequence": 1, "createdAt": "2026-06-08T10:00:00Z", - "eventType": "pipeline_started", + "eventType": "pipeline_failed", "scope": "pipeline", "pipelineRunId": "ctx-1", "taskId": "task-1", "contextId": "ctx-1", "pipelineName": "selling", - "status": "working", - "data": {}, + "status": "failed", + "data": {"source": "executor"}, } - A2APipelineJournal(tmp_path / "sidecar").append(running_event) - A2APipelineSnapshotStore(tmp_path / "sidecar").save(reduce_pipeline_events([running_event])) + journal = A2APipelineJournal(session_dir) + journal.append(failed_event) + A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([failed_event])) + fake_pipeline = FakePipeline([], session_dir=session_dir) + fake_pipeline.sidecar_status = "completed" monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") queue = FakeEventQueue() - executor = IacCodeA2AExecutor(task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), model="qwen3.6-plus") await executor.execute( - FakeRequestContext(text="stop deploying", metadata={"iac_code": {"cwd": str(tmp_path)}}), + FakeRequestContext( + task_id="task-1", + context_id="ctx-1", + text="new request", + metadata={"iac_code": {"cwd": str(tmp_path)}}, + ), queue, ) - assert fake_pipeline.continue_calls == 1 - events = A2APipelineJournal(tmp_path / "sidecar").read_all() - assert events[-1]["eventType"] == "input_required" - assert events[-1]["status"] == "input_required" - assert events[-1]["data"]["kind"] == "pipeline_pause_confirmation" - assert "timeout" in events[-1]["data"]["reason"] - statuses = _status_events(queue) - assert statuses[-1]["status"]["state"] == "TASK_STATE_INPUT_REQUIRED" + events = journal.read_all() + assert [event["eventType"] for event in events] == ["pipeline_failed"] + snapshot = A2APipelineSnapshotStore(session_dir).load() + assert snapshot is not None + assert snapshot["status"] == "failed" + assert _status_events(queue)[-1]["status"]["state"] == "TASK_STATE_FAILED" @pytest.mark.asyncio -async def test_executor_routes_waiting_input_pause_confirmation_through_interrupt_path( +async def test_executor_rebuilds_stale_snapshot_from_existing_terminal_recovery_event( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: monkeypatch.setenv("IAC_CODE_MODE", "pipeline") session_dir = tmp_path / "sidecar" - pending = { + journal = A2APipelineJournal(session_dir) + working_event = { "schemaVersion": "1.0", "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", - "eventId": "evt-pause", + "eventId": "evt-working", "sequence": 1, "createdAt": "2026-06-08T10:00:00Z", - "eventType": "input_required", - "scope": "step", + "eventType": "pipeline_started", + "scope": "pipeline", "pipelineRunId": "ctx-1", "taskId": "task-1", "contextId": "ctx-1", "pipelineName": "selling", - "status": "input_required", - "step": {"runId": "step-deploying-1", "id": "deploying", "attempt": 1}, - "data": { - "kind": "pipeline_pause_confirmation", - "prompt": "Pipeline paused.", - "reason": "judge failed: timeout", - "paused": True, - "options": [], - }, + "status": "working", + "data": {}, } - A2APipelineJournal(session_dir).append(pending) - A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([pending])) - fake_pipeline = FakePipeline( - [ - PipelineEvent( - type=PipelineEventType.PIPELINE_COMPLETED, - step_id=None, - timestamp=1717821601.0, - data={}, - ) - ], - session_dir=session_dir, - ) - fake_pipeline.sidecar_status = "waiting_input" - monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) - monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + terminal_event = dict(working_event) + terminal_event.update( + { + "eventId": "evt-terminal", + "sequence": 2, + "eventType": "pipeline_failed", + "status": "failed", + "data": {"sidecarStatus": "failed", "recovered": True}, + } + ) + other_context_terminal_event = dict(working_event) + other_context_terminal_event.update( + { + "eventId": "evt-other-terminal", + "sequence": 99, + "eventType": "pipeline_completed", + "pipelineRunId": "ctx-other", + "taskId": "task-other", + "contextId": "ctx-other", + "status": "completed", + "data": {"sidecarStatus": "completed", "recovered": True}, + } + ) + journal.append(working_event) + journal.append(terminal_event) + journal.append(other_context_terminal_event) + A2APipelineSnapshotStore(session_dir).save( + { + "schemaVersion": "1.0", + "snapshotVersion": 1, + "pipelineRunId": "ctx-1", + "taskId": "task-1", + "contextId": "ctx-1", + "pipelineName": "selling", + "status": "working", + "lastSequence": 1, + "steps": [], + "display": {"messages": [], "diagrams": [], "candidateDetails": [], "artifacts": []}, + "pendingInput": None, + "control": {"activeCandidateRunIds": [], "rollbackHistory": [], "candidateRestarts": []}, + "seenEventIds": ["evt-working"], + } + ) + fake_pipeline = FakePipeline([], session_dir=session_dir) + fake_pipeline.sidecar_status = "failed" + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + queue = FakeEventQueue() - executor = IacCodeA2AExecutor(task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), model="qwen3.6-plus") await executor.execute( - FakeRequestContext(text="rollback to design", metadata={"iac_code": {"cwd": str(tmp_path)}}), - FakeEventQueue(), + FakeRequestContext( + task_id="task-1", + context_id="ctx-1", + text="new request", + metadata={"iac_code": {"cwd": str(tmp_path)}}, + ), + queue, ) - assert fake_pipeline.continue_inputs == ["rollback to design"] - assert fake_pipeline.resume_prompts == [] + terminal_events = [event for event in journal.read_all() if event["eventType"] == "pipeline_failed"] + assert len(terminal_events) == 1 + snapshot = A2APipelineSnapshotStore(session_dir).load() + assert snapshot is not None + assert snapshot["status"] == "failed" + assert snapshot["lastSequence"] == 2 + assert snapshot["taskId"] == "task-1" + assert snapshot["contextId"] == "ctx-1" + assert _status_events(queue)[-1]["status"]["state"] == "TASK_STATE_FAILED" @pytest.mark.asyncio -async def test_executor_rejects_previous_task_running_sidecar_without_starting_new_task( +async def test_executor_does_not_rebuild_terminal_snapshot_from_unrepairable_journal( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - from iac_code.a2a.pipeline_executor import RecoverablePipelineInvalidParamsError - monkeypatch.setenv("IAC_CODE_MODE", "pipeline") session_dir = tmp_path / "sidecar" - old_running = { + working_event = { "schemaVersion": "1.0", "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", - "eventId": "evt-old-running", + "eventId": "evt-working", "sequence": 1, "createdAt": "2026-06-08T10:00:00Z", "eventType": "pipeline_started", "scope": "pipeline", "pipelineRunId": "ctx-1", - "taskId": "task-old", + "taskId": "task-1", "contextId": "ctx-1", "pipelineName": "selling", "status": "working", "data": {}, } - A2APipelineJournal(session_dir).append(old_running) - A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([old_running])) - fake_pipeline = FakePipeline( - [ - TextDeltaEvent(text="new output"), - PipelineEvent(type=PipelineEventType.PIPELINE_COMPLETED, step_id=None, timestamp=1717821601.0, data={}), - ], - session_dir=session_dir, + terminal_event = dict(working_event) + terminal_event.update( + { + "eventId": "evt-terminal", + "sequence": 3, + "eventType": "pipeline_failed", + "status": "failed", + "data": {"sidecarStatus": "failed", "recovered": True}, + } ) - fake_pipeline.sidecar_status = "running" + journal = A2APipelineJournal(session_dir) + journal.append(working_event) + journal.path.write_text( + journal.path.read_text(encoding="utf-8") + + "not-json\n" + + json.dumps(terminal_event, ensure_ascii=False, separators=(",", ":")) + + "\n", + encoding="utf-8", + ) + A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([working_event])) + + class TerminalAfterRunPipeline(FakePipeline): + async def run(self, prompt: str): + self.run_prompts.append(prompt) + self.sidecar_status = "failed" + if False: + yield None + + fake_pipeline = TerminalAfterRunPipeline([], session_dir=session_dir) monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) executor = IacCodeA2AExecutor(task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), model="qwen3.6-plus") + queue = FakeEventQueue() - with pytest.raises(RecoverablePipelineInvalidParamsError) as exc_info: - await executor.execute( - FakeRequestContext( - task_id="task-new", - context_id="ctx-1", - text="new request", - metadata={"iac_code": {"cwd": str(tmp_path)}}, - ), - FakeEventQueue(), - ) + await executor.execute( + FakeRequestContext( + task_id="task-1", + context_id="ctx-1", + text="resume", + metadata={"iac_code": {"cwd": str(tmp_path)}}, + ), + queue, + ) - assert exc_info.value.data == { - "recoverableTaskId": "task-old", - "contextId": "ctx-1", - "sidecarStatus": "running", - } - assert fake_pipeline.clear_sidecar_calls == 0 - assert fake_pipeline.continue_calls == 0 - assert fake_pipeline.run_prompts == [] + snapshot = A2APipelineSnapshotStore(session_dir).load() + assert snapshot is not None + assert snapshot["status"] == "working" + assert snapshot["lastSequence"] == 1 + assert _status_events(queue)[-1]["status"]["state"] == "TASK_STATE_INPUT_REQUIRED" @pytest.mark.asyncio -async def test_executor_rejected_active_sidecar_mismatch_does_not_persist_new_working_task( +async def test_executor_repairs_same_task_terminal_sidecar_with_nonterminal_snapshot_without_rerun( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - from iac_code.a2a.pipeline_executor import RecoverablePipelineInvalidParamsError - monkeypatch.setenv("IAC_CODE_MODE", "pipeline") - persistence = A2APersistenceStore(tmp_path / "a2a") session_dir = tmp_path / "sidecar" - owner_event = { + working_event = { "schemaVersion": "1.0", "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", - "eventId": "evt-owner-running", + "eventId": "evt-working", "sequence": 1, "createdAt": "2026-06-08T10:00:00Z", "eventType": "pipeline_started", "scope": "pipeline", "pipelineRunId": "ctx-1", - "taskId": "task-owner", + "taskId": "task-1", "contextId": "ctx-1", "pipelineName": "selling", "status": "working", "data": {}, } - A2APipelineJournal(session_dir).append(owner_event) - A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([owner_event])) + journal = A2APipelineJournal(session_dir) + journal.append(working_event) + A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([working_event])) fake_pipeline = FakePipeline( [ - TextDeltaEvent(text="new output"), + TextDeltaEvent(text="should not rerun"), PipelineEvent(type=PipelineEventType.PIPELINE_COMPLETED, step_id=None, timestamp=1717821601.0, data={}), ], session_dir=session_dir, ) - fake_pipeline.sidecar_status = "running" + fake_pipeline.sidecar_status = "completed" monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) - task_store = A2ATaskStore(metrics=NoOpA2AMetrics(), persistence=persistence) - executor = IacCodeA2AExecutor(task_store=task_store, model="qwen3.6-plus") + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + queue = FakeEventQueue() - with pytest.raises(RecoverablePipelineInvalidParamsError): - await executor.execute( - FakeRequestContext( - task_id="task-new", - context_id="ctx-1", - text="new request", - metadata={"iac_code": {"cwd": str(tmp_path)}}, - ), - FakeEventQueue(), - ) + await executor.execute( + FakeRequestContext( + task_id="task-1", + context_id="ctx-1", + text="new request", + metadata={"iac_code": {"cwd": str(tmp_path)}}, + ), + queue, + ) assert fake_pipeline.clear_sidecar_calls == 0 assert fake_pipeline.run_prompts == [] - rejected_task = persistence.load_task("task-new") - assert rejected_task is not None - assert rejected_task.state != "working" - assert [task.task_id for task in persistence.list_tasks() if task.state == "working"] == [] - + events = journal.read_all() + assert [event["eventType"] for event in events] == [ + "pipeline_started", + "pipeline_completed", + "pipeline_completed", + "backup_committed", + ] + assert [event.get("visibility") for event in events[-3:-1]] == ["pending_backup", "committed"] + assert events[-2]["data"]["recovered"] is True + assert events[-1]["data"]["committedEventType"] == "pipeline_completed" + snapshot = A2APipelineSnapshotStore(session_dir).load() + assert snapshot is not None + assert snapshot["status"] == "completed" + assert _status_events(queue)[-1]["status"]["state"] == "TASK_STATE_COMPLETED" -def test_cleanup_handoff_missing_ledger_ignores_empty_public_cleanup_snapshot(tmp_path: Path) -> None: - from iac_code.a2a.pipeline_executor import _pipeline_cleanup_handoff_data_from_session - cleanup = _pipeline_cleanup_handoff_data_from_session( - cwd=str(tmp_path), - session_id="session-empty-cleanup", - public_snapshot={"cleanup": {"resourceCount": 0, "resources": [], "status": ""}}, +@pytest.mark.asyncio +async def test_executor_recovers_terminal_snapshot_with_unacknowledged_committed_event( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + session_dir = tmp_path / "sidecar" + pending_event = { + "schemaVersion": "1.0", + "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", + "eventId": "evt-pending-terminal", + "sequence": 1, + "createdAt": "2026-06-08T10:00:00Z", + "eventType": "pipeline_completed", + "scope": "pipeline", + "pipelineRunId": "ctx-1", + "taskId": "task-1", + "contextId": "ctx-1", + "pipelineName": "selling", + "status": "completed", + "visibility": "pending_backup", + "data": {}, + } + committed_event = { + **pending_event, + "eventId": "evt-committed-terminal", + "sequence": 2, + "visibility": "committed", + } + journal = A2APipelineJournal(session_dir) + journal.append_many([pending_event, committed_event], durable=True) + A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([pending_event, committed_event])) + fake_pipeline = FakePipeline( + [ + TextDeltaEvent(text="should not rerun"), + PipelineEvent(type=PipelineEventType.PIPELINE_COMPLETED, step_id=None, timestamp=1717821601.0, data={}), + ], + session_dir=session_dir, ) + fake_pipeline.sidecar_status = "completed" + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) - assert cleanup is None + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + queue = FakeEventQueue() + await executor.execute( + FakeRequestContext( + task_id="task-1", + context_id="ctx-1", + text="new request", + metadata={"iac_code": {"cwd": str(tmp_path)}}, + ), + queue, + ) -def test_cleanup_handoff_missing_ledger_does_not_reconstruct_prompt_from_public_snapshot(tmp_path: Path) -> None: - from iac_code.a2a.pipeline_executor import _pipeline_cleanup_handoff_data_from_session - - cleanup = _pipeline_cleanup_handoff_data_from_session( - cwd=str(tmp_path), - session_id="session-public-cleanup-only", - public_snapshot={ - "cleanup": { - "resourceCount": 1, - "resources": [ - { - "provider": "ros", - "resourceType": "stack", - "resourceId": "stack-public-only", - "cleanupStatus": "pending", - } - ], - "status": "pending", - } - }, - ) - - assert cleanup is not None - assert cleanup["status"] == "unavailable" - assert "prompt" not in cleanup - assert "resources" not in cleanup - assert "stack-public-only" not in repr(cleanup) + assert fake_pipeline.run_prompts == [] + events = journal.read_all() + assert [event["eventType"] for event in events[-3:]] == [ + "pipeline_completed", + "pipeline_completed", + "backup_committed", + ] + assert [event.get("visibility") for event in events[-3:-1]] == ["pending_backup", "committed"] + assert events[-1]["data"]["committedEventType"] == "pipeline_completed" + assert _status_events(queue)[-1]["status"]["state"] == "TASK_STATE_COMPLETED" @pytest.mark.asyncio -async def test_pipeline_executor_routes_second_prompt_as_interrupt(tmp_path: Path) -> None: - from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator - from iac_code.a2a.pipeline_executor import A2APipelineRuntime, IacCodeA2APipelineExecutor - from iac_code.a2a.pipeline_journal import A2APipelineJournal - from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore - from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher - from iac_code.services.providers.aliyun import AliyunCredential, AliyunCredentials - - captured_interrupt_credentials: list[str | None] = [] - - class InterruptiblePipeline(FakePipeline): - def __init__(self, *, session_dir: Path) -> None: - super().__init__([TextDeltaEvent(text="running")], session_dir=session_dir) - self.interrupts: list[str] = [] +async def test_executor_keeps_task_nonterminal_when_terminal_sidecar_publication_is_unavailable( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + session_dir = tmp_path / "sidecar" + working_event = { + "schemaVersion": "1.0", + "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", + "eventId": "evt-working", + "sequence": 1, + "createdAt": "2026-06-08T10:00:00Z", + "eventType": "pipeline_started", + "scope": "pipeline", + "pipelineRunId": "ctx-1", + "taskId": "task-1", + "contextId": "ctx-1", + "pipelineName": "selling", + "status": "working", + "data": {}, + } + journal = A2APipelineJournal(session_dir) + journal.append(working_event) + A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([working_event])) + fake_pipeline = FakePipeline([], session_dir=session_dir) + fake_pipeline.sidecar_status = "completed" + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) - async def handle_user_interrupt(self, message: str) -> SimpleNamespace: - credential = AliyunCredentials.load() - captured_interrupt_credentials.append(credential.access_key_id if credential else None) - self.interrupts.append(message) - return SimpleNamespace( - action="supplement", - reason="added context", - rollback_target=None, - candidate_scope=None, - ) + async def block_publication(*args, **kwargs) -> bool: + return False - store = A2ATaskStore(metrics=NoOpA2AMetrics()) - task = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") - ctx = await store.get_or_create_context( - context_id="ctx-1", - cwd=str(tmp_path), - runtime_factory=lambda _session_id: _fake_runtime(), + monkeypatch.setattr( + "iac_code.a2a.pipeline_executor.IacCodeA2APipelineExecutor._backup_before_pipeline_publication", + block_publication, ) - ctx.active_task_id = "task-1" + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") queue = FakeEventQueue() - pipeline = InterruptiblePipeline(session_dir=tmp_path / "sidecar") - publisher = PipelineA2AEventPublisher( - event_queue=queue, - translator=PipelineEventTranslator( - PipelineA2AContext( - pipeline_run_id="ctx-1", - task_id="task-1", - context_id="ctx-1", - pipeline_name="selling", - ) - ), - journal=A2APipelineJournal(tmp_path / "pipeline"), - snapshot_store=A2APipelineSnapshotStore(tmp_path / "pipeline"), - ) - ctx.runtime = A2APipelineRuntime(agent_runtime=_fake_runtime(), pipeline=pipeline, publisher=publisher) - store.mirror_context(ctx) - - executor = IacCodeA2APipelineExecutor( - task_store=store, - model="qwen3.6-plus", - metrics=NoOpA2AMetrics(), - artifact_store=None, - push_notifier=None, - permission_resolver=None, - auto_approve_permissions=False, - thinking_exposure_types=None, - aliyun_credential=AliyunCredential( - access_key_id="client-id", - access_key_secret="client-secret", - region_id="cn-beijing", - ), - ) await executor.execute( - context=FakeRequestContext( - text="please change cpu", + FakeRequestContext( + task_id="task-1", + context_id="ctx-1", + text="new request", metadata={"iac_code": {"cwd": str(tmp_path)}}, ), - event_queue=queue, - task=task, - task_id="task-1", - context_id="ctx-1", - cwd=str(tmp_path), - prompt="please change cpu", + queue, ) - assert pipeline.interrupts == ["please change cpu"] - assert captured_interrupt_credentials == ["client-id"] - event_types = [event["eventType"] for event in publisher.journal.read_all()] - assert event_types == ["interrupt_received", "interrupt_classified"] + events = journal.read_all() + assert [event["eventType"] for event in events] == [ + "pipeline_started", + "pipeline_completed", + "pipeline_completed", + "input_required", + ] + assert [event.get("visibility") for event in events[1:3]] == ["pending_backup", "committed"] + assert events[-1]["data"]["kind"] == "terminal_publication_unavailable" + assert A2APipelineSnapshotStore(session_dir).load()["status"] == "waiting_input" + assert _status_events(queue)[-1]["status"]["state"] == "TASK_STATE_INPUT_REQUIRED" + record = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + assert record.state == "input-required" @pytest.mark.asyncio -async def test_pipeline_executor_reconfigures_active_interrupt_runtime_thinking_per_call( +async def test_executor_keeps_exception_task_nonterminal_when_terminal_publication_is_unavailable( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator - from iac_code.a2a.pipeline_executor import A2APipelineRuntime, IacCodeA2APipelineExecutor - from iac_code.a2a.pipeline_journal import A2APipelineJournal - from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore - from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher - from iac_code.providers.request_policy import ProviderRequestPolicy - - class FakeProviderManager: - def __init__(self) -> None: - self.calls: list[tuple[str, object | None]] = [] - - def reconfigure( - self, - model, - credentials, - provider_key_override=None, - base_url_override=None, - request_policy_override=None, - ): - self.calls.append((model, request_policy_override)) - - class InterruptiblePipeline(FakePipeline): - async def handle_user_interrupt(self, message: str) -> SimpleNamespace: - return SimpleNamespace( - action="supplement", - reason="added context", - rollback_target=None, - candidate_scope=None, - ) + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + session_dir = tmp_path / "sidecar" + fake_pipeline = FakePipeline([RuntimeError("boom")], session_dir=session_dir) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) - provider_manager = FakeProviderManager() - agent_runtime = SimpleNamespace(provider_manager=provider_manager, tool_registry=object()) - monkeypatch.setattr("iac_code.config.load_credentials", lambda model=None: {}) + async def block_publication(*args, **kwargs) -> bool: + return False - store = A2ATaskStore(metrics=NoOpA2AMetrics()) - task = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") - ctx = await store.get_or_create_context( - context_id="ctx-1", - cwd=str(tmp_path), - runtime_factory=lambda _session_id: agent_runtime, + monkeypatch.setattr( + "iac_code.a2a.pipeline_executor.IacCodeA2APipelineExecutor._backup_before_pipeline_publication", + block_publication, ) - ctx.active_task_id = "task-1" + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") queue = FakeEventQueue() - pipeline = InterruptiblePipeline([TextDeltaEvent(text="running")], session_dir=tmp_path / "sidecar") - publisher = PipelineA2AEventPublisher( - event_queue=queue, - translator=PipelineEventTranslator( - PipelineA2AContext( - pipeline_run_id="ctx-1", - task_id="task-1", - context_id="ctx-1", - pipeline_name="selling", - ) - ), - journal=A2APipelineJournal(tmp_path / "pipeline"), - snapshot_store=A2APipelineSnapshotStore(tmp_path / "pipeline"), - ) - ctx.runtime = A2APipelineRuntime(agent_runtime=agent_runtime, pipeline=pipeline, publisher=publisher) - store.mirror_context(ctx) - - executor = IacCodeA2APipelineExecutor( - task_store=store, - model="qwen3.6-plus", - metrics=NoOpA2AMetrics(), - artifact_store=None, - push_notifier=None, - permission_resolver=None, - auto_approve_permissions=False, - thinking_exposure_types=None, - request_policy_override=ProviderRequestPolicy(thinking_enabled=False, effort="high", thinking_budget=2048), - ) await executor.execute( - context=FakeRequestContext(text="please change cpu", metadata={"iac_code": {"cwd": str(tmp_path)}}), - event_queue=queue, - task=task, - task_id="task-1", - context_id="ctx-1", - cwd=str(tmp_path), - prompt="please change cpu", + FakeRequestContext( + task_id="task-1", + context_id="ctx-1", + text="new request", + metadata={"iac_code": {"cwd": str(tmp_path)}}, + ), + queue, ) - policy = provider_manager.calls[0][1] - assert provider_manager.calls[0][0] == "qwen3.6-plus" - assert getattr(policy, "thinking_enabled", None) is False - assert getattr(policy, "effort", None) == "high" - assert getattr(policy, "thinking_budget", None) == 2048 + events = A2APipelineJournal(session_dir).read_all() + assert [event["eventType"] for event in events] == [ + "pipeline_failed", + "pipeline_failed", + "input_required", + ] + assert [event.get("visibility") for event in events[:2]] == ["pending_backup", "committed"] + assert events[-1]["data"]["kind"] == "terminal_publication_unavailable" + assert A2APipelineSnapshotStore(session_dir).load()["status"] == "waiting_input" + assert _status_events(queue)[-1]["status"]["state"] == "TASK_STATE_INPUT_REQUIRED" + record = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + assert record.state == "input-required" @pytest.mark.asyncio -async def test_pipeline_executor_publishes_input_required_for_live_paused_interrupt(tmp_path: Path) -> None: - from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator - from iac_code.a2a.pipeline_executor import A2APipelineRuntime, IacCodeA2APipelineExecutor - from iac_code.a2a.pipeline_journal import A2APipelineJournal - from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore - from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher +async def test_executor_repairs_terminal_sidecar_after_partial_nonterminal_stream( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + session_dir = tmp_path / "sidecar" - class PausingPipeline(FakePipeline): - def __init__(self, *, session_dir: Path) -> None: - super().__init__([TextDeltaEvent(text="running")], session_dir=session_dir) - self.interrupts: list[str] = [] - self.saved_pause_verdicts: list[SimpleNamespace] = [] - - async def handle_user_interrupt(self, message: str) -> SimpleNamespace: - self.interrupts.append(message) - return SimpleNamespace( - action="continue", - reason="judge failed: timeout", - rollback_target=None, - candidate_scope=None, - paused=True, - ) + class PartialTerminalPipeline(FakePipeline): + async def run(self, prompt: str): + self.run_prompts.append(prompt) + yield TextDeltaEvent(text="partial output") + self.sidecar_status = "failed" - async def save_interrupt_pause(self, verdict: SimpleNamespace) -> PipelineEvent: - self.saved_pause_verdicts.append(verdict) - return PipelineEvent( - type=PipelineEventType.USER_INPUT_REQUIRED, - step_id="deploying", - timestamp=1717821601.0, - data={ - "kind": "pipeline_pause_confirmation", - "prompt": "Pipeline paused.", - "reason": verdict.reason, - "paused": True, - "options": [], - }, - ) + fake_pipeline = PartialTerminalPipeline([], session_dir=session_dir) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) store = A2ATaskStore(metrics=NoOpA2AMetrics()) - task = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") - task.state = "working" - task.active_task = asyncio.current_task() - ctx = await store.get_or_create_context( - context_id="ctx-1", - cwd=str(tmp_path), - runtime_factory=lambda _session_id: _fake_runtime(), - ) - ctx.active_task_id = "task-1" - + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") queue = FakeEventQueue() - pipeline = PausingPipeline(session_dir=tmp_path / "sidecar") - publisher = PipelineA2AEventPublisher( - event_queue=queue, - translator=PipelineEventTranslator( - PipelineA2AContext( - pipeline_run_id="ctx-1", - task_id="task-1", - context_id="ctx-1", - pipeline_name="selling", - ) - ), - journal=A2APipelineJournal(tmp_path / "pipeline"), - snapshot_store=A2APipelineSnapshotStore(tmp_path / "pipeline"), - ) - ctx.runtime = A2APipelineRuntime(agent_runtime=_fake_runtime(), pipeline=pipeline, publisher=publisher) - store.mirror_task(task) - store.mirror_context(ctx) - executor = IacCodeA2APipelineExecutor( - task_store=store, - model="qwen3.6-plus", - metrics=NoOpA2AMetrics(), - artifact_store=None, - push_notifier=None, - permission_resolver=None, - auto_approve_permissions=False, - thinking_exposure_types=None, - ) - - await executor.execute( - context=FakeRequestContext( - text="please stop", - metadata={"iac_code": {"cwd": str(tmp_path)}}, - ), - event_queue=queue, - task=task, - task_id="task-1", - context_id="ctx-1", - cwd=str(tmp_path), - prompt="please stop", - ) + await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), queue) - assert pipeline.interrupts == ["please stop"] - assert len(pipeline.saved_pause_verdicts) == 1 - event_types = [event["eventType"] for event in publisher.journal.read_all()] - assert event_types == ["interrupt_received", "interrupt_classified", "input_required"] - assert publisher.snapshot_store.load()["pendingInput"]["kind"] == "pipeline_pause_confirmation" - assert task.state == "input-required" + events = A2APipelineJournal(session_dir).read_all() + event_types = [event["eventType"] for event in events] + assert event_types == ["text_delta", "pipeline_failed", "pipeline_failed", "backup_committed"] + assert [event.get("visibility") for event in events[-3:-1]] == ["pending_backup", "committed"] + assert events[-1]["data"]["committedEventType"] == "pipeline_failed" + snapshot = A2APipelineSnapshotStore(session_dir).load() + assert snapshot is not None + assert snapshot["status"] == "failed" + assert _status_events(queue)[-1]["status"]["state"] == "TASK_STATE_FAILED" @pytest.mark.asyncio -async def test_live_paused_interrupt_releases_active_task_and_next_reply_clears_pending_input( +async def test_executor_routes_waiting_sidecar_prompt_to_resume( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: monkeypatch.setenv("IAC_CODE_MODE", "pipeline") - - class PausingPipeline(FakePipeline): - def __init__(self, *, session_dir: Path) -> None: - super().__init__([], session_dir=session_dir) - self.primary_stream = CloseableEventStream([TextDeltaEvent(text="before pause")]) - self.resume_stream = CloseableEventStream( - [ - PipelineEvent( - type=PipelineEventType.USER_INPUT_RECEIVED, - step_id="deploying", - timestamp=1717821602.0, - data={"kind": "pipeline_pause_confirmation", "user_input_length": 8}, - ), - PipelineEvent( - type=PipelineEventType.PIPELINE_COMPLETED, - step_id=None, - timestamp=1717821603.0, - data={}, - ), - ], - wait_until_closed=False, - ) - self.interrupts: list[str] = [] - self.saved_pause_verdicts: list[SimpleNamespace] = [] - - def run(self, prompt: str): - self.run_prompts.append(prompt) - return self.primary_stream - - def continue_from_sidecar(self, user_input: str | None = None): - self.continue_calls += 1 - self.continue_inputs.append(user_input) - self.sidecar_status = "running" - return self.resume_stream - - async def handle_user_interrupt(self, message: str) -> SimpleNamespace: - self.interrupts.append(message) - return SimpleNamespace( - action="continue", - reason="judge failed: timeout", - rollback_target=None, - candidate_scope=None, - paused=True, - ) - - async def save_interrupt_pause(self, verdict: SimpleNamespace) -> PipelineEvent: - self.saved_pause_verdicts.append(verdict) - self.sidecar_status = "waiting_input" - return PipelineEvent( - type=PipelineEventType.USER_INPUT_REQUIRED, - step_id="deploying", + fake_pipeline = FakePipeline( + [ + PipelineEvent( + type=PipelineEventType.PIPELINE_COMPLETED, + step_id=None, timestamp=1717821601.0, - data={ - "kind": "pipeline_pause_confirmation", - "prompt": "Pipeline paused.", - "reason": verdict.reason, - "paused": True, - "options": [], - }, + data={}, ) - - pipeline = PausingPipeline(session_dir=tmp_path / "sidecar") - monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: pipeline) - monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: _fake_runtime()) - monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) - - store = A2ATaskStore(metrics=NoOpA2AMetrics()) - executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") - queue = FakeEventQueue() - first = asyncio.create_task( - executor.execute( - FakeRequestContext(task_id="task-1", context_id="ctx-1", metadata={"iac_code": {"cwd": str(tmp_path)}}), - queue, - ) + ], + session_dir=tmp_path / "sidecar", ) - await _wait_for_output_text(await store.get_or_create_task(task_id="task-1", context_id="ctx-1"), "before pause") + fake_pipeline.sidecar_status = "waiting_input" + input_event = { + "schemaVersion": "1.0", + "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", + "eventId": "evt-input", + "sequence": 1, + "createdAt": "2026-06-08T10:00:00Z", + "eventType": "input_required", + "scope": "pipeline", + "pipelineRunId": "ctx-1", + "taskId": "task-1", + "contextId": "ctx-1", + "pipelineName": "selling", + "status": "waiting_input", + "data": {"prompt": "choose"}, + } + A2APipelineJournal(tmp_path / "sidecar").append(input_event) + A2APipelineSnapshotStore(tmp_path / "sidecar").save(reduce_pipeline_events([input_event])) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) - await executor.execute( - FakeRequestContext( - task_id="task-1", - context_id="ctx-1", - text="please pause", - metadata={"iac_code": {"cwd": str(tmp_path)}}, - ), - queue, - ) - await asyncio.wait_for(first, timeout=1) - ctx = await store.get_or_create_context(context_id="ctx-1", cwd=str(tmp_path), runtime_factory=lambda _sid: None) - assert ctx.active_task_id is None - assert pipeline.primary_stream.closed is True - assert pipeline.sidecar_status == "waiting_input" - assert pipeline.interrupts == ["please pause"] - assert ( - A2APipelineSnapshotStore(tmp_path / "sidecar").load()["pendingInput"]["kind"] == "pipeline_pause_confirmation" - ) + executor = IacCodeA2AExecutor(task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), model="qwen3.6-plus") await executor.execute( - FakeRequestContext( - task_id="task-1", - context_id="ctx-1", - text="continue", - metadata={"iac_code": {"cwd": str(tmp_path)}}, - ), - queue, + FakeRequestContext(text="selected", metadata={"iac_code": {"cwd": str(tmp_path)}}), + FakeEventQueue(), ) - events = A2APipelineJournal(tmp_path / "sidecar").read_all() - assert [event["eventType"] for event in events][-2:] == ["input_received", "pipeline_completed"] - assert A2APipelineSnapshotStore(tmp_path / "sidecar").load()["pendingInput"] is None - assert pipeline.continue_inputs == ["continue"] + assert fake_pipeline.resume_prompts == ["selected"] + assert fake_pipeline.run_prompts == [] @pytest.mark.asyncio -async def test_active_pause_continuation_keeps_active_owner_until_continuation_finishes( +async def test_executor_does_not_resume_waiting_sidecar_when_restore_failed( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - monkeypatch.setenv("IAC_CODE_MODE", "pipeline") - - class BlockingCloseStream(CloseableEventStream): - def __init__(self, events) -> None: - super().__init__(events) - self.allow_close = asyncio.Event() - - async def aclose(self) -> None: - self.closed = True - self.closed_event.set() - await self.allow_close.wait() + from iac_code.pipeline.engine.session import RestoreResult - class PausingPipeline(FakePipeline): - def __init__(self, *, session_dir: Path) -> None: - super().__init__([], session_dir=session_dir) - self.primary_stream = BlockingCloseStream([TextDeltaEvent(text="before pause")]) - self.continuation_started = asyncio.Event() - self.finish_continuation = asyncio.Event() - - def run(self, prompt: str): - return self.primary_stream - - async def handle_user_interrupt(self, message: str) -> SimpleNamespace: - return SimpleNamespace(action="continue", reason="pause requested", paused=True) - - async def save_interrupt_pause(self, verdict: SimpleNamespace) -> PipelineEvent: - self.sidecar_status = "waiting_input" - return PipelineEvent( - type=PipelineEventType.USER_INPUT_REQUIRED, - step_id="deploying", + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + session_dir = tmp_path / "sidecar" + fake_pipeline = FakePipeline( + [ + PipelineEvent( + type=PipelineEventType.PIPELINE_COMPLETED, + step_id=None, timestamp=1717821601.0, - data={ - "kind": "pipeline_pause_confirmation", - "prompt": "Pipeline paused.", - "reason": verdict.reason, - "paused": True, - "options": [], - }, + data={}, ) - - def continue_from_sidecar(self, user_input: str | None = None): - self.continue_calls += 1 - self.continue_inputs.append(user_input) - self.sidecar_status = "running" - - async def stream(): - self.continuation_started.set() - yield PipelineEvent( - type=PipelineEventType.USER_INPUT_RECEIVED, - step_id="deploying", - timestamp=1717821602.0, - data={"kind": "pipeline_pause_confirmation", "user_input_length": len(user_input or "")}, - ) - await self.finish_continuation.wait() - yield PipelineEvent( - type=PipelineEventType.PIPELINE_COMPLETED, - step_id=None, - timestamp=1717821603.0, - data={}, - ) - - return stream() - - pipeline = PausingPipeline(session_dir=tmp_path / "sidecar") - monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: pipeline) - monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: _fake_runtime()) + ], + session_dir=session_dir, + ) + fake_pipeline.sidecar_status = "waiting_input" + fake_pipeline.sidecar_restore_result = RestoreResult(ok=False, status="waiting_input", reason="invalid_context") + input_event = { + "schemaVersion": "1.0", + "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", + "eventId": "evt-input", + "sequence": 1, + "createdAt": "2026-06-08T10:00:00Z", + "eventType": "input_required", + "scope": "pipeline", + "pipelineRunId": "ctx-1", + "taskId": "task-1", + "contextId": "ctx-1", + "pipelineName": "selling", + "status": "waiting_input", + "data": {"prompt": "choose"}, + } + A2APipelineJournal(session_dir).append(input_event) + A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([input_event])) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) - store = A2ATaskStore(metrics=NoOpA2AMetrics()) - executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + executor = IacCodeA2AExecutor(task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), model="qwen3.6-plus") queue = FakeEventQueue() - first = asyncio.create_task( - executor.execute( - FakeRequestContext(task_id="task-1", context_id="ctx-1", metadata={"iac_code": {"cwd": str(tmp_path)}}), - queue, - ) - ) - active_task = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") - await _wait_for_output_text(active_task, "before pause") await executor.execute( - FakeRequestContext( - task_id="task-1", - context_id="ctx-1", - text="please pause", - metadata={"iac_code": {"cwd": str(tmp_path)}}, - ), + FakeRequestContext(text="selected", metadata={"iac_code": {"cwd": str(tmp_path)}}), queue, ) - continuation = asyncio.create_task( - executor.execute( - FakeRequestContext( - task_id="task-1", - context_id="ctx-1", - text="continue", - metadata={"iac_code": {"cwd": str(tmp_path)}}, - ), - queue, - ) - ) - try: - await asyncio.wait_for(pipeline.continuation_started.wait(), timeout=1) - pipeline.primary_stream.allow_close.set() - await asyncio.wait_for(first, timeout=1) - - ctx = await store.get_or_create_context( - context_id="ctx-1", cwd=str(tmp_path), runtime_factory=lambda _sid: None - ) - active_task = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") - assert ctx.active_task_id == "task-1" - assert active_task.active_task is continuation - - pipeline.finish_continuation.set() - await asyncio.wait_for(continuation, timeout=1) - assert ctx.active_task_id is None - assert active_task.active_task is None - finally: - pipeline.primary_stream.allow_close.set() - pipeline.finish_continuation.set() - for runner in (first, continuation): - if not runner.done(): - runner.cancel() - with contextlib.suppress(asyncio.CancelledError): - await runner + assert fake_pipeline.resume_prompts == [] + assert fake_pipeline.run_prompts == [] + assert _status_events(queue)[-1]["status"]["state"] == "TASK_STATE_FAILED" @pytest.mark.asyncio -async def test_active_task_route_continues_pending_pause_confirmation(tmp_path: Path) -> None: - from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator - from iac_code.a2a.pipeline_executor import A2APipelineRuntime, IacCodeA2APipelineExecutor - from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher - - class PausedPipeline(FakePipeline): - def __init__(self, *, session_dir: Path) -> None: - super().__init__([], session_dir=session_dir) - self.sidecar_status = "waiting_input" - self.interrupts: list[str] = [] +async def test_executor_resumes_matching_waiting_sidecar_when_journal_has_partial_tail( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + session_dir = tmp_path / "sidecar" + fake_pipeline = FakePipeline( + [ + PipelineEvent( + type=PipelineEventType.PIPELINE_COMPLETED, + step_id=None, + timestamp=1717821601.0, + data={}, + ) + ], + session_dir=session_dir, + ) + fake_pipeline.sidecar_status = "waiting_input" + input_event = { + "schemaVersion": "1.0", + "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", + "eventId": "evt-input", + "sequence": 1, + "createdAt": "2026-06-08T10:00:00Z", + "eventType": "input_required", + "scope": "pipeline", + "pipelineRunId": "ctx-1", + "taskId": "task-1", + "contextId": "ctx-1", + "pipelineName": "selling", + "status": "waiting_input", + "data": {"prompt": "choose"}, + } + journal = A2APipelineJournal(session_dir) + journal.append(input_event) + journal.path.write_text(journal.path.read_text(encoding="utf-8") + '{"eventId":"evt-partial"', encoding="utf-8") + A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([input_event])) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) - async def handle_user_interrupt(self, message: str) -> SimpleNamespace: - self.interrupts.append(message) - return SimpleNamespace(action="continue", reason="should not run") + executor = IacCodeA2AExecutor(task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), model="qwen3.6-plus") - def continue_from_sidecar(self, user_input: str | None = None): - self.continue_calls += 1 - self.continue_inputs.append(user_input) + await executor.execute( + FakeRequestContext(text="selected", metadata={"iac_code": {"cwd": str(tmp_path)}}), + FakeEventQueue(), + ) - async def stream(): - yield PipelineEvent( - type=PipelineEventType.USER_INPUT_RECEIVED, - step_id="deploying", - timestamp=1717821602.0, - data={"kind": "pipeline_pause_confirmation", "user_input_length": len(user_input or "")}, - ) - yield PipelineEvent( - type=PipelineEventType.PIPELINE_COMPLETED, - step_id=None, - timestamp=1717821603.0, - data={}, - ) + assert fake_pipeline.clear_sidecar_calls == 0 + assert fake_pipeline.resume_prompts == ["selected"] + assert fake_pipeline.run_prompts == [] - return stream() - sidecar_dir = tmp_path / "sidecar" - pending = { +@pytest.mark.asyncio +async def test_executor_does_not_trust_snapshot_owner_when_journal_has_middle_corruption( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + session_dir = tmp_path / "sidecar" + fake_pipeline = FakePipeline( + [ + PipelineEvent( + type=PipelineEventType.PIPELINE_COMPLETED, + step_id=None, + timestamp=1717821601.0, + data={}, + ) + ], + session_dir=session_dir, + ) + fake_pipeline.sidecar_status = "waiting_input" + old_input = { "schemaVersion": "1.0", "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", - "eventId": "evt-pause", + "eventId": "evt-old-input", "sequence": 1, "createdAt": "2026-06-08T10:00:00Z", "eventType": "input_required", "scope": "pipeline", "pipelineRunId": "ctx-1", - "taskId": "task-1", + "taskId": "task-old", "contextId": "ctx-1", "pipelineName": "selling", - "status": "input_required", - "data": { - "kind": "pipeline_pause_confirmation", - "prompt": "Pipeline paused.", - "reason": "judge failed: timeout", - "paused": True, - "options": [], - }, + "status": "waiting_input", + "data": {"prompt": "old choice"}, } - A2APipelineJournal(sidecar_dir).append(pending) - A2APipelineSnapshotStore(sidecar_dir).save(reduce_pipeline_events([pending])) - pipeline = PausedPipeline(session_dir=sidecar_dir) - queue = FakeEventQueue() - publisher = PipelineA2AEventPublisher( - event_queue=queue, - translator=PipelineEventTranslator( - PipelineA2AContext( - pipeline_run_id="ctx-1", - task_id="task-1", - context_id="ctx-1", - pipeline_name="selling", - ) - ), - journal=A2APipelineJournal(sidecar_dir), - snapshot_store=A2APipelineSnapshotStore(sidecar_dir), + new_event = dict(old_input) + new_event.update( + { + "eventId": "evt-new", + "sequence": 3, + "taskId": "task-new", + "status": "working", + "eventType": "pipeline_started", + "data": {}, + } ) - store = A2ATaskStore(metrics=NoOpA2AMetrics()) - task = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") - task.state = "input-required" - task.active_task = asyncio.current_task() - ctx = await store.get_or_create_context( - context_id="ctx-1", - cwd=str(tmp_path), - runtime_factory=lambda _session_id: _fake_runtime(), + journal = A2APipelineJournal(session_dir) + journal.append(old_input) + journal.path.write_text( + journal.path.read_text(encoding="utf-8") + + "not-json\n" + + json.dumps(new_event, ensure_ascii=False, separators=(",", ":")) + + "\n", + encoding="utf-8", ) - ctx.active_task_id = "task-1" - ctx.runtime = A2APipelineRuntime(agent_runtime=_fake_runtime(), pipeline=pipeline, publisher=publisher) - store.mirror_task(task) - store.mirror_context(ctx) + A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([old_input])) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) - executor = IacCodeA2APipelineExecutor( - task_store=store, - model="qwen3.6-plus", - metrics=NoOpA2AMetrics(), - artifact_store=None, - push_notifier=None, - permission_resolver=None, - auto_approve_permissions=False, - thinking_exposure_types=None, - ) + executor = IacCodeA2AExecutor(task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), model="qwen3.6-plus") + queue = FakeEventQueue() await executor.execute( - context=FakeRequestContext(text="continue", metadata={"iac_code": {"cwd": str(tmp_path)}}), - event_queue=queue, - task=task, - task_id="task-1", - context_id="ctx-1", - cwd=str(tmp_path), - prompt="continue", + FakeRequestContext( + task_id="task-old", + context_id="ctx-1", + text="old followup", + metadata={"iac_code": {"cwd": str(tmp_path)}}, + ), + queue, ) - assert pipeline.interrupts == [] - assert pipeline.continue_inputs == ["continue"] - events = A2APipelineJournal(sidecar_dir).read_all() - assert [event["eventType"] for event in events][-2:] == ["input_received", "pipeline_completed"] - assert A2APipelineSnapshotStore(sidecar_dir).load()["pendingInput"] is None + assert fake_pipeline.clear_sidecar_calls == 0 + assert fake_pipeline.resume_prompts == [] + assert fake_pipeline.run_prompts == [] + assert _status_events(queue)[-1]["status"]["state"] == "TASK_STATE_INPUT_REQUIRED" @pytest.mark.asyncio -async def test_active_pause_confirmation_failure_marks_task_and_pipeline_failed(tmp_path: Path) -> None: - from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator - from iac_code.a2a.pipeline_executor import A2APipelineRuntime, IacCodeA2APipelineExecutor - from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher - - class FailingPausedPipeline(FakePipeline): - def __init__(self, *, session_dir: Path) -> None: - super().__init__([], session_dir=session_dir) - self.sidecar_status = "waiting_input" - - def continue_from_sidecar(self, user_input: str | None = None): - self.continue_calls += 1 - self.continue_inputs.append(user_input) - - async def stream(): - raise RuntimeError("pause continuation failed token=secret-value") - if False: - yield - - return stream() - - sidecar_dir = tmp_path / "sidecar" - pending = { +@pytest.mark.parametrize("sidecar_status", ["waiting_input", "running"]) +@pytest.mark.parametrize( + ("terminal_event_type", "terminal_status", "expected_state"), + [ + ("pipeline_completed", "completed", "TASK_STATE_COMPLETED"), + ("pipeline_failed", "failed", "TASK_STATE_FAILED"), + ("pipeline_canceled", "canceled", "TASK_STATE_CANCELED"), + ], +) +async def test_executor_does_not_resume_nonterminal_sidecar_when_a2a_state_is_terminal( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + sidecar_status: str, + terminal_event_type: str, + terminal_status: str, + expected_state: str, +) -> None: + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + session_dir = tmp_path / "sidecar" + terminal_event = { "schemaVersion": "1.0", "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", - "eventId": "evt-pause", + "eventId": "evt-terminal", "sequence": 1, "createdAt": "2026-06-08T10:00:00Z", - "eventType": "input_required", + "eventType": terminal_event_type, "scope": "pipeline", "pipelineRunId": "ctx-1", "taskId": "task-1", "contextId": "ctx-1", "pipelineName": "selling", - "status": "input_required", - "data": {"kind": "pipeline_pause_confirmation", "prompt": "Pipeline paused."}, + "status": terminal_status, + "data": {}, } - A2APipelineJournal(sidecar_dir).append(pending) - A2APipelineSnapshotStore(sidecar_dir).save(reduce_pipeline_events([pending])) - queue = FakeEventQueue() - publisher = PipelineA2AEventPublisher( - event_queue=queue, - translator=PipelineEventTranslator( - PipelineA2AContext( - pipeline_run_id="ctx-1", - task_id="task-1", - context_id="ctx-1", - pipeline_name="selling", - ) - ), - journal=A2APipelineJournal(sidecar_dir), - snapshot_store=A2APipelineSnapshotStore(sidecar_dir), - ) - store = A2ATaskStore(metrics=NoOpA2AMetrics()) - task = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") - task.state = "input-required" - ctx = await store.get_or_create_context( - context_id="ctx-1", - cwd=str(tmp_path), - runtime_factory=lambda _session_id: _fake_runtime(), - ) - ctx.active_task_id = "task-1" - ctx.runtime = A2APipelineRuntime( - agent_runtime=_fake_runtime(), - pipeline=FailingPausedPipeline(session_dir=sidecar_dir), - publisher=publisher, - ) - store.mirror_task(task) - store.mirror_context(ctx) - executor = IacCodeA2APipelineExecutor( - task_store=store, - model="qwen3.6-plus", - metrics=NoOpA2AMetrics(), - artifact_store=None, - push_notifier=None, - permission_resolver=None, - auto_approve_permissions=False, - thinking_exposure_types=None, + A2APipelineJournal(session_dir).append(terminal_event) + A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([terminal_event])) + fake_pipeline = FakePipeline( + [ + TextDeltaEvent(text="fresh output"), + PipelineEvent(type=PipelineEventType.PIPELINE_COMPLETED, step_id=None, timestamp=1717821601.0, data={}), + ], + session_dir=session_dir, ) + fake_pipeline.sidecar_status = sidecar_status + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + + executor = IacCodeA2AExecutor(task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), model="qwen3.6-plus") + queue = FakeEventQueue() await executor.execute( - context=FakeRequestContext(text="continue", metadata={"iac_code": {"cwd": str(tmp_path)}}), - event_queue=queue, - task=task, - task_id="task-1", - context_id="ctx-1", - cwd=str(tmp_path), - prompt="continue", + FakeRequestContext( + task_id="task-1", + context_id="ctx-1", + text="retry", + metadata={"iac_code": {"cwd": str(tmp_path)}}, + ), + queue, ) - assert task.state == "failed" - journal_events = A2APipelineJournal(sidecar_dir).read_all() - assert journal_events[-1]["eventType"] == "pipeline_failed" - snapshot = A2APipelineSnapshotStore(sidecar_dir).load() - assert snapshot["status"] == "failed" - assert snapshot["pendingInput"] is None - assert any( - dump(event).get("metadata", {}).get("iac_code", {}).get("pipeline", {}).get("eventType") == "pipeline_failed" - for event in queue.events - ) + assert fake_pipeline.clear_sidecar_calls == 0 + assert fake_pipeline.resume_prompts == [] + assert fake_pipeline.continue_calls == 0 + assert fake_pipeline.run_prompts == [] + assert _status_events(queue)[-1]["status"]["state"] == expected_state + events = A2APipelineJournal(session_dir).read_all() + assert [event["eventType"] for event in events] == [terminal_event_type] + snapshot = A2APipelineSnapshotStore(session_dir).load() + assert snapshot is not None + assert snapshot["status"] == terminal_status @pytest.mark.asyncio -async def test_pipeline_executor_publishes_interrupt_received_before_slow_judge(tmp_path: Path) -> None: - from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator - from iac_code.a2a.pipeline_executor import A2APipelineRuntime, IacCodeA2APipelineExecutor - from iac_code.a2a.pipeline_journal import A2APipelineJournal - from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore - from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher - - class SlowInterruptPipeline(FakePipeline): - def __init__(self, *, session_dir: Path) -> None: - super().__init__([], session_dir=session_dir) - self.judge_started = asyncio.Event() - self.finish_judge = asyncio.Event() - - async def handle_user_interrupt(self, message: str) -> SimpleNamespace: - self.judge_started.set() - await self.finish_judge.wait() - return SimpleNamespace( - action="continue", - reason="not relevant", - rollback_target=None, - candidate_scope=None, - supplement_target=None, - ) +async def test_executor_rejects_previous_task_waiting_sidecar_without_starting_new_task( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from iac_code.a2a.pipeline_executor import RecoverablePipelineInvalidParamsError - store = A2ATaskStore(metrics=NoOpA2AMetrics()) - task = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") - task.state = "working" - task.active_task = asyncio.current_task() - ctx = await store.get_or_create_context( - context_id="ctx-1", - cwd=str(tmp_path), - runtime_factory=lambda _session_id: _fake_runtime(), - ) - ctx.active_task_id = "task-1" - pipeline = SlowInterruptPipeline(session_dir=tmp_path / "sidecar") - publisher = PipelineA2AEventPublisher( - event_queue=FakeEventQueue(), - translator=PipelineEventTranslator( + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + session_dir = tmp_path / "sidecar" + old_input = { + "schemaVersion": "1.0", + "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", + "eventId": "evt-old-input", + "sequence": 1, + "createdAt": "2026-06-08T10:00:00Z", + "eventType": "input_required", + "scope": "pipeline", + "pipelineRunId": "ctx-1", + "taskId": "task-old", + "contextId": "ctx-1", + "pipelineName": "selling", + "status": "waiting_input", + "data": {"prompt": "old choice"}, + } + A2APipelineJournal(session_dir).append(old_input) + A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([old_input])) + fake_pipeline = FakePipeline( + [ + TextDeltaEvent(text="new output"), + PipelineEvent(type=PipelineEventType.PIPELINE_COMPLETED, step_id=None, timestamp=1717821601.0, data={}), + ], + session_dir=session_dir, + ) + fake_pipeline.sidecar_status = "waiting_input" + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + + executor = IacCodeA2AExecutor(task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), model="qwen3.6-plus") + + with pytest.raises(RecoverablePipelineInvalidParamsError) as exc_info: + await executor.execute( + FakeRequestContext( + task_id="task-new", + context_id="ctx-1", + text="new request", + metadata={"iac_code": {"cwd": str(tmp_path)}}, + ), + FakeEventQueue(), + ) + + assert exc_info.value.data == { + "recoverableTaskId": "task-old", + "contextId": "ctx-1", + "sidecarStatus": "waiting_input", + } + assert fake_pipeline.clear_sidecar_calls == 0 + assert fake_pipeline.resume_prompts == [] + assert fake_pipeline.run_prompts == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("sidecar_status", "event_type", "event_status"), + [ + ("waiting_input", "input_required", "waiting_input"), + ("running", "pipeline_started", "working"), + ], +) +async def test_executor_does_not_attach_current_sidecar_to_historical_task( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + sidecar_status: str, + event_type: str, + event_status: str, +) -> None: + from iac_code.a2a.pipeline_executor import RecoverablePipelineInvalidParamsError + + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + session_dir = tmp_path / "sidecar" + old_event = { + "schemaVersion": "1.0", + "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", + "eventId": "evt-old", + "sequence": 1, + "createdAt": "2026-06-08T10:00:00Z", + "eventType": event_type, + "scope": "pipeline", + "pipelineRunId": "ctx-1", + "taskId": "task-old", + "contextId": "ctx-1", + "pipelineName": "selling", + "status": event_status, + "data": {"prompt": "old choice"} if event_type == "input_required" else {}, + } + current_event = dict(old_event) + current_event.update( + { + "eventId": "evt-current", + "sequence": 2, + "taskId": "task-current", + "status": event_status, + "data": {"prompt": "current choice"} if event_type == "input_required" else {}, + } + ) + journal = A2APipelineJournal(session_dir) + journal.append(old_event) + journal.append(current_event) + A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([old_event, current_event])) + fake_pipeline = FakePipeline( + [ + TextDeltaEvent(text="old followup output"), + PipelineEvent(type=PipelineEventType.PIPELINE_COMPLETED, step_id=None, timestamp=1717821601.0, data={}), + ], + session_dir=session_dir, + ) + fake_pipeline.sidecar_status = sidecar_status + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + + executor = IacCodeA2AExecutor(task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), model="qwen3.6-plus") + + with pytest.raises(RecoverablePipelineInvalidParamsError) as exc_info: + await executor.execute( + FakeRequestContext( + task_id="task-old", + context_id="ctx-1", + text="old followup", + metadata={"iac_code": {"cwd": str(tmp_path)}}, + ), + FakeEventQueue(), + ) + + assert exc_info.value.data == { + "recoverableTaskId": "task-current", + "contextId": "ctx-1", + "sidecarStatus": sidecar_status, + } + assert fake_pipeline.clear_sidecar_calls == 0 + assert fake_pipeline.resume_prompts == [] + assert fake_pipeline.continue_calls == 0 + assert fake_pipeline.run_prompts == [] + assert journal.read_all()[-1]["taskId"] == "task-current" + + +@pytest.mark.asyncio +async def test_executor_routes_running_sidecar_to_continue(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + fake_pipeline = FakePipeline( + [ + PipelineEvent( + type=PipelineEventType.PIPELINE_COMPLETED, + step_id=None, + timestamp=1717821601.0, + data={}, + ) + ], + session_dir=tmp_path / "sidecar", + ) + fake_pipeline.sidecar_status = "running" + running_event = { + "schemaVersion": "1.0", + "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", + "eventId": "evt-running", + "sequence": 1, + "createdAt": "2026-06-08T10:00:00Z", + "eventType": "pipeline_started", + "scope": "pipeline", + "pipelineRunId": "ctx-1", + "taskId": "task-1", + "contextId": "ctx-1", + "pipelineName": "selling", + "status": "working", + "data": {}, + } + A2APipelineJournal(tmp_path / "sidecar").append(running_event) + A2APipelineSnapshotStore(tmp_path / "sidecar").save(reduce_pipeline_events([running_event])) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + + executor = IacCodeA2AExecutor(task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), model="qwen3.6-plus") + + await executor.execute( + FakeRequestContext(text="not fresh input", metadata={"iac_code": {"cwd": str(tmp_path)}}), + FakeEventQueue(), + ) + + assert fake_pipeline.continue_calls == 1 + assert fake_pipeline.continue_inputs == ["not fresh input"] + assert fake_pipeline.run_prompts == ["not fresh input"] + + +@pytest.mark.asyncio +async def test_executor_preserves_running_sidecar_pause_as_input_required( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + pause_event = PipelineEvent( + type=PipelineEventType.USER_INPUT_REQUIRED, + step_id="deploying", + timestamp=1717821601.0, + data={ + "kind": "pipeline_pause_confirmation", + "prompt": "Pipeline paused.", + "reason": "judge failed: timeout after 90.0s", + "paused": True, + "options": [], + }, + ) + fake_pipeline = FakePipeline([pause_event], session_dir=tmp_path / "sidecar") + fake_pipeline.sidecar_status = "running" + running_event = { + "schemaVersion": "1.0", + "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", + "eventId": "evt-running", + "sequence": 1, + "createdAt": "2026-06-08T10:00:00Z", + "eventType": "pipeline_started", + "scope": "pipeline", + "pipelineRunId": "ctx-1", + "taskId": "task-1", + "contextId": "ctx-1", + "pipelineName": "selling", + "status": "working", + "data": {}, + } + A2APipelineJournal(tmp_path / "sidecar").append(running_event) + A2APipelineSnapshotStore(tmp_path / "sidecar").save(reduce_pipeline_events([running_event])) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + + queue = FakeEventQueue() + executor = IacCodeA2AExecutor(task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), model="qwen3.6-plus") + + await executor.execute( + FakeRequestContext(text="stop deploying", metadata={"iac_code": {"cwd": str(tmp_path)}}), + queue, + ) + + assert fake_pipeline.continue_calls == 1 + events = A2APipelineJournal(tmp_path / "sidecar").read_all() + assert events[-1]["eventType"] == "input_required" + assert events[-1]["status"] == "input_required" + assert events[-1]["data"]["kind"] == "pipeline_pause_confirmation" + assert "timeout" in events[-1]["data"]["reason"] + statuses = _status_events(queue) + assert statuses[-1]["status"]["state"] == "TASK_STATE_INPUT_REQUIRED" + + +@pytest.mark.asyncio +async def test_executor_routes_waiting_input_pause_confirmation_through_interrupt_path( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + session_dir = tmp_path / "sidecar" + pending = { + "schemaVersion": "1.0", + "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", + "eventId": "evt-pause", + "sequence": 1, + "createdAt": "2026-06-08T10:00:00Z", + "eventType": "input_required", + "scope": "step", + "pipelineRunId": "ctx-1", + "taskId": "task-1", + "contextId": "ctx-1", + "pipelineName": "selling", + "status": "input_required", + "step": {"runId": "step-deploying-1", "id": "deploying", "attempt": 1}, + "data": { + "kind": "pipeline_pause_confirmation", + "prompt": "Pipeline paused.", + "reason": "judge failed: timeout", + "paused": True, + "options": [], + }, + } + A2APipelineJournal(session_dir).append(pending) + A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([pending])) + fake_pipeline = FakePipeline( + [ + PipelineEvent( + type=PipelineEventType.PIPELINE_COMPLETED, + step_id=None, + timestamp=1717821601.0, + data={}, + ) + ], + session_dir=session_dir, + ) + fake_pipeline.sidecar_status = "waiting_input" + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + + executor = IacCodeA2AExecutor(task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), model="qwen3.6-plus") + await executor.execute( + FakeRequestContext(text="rollback to design", metadata={"iac_code": {"cwd": str(tmp_path)}}), + FakeEventQueue(), + ) + + assert fake_pipeline.continue_inputs == ["rollback to design"] + assert fake_pipeline.resume_prompts == [] + + +@pytest.mark.asyncio +async def test_executor_rejects_previous_task_running_sidecar_without_starting_new_task( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from iac_code.a2a.pipeline_executor import RecoverablePipelineInvalidParamsError + + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + session_dir = tmp_path / "sidecar" + old_running = { + "schemaVersion": "1.0", + "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", + "eventId": "evt-old-running", + "sequence": 1, + "createdAt": "2026-06-08T10:00:00Z", + "eventType": "pipeline_started", + "scope": "pipeline", + "pipelineRunId": "ctx-1", + "taskId": "task-old", + "contextId": "ctx-1", + "pipelineName": "selling", + "status": "working", + "data": {}, + } + A2APipelineJournal(session_dir).append(old_running) + A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([old_running])) + fake_pipeline = FakePipeline( + [ + TextDeltaEvent(text="new output"), + PipelineEvent(type=PipelineEventType.PIPELINE_COMPLETED, step_id=None, timestamp=1717821601.0, data={}), + ], + session_dir=session_dir, + ) + fake_pipeline.sidecar_status = "running" + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + + executor = IacCodeA2AExecutor(task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), model="qwen3.6-plus") + + with pytest.raises(RecoverablePipelineInvalidParamsError) as exc_info: + await executor.execute( + FakeRequestContext( + task_id="task-new", + context_id="ctx-1", + text="new request", + metadata={"iac_code": {"cwd": str(tmp_path)}}, + ), + FakeEventQueue(), + ) + + assert exc_info.value.data == { + "recoverableTaskId": "task-old", + "contextId": "ctx-1", + "sidecarStatus": "running", + } + assert fake_pipeline.clear_sidecar_calls == 0 + assert fake_pipeline.continue_calls == 0 + assert fake_pipeline.run_prompts == [] + + +@pytest.mark.asyncio +async def test_executor_rejected_active_sidecar_mismatch_does_not_persist_new_working_task( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from iac_code.a2a.pipeline_executor import RecoverablePipelineInvalidParamsError + + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + persistence = A2APersistenceStore(tmp_path / "a2a") + session_dir = tmp_path / "sidecar" + owner_event = { + "schemaVersion": "1.0", + "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", + "eventId": "evt-owner-running", + "sequence": 1, + "createdAt": "2026-06-08T10:00:00Z", + "eventType": "pipeline_started", + "scope": "pipeline", + "pipelineRunId": "ctx-1", + "taskId": "task-owner", + "contextId": "ctx-1", + "pipelineName": "selling", + "status": "working", + "data": {}, + } + A2APipelineJournal(session_dir).append(owner_event) + A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([owner_event])) + fake_pipeline = FakePipeline( + [ + TextDeltaEvent(text="new output"), + PipelineEvent(type=PipelineEventType.PIPELINE_COMPLETED, step_id=None, timestamp=1717821601.0, data={}), + ], + session_dir=session_dir, + ) + fake_pipeline.sidecar_status = "running" + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + + task_store = A2ATaskStore(metrics=NoOpA2AMetrics(), persistence=persistence) + executor = IacCodeA2AExecutor(task_store=task_store, model="qwen3.6-plus") + + with pytest.raises(RecoverablePipelineInvalidParamsError): + await executor.execute( + FakeRequestContext( + task_id="task-new", + context_id="ctx-1", + text="new request", + metadata={"iac_code": {"cwd": str(tmp_path)}}, + ), + FakeEventQueue(), + ) + + assert fake_pipeline.clear_sidecar_calls == 0 + assert fake_pipeline.run_prompts == [] + rejected_task = persistence.load_task("task-new") + assert rejected_task is not None + assert rejected_task.state != "working" + assert [task.task_id for task in persistence.list_tasks() if task.state == "working"] == [] + + +def test_cleanup_handoff_missing_ledger_ignores_empty_public_cleanup_snapshot(tmp_path: Path) -> None: + from iac_code.a2a.pipeline_executor import _pipeline_cleanup_handoff_data_from_session + + cleanup = _pipeline_cleanup_handoff_data_from_session( + cwd=str(tmp_path), + session_id="session-empty-cleanup", + public_snapshot={"cleanup": {"resourceCount": 0, "resources": [], "status": ""}}, + ) + + assert cleanup is None + + +def test_cleanup_handoff_missing_ledger_does_not_reconstruct_prompt_from_public_snapshot(tmp_path: Path) -> None: + from iac_code.a2a.pipeline_executor import _pipeline_cleanup_handoff_data_from_session + + cleanup = _pipeline_cleanup_handoff_data_from_session( + cwd=str(tmp_path), + session_id="session-public-cleanup-only", + public_snapshot={ + "cleanup": { + "resourceCount": 1, + "resources": [ + { + "provider": "ros", + "resourceType": "stack", + "resourceId": "stack-public-only", + "cleanupStatus": "pending", + } + ], + "status": "pending", + } + }, + ) + + assert cleanup is not None + assert cleanup["status"] == "unavailable" + assert "prompt" not in cleanup + assert "resources" not in cleanup + assert "stack-public-only" not in repr(cleanup) + + +@pytest.mark.asyncio +async def test_pipeline_executor_routes_second_prompt_as_interrupt(tmp_path: Path) -> None: + from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator + from iac_code.a2a.pipeline_executor import A2APipelineRuntime, IacCodeA2APipelineExecutor + from iac_code.a2a.pipeline_journal import A2APipelineJournal + from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore + from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher + from iac_code.services.providers.aliyun import AliyunCredential, AliyunCredentials + + captured_interrupt_credentials: list[str | None] = [] + + class InterruptiblePipeline(FakePipeline): + def __init__(self, *, session_dir: Path) -> None: + super().__init__([TextDeltaEvent(text="running")], session_dir=session_dir) + self.interrupts: list[str] = [] + + async def handle_user_interrupt(self, message: str) -> SimpleNamespace: + credential = AliyunCredentials.load() + captured_interrupt_credentials.append(credential.access_key_id if credential else None) + self.interrupts.append(message) + return SimpleNamespace( + action="supplement", + reason="added context", + rollback_target=None, + candidate_scope=None, + ) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + task = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + ctx = await store.get_or_create_context( + context_id="ctx-1", + cwd=str(tmp_path), + runtime_factory=lambda _session_id: _fake_runtime(), + ) + ctx.active_task_id = "task-1" + + queue = FakeEventQueue() + pipeline = InterruptiblePipeline(session_dir=tmp_path / "sidecar") + publisher = PipelineA2AEventPublisher( + event_queue=queue, + translator=PipelineEventTranslator( + PipelineA2AContext( + pipeline_run_id="ctx-1", + task_id="task-1", + context_id="ctx-1", + pipeline_name="selling", + ) + ), + journal=A2APipelineJournal(tmp_path / "pipeline"), + snapshot_store=A2APipelineSnapshotStore(tmp_path / "pipeline"), + ) + ctx.runtime = A2APipelineRuntime(agent_runtime=_fake_runtime(), pipeline=pipeline, publisher=publisher) + store.mirror_context(ctx) + + executor = IacCodeA2APipelineExecutor( + task_store=store, + model="qwen3.6-plus", + metrics=NoOpA2AMetrics(), + artifact_store=None, + push_notifier=None, + permission_resolver=None, + auto_approve_permissions=False, + thinking_exposure_types=None, + aliyun_credential=AliyunCredential( + access_key_id="client-id", + access_key_secret="client-secret", + region_id="cn-beijing", + ), + ) + + await executor.execute( + context=FakeRequestContext( + text="please change cpu", + metadata={"iac_code": {"cwd": str(tmp_path)}}, + ), + event_queue=queue, + task=task, + task_id="task-1", + context_id="ctx-1", + cwd=str(tmp_path), + prompt="please change cpu", + ) + + assert pipeline.interrupts == ["please change cpu"] + assert captured_interrupt_credentials == ["client-id"] + event_types = [event["eventType"] for event in publisher.journal.read_all()] + assert event_types == ["interrupt_received", "interrupt_classified"] + + +@pytest.mark.asyncio +async def test_pipeline_executor_reconfigures_active_interrupt_runtime_thinking_per_call( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator + from iac_code.a2a.pipeline_executor import A2APipelineRuntime, IacCodeA2APipelineExecutor + from iac_code.a2a.pipeline_journal import A2APipelineJournal + from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore + from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher + from iac_code.providers.request_policy import ProviderRequestPolicy + + class FakeProviderManager: + def __init__(self) -> None: + self.calls: list[tuple[str, object | None]] = [] + + def reconfigure( + self, + model, + credentials, + provider_key_override=None, + base_url_override=None, + request_policy_override=None, + ): + self.calls.append((model, request_policy_override)) + + class InterruptiblePipeline(FakePipeline): + async def handle_user_interrupt(self, message: str) -> SimpleNamespace: + return SimpleNamespace( + action="supplement", + reason="added context", + rollback_target=None, + candidate_scope=None, + ) + + provider_manager = FakeProviderManager() + agent_runtime = SimpleNamespace(provider_manager=provider_manager, tool_registry=object()) + monkeypatch.setattr("iac_code.config.load_credentials", lambda model=None: {}) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + task = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + ctx = await store.get_or_create_context( + context_id="ctx-1", + cwd=str(tmp_path), + runtime_factory=lambda _session_id: agent_runtime, + ) + ctx.active_task_id = "task-1" + + queue = FakeEventQueue() + pipeline = InterruptiblePipeline([TextDeltaEvent(text="running")], session_dir=tmp_path / "sidecar") + publisher = PipelineA2AEventPublisher( + event_queue=queue, + translator=PipelineEventTranslator( + PipelineA2AContext( + pipeline_run_id="ctx-1", + task_id="task-1", + context_id="ctx-1", + pipeline_name="selling", + ) + ), + journal=A2APipelineJournal(tmp_path / "pipeline"), + snapshot_store=A2APipelineSnapshotStore(tmp_path / "pipeline"), + ) + ctx.runtime = A2APipelineRuntime(agent_runtime=agent_runtime, pipeline=pipeline, publisher=publisher) + store.mirror_context(ctx) + + executor = IacCodeA2APipelineExecutor( + task_store=store, + model="qwen3.6-plus", + metrics=NoOpA2AMetrics(), + artifact_store=None, + push_notifier=None, + permission_resolver=None, + auto_approve_permissions=False, + thinking_exposure_types=None, + request_policy_override=ProviderRequestPolicy(thinking_enabled=False, effort="high", thinking_budget=2048), + ) + + await executor.execute( + context=FakeRequestContext(text="please change cpu", metadata={"iac_code": {"cwd": str(tmp_path)}}), + event_queue=queue, + task=task, + task_id="task-1", + context_id="ctx-1", + cwd=str(tmp_path), + prompt="please change cpu", + ) + + policy = provider_manager.calls[0][1] + assert provider_manager.calls[0][0] == "qwen3.6-plus" + assert getattr(policy, "thinking_enabled", None) is False + assert getattr(policy, "effort", None) == "high" + assert getattr(policy, "thinking_budget", None) == 2048 + + +@pytest.mark.asyncio +async def test_pipeline_executor_publishes_input_required_for_live_paused_interrupt(tmp_path: Path) -> None: + from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator + from iac_code.a2a.pipeline_executor import A2APipelineRuntime, IacCodeA2APipelineExecutor + from iac_code.a2a.pipeline_journal import A2APipelineJournal + from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore + from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher + + class PausingPipeline(FakePipeline): + def __init__(self, *, session_dir: Path) -> None: + super().__init__([TextDeltaEvent(text="running")], session_dir=session_dir) + self.interrupts: list[str] = [] + self.saved_pause_verdicts: list[SimpleNamespace] = [] + + async def handle_user_interrupt(self, message: str) -> SimpleNamespace: + self.interrupts.append(message) + return SimpleNamespace( + action="continue", + reason="judge failed: timeout", + rollback_target=None, + candidate_scope=None, + paused=True, + ) + + async def save_interrupt_pause(self, verdict: SimpleNamespace) -> PipelineEvent: + self.saved_pause_verdicts.append(verdict) + return PipelineEvent( + type=PipelineEventType.USER_INPUT_REQUIRED, + step_id="deploying", + timestamp=1717821601.0, + data={ + "kind": "pipeline_pause_confirmation", + "prompt": "Pipeline paused.", + "reason": verdict.reason, + "paused": True, + "options": [], + }, + ) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + task = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + task.state = "working" + task.active_task = asyncio.current_task() + ctx = await store.get_or_create_context( + context_id="ctx-1", + cwd=str(tmp_path), + runtime_factory=lambda _session_id: _fake_runtime(), + ) + ctx.active_task_id = "task-1" + + queue = FakeEventQueue() + pipeline = PausingPipeline(session_dir=tmp_path / "sidecar") + publisher = PipelineA2AEventPublisher( + event_queue=queue, + translator=PipelineEventTranslator( + PipelineA2AContext( + pipeline_run_id="ctx-1", + task_id="task-1", + context_id="ctx-1", + pipeline_name="selling", + ) + ), + journal=A2APipelineJournal(tmp_path / "pipeline"), + snapshot_store=A2APipelineSnapshotStore(tmp_path / "pipeline"), + ) + ctx.runtime = A2APipelineRuntime(agent_runtime=_fake_runtime(), pipeline=pipeline, publisher=publisher) + store.mirror_task(task) + store.mirror_context(ctx) + + executor = IacCodeA2APipelineExecutor( + task_store=store, + model="qwen3.6-plus", + metrics=NoOpA2AMetrics(), + artifact_store=None, + push_notifier=None, + permission_resolver=None, + auto_approve_permissions=False, + thinking_exposure_types=None, + ) + + await executor.execute( + context=FakeRequestContext( + text="please stop", + metadata={"iac_code": {"cwd": str(tmp_path)}}, + ), + event_queue=queue, + task=task, + task_id="task-1", + context_id="ctx-1", + cwd=str(tmp_path), + prompt="please stop", + ) + + assert pipeline.interrupts == ["please stop"] + assert len(pipeline.saved_pause_verdicts) == 1 + event_types = [event["eventType"] for event in publisher.journal.read_all()] + assert event_types == ["interrupt_received", "interrupt_classified", "input_required"] + assert publisher.snapshot_store.load()["pendingInput"]["kind"] == "pipeline_pause_confirmation" + assert task.state == "input-required" + + +@pytest.mark.asyncio +async def test_live_paused_interrupt_releases_active_task_and_next_reply_clears_pending_input( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + + class PausingPipeline(FakePipeline): + def __init__(self, *, session_dir: Path) -> None: + super().__init__([], session_dir=session_dir) + self.primary_stream = CloseableEventStream([TextDeltaEvent(text="before pause")]) + self.resume_stream = CloseableEventStream( + [ + PipelineEvent( + type=PipelineEventType.USER_INPUT_RECEIVED, + step_id="deploying", + timestamp=1717821602.0, + data={"kind": "pipeline_pause_confirmation", "user_input_length": 8}, + ), + PipelineEvent( + type=PipelineEventType.PIPELINE_COMPLETED, + step_id=None, + timestamp=1717821603.0, + data={}, + ), + ], + wait_until_closed=False, + ) + self.interrupts: list[str] = [] + self.saved_pause_verdicts: list[SimpleNamespace] = [] + + def run(self, prompt: str): + self.run_prompts.append(prompt) + return self.primary_stream + + def continue_from_sidecar(self, user_input: str | None = None): + self.continue_calls += 1 + self.continue_inputs.append(user_input) + self.sidecar_status = "running" + return self.resume_stream + + async def handle_user_interrupt(self, message: str) -> SimpleNamespace: + self.interrupts.append(message) + return SimpleNamespace( + action="continue", + reason="judge failed: timeout", + rollback_target=None, + candidate_scope=None, + paused=True, + ) + + async def save_interrupt_pause(self, verdict: SimpleNamespace) -> PipelineEvent: + self.saved_pause_verdicts.append(verdict) + self.sidecar_status = "waiting_input" + return PipelineEvent( + type=PipelineEventType.USER_INPUT_REQUIRED, + step_id="deploying", + timestamp=1717821601.0, + data={ + "kind": "pipeline_pause_confirmation", + "prompt": "Pipeline paused.", + "reason": verdict.reason, + "paused": True, + "options": [], + }, + ) + + pipeline = PausingPipeline(session_dir=tmp_path / "sidecar") + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: pipeline) + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: _fake_runtime()) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + queue = FakeEventQueue() + first = asyncio.create_task( + executor.execute( + FakeRequestContext(task_id="task-1", context_id="ctx-1", metadata={"iac_code": {"cwd": str(tmp_path)}}), + queue, + ) + ) + await _wait_for_output_text(await store.get_or_create_task(task_id="task-1", context_id="ctx-1"), "before pause") + + await executor.execute( + FakeRequestContext( + task_id="task-1", + context_id="ctx-1", + text="please pause", + metadata={"iac_code": {"cwd": str(tmp_path)}}, + ), + queue, + ) + await asyncio.wait_for(first, timeout=1) + ctx = await store.get_or_create_context(context_id="ctx-1", cwd=str(tmp_path), runtime_factory=lambda _sid: None) + assert ctx.active_task_id is None + assert pipeline.primary_stream.closed is True + assert pipeline.sidecar_status == "waiting_input" + assert pipeline.interrupts == ["please pause"] + assert ( + A2APipelineSnapshotStore(tmp_path / "sidecar").load()["pendingInput"]["kind"] == "pipeline_pause_confirmation" + ) + + await executor.execute( + FakeRequestContext( + task_id="task-1", + context_id="ctx-1", + text="continue", + metadata={"iac_code": {"cwd": str(tmp_path)}}, + ), + queue, + ) + + events = A2APipelineJournal(tmp_path / "sidecar").read_all() + assert [event["eventType"] for event in events][-4:] == [ + "input_received", + "pipeline_completed", + "pipeline_completed", + "backup_committed", + ] + assert [event.get("visibility") for event in events[-3:-1]] == ["pending_backup", "committed"] + assert events[-1]["data"]["committedEventType"] == "pipeline_completed" + assert A2APipelineSnapshotStore(tmp_path / "sidecar").load()["pendingInput"] is None + assert pipeline.continue_inputs == ["continue"] + + +@pytest.mark.asyncio +async def test_active_pause_continuation_keeps_active_owner_until_continuation_finishes( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + + class BlockingCloseStream(CloseableEventStream): + def __init__(self, events) -> None: + super().__init__(events) + self.allow_close = asyncio.Event() + + async def aclose(self) -> None: + self.closed = True + self.closed_event.set() + await self.allow_close.wait() + + class PausingPipeline(FakePipeline): + def __init__(self, *, session_dir: Path) -> None: + super().__init__([], session_dir=session_dir) + self.primary_stream = BlockingCloseStream([TextDeltaEvent(text="before pause")]) + self.continuation_started = asyncio.Event() + self.finish_continuation = asyncio.Event() + + def run(self, prompt: str): + return self.primary_stream + + async def handle_user_interrupt(self, message: str) -> SimpleNamespace: + return SimpleNamespace(action="continue", reason="pause requested", paused=True) + + async def save_interrupt_pause(self, verdict: SimpleNamespace) -> PipelineEvent: + self.sidecar_status = "waiting_input" + return PipelineEvent( + type=PipelineEventType.USER_INPUT_REQUIRED, + step_id="deploying", + timestamp=1717821601.0, + data={ + "kind": "pipeline_pause_confirmation", + "prompt": "Pipeline paused.", + "reason": verdict.reason, + "paused": True, + "options": [], + }, + ) + + def continue_from_sidecar(self, user_input: str | None = None): + self.continue_calls += 1 + self.continue_inputs.append(user_input) + self.sidecar_status = "running" + + async def stream(): + self.continuation_started.set() + yield PipelineEvent( + type=PipelineEventType.USER_INPUT_RECEIVED, + step_id="deploying", + timestamp=1717821602.0, + data={"kind": "pipeline_pause_confirmation", "user_input_length": len(user_input or "")}, + ) + await self.finish_continuation.wait() + yield PipelineEvent( + type=PipelineEventType.PIPELINE_COMPLETED, + step_id=None, + timestamp=1717821603.0, + data={}, + ) + + return stream() + + pipeline = PausingPipeline(session_dir=tmp_path / "sidecar") + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: pipeline) + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: _fake_runtime()) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + queue = FakeEventQueue() + first = asyncio.create_task( + executor.execute( + FakeRequestContext(task_id="task-1", context_id="ctx-1", metadata={"iac_code": {"cwd": str(tmp_path)}}), + queue, + ) + ) + active_task = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + await _wait_for_output_text(active_task, "before pause") + + await executor.execute( + FakeRequestContext( + task_id="task-1", + context_id="ctx-1", + text="please pause", + metadata={"iac_code": {"cwd": str(tmp_path)}}, + ), + queue, + ) + + continuation = asyncio.create_task( + executor.execute( + FakeRequestContext( + task_id="task-1", + context_id="ctx-1", + text="continue", + metadata={"iac_code": {"cwd": str(tmp_path)}}, + ), + queue, + ) + ) + try: + await asyncio.wait_for(pipeline.continuation_started.wait(), timeout=1) + pipeline.primary_stream.allow_close.set() + await asyncio.wait_for(first, timeout=1) + + ctx = await store.get_or_create_context( + context_id="ctx-1", cwd=str(tmp_path), runtime_factory=lambda _sid: None + ) + active_task = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + assert ctx.active_task_id == "task-1" + assert active_task.active_task is continuation + + pipeline.finish_continuation.set() + await asyncio.wait_for(continuation, timeout=1) + assert ctx.active_task_id is None + assert active_task.active_task is None + finally: + pipeline.primary_stream.allow_close.set() + pipeline.finish_continuation.set() + for runner in (first, continuation): + if not runner.done(): + runner.cancel() + with contextlib.suppress(asyncio.CancelledError): + await runner + + +@pytest.mark.asyncio +async def test_active_task_route_continues_pending_pause_confirmation(tmp_path: Path) -> None: + from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator + from iac_code.a2a.pipeline_executor import A2APipelineRuntime, IacCodeA2APipelineExecutor + from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher + + class PausedPipeline(FakePipeline): + def __init__(self, *, session_dir: Path) -> None: + super().__init__([], session_dir=session_dir) + self.sidecar_status = "waiting_input" + self.interrupts: list[str] = [] + + async def handle_user_interrupt(self, message: str) -> SimpleNamespace: + self.interrupts.append(message) + return SimpleNamespace(action="continue", reason="should not run") + + def continue_from_sidecar(self, user_input: str | None = None): + self.continue_calls += 1 + self.continue_inputs.append(user_input) + + async def stream(): + yield PipelineEvent( + type=PipelineEventType.USER_INPUT_RECEIVED, + step_id="deploying", + timestamp=1717821602.0, + data={"kind": "pipeline_pause_confirmation", "user_input_length": len(user_input or "")}, + ) + yield PipelineEvent( + type=PipelineEventType.PIPELINE_COMPLETED, + step_id=None, + timestamp=1717821603.0, + data={}, + ) + + return stream() + + sidecar_dir = tmp_path / "sidecar" + pending = { + "schemaVersion": "1.0", + "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", + "eventId": "evt-pause", + "sequence": 1, + "createdAt": "2026-06-08T10:00:00Z", + "eventType": "input_required", + "scope": "pipeline", + "pipelineRunId": "ctx-1", + "taskId": "task-1", + "contextId": "ctx-1", + "pipelineName": "selling", + "status": "input_required", + "data": { + "kind": "pipeline_pause_confirmation", + "prompt": "Pipeline paused.", + "reason": "judge failed: timeout", + "paused": True, + "options": [], + }, + } + A2APipelineJournal(sidecar_dir).append(pending) + A2APipelineSnapshotStore(sidecar_dir).save(reduce_pipeline_events([pending])) + pipeline = PausedPipeline(session_dir=sidecar_dir) + queue = FakeEventQueue() + publisher = PipelineA2AEventPublisher( + event_queue=queue, + translator=PipelineEventTranslator( + PipelineA2AContext( + pipeline_run_id="ctx-1", + task_id="task-1", + context_id="ctx-1", + pipeline_name="selling", + ) + ), + journal=A2APipelineJournal(sidecar_dir), + snapshot_store=A2APipelineSnapshotStore(sidecar_dir), + ) + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + task = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + task.state = "input-required" + task.active_task = asyncio.current_task() + ctx = await store.get_or_create_context( + context_id="ctx-1", + cwd=str(tmp_path), + runtime_factory=lambda _session_id: _fake_runtime(), + ) + ctx.active_task_id = "task-1" + ctx.runtime = A2APipelineRuntime(agent_runtime=_fake_runtime(), pipeline=pipeline, publisher=publisher) + store.mirror_task(task) + store.mirror_context(ctx) + + executor = IacCodeA2APipelineExecutor( + task_store=store, + model="qwen3.6-plus", + metrics=NoOpA2AMetrics(), + artifact_store=None, + push_notifier=None, + permission_resolver=None, + auto_approve_permissions=False, + thinking_exposure_types=None, + ) + + await executor.execute( + context=FakeRequestContext(text="continue", metadata={"iac_code": {"cwd": str(tmp_path)}}), + event_queue=queue, + task=task, + task_id="task-1", + context_id="ctx-1", + cwd=str(tmp_path), + prompt="continue", + ) + + assert pipeline.interrupts == [] + assert pipeline.continue_inputs == ["continue"] + events = A2APipelineJournal(sidecar_dir).read_all() + assert [event["eventType"] for event in events][-2:] == ["input_received", "pipeline_completed"] + assert A2APipelineSnapshotStore(sidecar_dir).load()["pendingInput"] is None + + +@pytest.mark.asyncio +async def test_active_pause_confirmation_failure_marks_task_and_pipeline_failed(tmp_path: Path) -> None: + from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator + from iac_code.a2a.pipeline_executor import A2APipelineRuntime, IacCodeA2APipelineExecutor + from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher + + class FailingPausedPipeline(FakePipeline): + def __init__(self, *, session_dir: Path) -> None: + super().__init__([], session_dir=session_dir) + self.sidecar_status = "waiting_input" + + def continue_from_sidecar(self, user_input: str | None = None): + self.continue_calls += 1 + self.continue_inputs.append(user_input) + + async def stream(): + raise RuntimeError("pause continuation failed token=secret-value") + if False: + yield + + return stream() + + sidecar_dir = tmp_path / "sidecar" + pending = { + "schemaVersion": "1.0", + "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", + "eventId": "evt-pause", + "sequence": 1, + "createdAt": "2026-06-08T10:00:00Z", + "eventType": "input_required", + "scope": "pipeline", + "pipelineRunId": "ctx-1", + "taskId": "task-1", + "contextId": "ctx-1", + "pipelineName": "selling", + "status": "input_required", + "data": {"kind": "pipeline_pause_confirmation", "prompt": "Pipeline paused."}, + } + A2APipelineJournal(sidecar_dir).append(pending) + A2APipelineSnapshotStore(sidecar_dir).save(reduce_pipeline_events([pending])) + queue = FakeEventQueue() + publisher = PipelineA2AEventPublisher( + event_queue=queue, + translator=PipelineEventTranslator( + PipelineA2AContext( + pipeline_run_id="ctx-1", + task_id="task-1", + context_id="ctx-1", + pipeline_name="selling", + ) + ), + journal=A2APipelineJournal(sidecar_dir), + snapshot_store=A2APipelineSnapshotStore(sidecar_dir), + ) + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + task = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + task.state = "input-required" + ctx = await store.get_or_create_context( + context_id="ctx-1", + cwd=str(tmp_path), + runtime_factory=lambda _session_id: _fake_runtime(), + ) + ctx.active_task_id = "task-1" + ctx.runtime = A2APipelineRuntime( + agent_runtime=_fake_runtime(), + pipeline=FailingPausedPipeline(session_dir=sidecar_dir), + publisher=publisher, + ) + store.mirror_task(task) + store.mirror_context(ctx) + executor = IacCodeA2APipelineExecutor( + task_store=store, + model="qwen3.6-plus", + metrics=NoOpA2AMetrics(), + artifact_store=None, + push_notifier=None, + permission_resolver=None, + auto_approve_permissions=False, + thinking_exposure_types=None, + ) + + await executor.execute( + context=FakeRequestContext(text="continue", metadata={"iac_code": {"cwd": str(tmp_path)}}), + event_queue=queue, + task=task, + task_id="task-1", + context_id="ctx-1", + cwd=str(tmp_path), + prompt="continue", + ) + + assert task.state == "failed" + journal_events = A2APipelineJournal(sidecar_dir).read_all() + assert journal_events[-1]["eventType"] == "pipeline_failed" + snapshot = A2APipelineSnapshotStore(sidecar_dir).load() + assert snapshot["status"] == "failed" + assert snapshot["pendingInput"] is None + assert any( + dump(event).get("metadata", {}).get("iac_code", {}).get("pipeline", {}).get("eventType") == "pipeline_failed" + for event in queue.events + ) + + +@pytest.mark.asyncio +async def test_pipeline_executor_publishes_interrupt_received_before_slow_judge(tmp_path: Path) -> None: + from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator + from iac_code.a2a.pipeline_executor import A2APipelineRuntime, IacCodeA2APipelineExecutor + from iac_code.a2a.pipeline_journal import A2APipelineJournal + from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore + from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher + + class SlowInterruptPipeline(FakePipeline): + def __init__(self, *, session_dir: Path) -> None: + super().__init__([], session_dir=session_dir) + self.judge_started = asyncio.Event() + self.finish_judge = asyncio.Event() + + async def handle_user_interrupt(self, message: str) -> SimpleNamespace: + self.judge_started.set() + await self.finish_judge.wait() + return SimpleNamespace( + action="continue", + reason="not relevant", + rollback_target=None, + candidate_scope=None, + supplement_target=None, + ) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + task = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + task.state = "working" + task.active_task = asyncio.current_task() + ctx = await store.get_or_create_context( + context_id="ctx-1", + cwd=str(tmp_path), + runtime_factory=lambda _session_id: _fake_runtime(), + ) + ctx.active_task_id = "task-1" + pipeline = SlowInterruptPipeline(session_dir=tmp_path / "sidecar") + publisher = PipelineA2AEventPublisher( + event_queue=FakeEventQueue(), + translator=PipelineEventTranslator( + PipelineA2AContext( + pipeline_run_id="ctx-1", + task_id="task-1", + context_id="ctx-1", + pipeline_name="selling", + ) + ), + journal=A2APipelineJournal(tmp_path / "pipeline"), + snapshot_store=A2APipelineSnapshotStore(tmp_path / "pipeline"), + ) + ctx.runtime = A2APipelineRuntime(agent_runtime=_fake_runtime(), pipeline=pipeline, publisher=publisher) + store.mirror_task(task) + store.mirror_context(ctx) + + executor = IacCodeA2APipelineExecutor( + task_store=store, + model="qwen3.6-plus", + metrics=NoOpA2AMetrics(), + artifact_store=None, + push_notifier=None, + permission_resolver=None, + auto_approve_permissions=False, + thinking_exposure_types=None, + ) + interrupt_task = asyncio.create_task( + executor.execute( + context=FakeRequestContext(text="hello", metadata={"iac_code": {"cwd": str(tmp_path)}}), + event_queue=FakeEventQueue(), + task=task, + task_id="task-1", + context_id="ctx-1", + cwd=str(tmp_path), + prompt="hello", + ) + ) + try: + await asyncio.wait_for(pipeline.judge_started.wait(), timeout=1) + assert [event["eventType"] for event in publisher.journal.read_all()] == ["interrupt_received"] + finally: + pipeline.finish_judge.set() + await asyncio.wait_for(interrupt_task, timeout=1) + + assert [event["eventType"] for event in publisher.journal.read_all()] == [ + "interrupt_received", + "interrupt_classified", + ] + + +@pytest.mark.asyncio +async def test_pipeline_executor_stops_at_ask_user_question_without_holding_active_task( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from iac_code.a2a.pipeline_executor import IacCodeA2APipelineExecutor + + class AskingPipeline(FakePipeline): + def __init__(self, *, session_dir: Path) -> None: + super().__init__([], session_dir=session_dir) + self.question_ready = asyncio.Event() + self.answers: list[dict[str, str] | None] = [] + self.future: asyncio.Future[dict[str, str] | None] | None = None + self.closed = asyncio.Event() + + async def run(self, prompt: str): + self.run_prompts.append(prompt) + self.future = asyncio.get_running_loop().create_future() + self.question_ready.set() + answer = None + try: + yield AskUserQuestionEvent( + tool_use_id="ask-1", + question="请选择部署目标", + options=[ + {"id": "nginx", "label": "Nginx 网站"}, + {"id": "ecs", "label": "ECS 应用"}, + ], + allow_free_text=True, + free_text_prompt="也可以直接描述目标", + response_future=self.future, + ) + answer = await self.future + self.answers.append(answer) + yield TextDeltaEvent(text=answer["selected_id"] if answer else "cancelled") + yield PipelineEvent( + type=PipelineEventType.PIPELINE_COMPLETED, + step_id=None, + timestamp=1717821602.0, + data={}, + ) + finally: + self.closed.set() + if answer is None and self.future is not None and not self.future.done(): + self.future.set_result(None) + + pipeline = AskingPipeline(session_dir=tmp_path / "sidecar") + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: pipeline) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + active_task = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + executor = IacCodeA2APipelineExecutor( + task_store=store, + model="qwen3.6-plus", + metrics=NoOpA2AMetrics(), + artifact_store=None, + push_notifier=None, + permission_resolver=None, + auto_approve_permissions=False, + thinking_exposure_types=None, + ) + queue = FakeEventQueue() + runner = asyncio.create_task( + executor.execute( + context=FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), + event_queue=queue, + task=active_task, + task_id="task-1", + context_id="ctx-1", + cwd=str(tmp_path), + prompt="帮我部署网站", + ) + ) + await asyncio.wait_for(pipeline.question_ready.wait(), timeout=1) + await _wait_for_pipeline_event(queue, "input_required") + + await asyncio.wait_for(runner, timeout=1) + await asyncio.wait_for(pipeline.closed.wait(), timeout=1) + + ctx = await store.get_or_create_context( + context_id="ctx-1", + cwd=str(tmp_path), + runtime_factory=lambda _session_id: _fake_runtime(), + ) + assert ctx.active_task_id is None + assert active_task.state == "input-required" + assert pipeline.answers == [] + assert "".join(active_task.output_text) == "" + event_types = [ + dump(event)["metadata"]["iac_code"]["pipeline"]["eventType"] + for event in queue.events + if isinstance(event, TaskStatusUpdateEvent) + and "pipeline" in dump(event).get("metadata", {}).get("iac_code", {}) + ] + assert event_types == ["input_required"] + + +def test_ask_user_question_answer_accepts_one_based_option_index() -> None: + from iac_code.a2a.pipeline_executor import _ask_user_question_answer_from_prompt + + answer = _ask_user_question_answer_from_prompt( + AskUserQuestionEvent( + tool_use_id="ask-1", + question="请选择部署目标", + options=[ + {"id": "nginx", "label": "Nginx 网站"}, + {"id": "ecs", "label": "ECS 应用"}, + ], + ), + "1", + ) + + assert answer == {"selected_id": "nginx", "selected_label": "Nginx 网站", "free_text": ""} + + +@pytest.mark.asyncio +async def test_pipeline_executor_does_not_resolve_pending_question_when_input_received_publish_fails( + tmp_path: Path, +) -> None: + from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator + from iac_code.a2a.pipeline_executor import ( + A2APipelineRuntime, + IacCodeA2APipelineExecutor, + _PendingAskUserQuestion, + ) + from iac_code.a2a.pipeline_journal import A2APipelineJournal + from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore + from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher + + future: asyncio.Future[dict[str, str] | None] = asyncio.get_running_loop().create_future() + question = AskUserQuestionEvent( + tool_use_id="ask-1", + question="请选择部署目标", + options=[{"id": "nginx", "label": "Nginx 网站"}], + response_future=future, + ) + publisher = PipelineA2AEventPublisher( + event_queue=FakeEventQueue(), + translator=PipelineEventTranslator( + PipelineA2AContext( + pipeline_run_id="ctx-1", + task_id="task-1", + context_id="ctx-1", + pipeline_name="selling", + ) + ), + journal=A2APipelineJournal(tmp_path / "pipeline"), + snapshot_store=A2APipelineSnapshotStore(tmp_path / "pipeline"), + ) + publisher.publish_manual = AsyncMock(return_value=None) # type: ignore[method-assign] + runtime = A2APipelineRuntime(agent_runtime=_fake_runtime(), publisher=publisher) + runtime.pending_question = _PendingAskUserQuestion( + event=question, + envelope={ + "eventType": "input_required", + "scope": "step", + "input": {"inputId": "ask-ask-1"}, + "step": {"runId": "step-intent_parsing-1", "id": "intent_parsing"}, + }, + ) + executor = IacCodeA2APipelineExecutor( + task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), + model="qwen3.6-plus", + metrics=NoOpA2AMetrics(), + artifact_store=None, + push_notifier=None, + permission_resolver=None, + auto_approve_permissions=False, + thinking_exposure_types=None, + ) + + routed = await executor._route_pending_question_answer(runtime, "Nginx 网站") + + assert routed == "not_routed" + assert future.done() is False + assert runtime.pending_question is not None + + +@pytest.mark.asyncio +async def test_active_task_route_does_not_treat_finished_pending_question_as_interrupt( + tmp_path: Path, +) -> None: + from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator + from iac_code.a2a.pipeline_executor import ( + A2APipelineRuntime, + IacCodeA2APipelineExecutor, + _PendingAskUserQuestion, + ) + from iac_code.a2a.pipeline_journal import A2APipelineJournal + from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore + from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher + + class InterruptRecordingPipeline(FakePipeline): + def __init__(self, *, session_dir: Path) -> None: + super().__init__([], session_dir=session_dir) + self.interrupts: list[str] = [] + + async def handle_user_interrupt(self, message: str) -> SimpleNamespace: + self.interrupts.append(message) + return SimpleNamespace(action="supplement", reason="wrong route") + + future: asyncio.Future[dict[str, str] | None] = asyncio.get_running_loop().create_future() + future.set_result(None) + question = AskUserQuestionEvent( + tool_use_id="ask-1", + question="请选择部署目标", + options=[{"id": "nginx", "label": "Nginx 网站"}], + response_future=future, + ) + publisher = PipelineA2AEventPublisher( + event_queue=FakeEventQueue(), + translator=PipelineEventTranslator( + PipelineA2AContext( + pipeline_run_id="ctx-1", + task_id="task-1", + context_id="ctx-1", + pipeline_name="selling", + ) + ), + journal=A2APipelineJournal(tmp_path / "pipeline"), + snapshot_store=A2APipelineSnapshotStore(tmp_path / "pipeline"), + ) + runtime = A2APipelineRuntime( + agent_runtime=_fake_runtime(), + pipeline=InterruptRecordingPipeline(session_dir=tmp_path / "sidecar"), + publisher=publisher, + ) + runtime.pending_question = _PendingAskUserQuestion( + event=question, + envelope={ + "eventType": "input_required", + "scope": "step", + "input": {"inputId": "ask-ask-1"}, + "step": {"runId": "step-intent_parsing-1", "id": "intent_parsing"}, + }, + ) + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + task = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + ctx = await store.get_or_create_context( + context_id="ctx-1", + cwd=str(tmp_path), + runtime_factory=lambda _session_id: _fake_runtime(), + ) + ctx.runtime = runtime + ctx.active_task_id = "task-1" + executor = IacCodeA2APipelineExecutor( + task_store=store, + model="qwen3.6-plus", + metrics=NoOpA2AMetrics(), + artifact_store=None, + push_notifier=None, + permission_resolver=None, + auto_approve_permissions=False, + thinking_exposure_types=None, + ) + + routed = await executor._route_active_pipeline_interrupt( + FakeEventQueue(), + task=task, + ctx=ctx, + task_id="task-1", + context_id="ctx-1", + cwd=str(tmp_path), + pipeline_input="Nginx 网站", + preserve_task_record=True, + ) + + assert routed is True + assert runtime.pipeline.interrupts == [] + + +@pytest.mark.asyncio +async def test_active_task_route_answers_pending_question_without_marking_input_required( + tmp_path: Path, +) -> None: + from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator + from iac_code.a2a.pipeline_executor import ( + A2APipelineRuntime, + IacCodeA2APipelineExecutor, + _PendingAskUserQuestion, + ) + from iac_code.a2a.pipeline_journal import A2APipelineJournal + from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore + from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher + + class InterruptRecordingPipeline(FakePipeline): + def __init__(self, *, session_dir: Path) -> None: + super().__init__([], session_dir=session_dir) + self.interrupts: list[str] = [] + + async def handle_user_interrupt(self, message: str) -> SimpleNamespace: + self.interrupts.append(message) + return SimpleNamespace(action="supplement", reason="wrong route") + + future: asyncio.Future[dict[str, str] | None] = asyncio.get_running_loop().create_future() + question = AskUserQuestionEvent( + tool_use_id="ask-1", + question="请选择部署目标", + options=[{"id": "nginx", "label": "Nginx 网站"}], + response_future=future, + ) + publisher = PipelineA2AEventPublisher( + event_queue=FakeEventQueue(), + translator=PipelineEventTranslator( PipelineA2AContext( pipeline_run_id="ctx-1", task_id="task-1", @@ -3381,10 +5456,30 @@ async def handle_user_interrupt(self, message: str) -> SimpleNamespace: journal=A2APipelineJournal(tmp_path / "pipeline"), snapshot_store=A2APipelineSnapshotStore(tmp_path / "pipeline"), ) - ctx.runtime = A2APipelineRuntime(agent_runtime=_fake_runtime(), pipeline=pipeline, publisher=publisher) - store.mirror_task(task) - store.mirror_context(ctx) - + runtime = A2APipelineRuntime( + agent_runtime=_fake_runtime(), + pipeline=InterruptRecordingPipeline(session_dir=tmp_path / "sidecar"), + publisher=publisher, + ) + runtime.pending_question = _PendingAskUserQuestion( + event=question, + envelope={ + "eventType": "input_required", + "scope": "step", + "input": {"inputId": "ask-ask-1"}, + "step": {"runId": "step-intent_parsing-1", "id": "intent_parsing"}, + }, + ) + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + task = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + task.state = "input-required" + ctx = await store.get_or_create_context( + context_id="ctx-1", + cwd=str(tmp_path), + runtime_factory=lambda _session_id: _fake_runtime(), + ) + ctx.runtime = runtime + ctx.active_task_id = "task-1" executor = IacCodeA2APipelineExecutor( task_store=store, model="qwen3.6-plus", @@ -3395,690 +5490,743 @@ async def handle_user_interrupt(self, message: str) -> SimpleNamespace: auto_approve_permissions=False, thinking_exposure_types=None, ) - interrupt_task = asyncio.create_task( - executor.execute( - context=FakeRequestContext(text="hello", metadata={"iac_code": {"cwd": str(tmp_path)}}), - event_queue=FakeEventQueue(), - task=task, - task_id="task-1", - context_id="ctx-1", - cwd=str(tmp_path), - prompt="hello", - ) + + routed = await executor._route_active_pipeline_interrupt( + FakeEventQueue(), + task=task, + ctx=ctx, + task_id="task-1", + context_id="ctx-1", + cwd=str(tmp_path), + pipeline_input="Nginx 网站", + preserve_task_record=True, + ) + + assert routed is True + assert runtime.pipeline.interrupts == [] + assert future.result()["selected_id"] == "nginx" + assert runtime.pending_question is None + assert task.state == "working" + + +@pytest.mark.asyncio +async def test_executor_routes_running_sidecar_pending_ask_to_ask_resume( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from iac_code.services.providers.aliyun import AliyunCredentials + + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + captured_resume_credentials: list[str | None] = [] + + class AskResumePipeline(FakePipeline): + def __init__(self, *, session_dir: Path) -> None: + super().__init__( + [ + TextDeltaEvent(text="nginx selected"), + PipelineEvent( + type=PipelineEventType.PIPELINE_COMPLETED, + step_id=None, + timestamp=1717821601.0, + data={}, + ), + ], + session_dir=session_dir, + ) + self.ask_answers: list[dict[str, str]] = [] + self.pending_inputs: list[dict[str, object] | None] = [] + + async def resume_ask_user_question( + self, + answer: dict[str, str], + *, + tool_use_id: str, + pending_input: dict[str, object] | None = None, + ): + credential = AliyunCredentials.load() + captured_resume_credentials.append(credential.access_key_id if credential else None) + self.ask_answers.append(answer) + self.pending_inputs.append(pending_input) + assert tool_use_id == "ask-1" + for event in self.events: + yield event + + session_dir = tmp_path / "sidecar" + pending = { + "schemaVersion": "1.0", + "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", + "eventId": "evt-ask", + "sequence": 1, + "createdAt": "2026-06-08T10:00:00Z", + "eventType": "input_required", + "scope": "candidate_step", + "pipelineRunId": "ctx-1", + "taskId": "task-1", + "contextId": "ctx-1", + "pipelineName": "selling", + "status": "input_required", + "step": {"runId": "step-intent_parsing-1", "id": "intent_parsing", "attempt": 1}, + "candidate": {"runId": "candidate-evaluate_candidate-0-1", "id": "evaluate_candidate", "index": 0}, + "candidateStep": { + "runId": "candidate-evaluate_candidate-0-1-template_generating-1", + "id": "template_generating", + }, + "data": {"kind": "ask_user_question", "toolUseId": "ask-1"}, + "input": { + "inputId": "ask-ask-1", + "kind": "ask_user_question", + "toolUseId": "ask-1", + "question": "请选择部署目标", + "options": [{"id": "nginx", "label": "Nginx 网站"}], + "allowFreeText": True, + }, + } + A2APipelineJournal(session_dir).append(pending) + A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([pending])) + fake_pipeline = AskResumePipeline(session_dir=session_dir) + fake_pipeline.sidecar_status = "running" + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + monkeypatch.setattr("iac_code.tools.cloud.registry.register_cloud_tools", lambda *args, **kwargs: None) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + queue = FakeEventQueue() + + await executor.execute( + FakeRequestContext( + text="Nginx 网站", + metadata={ + "iac_code": { + "cwd": str(tmp_path), + "alibaba_cloud_access_key_id": "client-id", + "alibaba_cloud_access_key_secret": "client-secret", + "alibaba_cloud_region_id": "cn-beijing", + } + }, + ), + queue, + ) + + assert fake_pipeline.continue_calls == 0 + assert fake_pipeline.ask_answers == [{"selected_id": "nginx", "selected_label": "Nginx 网站", "free_text": ""}] + assert captured_resume_credentials == ["client-id"] + assert fake_pipeline.pending_inputs[0]["candidate"] == { + "runId": "candidate-evaluate_candidate-0-1", + "id": "evaluate_candidate", + "index": 0, + } + assert fake_pipeline.pending_inputs[0]["candidateStep"] == { + "runId": "candidate-evaluate_candidate-0-1-template_generating-1", + "id": "template_generating", + } + task_record = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + assert "nginx selected" in "".join(task_record.output_text) + assert "input_received" in [event["eventType"] for event in A2APipelineJournal(session_dir).read_all()] + + +@pytest.mark.asyncio +async def test_executor_routes_waiting_input_sidecar_pending_ask_to_ask_resume( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + + class AskResumePipeline(FakePipeline): + def __init__(self, *, session_dir: Path) -> None: + super().__init__( + [ + TextDeltaEvent(text="nginx selected"), + PipelineEvent( + type=PipelineEventType.PIPELINE_COMPLETED, + step_id=None, + timestamp=1717821601.0, + data={}, + ), + ], + session_dir=session_dir, + ) + self.ask_answers: list[dict[str, str]] = [] + + async def resume_ask_user_question(self, answer: dict[str, str], *, tool_use_id: str): + self.ask_answers.append(answer) + assert tool_use_id == "ask-1" + for event in self.events: + yield event + + session_dir = tmp_path / "sidecar" + pending = { + "schemaVersion": "1.0", + "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", + "eventId": "evt-ask", + "sequence": 1, + "createdAt": "2026-06-08T10:00:00Z", + "eventType": "input_required", + "scope": "step", + "pipelineRunId": "ctx-1", + "taskId": "task-1", + "contextId": "ctx-1", + "pipelineName": "selling", + "status": "input_required", + "step": {"runId": "step-intent_parsing-1", "id": "intent_parsing", "attempt": 1}, + "data": {"kind": "ask_user_question", "toolUseId": "ask-1"}, + "input": { + "inputId": "ask-ask-1", + "kind": "ask_user_question", + "toolUseId": "ask-1", + "question": "请选择部署目标", + "options": [{"id": "nginx", "label": "Nginx 网站"}], + "allowFreeText": True, + }, + } + A2APipelineJournal(session_dir).append(pending) + A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([pending])) + fake_pipeline = AskResumePipeline(session_dir=session_dir) + fake_pipeline.sidecar_status = "waiting_input" + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + queue = FakeEventQueue() + + await executor.execute( + FakeRequestContext( + text="Nginx 网站", + metadata={"iac_code": {"cwd": str(tmp_path)}}, + ), + queue, ) - try: - await asyncio.wait_for(pipeline.judge_started.wait(), timeout=1) - assert [event["eventType"] for event in publisher.journal.read_all()] == ["interrupt_received"] - finally: - pipeline.finish_judge.set() - await asyncio.wait_for(interrupt_task, timeout=1) - assert [event["eventType"] for event in publisher.journal.read_all()] == [ - "interrupt_received", - "interrupt_classified", - ] + assert fake_pipeline.resume_prompts == [] + assert fake_pipeline.ask_answers == [{"selected_id": "nginx", "selected_label": "Nginx 网站", "free_text": ""}] + task_record = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + assert "nginx selected" in "".join(task_record.output_text) + assert "input_received" in [event["eventType"] for event in A2APipelineJournal(session_dir).read_all()] @pytest.mark.asyncio -async def test_pipeline_executor_stops_at_ask_user_question_without_holding_active_task( +async def test_executor_routes_waiting_input_sidecar_by_context_when_task_id_is_missing( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - from iac_code.a2a.pipeline_executor import IacCodeA2APipelineExecutor + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") - class AskingPipeline(FakePipeline): + class AskResumePipeline(FakePipeline): def __init__(self, *, session_dir: Path) -> None: - super().__init__([], session_dir=session_dir) - self.question_ready = asyncio.Event() - self.answers: list[dict[str, str] | None] = [] - self.future: asyncio.Future[dict[str, str] | None] | None = None - self.closed = asyncio.Event() - - async def run(self, prompt: str): - self.run_prompts.append(prompt) - self.future = asyncio.get_running_loop().create_future() - self.question_ready.set() - answer = None - try: - yield AskUserQuestionEvent( - tool_use_id="ask-1", - question="请选择部署目标", - options=[ - {"id": "nginx", "label": "Nginx 网站"}, - {"id": "ecs", "label": "ECS 应用"}, - ], - allow_free_text=True, - free_text_prompt="也可以直接描述目标", - response_future=self.future, - ) - answer = await self.future - self.answers.append(answer) - yield TextDeltaEvent(text=answer["selected_id"] if answer else "cancelled") - yield PipelineEvent( - type=PipelineEventType.PIPELINE_COMPLETED, - step_id=None, - timestamp=1717821602.0, - data={}, - ) - finally: - self.closed.set() - if answer is None and self.future is not None and not self.future.done(): - self.future.set_result(None) - - pipeline = AskingPipeline(session_dir=tmp_path / "sidecar") - monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: pipeline) - monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) - - store = A2ATaskStore(metrics=NoOpA2AMetrics()) - active_task = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") - executor = IacCodeA2APipelineExecutor( - task_store=store, - model="qwen3.6-plus", - metrics=NoOpA2AMetrics(), - artifact_store=None, - push_notifier=None, - permission_resolver=None, - auto_approve_permissions=False, - thinking_exposure_types=None, - ) - queue = FakeEventQueue() - runner = asyncio.create_task( - executor.execute( - context=FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), - event_queue=queue, - task=active_task, - task_id="task-1", - context_id="ctx-1", - cwd=str(tmp_path), - prompt="帮我部署网站", - ) - ) - await asyncio.wait_for(pipeline.question_ready.wait(), timeout=1) - await _wait_for_pipeline_event(queue, "input_required") + super().__init__( + [ + TextDeltaEvent(text="nginx selected"), + PipelineEvent( + type=PipelineEventType.PIPELINE_COMPLETED, + step_id=None, + timestamp=1717821601.0, + data={}, + ), + ], + session_dir=session_dir, + ) + self.ask_answers: list[dict[str, str]] = [] - await asyncio.wait_for(runner, timeout=1) - await asyncio.wait_for(pipeline.closed.wait(), timeout=1) + async def resume_ask_user_question(self, answer: dict[str, str], *, tool_use_id: str): + self.ask_answers.append(answer) + assert tool_use_id == "ask-1" + for event in self.events: + yield event - ctx = await store.get_or_create_context( - context_id="ctx-1", - cwd=str(tmp_path), - runtime_factory=lambda _session_id: _fake_runtime(), - ) - assert ctx.active_task_id is None - assert active_task.state == "input-required" - assert pipeline.answers == [] - assert "".join(active_task.output_text) == "" - event_types = [ - dump(event)["metadata"]["iac_code"]["pipeline"]["eventType"] - for event in queue.events - if isinstance(event, TaskStatusUpdateEvent) - and "pipeline" in dump(event).get("metadata", {}).get("iac_code", {}) - ] - assert event_types == ["input_required"] + from iac_code.a2a.persistence import A2AContextSnapshot, A2APersistenceStore + from iac_code.a2a.pipeline_paths import a2a_pipeline_dir_for_session + persistence = A2APersistenceStore(tmp_path / "a2a") + session_id = "session-ctx-1" + persistence.save_context(A2AContextSnapshot(context_id="ctx-1", session_id=session_id, cwd=str(tmp_path))) + session_dir = a2a_pipeline_dir_for_session(cwd=str(tmp_path), session_id=session_id) + pending = { + "schemaVersion": "1.0", + "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", + "eventId": "evt-ask", + "sequence": 1, + "createdAt": "2026-06-08T10:00:00Z", + "eventType": "input_required", + "scope": "step", + "pipelineRunId": "ctx-1", + "taskId": "task-1", + "contextId": "ctx-1", + "pipelineName": "selling", + "status": "input_required", + "step": {"runId": "step-intent_parsing-1", "id": "intent_parsing", "attempt": 1}, + "data": {"kind": "ask_user_question", "toolUseId": "ask-1"}, + "input": { + "inputId": "ask-ask-1", + "kind": "ask_user_question", + "toolUseId": "ask-1", + "question": "请选择部署目标", + "options": [{"id": "nginx", "label": "Nginx 网站"}], + "allowFreeText": True, + }, + } + A2APipelineJournal(session_dir).append(pending) + A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([pending])) + fake_pipeline = AskResumePipeline(session_dir=session_dir) + fake_pipeline.session = SimpleNamespace() + fake_pipeline.sidecar_status = "waiting_input" + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) -def test_ask_user_question_answer_accepts_one_based_option_index() -> None: - from iac_code.a2a.pipeline_executor import _ask_user_question_answer_from_prompt + store = A2ATaskStore(metrics=NoOpA2AMetrics(), persistence=persistence) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") - answer = _ask_user_question_answer_from_prompt( - AskUserQuestionEvent( - tool_use_id="ask-1", - question="请选择部署目标", - options=[ - {"id": "nginx", "label": "Nginx 网站"}, - {"id": "ecs", "label": "ECS 应用"}, - ], + await executor.execute( + FakeRequestContext( + task_id=None, + context_id="ctx-1", + text="Nginx 网站", + metadata={"iac_code": {"cwd": str(tmp_path)}}, ), - "1", + FakeEventQueue(), ) - assert answer == {"selected_id": "nginx", "selected_label": "Nginx 网站", "free_text": ""} - + assert fake_pipeline.clear_sidecar_calls == 0 + assert fake_pipeline.resume_prompts == [] + assert fake_pipeline.ask_answers == [{"selected_id": "nginx", "selected_label": "Nginx 网站", "free_text": ""}] + task_record = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + assert "nginx selected" in "".join(task_record.output_text) -@pytest.mark.asyncio -async def test_pipeline_executor_does_not_resolve_pending_question_when_input_received_publish_fails( - tmp_path: Path, -) -> None: - from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator - from iac_code.a2a.pipeline_executor import ( - A2APipelineRuntime, - IacCodeA2APipelineExecutor, - _PendingAskUserQuestion, - ) - from iac_code.a2a.pipeline_journal import A2APipelineJournal - from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore - from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher - future: asyncio.Future[dict[str, str] | None] = asyncio.get_running_loop().create_future() - question = AskUserQuestionEvent( - tool_use_id="ask-1", - question="请选择部署目标", - options=[{"id": "nginx", "label": "Nginx 网站"}], - response_future=future, - ) - publisher = PipelineA2AEventPublisher( - event_queue=FakeEventQueue(), - translator=PipelineEventTranslator( - PipelineA2AContext( - pipeline_run_id="ctx-1", - task_id="task-1", - context_id="ctx-1", - pipeline_name="selling", - ) - ), - journal=A2APipelineJournal(tmp_path / "pipeline"), - snapshot_store=A2APipelineSnapshotStore(tmp_path / "pipeline"), - ) - publisher.publish_manual = AsyncMock(return_value=None) # type: ignore[method-assign] - runtime = A2APipelineRuntime(agent_runtime=_fake_runtime(), publisher=publisher) - runtime.pending_question = _PendingAskUserQuestion( - event=question, - envelope={ - "eventType": "input_required", - "scope": "step", - "input": {"inputId": "ask-ask-1"}, - "step": {"runId": "step-intent_parsing-1", "id": "intent_parsing"}, - }, - ) - executor = IacCodeA2APipelineExecutor( - task_store=A2ATaskStore(metrics=NoOpA2AMetrics()), - model="qwen3.6-plus", - metrics=NoOpA2AMetrics(), - artifact_store=None, - push_notifier=None, - permission_resolver=None, - auto_approve_permissions=False, - thinking_exposure_types=None, - ) +def test_waiting_input_task_id_from_sidecar_accepts_candidate_selection(tmp_path: Path) -> None: + from iac_code.a2a.pipeline_executor import waiting_input_task_id_from_sidecar + from iac_code.a2a.pipeline_paths import a2a_pipeline_dir_for_session - routed = await executor._route_pending_question_answer(runtime, "Nginx 网站") + cwd = tmp_path / "workspace" + session_id = "session-ctx-1" + context_id = "ctx-1" + session_dir = a2a_pipeline_dir_for_session(cwd=str(cwd), session_id=session_id) + pending = { + "schemaVersion": "1.0", + "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", + "eventId": "evt-selection", + "sequence": 1, + "createdAt": "2026-06-08T10:00:00Z", + "eventType": "input_required", + "scope": "step", + "pipelineRunId": context_id, + "taskId": "task-1", + "contextId": context_id, + "pipelineName": "selling", + "status": "input_required", + "step": {"runId": "step-confirm_and_select-1", "id": "confirm_and_select", "attempt": 1}, + "input": { + "inputId": "input-confirm_and_select-1", + "kind": "candidate_selection", + "prompt": "请选择方案", + "options": [{"name": "方案A", "candidate_index": 0}], + }, + } + A2APipelineJournal(session_dir).append(pending) + A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([pending])) - assert routed == "not_routed" - assert future.done() is False - assert runtime.pending_question is not None + assert waiting_input_task_id_from_sidecar(cwd=str(cwd), session_id=session_id, context_id=context_id) == "task-1" -@pytest.mark.asyncio -async def test_active_task_route_does_not_treat_finished_pending_question_as_interrupt( +def test_cancel_waiting_input_sidecar_appends_cancel_handoff_as_durable_group( tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: - from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator from iac_code.a2a.pipeline_executor import ( - A2APipelineRuntime, - IacCodeA2APipelineExecutor, - _PendingAskUserQuestion, + WaitingInputCancelResult, + cancel_waiting_input_task_from_sidecar, + terminal_task_state_from_sidecar, ) - from iac_code.a2a.pipeline_journal import A2APipelineJournal - from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore - from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher + from iac_code.a2a.pipeline_paths import a2a_pipeline_dir_for_session - class InterruptRecordingPipeline(FakePipeline): - def __init__(self, *, session_dir: Path) -> None: - super().__init__([], session_dir=session_dir) - self.interrupts: list[str] = [] + cwd = tmp_path / "workspace" + session_id = "session-ctx-1" + context_id = "ctx-1" + pipeline_dir = a2a_pipeline_dir_for_session(cwd=str(cwd), session_id=session_id) + pending = { + "schemaVersion": "1.0", + "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", + "eventId": "evt-selection", + "sequence": 1, + "createdAt": "2026-06-08T10:00:00Z", + "eventType": "input_required", + "scope": "step", + "pipelineRunId": context_id, + "taskId": "task-1", + "contextId": context_id, + "pipelineName": "selling", + "status": "input_required", + "step": {"runId": "step-confirm_and_select-1", "id": "confirm_and_select", "attempt": 1}, + "input": { + "inputId": "input-confirm_and_select-1", + "kind": "candidate_selection", + "prompt": "请选择方案", + "options": [{"name": "方案A", "candidate_index": 0}], + }, + } + A2APipelineJournal(pipeline_dir).append(pending) + A2APipelineSnapshotStore(pipeline_dir).save(reduce_pipeline_events([pending])) + append_many_calls = [] + original_append_many = A2APipelineJournal.append_many - async def handle_user_interrupt(self, message: str) -> SimpleNamespace: - self.interrupts.append(message) - return SimpleNamespace(action="supplement", reason="wrong route") + def recording_append_many(self, events, durable: bool = False): + append_many_calls.append(([event["eventType"] for event in events], durable)) + return original_append_many(self, events, durable=durable) - future: asyncio.Future[dict[str, str] | None] = asyncio.get_running_loop().create_future() - future.set_result(None) - question = AskUserQuestionEvent( - tool_use_id="ask-1", - question="请选择部署目标", - options=[{"id": "nginx", "label": "Nginx 网站"}], - response_future=future, - ) - publisher = PipelineA2AEventPublisher( - event_queue=FakeEventQueue(), - translator=PipelineEventTranslator( - PipelineA2AContext( - pipeline_run_id="ctx-1", - task_id="task-1", - context_id="ctx-1", - pipeline_name="selling", - ) - ), - journal=A2APipelineJournal(tmp_path / "pipeline"), - snapshot_store=A2APipelineSnapshotStore(tmp_path / "pipeline"), - ) - runtime = A2APipelineRuntime( - agent_runtime=_fake_runtime(), - pipeline=InterruptRecordingPipeline(session_dir=tmp_path / "sidecar"), - publisher=publisher, - ) - runtime.pending_question = _PendingAskUserQuestion( - event=question, - envelope={ - "eventType": "input_required", - "scope": "step", - "input": {"inputId": "ask-ask-1"}, - "step": {"runId": "step-intent_parsing-1", "id": "intent_parsing"}, - }, - ) - store = A2ATaskStore(metrics=NoOpA2AMetrics()) - task = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") - ctx = await store.get_or_create_context( - context_id="ctx-1", - cwd=str(tmp_path), - runtime_factory=lambda _session_id: _fake_runtime(), - ) - ctx.runtime = runtime - ctx.active_task_id = "task-1" - executor = IacCodeA2APipelineExecutor( - task_store=store, - model="qwen3.6-plus", - metrics=NoOpA2AMetrics(), - artifact_store=None, - push_notifier=None, - permission_resolver=None, - auto_approve_permissions=False, - thinking_exposure_types=None, - ) + monkeypatch.setattr(A2APipelineJournal, "append_many", recording_append_many) - routed = await executor._route_active_pipeline_interrupt( - FakeEventQueue(), - task=task, - ctx=ctx, + canceled = cancel_waiting_input_task_from_sidecar( + cwd=str(cwd), + session_id=session_id, + context_id=context_id, task_id="task-1", - context_id="ctx-1", - cwd=str(tmp_path), - pipeline_input="Nginx 网站", - preserve_task_record=True, + reason="user canceled", ) - assert routed is True - assert runtime.pipeline.interrupts == [] + assert canceled == WaitingInputCancelResult.CANCELED + assert append_many_calls[-2:] == [ + (["pipeline_canceled", "pipeline_handoff_ready"], True), + (["backup_committed", "backup_committed"], True), + ] + events = A2APipelineJournal(pipeline_dir).read_all() + assert [event["eventType"] for event in events[-4:]] == [ + "pipeline_canceled", + "pipeline_handoff_ready", + "backup_committed", + "backup_committed", + ] + assert ( + terminal_task_state_from_sidecar( + cwd=str(cwd), + session_id=session_id, + context_id=context_id, + task_id="task-1", + ) + == "canceled" + ) @pytest.mark.asyncio -async def test_active_task_route_answers_pending_question_without_marking_input_required( +async def test_cancel_waiting_input_backup_sees_committed_cancel_and_mirrored_task( tmp_path: Path, ) -> None: - from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator - from iac_code.a2a.pipeline_executor import ( - A2APipelineRuntime, - IacCodeA2APipelineExecutor, - _PendingAskUserQuestion, - ) - from iac_code.a2a.pipeline_journal import A2APipelineJournal - from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore - from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher - - class InterruptRecordingPipeline(FakePipeline): - def __init__(self, *, session_dir: Path) -> None: - super().__init__([], session_dir=session_dir) - self.interrupts: list[str] = [] - - async def handle_user_interrupt(self, message: str) -> SimpleNamespace: - self.interrupts.append(message) - return SimpleNamespace(action="supplement", reason="wrong route") + from iac_code.a2a.pipeline_executor import WaitingInputCancelResult, cancel_waiting_input_task_from_sidecar + from iac_code.a2a.pipeline_paths import a2a_pipeline_dir_for_session - future: asyncio.Future[dict[str, str] | None] = asyncio.get_running_loop().create_future() - question = AskUserQuestionEvent( - tool_use_id="ask-1", - question="请选择部署目标", - options=[{"id": "nginx", "label": "Nginx 网站"}], - response_future=future, - ) - publisher = PipelineA2AEventPublisher( - event_queue=FakeEventQueue(), - translator=PipelineEventTranslator( - PipelineA2AContext( - pipeline_run_id="ctx-1", - task_id="task-1", - context_id="ctx-1", - pipeline_name="selling", + class InspectingBackupService: + def backup_session(self, cwd_arg, session_id_arg, *, reason, critical) -> BackupResult: + assert (cwd_arg, session_id_arg, reason, critical) == ( + str(cwd), + session_id, + BackupReason.TERMINAL, + True, ) - ), - journal=A2APipelineJournal(tmp_path / "pipeline"), - snapshot_store=A2APipelineSnapshotStore(tmp_path / "pipeline"), - ) - runtime = A2APipelineRuntime( - agent_runtime=_fake_runtime(), - pipeline=InterruptRecordingPipeline(session_dir=tmp_path / "sidecar"), - publisher=publisher, - ) - runtime.pending_question = _PendingAskUserQuestion( - event=question, - envelope={ - "eventType": "input_required", - "scope": "step", - "input": {"inputId": "ask-ask-1"}, - "step": {"runId": "step-intent_parsing-1", "id": "intent_parsing"}, - }, - ) + events = A2APipelineJournal(pipeline_dir).read_all() + assert [event.get("visibility") for event in events[-2:]] == ["committed", "committed"] + task_snapshot = json.loads((session_dir / "a2a" / "task.json").read_text(encoding="utf-8")) + context_snapshot = json.loads((session_dir / "a2a" / "context.json").read_text(encoding="utf-8")) + assert task_snapshot["state"] == "input-required" + assert context_snapshot["active_task_id"] == "task-1" + return BackupResult(enabled=True, retry_count=1) + + cwd = tmp_path / "workspace" + context_id = "ctx-1" store = A2ATaskStore(metrics=NoOpA2AMetrics()) - task = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") - task.state = "input-required" - ctx = await store.get_or_create_context( - context_id="ctx-1", - cwd=str(tmp_path), + task_record = await store.get_or_create_task(task_id="task-1", context_id=context_id) + task_record.state = "input-required" + store.mirror_task(task_record) + context_record = await store.get_or_create_context( + context_id=context_id, + cwd=str(cwd), runtime_factory=lambda _session_id: _fake_runtime(), ) - ctx.runtime = runtime - ctx.active_task_id = "task-1" - executor = IacCodeA2APipelineExecutor( - task_store=store, - model="qwen3.6-plus", - metrics=NoOpA2AMetrics(), - artifact_store=None, - push_notifier=None, - permission_resolver=None, - auto_approve_permissions=False, - thinking_exposure_types=None, - ) + context_record.active_task_id = "task-1" + store.mirror_context(context_record) + session_id = context_record.session_id + session_dir = SessionStorage().session_dir(str(cwd), session_id) + pipeline_dir = a2a_pipeline_dir_for_session(cwd=str(cwd), session_id=session_id) + pending = { + "schemaVersion": "1.0", + "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", + "eventId": "evt-selection", + "sequence": 1, + "createdAt": "2026-06-08T10:00:00Z", + "eventType": "input_required", + "scope": "step", + "pipelineRunId": context_id, + "taskId": "task-1", + "contextId": context_id, + "pipelineName": "selling", + "status": "input_required", + "step": {"runId": "step-confirm_and_select-1", "id": "confirm_and_select", "attempt": 1}, + "input": { + "inputId": "input-confirm_and_select-1", + "kind": "candidate_selection", + "prompt": "请选择方案", + "options": [{"name": "方案A", "candidate_index": 0}], + }, + } + A2APipelineJournal(pipeline_dir).append(pending) + A2APipelineSnapshotStore(pipeline_dir).save(reduce_pipeline_events([pending])) + metrics = SpyMetrics() - routed = await executor._route_active_pipeline_interrupt( - FakeEventQueue(), - task=task, - ctx=ctx, + canceled = cancel_waiting_input_task_from_sidecar( + cwd=str(cwd), + session_id=session_id, + context_id=context_id, task_id="task-1", - context_id="ctx-1", - cwd=str(tmp_path), - pipeline_input="Nginx 网站", - preserve_task_record=True, + reason="user canceled", + backup_service=InspectingBackupService(), + task_store=store, + task_record=task_record, + context_record=context_record, + metrics=metrics, ) - assert routed is True - assert runtime.pipeline.interrupts == [] - assert future.result()["selected_id"] == "nginx" - assert runtime.pending_question is None - assert task.state == "working" + assert canceled == WaitingInputCancelResult.CANCELED + assert metrics.backup_succeeded == [(BackupReason.TERMINAL.value, True, 1)] + assert metrics.backup_failed == [] -@pytest.mark.asyncio -async def test_executor_routes_running_sidecar_pending_ask_to_ask_resume( - monkeypatch: pytest.MonkeyPatch, +def test_cancel_waiting_input_sidecar_returns_false_when_durable_group_fails( tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: - from iac_code.services.providers.aliyun import AliyunCredentials - - monkeypatch.setenv("IAC_CODE_MODE", "pipeline") - captured_resume_credentials: list[str | None] = [] - - class AskResumePipeline(FakePipeline): - def __init__(self, *, session_dir: Path) -> None: - super().__init__( - [ - TextDeltaEvent(text="nginx selected"), - PipelineEvent( - type=PipelineEventType.PIPELINE_COMPLETED, - step_id=None, - timestamp=1717821601.0, - data={}, - ), - ], - session_dir=session_dir, - ) - self.ask_answers: list[dict[str, str]] = [] - self.pending_inputs: list[dict[str, object] | None] = [] - - async def resume_ask_user_question( - self, - answer: dict[str, str], - *, - tool_use_id: str, - pending_input: dict[str, object] | None = None, - ): - credential = AliyunCredentials.load() - captured_resume_credentials.append(credential.access_key_id if credential else None) - self.ask_answers.append(answer) - self.pending_inputs.append(pending_input) - assert tool_use_id == "ask-1" - for event in self.events: - yield event + from iac_code.a2a.pipeline_executor import WaitingInputCancelResult, cancel_waiting_input_task_from_sidecar + from iac_code.a2a.pipeline_paths import a2a_pipeline_dir_for_session - session_dir = tmp_path / "sidecar" + cwd = tmp_path / "workspace" + session_id = "session-ctx-1" + context_id = "ctx-1" + pipeline_dir = a2a_pipeline_dir_for_session(cwd=str(cwd), session_id=session_id) pending = { "schemaVersion": "1.0", "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", - "eventId": "evt-ask", + "eventId": "evt-selection", "sequence": 1, "createdAt": "2026-06-08T10:00:00Z", "eventType": "input_required", - "scope": "candidate_step", - "pipelineRunId": "ctx-1", + "scope": "step", + "pipelineRunId": context_id, "taskId": "task-1", - "contextId": "ctx-1", + "contextId": context_id, "pipelineName": "selling", "status": "input_required", - "step": {"runId": "step-intent_parsing-1", "id": "intent_parsing", "attempt": 1}, - "candidate": {"runId": "candidate-evaluate_candidate-0-1", "id": "evaluate_candidate", "index": 0}, - "candidateStep": { - "runId": "candidate-evaluate_candidate-0-1-template_generating-1", - "id": "template_generating", - }, - "data": {"kind": "ask_user_question", "toolUseId": "ask-1"}, + "step": {"runId": "step-confirm_and_select-1", "id": "confirm_and_select", "attempt": 1}, "input": { - "inputId": "ask-ask-1", - "kind": "ask_user_question", - "toolUseId": "ask-1", - "question": "请选择部署目标", - "options": [{"id": "nginx", "label": "Nginx 网站"}], - "allowFreeText": True, + "inputId": "input-confirm_and_select-1", + "kind": "candidate_selection", + "prompt": "请选择方案", + "options": [{"name": "方案A", "candidate_index": 0}], }, } - A2APipelineJournal(session_dir).append(pending) - A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([pending])) - fake_pipeline = AskResumePipeline(session_dir=session_dir) - fake_pipeline.sidecar_status = "running" - monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) - monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) - monkeypatch.setattr("iac_code.tools.cloud.registry.register_cloud_tools", lambda *args, **kwargs: None) + A2APipelineJournal(pipeline_dir).append(pending) + A2APipelineSnapshotStore(pipeline_dir).save(reduce_pipeline_events([pending])) - store = A2ATaskStore(metrics=NoOpA2AMetrics()) - executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") - queue = FakeEventQueue() + def fail_append_many(self, events, durable: bool = False): + assert durable is True + assert [event["eventType"] for event in events] == ["pipeline_canceled", "pipeline_handoff_ready"] + raise OSError("journal locked") - await executor.execute( - FakeRequestContext( - text="Nginx 网站", - metadata={ - "iac_code": { - "cwd": str(tmp_path), - "alibaba_cloud_access_key_id": "client-id", - "alibaba_cloud_access_key_secret": "client-secret", - "alibaba_cloud_region_id": "cn-beijing", - } - }, - ), - queue, + monkeypatch.setattr(A2APipelineJournal, "append_many", fail_append_many) + + canceled = cancel_waiting_input_task_from_sidecar( + cwd=str(cwd), + session_id=session_id, + context_id=context_id, + task_id="task-1", + reason="user canceled", ) - assert fake_pipeline.continue_calls == 0 - assert fake_pipeline.ask_answers == [{"selected_id": "nginx", "selected_label": "Nginx 网站", "free_text": ""}] - assert captured_resume_credentials == ["client-id"] - assert fake_pipeline.pending_inputs[0]["candidate"] == { - "runId": "candidate-evaluate_candidate-0-1", - "id": "evaluate_candidate", - "index": 0, - } - assert fake_pipeline.pending_inputs[0]["candidateStep"] == { - "runId": "candidate-evaluate_candidate-0-1-template_generating-1", - "id": "template_generating", - } - task_record = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") - assert "nginx selected" in "".join(task_record.output_text) - assert "input_received" in [event["eventType"] for event in A2APipelineJournal(session_dir).read_all()] + assert canceled == WaitingInputCancelResult.PERSIST_FAILED + assert [event["eventType"] for event in A2APipelineJournal(pipeline_dir).read_all()] == ["input_required"] + snapshot = A2APipelineSnapshotStore(pipeline_dir).load() + assert snapshot is not None + assert snapshot["status"] == "waiting_input" -@pytest.mark.asyncio -async def test_executor_routes_waiting_input_sidecar_pending_ask_to_ask_resume( - monkeypatch: pytest.MonkeyPatch, +def test_cancel_waiting_input_backup_blocked_persist_failure_records_unavailable_marker( tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setenv("IAC_CODE_MODE", "pipeline") - - class AskResumePipeline(FakePipeline): - def __init__(self, *, session_dir: Path) -> None: - super().__init__( - [ - TextDeltaEvent(text="nginx selected"), - PipelineEvent( - type=PipelineEventType.PIPELINE_COMPLETED, - step_id=None, - timestamp=1717821601.0, - data={}, - ), - ], - session_dir=session_dir, - ) - self.ask_answers: list[dict[str, str]] = [] + from iac_code.a2a.pipeline_executor import ( + WaitingInputCancelResult, + cancel_waiting_input_task_from_sidecar, + terminal_task_state_from_sidecar, + ) + from iac_code.a2a.pipeline_paths import a2a_pipeline_dir_for_session - async def resume_ask_user_question(self, answer: dict[str, str], *, tool_use_id: str): - self.ask_answers.append(answer) - assert tool_use_id == "ask-1" - for event in self.events: - yield event + class BlockingBackupService: + def backup_session(self, *args, **kwargs) -> None: + raise SessionBackupBlocked("backup unavailable at /tmp/secret-token", retry_count=2) - session_dir = tmp_path / "sidecar" + cwd = tmp_path / "workspace" + session_id = "session-ctx-1" + context_id = "ctx-1" + pipeline_dir = a2a_pipeline_dir_for_session(cwd=str(cwd), session_id=session_id) pending = { "schemaVersion": "1.0", "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", - "eventId": "evt-ask", + "eventId": "evt-selection", "sequence": 1, "createdAt": "2026-06-08T10:00:00Z", "eventType": "input_required", "scope": "step", - "pipelineRunId": "ctx-1", + "pipelineRunId": context_id, "taskId": "task-1", - "contextId": "ctx-1", + "contextId": context_id, "pipelineName": "selling", "status": "input_required", - "step": {"runId": "step-intent_parsing-1", "id": "intent_parsing", "attempt": 1}, - "data": {"kind": "ask_user_question", "toolUseId": "ask-1"}, + "step": {"runId": "step-confirm_and_select-1", "id": "confirm_and_select", "attempt": 1}, "input": { - "inputId": "ask-ask-1", - "kind": "ask_user_question", - "toolUseId": "ask-1", - "question": "请选择部署目标", - "options": [{"id": "nginx", "label": "Nginx 网站"}], - "allowFreeText": True, + "inputId": "input-confirm_and_select-1", + "kind": "candidate_selection", + "prompt": "请选择方案", + "options": [{"name": "方案A", "candidate_index": 0}], }, } - A2APipelineJournal(session_dir).append(pending) - A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([pending])) - fake_pipeline = AskResumePipeline(session_dir=session_dir) - fake_pipeline.sidecar_status = "waiting_input" - monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) - monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) - - store = A2ATaskStore(metrics=NoOpA2AMetrics()) - executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") - queue = FakeEventQueue() - - await executor.execute( - FakeRequestContext( - text="Nginx 网站", - metadata={"iac_code": {"cwd": str(tmp_path)}}, - ), - queue, - ) - - assert fake_pipeline.resume_prompts == [] - assert fake_pipeline.ask_answers == [{"selected_id": "nginx", "selected_label": "Nginx 网站", "free_text": ""}] - task_record = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") - assert "nginx selected" in "".join(task_record.output_text) - assert "input_received" in [event["eventType"] for event in A2APipelineJournal(session_dir).read_all()] + A2APipelineJournal(pipeline_dir).append(pending) + A2APipelineSnapshotStore(pipeline_dir).save(reduce_pipeline_events([pending])) + original_append = A2APipelineJournal.append + def fail_backup_blocked_append(self, event, durable: bool = False): + if event.get("eventType") == "backup_blocked": + raise OSError("journal locked") + return original_append(self, event, durable=durable) -@pytest.mark.asyncio -async def test_executor_routes_waiting_input_sidecar_by_context_when_task_id_is_missing( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + monkeypatch.setattr(A2APipelineJournal, "append", fail_backup_blocked_append) + metrics = SpyMetrics() - class AskResumePipeline(FakePipeline): - def __init__(self, *, session_dir: Path) -> None: - super().__init__( - [ - TextDeltaEvent(text="nginx selected"), - PipelineEvent( - type=PipelineEventType.PIPELINE_COMPLETED, - step_id=None, - timestamp=1717821601.0, - data={}, - ), - ], - session_dir=session_dir, - ) - self.ask_answers: list[dict[str, str]] = [] + canceled = cancel_waiting_input_task_from_sidecar( + cwd=str(cwd), + session_id=session_id, + context_id=context_id, + task_id="task-1", + reason="user canceled", + backup_service=BlockingBackupService(), + metrics=metrics, + ) - async def resume_ask_user_question(self, answer: dict[str, str], *, tool_use_id: str): - self.ask_answers.append(answer) - assert tool_use_id == "ask-1" - for event in self.events: - yield event + assert canceled == WaitingInputCancelResult.BACKUP_BLOCKED_PERSIST_FAILED + assert metrics.backup_blocked == [(BackupReason.TERMINAL.value, False)] + events = A2APipelineJournal(pipeline_dir).read_all() + assert [event["eventType"] for event in events] == [ + "input_required", + "pipeline_canceled", + "pipeline_handoff_ready", + "pipeline_canceled", + "pipeline_handoff_ready", + "input_required", + ] + assert [event.get("visibility") for event in events[1:5]] == [ + "pending_backup", + "pending_backup", + "committed", + "committed", + ] + assert events[-1]["data"]["kind"] == "terminal_publication_unavailable" + snapshot = A2APipelineSnapshotStore(pipeline_dir).load() + assert snapshot is not None + assert snapshot["status"] == "waiting_input" + assert snapshot["normalHandoff"] is None + assert snapshot["pendingNormalHandoff"] is None + assert ( + terminal_task_state_from_sidecar( + cwd=str(cwd), + session_id=session_id, + context_id=context_id, + task_id="task-1", + ) + is None + ) - from iac_code.a2a.persistence import A2AContextSnapshot, A2APersistenceStore + +def test_cancel_waiting_input_failed_backup_result_persist_failure_records_unrecoverable_metric( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from iac_code.a2a.pipeline_executor import ( + WaitingInputCancelResult, + cancel_waiting_input_task_from_sidecar, + ) from iac_code.a2a.pipeline_paths import a2a_pipeline_dir_for_session - persistence = A2APersistenceStore(tmp_path / "a2a") + class FailedBackupService: + def backup_session(self, *args, **kwargs) -> BackupResult: + return BackupResult( + enabled=True, + succeeded=False, + error="backup unavailable at /tmp/secret-token", + retry_count=3, + ) + + cwd = tmp_path / "workspace" session_id = "session-ctx-1" - persistence.save_context(A2AContextSnapshot(context_id="ctx-1", session_id=session_id, cwd=str(tmp_path))) - session_dir = a2a_pipeline_dir_for_session(cwd=str(tmp_path), session_id=session_id) + context_id = "ctx-1" + pipeline_dir = a2a_pipeline_dir_for_session(cwd=str(cwd), session_id=session_id) pending = { "schemaVersion": "1.0", "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", - "eventId": "evt-ask", + "eventId": "evt-selection", "sequence": 1, "createdAt": "2026-06-08T10:00:00Z", "eventType": "input_required", "scope": "step", - "pipelineRunId": "ctx-1", + "pipelineRunId": context_id, "taskId": "task-1", - "contextId": "ctx-1", + "contextId": context_id, "pipelineName": "selling", "status": "input_required", - "step": {"runId": "step-intent_parsing-1", "id": "intent_parsing", "attempt": 1}, - "data": {"kind": "ask_user_question", "toolUseId": "ask-1"}, + "step": {"runId": "step-confirm_and_select-1", "id": "confirm_and_select", "attempt": 1}, "input": { - "inputId": "ask-ask-1", - "kind": "ask_user_question", - "toolUseId": "ask-1", - "question": "请选择部署目标", - "options": [{"id": "nginx", "label": "Nginx 网站"}], - "allowFreeText": True, + "inputId": "input-confirm_and_select-1", + "kind": "candidate_selection", + "prompt": "请选择方案", + "options": [{"name": "方案A", "candidate_index": 0}], }, } - A2APipelineJournal(session_dir).append(pending) - A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([pending])) - fake_pipeline = AskResumePipeline(session_dir=session_dir) - fake_pipeline.session = SimpleNamespace() - fake_pipeline.sidecar_status = "waiting_input" - monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) - monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: _fake_runtime()) + A2APipelineJournal(pipeline_dir).append(pending) + A2APipelineSnapshotStore(pipeline_dir).save(reduce_pipeline_events([pending])) + original_append = A2APipelineJournal.append - store = A2ATaskStore(metrics=NoOpA2AMetrics(), persistence=persistence) - executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + def fail_backup_blocked_append(self, event, durable: bool = False): + if event.get("eventType") == "backup_blocked": + raise OSError("journal locked") + return original_append(self, event, durable=durable) - await executor.execute( - FakeRequestContext( - task_id=None, - context_id="ctx-1", - text="Nginx 网站", - metadata={"iac_code": {"cwd": str(tmp_path)}}, - ), - FakeEventQueue(), + monkeypatch.setattr(A2APipelineJournal, "append", fail_backup_blocked_append) + metrics = SpyMetrics() + + canceled = cancel_waiting_input_task_from_sidecar( + cwd=str(cwd), + session_id=session_id, + context_id=context_id, + task_id="task-1", + reason="user canceled", + backup_service=FailedBackupService(), + metrics=metrics, ) - assert fake_pipeline.clear_sidecar_calls == 0 - assert fake_pipeline.resume_prompts == [] - assert fake_pipeline.ask_answers == [{"selected_id": "nginx", "selected_label": "Nginx 网站", "free_text": ""}] - task_record = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") - assert "nginx selected" in "".join(task_record.output_text) + assert canceled == WaitingInputCancelResult.BACKUP_BLOCKED_PERSIST_FAILED + assert metrics.backup_blocked == [(BackupReason.TERMINAL.value, False)] + assert metrics.backup_failed == [(BackupReason.TERMINAL.value, True, 3)] -def test_waiting_input_task_id_from_sidecar_accepts_candidate_selection(tmp_path: Path) -> None: - from iac_code.a2a.pipeline_executor import waiting_input_task_id_from_sidecar +def test_cancel_waiting_input_backup_blocked_records_metric(tmp_path: Path) -> None: + from iac_code.a2a.pipeline_executor import ( + WaitingInputCancelResult, + cancel_waiting_input_task_from_sidecar, + ) from iac_code.a2a.pipeline_paths import a2a_pipeline_dir_for_session + class BlockingBackupService: + def backup_session(self, *args, **kwargs) -> None: + raise SessionBackupBlocked("backup unavailable at /tmp/secret-token", retry_count=2) + cwd = tmp_path / "workspace" session_id = "session-ctx-1" context_id = "ctx-1" - session_dir = a2a_pipeline_dir_for_session(cwd=str(cwd), session_id=session_id) + pipeline_dir = a2a_pipeline_dir_for_session(cwd=str(cwd), session_id=session_id) pending = { "schemaVersion": "1.0", "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", @@ -4100,19 +6248,43 @@ def test_waiting_input_task_id_from_sidecar_accepts_candidate_selection(tmp_path "options": [{"name": "方案A", "candidate_index": 0}], }, } - A2APipelineJournal(session_dir).append(pending) - A2APipelineSnapshotStore(session_dir).save(reduce_pipeline_events([pending])) + A2APipelineJournal(pipeline_dir).append(pending) + A2APipelineSnapshotStore(pipeline_dir).save(reduce_pipeline_events([pending])) + metrics = SpyMetrics() - assert waiting_input_task_id_from_sidecar(cwd=str(cwd), session_id=session_id, context_id=context_id) == "task-1" + canceled = cancel_waiting_input_task_from_sidecar( + cwd=str(cwd), + session_id=session_id, + context_id=context_id, + task_id="task-1", + reason="user canceled", + backup_service=BlockingBackupService(), + metrics=metrics, + ) + assert canceled == WaitingInputCancelResult.BACKUP_BLOCKED + assert metrics.backup_blocked == [(BackupReason.TERMINAL.value, True)] + assert metrics.backup_failed == [(BackupReason.TERMINAL.value, True, 2)] + assert metrics.backup_succeeded == [] + assert A2APipelineJournal(pipeline_dir).read_all()[-1]["eventType"] == "backup_blocked" -def test_cancel_waiting_input_sidecar_appends_cancel_handoff_as_durable_group( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - from iac_code.a2a.pipeline_executor import cancel_waiting_input_task_from_sidecar + +def test_cancel_waiting_input_failed_backup_result_records_blocked_metric(tmp_path: Path) -> None: + from iac_code.a2a.pipeline_executor import ( + WaitingInputCancelResult, + cancel_waiting_input_task_from_sidecar, + ) from iac_code.a2a.pipeline_paths import a2a_pipeline_dir_for_session + class FailedBackupService: + def backup_session(self, *args, **kwargs) -> BackupResult: + return BackupResult( + enabled=True, + succeeded=False, + error="backup unavailable at /tmp/secret-token", + retry_count=3, + ) + cwd = tmp_path / "workspace" session_id = "session-ctx-1" context_id = "ctx-1" @@ -4140,14 +6312,7 @@ def test_cancel_waiting_input_sidecar_appends_cancel_handoff_as_durable_group( } A2APipelineJournal(pipeline_dir).append(pending) A2APipelineSnapshotStore(pipeline_dir).save(reduce_pipeline_events([pending])) - append_many_calls = [] - original_append_many = A2APipelineJournal.append_many - - def recording_append_many(self, events, durable: bool = False): - append_many_calls.append(([event["eventType"] for event in events], durable)) - return original_append_many(self, events, durable=durable) - - monkeypatch.setattr(A2APipelineJournal, "append_many", recording_append_many) + metrics = SpyMetrics() canceled = cancel_waiting_input_task_from_sidecar( cwd=str(cwd), @@ -4155,24 +6320,53 @@ def recording_append_many(self, events, durable: bool = False): context_id=context_id, task_id="task-1", reason="user canceled", + backup_service=FailedBackupService(), + metrics=metrics, ) - assert canceled is True - assert append_many_calls[-1] == (["pipeline_canceled", "pipeline_handoff_ready"], True) - events = A2APipelineJournal(pipeline_dir).read_all() - assert [event["eventType"] for event in events[-2:]] == ["pipeline_canceled", "pipeline_handoff_ready"] + assert canceled == WaitingInputCancelResult.BACKUP_BLOCKED + assert metrics.backup_blocked == [(BackupReason.TERMINAL.value, True)] + assert metrics.backup_failed == [(BackupReason.TERMINAL.value, True, 3)] + assert metrics.backup_succeeded == [] + assert A2APipelineJournal(pipeline_dir).read_all()[-1]["eventType"] == "backup_blocked" -def test_cancel_waiting_input_sidecar_returns_false_when_durable_group_fails( +@pytest.mark.asyncio +@pytest.mark.parametrize("failure_mode", ["append_many", "snapshot_save", "fsync_after_write"]) +async def test_cancel_waiting_input_committed_persist_failure_keeps_task_input_required( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + failure_mode: str, ) -> None: - from iac_code.a2a.pipeline_executor import cancel_waiting_input_task_from_sidecar + from iac_code.a2a.pipeline_executor import ( + WaitingInputCancelResult, + cancel_waiting_input_task_from_sidecar, + recoverable_task_id_from_sidecar, + terminal_task_state_from_sidecar, + ) from iac_code.a2a.pipeline_paths import a2a_pipeline_dir_for_session + class SuccessfulBackupService: + def __init__(self) -> None: + self.calls = 0 + + def backup_session(self, *args, **kwargs) -> None: + self.calls += 1 + cwd = tmp_path / "workspace" - session_id = "session-ctx-1" context_id = "ctx-1" + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + task_record = await store.get_or_create_task(task_id="task-1", context_id=context_id) + task_record.state = "input-required" + store.mirror_task(task_record) + context_record = await store.get_or_create_context( + context_id=context_id, + cwd=str(cwd), + runtime_factory=lambda _session_id: _fake_runtime(), + ) + context_record.active_task_id = "task-1" + store.mirror_context(context_record) + session_id = context_record.session_id pipeline_dir = a2a_pipeline_dir_for_session(cwd=str(cwd), session_id=session_id) pending = { "schemaVersion": "1.0", @@ -4197,13 +6391,52 @@ def test_cancel_waiting_input_sidecar_returns_false_when_durable_group_fails( } A2APipelineJournal(pipeline_dir).append(pending) A2APipelineSnapshotStore(pipeline_dir).save(reduce_pipeline_events([pending])) + original_append_many = A2APipelineJournal.append_many + append_many_calls = 0 + fail_during_committed_append = False - def fail_append_many(self, events, durable: bool = False): + def fail_committed_append_many(self, events, durable: bool = False): + nonlocal append_many_calls, fail_during_committed_append + append_many_calls += 1 assert durable is True - assert [event["eventType"] for event in events] == ["pipeline_canceled", "pipeline_handoff_ready"] - raise OSError("journal locked") + if failure_mode == "append_many" and append_many_calls == 2: + raise OSError("journal locked") + if failure_mode == "fsync_after_write" and append_many_calls == 2: + fail_during_committed_append = True + try: + return original_append_many(self, events, durable=durable) + finally: + fail_during_committed_append = False + return original_append_many(self, events, durable=durable) - monkeypatch.setattr(A2APipelineJournal, "append_many", fail_append_many) + monkeypatch.setattr(A2APipelineJournal, "append_many", fail_committed_append_many) + original_save = A2APipelineSnapshotStore.save + save_calls = 0 + + def fail_committed_snapshot_save(self, snapshot): + nonlocal save_calls + if Path(self.pipeline_dir) == pipeline_dir: + save_calls += 1 + if failure_mode == "snapshot_save" and save_calls == 2: + return False + return original_save(self, snapshot) + + monkeypatch.setattr(A2APipelineSnapshotStore, "save", fail_committed_snapshot_save) + if failure_mode == "fsync_after_write": + from iac_code.a2a import pipeline_journal as pipeline_journal_module + + real_fsync = os.fsync + raised = False + + def fail_committed_journal_fsync(fd: int) -> None: + nonlocal raised + if fail_during_committed_append and not raised: + raised = True + raise OSError("fsync failed after journal write") + real_fsync(fd) + + monkeypatch.setattr(pipeline_journal_module.os, "fsync", fail_committed_journal_fsync) + backup_service = SuccessfulBackupService() canceled = cancel_waiting_input_task_from_sidecar( cwd=str(cwd), @@ -4211,13 +6444,266 @@ def fail_append_many(self, events, durable: bool = False): context_id=context_id, task_id="task-1", reason="user canceled", + backup_service=backup_service, + task_store=store, + task_record=task_record, + context_record=context_record, ) - assert canceled is False - assert [event["eventType"] for event in A2APipelineJournal(pipeline_dir).read_all()] == ["input_required"] + assert canceled == WaitingInputCancelResult.PERSIST_FAILED + assert backup_service.calls == 0 + assert task_record.state == "input-required" + assert context_record.active_task_id == "task-1" + persisted_task = await store.get_task_record("task-1") + assert persisted_task.state == "input-required" + events = A2APipelineJournal(pipeline_dir).read_all() + assert [event["eventType"] for event in events[:3]] == [ + "input_required", + "pipeline_canceled", + "pipeline_handoff_ready", + ] + assert all(event.get("visibility") == "pending_backup" for event in events[1:3]) + assert events[-1]["eventType"] == "input_required" + assert events[-1]["data"]["kind"] == "terminal_publication_unavailable" snapshot = A2APipelineSnapshotStore(pipeline_dir).load() assert snapshot is not None assert snapshot["status"] == "waiting_input" + assert snapshot["normalHandoff"] is None + assert ( + terminal_task_state_from_sidecar( + cwd=str(cwd), + session_id=session_id, + context_id=context_id, + task_id="task-1", + ) + is None + ) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + assert not await executor._should_route_pipeline_handoff_to_normal(context_id=context_id, cwd=str(cwd)) + assert ( + recoverable_task_id_from_sidecar( + cwd=str(cwd), + session_id=session_id, + context_id=context_id, + include_running=False, + ) + == "task-1" + ) + + +def test_concurrent_cancel_waiting_input_sidecar_is_serialized(tmp_path: Path) -> None: + from iac_code.a2a.pipeline_executor import WaitingInputCancelResult, cancel_waiting_input_task_from_sidecar + from iac_code.a2a.pipeline_paths import a2a_pipeline_dir_for_session + + class BlockingBackupService: + def __init__(self) -> None: + self.calls = 0 + self._lock = threading.Lock() + self.entered = threading.Event() + self.second_entered = threading.Event() + self.release = threading.Event() + + def backup_session(self, *args, **kwargs) -> None: + with self._lock: + self.calls += 1 + calls = self.calls + if calls == 1: + self.entered.set() + else: + self.second_entered.set() + assert self.release.wait(timeout=2) + + cwd = tmp_path / "workspace" + session_id = "session-ctx-1" + context_id = "ctx-1" + pipeline_dir = a2a_pipeline_dir_for_session(cwd=str(cwd), session_id=session_id) + pending = { + "schemaVersion": "1.0", + "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", + "eventId": "evt-selection", + "sequence": 1, + "createdAt": "2026-06-08T10:00:00Z", + "eventType": "input_required", + "scope": "step", + "pipelineRunId": context_id, + "taskId": "task-1", + "contextId": context_id, + "pipelineName": "selling", + "status": "input_required", + "step": {"runId": "step-confirm_and_select-1", "id": "confirm_and_select", "attempt": 1}, + "input": { + "inputId": "input-confirm_and_select-1", + "kind": "candidate_selection", + "prompt": "请选择方案", + "options": [{"name": "方案A", "candidate_index": 0}], + }, + } + A2APipelineJournal(pipeline_dir).append(pending) + A2APipelineSnapshotStore(pipeline_dir).save(reduce_pipeline_events([pending])) + backup_service = BlockingBackupService() + results: list[WaitingInputCancelResult] = [] + errors: list[BaseException] = [] + + def cancel() -> None: + try: + results.append( + cancel_waiting_input_task_from_sidecar( + cwd=str(cwd), + session_id=session_id, + context_id=context_id, + task_id="task-1", + reason="user canceled", + backup_service=backup_service, + ) + ) + except BaseException as exc: + errors.append(exc) + + first = threading.Thread(target=cancel) + second = threading.Thread(target=cancel) + first.start() + assert backup_service.entered.wait(timeout=1) + second.start() + assert not backup_service.second_entered.wait(timeout=0.1) + backup_service.release.set() + first.join(timeout=2) + second.join(timeout=2) + + assert not first.is_alive() + assert not second.is_alive() + assert errors == [] + assert backup_service.calls == 1 + assert sorted(results) == sorted([WaitingInputCancelResult.CANCELED, WaitingInputCancelResult.NOT_OWNER]) + events = A2APipelineJournal(pipeline_dir).read_all() + assert [event["eventType"] for event in events].count("pipeline_canceled") == 2 + assert [event["eventType"] for event in events].count("pipeline_handoff_ready") == 2 + + +def test_pending_backup_terminal_journal_is_not_terminal_authoritative(tmp_path: Path) -> None: + from iac_code.a2a.pipeline_executor import ( + _latest_terminal_a2a_event, + recoverable_task_id_from_sidecar, + terminal_task_state_from_sidecar, + ) + from iac_code.a2a.pipeline_paths import a2a_pipeline_dir_for_session + + cwd = tmp_path / "workspace" + session_id = "session-ctx-1" + context_id = "ctx-1" + pipeline_dir = a2a_pipeline_dir_for_session(cwd=str(cwd), session_id=session_id) + pending_input = { + "schemaVersion": "1.0", + "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", + "eventId": "evt-selection", + "sequence": 1, + "createdAt": "2026-06-08T10:00:00Z", + "eventType": "input_required", + "scope": "step", + "pipelineRunId": context_id, + "taskId": "task-1", + "contextId": context_id, + "pipelineName": "selling", + "status": "input_required", + "step": {"runId": "step-confirm_and_select-1", "id": "confirm_and_select", "attempt": 1}, + "input": { + "inputId": "input-confirm_and_select-1", + "kind": "candidate_selection", + "prompt": "请选择方案", + "options": [{"name": "方案A", "candidate_index": 0}], + }, + } + pending_terminal = { + **pending_input, + "eventId": "evt-pending-terminal", + "sequence": 2, + "eventType": "pipeline_canceled", + "scope": "pipeline", + "status": "canceled", + "visibility": "pending_backup", + "data": {"source": "a2a_cancel", "reason": "user canceled"}, + } + pending_handoff = { + **pending_input, + "eventId": "evt-pending-handoff", + "sequence": 3, + "eventType": "pipeline_handoff_ready", + "scope": "pipeline", + "status": "canceled", + "visibility": "pending_backup", + "data": { + "action": "switch_to_normal", + "targetMode": "normal", + "outcome": "canceled", + "summary": "[Pipeline Handoff Context]\nOutcome: canceled", + }, + } + journal = A2APipelineJournal(pipeline_dir) + journal.append_many([pending_input, pending_terminal, pending_handoff], durable=True) + + events = journal.read_all() + assert _latest_terminal_a2a_event(events) is None + assert ( + terminal_task_state_from_sidecar( + cwd=str(cwd), + session_id=session_id, + context_id=context_id, + task_id="task-1", + ) + is None + ) + assert ( + recoverable_task_id_from_sidecar( + cwd=str(cwd), + session_id=session_id, + context_id=context_id, + include_running=False, + ) + == "task-1" + ) + + +def test_committed_backup_terminal_without_backup_ack_is_not_terminal_authoritative(tmp_path: Path) -> None: + from iac_code.a2a.pipeline_executor import ( + _latest_terminal_a2a_event, + terminal_task_state_from_sidecar, + ) + from iac_code.a2a.pipeline_paths import a2a_pipeline_dir_for_session + + cwd = tmp_path / "workspace" + session_id = "session-ctx-1" + context_id = "ctx-1" + pipeline_dir = a2a_pipeline_dir_for_session(cwd=str(cwd), session_id=session_id) + committed_terminal = { + "schemaVersion": "1.0", + "extensionUri": "urn:iac-code:a2a:pipeline-events:v1", + "eventId": "evt-terminal", + "sequence": 1, + "createdAt": "2026-06-08T10:00:00Z", + "eventType": "pipeline_completed", + "scope": "pipeline", + "pipelineRunId": context_id, + "taskId": "task-1", + "contextId": context_id, + "pipelineName": "selling", + "status": "completed", + "visibility": "committed", + "data": {"totalSteps": 1}, + } + journal = A2APipelineJournal(pipeline_dir) + journal.append(committed_terminal, durable=True) + A2APipelineSnapshotStore(pipeline_dir).save(reduce_pipeline_events([committed_terminal])) + + events = journal.read_all() + assert _latest_terminal_a2a_event(events) is None + assert ( + terminal_task_state_from_sidecar( + cwd=str(cwd), + session_id=session_id, + context_id=context_id, + task_id="task-1", + ) + is None + ) @pytest.mark.asyncio @@ -4782,11 +7268,20 @@ async def run(self, prompt: str): assert pipeline.primary_cancelled.is_set() assert pipeline.primary_closed.is_set() events = A2APipelineJournal(tmp_path / "sidecar").read_all() - assert [event["eventType"] for event in events[-2:]] == ["pipeline_canceled", "pipeline_handoff_ready"] - assert events[-2]["status"] == "canceled" - assert events[-1]["status"] == "canceled" - assert events[-1]["data"]["outcome"] == "canceled" - assert events[-1]["data"]["summary"] == "[Pipeline Handoff Context]\nOutcome: canceled" + assert [event["eventType"] for event in events[-4:]] == [ + "pipeline_canceled", + "pipeline_handoff_ready", + "backup_committed", + "backup_committed", + ] + assert events[-4]["status"] == "canceled" + assert events[-3]["status"] == "canceled" + assert events[-3]["data"]["outcome"] == "canceled" + assert events[-3]["data"]["summary"] == "[Pipeline Handoff Context]\nOutcome: canceled" + assert [event["data"]["committedEventType"] for event in events[-2:]] == [ + "pipeline_canceled", + "pipeline_handoff_ready", + ] snapshot = A2APipelineSnapshotStore(tmp_path / "sidecar").load() assert snapshot is not None assert snapshot["status"] == "canceled" diff --git a/tests/a2a/test_pipeline_journal.py b/tests/a2a/test_pipeline_journal.py index 19a997dd..0927ef60 100644 --- a/tests/a2a/test_pipeline_journal.py +++ b/tests/a2a/test_pipeline_journal.py @@ -1,8 +1,14 @@ from __future__ import annotations +import json +import multiprocessing +import os +import threading +import time + import pytest -from iac_code.a2a.pipeline_journal import A2APipelineJournal +from iac_code.a2a.pipeline_journal import A2APipelineJournal, A2APipelineJournalReadError def _event(sequence: int, event_id: str) -> dict: @@ -21,6 +27,58 @@ def _event(sequence: int, event_id: str) -> dict: } +def _journal_line(value: dict) -> bytes: + return json.dumps(value, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + b"\n" + + +def _failing_fsync_append_process( + pipeline_dir: str, + fsync_entered, + success_done, + result_queue, +) -> None: + from iac_code.a2a import pipeline_journal as pipeline_journal_module + + real_fsync = os.fsync + raised = False + + def fail_once(fd: int) -> None: + nonlocal raised + if not raised: + raised = True + fsync_entered.set() + success_done.wait(timeout=0.4) + raise OSError("fsync failed after write") + real_fsync(fd) + + pipeline_journal_module.os.fsync = fail_once + try: + A2APipelineJournal(pipeline_dir).append(_event(2, "evt-failed"), durable=True) + except BaseException as exc: + result_queue.put(("failing", type(exc).__name__, str(exc))) + else: + result_queue.put(("failing", "ok", "")) + + +def _successful_append_process( + pipeline_dir: str, + fsync_entered, + success_done, + result_queue, +) -> None: + if not fsync_entered.wait(timeout=2): + result_queue.put(("success", "timeout", "failing writer did not enter fsync")) + return + try: + A2APipelineJournal(pipeline_dir).append(_event(3, "evt-success"), durable=True) + except BaseException as exc: + result_queue.put(("success", type(exc).__name__, str(exc))) + else: + result_queue.put(("success", "ok", "")) + finally: + success_done.set() + + def test_append_and_read_all_preserves_order(tmp_path) -> None: journal = A2APipelineJournal(tmp_path / "pipeline") @@ -75,6 +133,466 @@ def test_durable_append_fsyncs_parent_directory_when_journal_is_created( assert calls == [journal.path] +@pytest.mark.parametrize("write_method", ["append", "append_many"]) +def test_durable_append_rolls_back_when_fsync_fails_after_write( + tmp_path, + monkeypatch: pytest.MonkeyPatch, + write_method: str, +) -> None: + from iac_code.a2a import pipeline_journal as pipeline_journal_module + + journal = A2APipelineJournal(tmp_path / "pipeline") + journal.append(_event(1, "evt-existing"), durable=True) + real_fsync = os.fsync + raised = False + + def fail_once(fd: int) -> None: + nonlocal raised + if not raised: + raised = True + raise OSError("fsync failed after write") + real_fsync(fd) + + monkeypatch.setattr(pipeline_journal_module.os, "fsync", fail_once) + + with pytest.raises(OSError, match="fsync failed after write"): + if write_method == "append": + journal.append(_event(2, "evt-after-failed-fsync"), durable=True) + else: + journal.append_many( + [_event(2, "evt-after-failed-fsync"), _event(3, "evt-handoff-failed-fsync")], + durable=True, + ) + + assert [event["eventId"] for event in journal.read_all_strict()] == ["evt-existing"] + + +def test_failed_concurrent_append_rollback_keeps_successful_append( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from iac_code.a2a import pipeline_journal as pipeline_journal_module + + journal = A2APipelineJournal(tmp_path / "pipeline") + journal.append(_event(1, "evt-existing"), durable=True) + real_fsync = os.fsync + failing_fsync_entered = threading.Event() + successful_append_done = threading.Event() + failing_error: list[BaseException] = [] + successful_error: list[BaseException] = [] + failed_once = False + + def controlled_fsync(fd: int) -> None: + nonlocal failed_once + if threading.current_thread().name == "failing-writer" and not failed_once: + failed_once = True + failing_fsync_entered.set() + successful_append_done.wait(timeout=0.5) + raise OSError("fsync failed after write") + real_fsync(fd) + + monkeypatch.setattr(pipeline_journal_module.os, "fsync", controlled_fsync) + + def failing_writer() -> None: + try: + journal.append(_event(2, "evt-failed"), durable=True) + except BaseException as exc: + failing_error.append(exc) + + def successful_writer() -> None: + assert failing_fsync_entered.wait(timeout=1) + try: + journal.append(_event(3, "evt-success"), durable=True) + except BaseException as exc: + successful_error.append(exc) + finally: + successful_append_done.set() + + failing_thread = threading.Thread(target=failing_writer, name="failing-writer") + successful_thread = threading.Thread(target=successful_writer, name="successful-writer") + failing_thread.start() + successful_thread.start() + failing_thread.join(timeout=2) + successful_thread.join(timeout=2) + + assert not failing_thread.is_alive() + assert not successful_thread.is_alive() + assert [type(exc) for exc in failing_error] == [OSError] + assert successful_error == [] + assert [event["eventId"] for event in journal.read_all_strict()] == ["evt-existing", "evt-success"] + + +def test_failed_cross_process_append_rollback_keeps_successful_append(tmp_path) -> None: + journal = A2APipelineJournal(tmp_path / "pipeline") + journal.append(_event(1, "evt-existing"), durable=True) + ctx = multiprocessing.get_context("spawn") + fsync_entered = ctx.Event() + success_done = ctx.Event() + result_queue = ctx.Queue() + failing = ctx.Process( + target=_failing_fsync_append_process, + args=(str(journal.pipeline_dir), fsync_entered, success_done, result_queue), + ) + succeeding = ctx.Process( + target=_successful_append_process, + args=(str(journal.pipeline_dir), fsync_entered, success_done, result_queue), + ) + + failing.start() + succeeding.start() + failing.join(timeout=5) + succeeding.join(timeout=5) + + try: + assert not failing.is_alive() + assert not succeeding.is_alive() + results = [result_queue.get(timeout=1), result_queue.get(timeout=1)] + result_by_name = {name: status for name, status, _message in results} + assert result_by_name == {"failing": "OSError", "success": "ok"} + assert [event["eventId"] for event in journal.read_all_strict()] == ["evt-existing", "evt-success"] + finally: + if failing.is_alive(): + failing.terminate() + if succeeding.is_alive(): + succeeding.terminate() + failing.join(timeout=1) + succeeding.join(timeout=1) + + +def test_repair_tail_does_not_drop_concurrent_append( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from iac_code.a2a import pipeline_journal as pipeline_journal_module + + journal = A2APipelineJournal(tmp_path / "pipeline") + journal.append(_event(1, "evt-existing"), durable=True) + journal.path.write_bytes(journal.path.read_bytes() + b'{"eventId":"evt-partial"') + original_repairable_tail_bytes = pipeline_journal_module._repairable_tail_bytes + repair_started = threading.Event() + + def slow_repairable_tail_bytes(content: bytes): + repair = original_repairable_tail_bytes(content) + repair_started.set() + time.sleep(0.2) + return repair + + monkeypatch.setattr(pipeline_journal_module, "_repairable_tail_bytes", slow_repairable_tail_bytes) + repair_result: list[bool] = [] + repair_error: list[BaseException] = [] + append_error: list[BaseException] = [] + + def repair() -> None: + try: + repair_result.append(journal.repair_tail()) + except BaseException as exc: + repair_error.append(exc) + + def append() -> None: + assert repair_started.wait(timeout=1) + try: + journal.append(_event(2, "evt-success"), durable=True) + except BaseException as exc: + append_error.append(exc) + + repair_thread = threading.Thread(target=repair) + append_thread = threading.Thread(target=append) + repair_thread.start() + append_thread.start() + repair_thread.join(timeout=2) + append_thread.join(timeout=2) + + assert not repair_thread.is_alive() + assert not append_thread.is_alive() + assert repair_error == [] + assert append_error == [] + assert repair_result == [True] + assert [event["eventId"] for event in journal.read_all_strict()] == ["evt-existing", "evt-success"] + + +def test_read_all_repairing_tail_waits_for_in_progress_append( + tmp_path, +) -> None: + from iac_code.a2a import pipeline_journal as pipeline_journal_module + + journal = A2APipelineJournal(tmp_path / "pipeline") + journal.append(_event(1, "evt-existing"), durable=True) + line = json.dumps(_event(2, "evt-success"), ensure_ascii=False, separators=(",", ":"), allow_nan=False) + "\n" + split_at = line.index('"eventId"') + len('"eventId":"evt') + partial_written = threading.Event() + reader_started = threading.Event() + reader_result: list[list[str]] = [] + reader_error: list[BaseException] = [] + + def writer() -> None: + with pipeline_journal_module._journal_transaction_lock(journal.path): + with journal.path.open("ab") as handle: + handle.write(line[:split_at].encode("utf-8")) + handle.flush() + partial_written.set() + assert reader_started.wait(timeout=1) + time.sleep(0.2) + handle.write(line[split_at:].encode("utf-8")) + handle.flush() + + def reader() -> None: + assert partial_written.wait(timeout=1) + reader_started.set() + try: + reader_result.append([event["eventId"] for event in journal.read_all_repairing_tail()]) + except BaseException as exc: + reader_error.append(exc) + + writer_thread = threading.Thread(target=writer) + reader_thread = threading.Thread(target=reader) + writer_thread.start() + reader_thread.start() + writer_thread.join(timeout=2) + reader_thread.join(timeout=2) + + assert not writer_thread.is_alive() + assert not reader_thread.is_alive() + assert reader_error == [] + assert reader_result == [["evt-existing", "evt-success"]] + + +def test_read_all_repairing_tail_missing_journal_has_no_side_effects(tmp_path) -> None: + pipeline_dir = tmp_path / "missing" / "pipeline" + journal = A2APipelineJournal(pipeline_dir) + + assert journal.read_all_repairing_tail() == [] + assert not pipeline_dir.exists() + + +def test_read_all_repairing_tail_waits_when_lock_exists_before_journal_creation(tmp_path) -> None: + from iac_code.a2a import pipeline_journal as pipeline_journal_module + + journal = A2APipelineJournal(tmp_path / "pipeline") + writer_locked = threading.Event() + reader_started = threading.Event() + reader_result: list[list[str]] = [] + reader_error: list[BaseException] = [] + + def writer() -> None: + journal.pipeline_dir.mkdir(parents=True, exist_ok=True) + with pipeline_journal_module._journal_transaction_lock(journal.path): + writer_locked.set() + assert reader_started.wait(timeout=1) + time.sleep(0.2) + journal.path.write_text( + json.dumps(_event(1, "evt-created"), separators=(",", ":"), ensure_ascii=False) + "\n", + encoding="utf-8", + ) + + def reader() -> None: + assert writer_locked.wait(timeout=1) + reader_started.set() + try: + reader_result.append([event["eventId"] for event in journal.read_all_repairing_tail()]) + except BaseException as exc: + reader_error.append(exc) + + writer_thread = threading.Thread(target=writer) + reader_thread = threading.Thread(target=reader) + writer_thread.start() + reader_thread.start() + writer_thread.join(timeout=2) + reader_thread.join(timeout=2) + + assert not writer_thread.is_alive() + assert not reader_thread.is_alive() + assert reader_error == [] + assert reader_result == [["evt-created"]] + + +def test_append_repairs_existing_corrupt_tail_before_writing_new_event( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + journal = A2APipelineJournal(tmp_path / "pipeline") + journal.append(_event(1, "evt-existing"), durable=True) + corrupt_tail = b'{"eventId":"evt-corrupt"' + journal.path.write_bytes(journal.path.read_bytes() + corrupt_tail) + original_read_all = A2APipelineJournal._read_all + + def fail_full_parse(self, *, strict: bool): + raise AssertionError("append preflight should not parse the full journal") + + monkeypatch.setattr(A2APipelineJournal, "_read_all", fail_full_parse) + + journal.append(_event(2, "evt-success"), durable=True) + + monkeypatch.setattr(A2APipelineJournal, "_read_all", original_read_all) + assert [event["eventId"] for event in journal.read_all_repairing_tail()] == ["evt-existing", "evt-success"] + assert journal.path.with_name("a2a-events.jsonl.corrupt").read_bytes() == corrupt_tail + b"\n" + + +@pytest.mark.parametrize("write_method", ["append", "append_many"]) +def test_append_rejects_unrepairable_middle_corruption_without_writing( + tmp_path, + write_method: str, +) -> None: + journal = A2APipelineJournal(tmp_path / "pipeline") + journal.append(_event(1, "evt-existing"), durable=True) + before = journal.path.read_bytes() + b'{"eventId":"evt-corrupt"\n' + before += json.dumps(_event(2, "evt-after-corrupt"), separators=(",", ":"), ensure_ascii=False).encode("utf-8") + before += b"\n" + journal.path.write_bytes(before) + + with pytest.raises(A2APipelineJournalReadError): + if write_method == "append": + journal.append(_event(3, "evt-new"), durable=True) + else: + journal.append_many([_event(3, "evt-new"), _event(4, "evt-new-group")], durable=True) + + assert journal.path.read_bytes() == before + + +@pytest.mark.parametrize("write_method", ["append", "append_many"]) +def test_append_rejects_middle_corruption_outside_recent_tail_window( + tmp_path, + write_method: str, +) -> None: + journal = A2APipelineJournal(tmp_path / "pipeline") + journal.append(_event(1, "evt-existing-1"), durable=True) + journal.append(_event(2, "evt-existing-2"), durable=True) + before = journal.path.read_bytes() + b'{"eventId":"evt-corrupt"\n' + for index in range(3, 16): + before += _journal_line(_event(index, f"evt-after-corrupt-{index}")) + journal.path.write_bytes(before) + + with pytest.raises(A2APipelineJournalReadError): + if write_method == "append": + journal.append(_event(16, "evt-new"), durable=True) + else: + journal.append_many([_event(16, "evt-new"), _event(17, "evt-new-group")], durable=True) + + assert journal.path.read_bytes() == before + + +@pytest.mark.parametrize("write_method", ["append", "append_many"]) +def test_append_rejects_malformed_middle_event_group_without_writing( + tmp_path, + write_method: str, +) -> None: + journal = A2APipelineJournal(tmp_path / "pipeline") + journal.append(_event(1, "evt-existing"), durable=True) + malformed_group = { + "__iac_code_record_type": "event_group", + "schemaVersion": "1.0", + "groupId": "bad-group", + "events": {"not": "a-list"}, + } + before = journal.path.read_bytes() + _journal_line(malformed_group) + _journal_line(_event(2, "evt-after-group")) + journal.path.write_bytes(before) + + with pytest.raises(A2APipelineJournalReadError): + if write_method == "append": + journal.append(_event(3, "evt-new"), durable=True) + else: + journal.append_many([_event(3, "evt-new"), _event(4, "evt-new-group")], durable=True) + + assert journal.path.read_bytes() == before + + +@pytest.mark.parametrize("write_method", ["append", "append_many"]) +def test_append_rejects_or_quarantines_malformed_tail_event_group( + tmp_path, + write_method: str, +) -> None: + journal = A2APipelineJournal(tmp_path / "pipeline") + journal.append(_event(1, "evt-existing"), durable=True) + malformed_group = { + "__iac_code_record_type": "event_group", + "schemaVersion": "1.0", + "groupId": "bad-group", + "events": {"not": "a-list"}, + } + before = journal.path.read_bytes() + _journal_line(malformed_group) + journal.path.write_bytes(before) + + try: + if write_method == "append": + journal.append(_event(2, "evt-new"), durable=True) + else: + journal.append_many([_event(2, "evt-new"), _event(3, "evt-new-group")], durable=True) + except A2APipelineJournalReadError: + assert journal.path.read_bytes() == before + else: + assert [event["eventId"] for event in journal.read_all_strict()] == ["evt-existing", "evt-new"] + ( + ["evt-new-group"] if write_method == "append_many" else [] + ) + assert journal.path.with_name("a2a-events.jsonl.corrupt").read_bytes() == _journal_line(malformed_group) + + +def test_append_preflight_only_parses_tail_record_for_clean_journal( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from iac_code.a2a import pipeline_journal as pipeline_journal_module + + journal = A2APipelineJournal(tmp_path / "pipeline") + for index in range(1, 6): + journal.append(_event(index, f"evt-old-{index}"), durable=True) + real_loads = json.loads + parsed_event_ids: list[str] = [] + + def recording_loads(value, *args, **kwargs): + loaded = real_loads(value, *args, **kwargs) + if isinstance(loaded, dict) and isinstance(loaded.get("eventId"), str): + parsed_event_ids.append(loaded["eventId"]) + return loaded + + monkeypatch.setattr(pipeline_journal_module.json, "loads", recording_loads) + + journal.append(_event(6, "evt-new"), durable=True) + + assert parsed_event_ids == ["evt-old-5"] + assert [event["eventId"] for event in journal.read_all_strict()] == [ + "evt-old-1", + "evt-old-2", + "evt-old-3", + "evt-old-4", + "evt-old-5", + "evt-new", + ] + + +def test_durable_append_unlinks_new_journal_when_parent_fsync_fails( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + journal = A2APipelineJournal(tmp_path / "pipeline") + + def fail_parent_fsync(path) -> None: + raise OSError("parent fsync failed") + + monkeypatch.setattr("iac_code.a2a.pipeline_journal.fsync_parent_dir", fail_parent_fsync) + + with pytest.raises(OSError, match="parent fsync failed"): + journal.append(_event(1, "evt-parent-fsync-failed"), durable=True) + + assert not journal.path.exists() + + +def test_durable_append_tolerates_unsupported_parent_fsync( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from iac_code.utils import state_io + + journal = A2APipelineJournal(tmp_path / "pipeline") + + def fail_parent_open(path, flags): + raise OSError("directory fsync unsupported") + + monkeypatch.setattr(state_io.os, "open", fail_parent_open) + + journal.append(_event(1, "evt-parent-fsync-unsupported"), durable=True) + + assert [event["eventId"] for event in journal.read_all_strict()] == ["evt-parent-fsync-unsupported"] + + def test_invalid_json_lines_are_skipped(tmp_path) -> None: journal = A2APipelineJournal(tmp_path / "pipeline") journal.append(_event(1, "evt-1")) diff --git a/tests/a2a/test_pipeline_snapshot.py b/tests/a2a/test_pipeline_snapshot.py index 30ff10f2..e2a47161 100644 --- a/tests/a2a/test_pipeline_snapshot.py +++ b/tests/a2a/test_pipeline_snapshot.py @@ -491,6 +491,38 @@ def test_reduce_records_normal_handoff_ready() -> None: assert snapshot["normalHandoff"]["summary"] == "[Pipeline Handoff Context]" +def test_reduce_defers_committed_handoff_until_backup_ack() -> None: + pending = _base("evt-pending", 1, "pipeline_handoff_ready", status="completed") + pending["visibility"] = "pending_backup" + pending["data"] = { + "action": "switch_to_normal", + "targetMode": "normal", + "outcome": "completed", + "summary": "[Pipeline Handoff Context]", + } + committed = dict(pending) + committed["eventId"] = "evt-committed" + committed["sequence"] = 2 + committed["visibility"] = "committed" + + snapshot = reduce_pipeline_events([pending, committed]) + + assert snapshot["normalHandoff"] is None + assert snapshot["pendingNormalHandoff"]["eventId"] == "evt-pending" + + ack = _base("evt-ack", 3, "backup_committed", status=None) + ack["data"] = { + "committedEventId": "evt-committed", + "committedEventType": "pipeline_handoff_ready", + "committedSequence": 2, + } + + snapshot = reduce_pipeline_events([pending, committed, ack]) + + assert snapshot["normalHandoff"]["eventId"] == "evt-committed" + assert snapshot["pendingNormalHandoff"] is None + + def test_reduce_is_idempotent_by_event_id() -> None: event = _base("evt-1", 1, "text_delta", scope="step") event["step"] = {"runId": "step-a-1", "id": "a", "index": 1, "total": 1, "attempt": 1} diff --git a/tests/a2a/test_pipeline_stream.py b/tests/a2a/test_pipeline_stream.py index 852634dd..291e3634 100644 --- a/tests/a2a/test_pipeline_stream.py +++ b/tests/a2a/test_pipeline_stream.py @@ -2,6 +2,7 @@ import asyncio import json +import logging from pathlib import Path from types import SimpleNamespace from typing import Any @@ -139,6 +140,34 @@ async def test_test_fault_injection_fires_after_snapshot_save(tmp_path: Path, mo assert queue.events == [] +@pytest.mark.asyncio +async def test_publish_serializes_delivery_while_before_enqueue_waits(tmp_path: Path) -> None: + publisher, queue = _publisher(tmp_path) + hook_started = asyncio.Event() + release_hook = asyncio.Event() + + async def before_enqueue(envelope: dict[str, Any]) -> bool: + if envelope.get("eventType") == "pipeline_started": + hook_started.set() + await release_hook.wait() + return True + + publisher.before_enqueue = before_enqueue + first = asyncio.create_task(publisher.publish_manual("pipeline_started", "pipeline", status="working")) + await asyncio.wait_for(hook_started.wait(), timeout=1) + second = asyncio.create_task(publisher.publish_manual("pipeline_warning", "pipeline", status="working")) + await asyncio.sleep(0.05) + + assert queue.events == [] + assert not second.done() + + release_hook.set() + await asyncio.wait_for(asyncio.gather(first, second), timeout=1) + + pipeline_events = [dump(event)["metadata"]["iac_code"]["pipeline"] for event in queue.events] + assert [event["eventType"] for event in pipeline_events] == ["pipeline_started", "pipeline_warning"] + + @pytest.mark.asyncio async def test_publish_rebuilds_missing_snapshot_from_journal_history(tmp_path: Path) -> None: publisher, _queue = _publisher(tmp_path) @@ -1142,6 +1171,46 @@ def fail_append(_event: dict[str, Any], durable: bool = False) -> None: assert dump(queue.events[0])["metadata"]["iac_code"]["pipeline"]["data"]["text"] == "still streams" +@pytest.mark.asyncio +async def test_publish_append_failure_warning_does_not_include_journal_path( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + publisher, queue = _publisher(tmp_path) + monkeypatch.setattr(publisher, "_ensure_monotonic_sequence", lambda _envelope: None) + journal_path = tmp_path / "pipeline" / "a2a-events.jsonl" + journal_path.parent.mkdir(parents=True, exist_ok=True) + existing_event = _envelope("text_delta") + existing_event["sequence"] = 1 + existing_event["data"] = {"text": "old"} + after_corrupt_event = _envelope("text_delta") + after_corrupt_event["eventId"] = "evt-after-corrupt" + after_corrupt_event["sequence"] = 2 + after_corrupt_event["data"] = {"text": "after"} + journal_path.write_text( + json.dumps(existing_event, separators=(",", ":")) + + "\n" + + '{"eventId":"evt-corrupt"\n' + + json.dumps(after_corrupt_event, separators=(",", ":")) + + "\n", + encoding="utf-8", + ) + + with caplog.at_level(logging.WARNING): + returned = await publisher.publish(TextDeltaEvent(text="still streams")) + + assert returned == "still streams" + assert dump(queue.events[0])["metadata"]["iac_code"]["pipeline"]["data"]["text"] == "still streams" + messages = [record.getMessage() for record in caplog.records] + assert any( + "Failed to append A2A pipeline journal event error_type=A2APipelineJournalReadError" in message + for message in messages + ) + assert str(journal_path) not in "\n".join(messages) + assert all(record.exc_info is None for record in caplog.records) + + @pytest.mark.asyncio async def test_publish_skips_pipeline_metadata_when_journal_read_fails_for_sequence_high_water(tmp_path: Path) -> None: publisher, queue = _publisher(tmp_path) @@ -1231,6 +1300,29 @@ def fail_append(_event: dict[str, Any], durable: bool = False) -> None: assert snapshot["display"]["messages"][0]["text"] == "old new" +@pytest.mark.asyncio +async def test_publish_manual_can_require_journal_metadata(tmp_path: Path) -> None: + publisher, queue = _publisher(tmp_path) + + def fail_append(_event: dict[str, Any], durable: bool = False) -> None: + raise OSError("journal locked") + + publisher.journal.append = fail_append # type: ignore[method-assign] + + result = await publisher.publish_manual( + "backup_blocked", + "pipeline", + status="input_required", + data={"reason": "terminal"}, + require_durable_metadata=True, + require_journal_metadata=True, + ) + + assert result is None + assert queue.events == [] + assert publisher.snapshot_store.load() is not None + + @pytest.mark.asyncio async def test_publish_sequence_uses_journal_when_snapshot_is_stale(tmp_path: Path) -> None: publisher, _queue = _publisher(tmp_path) diff --git a/tests/a2a/test_task_store.py b/tests/a2a/test_task_store.py index 821ae88a..d5012a0d 100644 --- a/tests/a2a/test_task_store.py +++ b/tests/a2a/test_task_store.py @@ -1,4 +1,6 @@ import asyncio +import json +import shutil import threading import time from collections.abc import Callable @@ -14,6 +16,15 @@ from iac_code.a2a.metrics import NoOpA2AMetrics from iac_code.a2a.persistence import A2APersistenceStore, A2ATaskSnapshot from iac_code.a2a.task_store import A2ATaskStore +from iac_code.services.session_layout import UnsupportedSessionLayoutError +from iac_code.services.session_storage import SessionStorage + + +def _symlink_or_skip(target, link, *, target_is_directory=False) -> None: + try: + link.symlink_to(target, target_is_directory=target_is_directory) + except (NotImplementedError, OSError) as exc: + pytest.skip(f"symlink creation unsupported: {exc}") class FailingPersistence: @@ -94,6 +105,50 @@ async def test_context_reuses_runtime_until_evicted() -> None: assert again.runtime == context.runtime +def test_mirror_session_task_treats_session_path_resolution_as_best_effort(monkeypatch: pytest.MonkeyPatch) -> None: + store = A2ATaskStore(metrics=NoOpA2AMetrics(), idle_timeout_seconds=60, cleanup_interval_seconds=300) + warning_calls = [] + + def fail_session_paths(context_id: str): + raise UnsupportedSessionLayoutError( + "future layout at /mnt/oss/customer-bucket/projects/sensitive-session-id/metadata.json" + ) + + monkeypatch.setattr(store, "_session_paths_for_task_context", fail_session_paths) + monkeypatch.setattr("iac_code.a2a.task_store.logger.warning", lambda *args, **kwargs: warning_calls.append(args)) + + store._mirror_session_task(A2ATaskSnapshot(task_id="task-1", context_id="ctx-1", state="working")) + + assert warning_calls + logged = " ".join(str(arg) for arg in warning_calls[0]) + assert "UnsupportedSessionLayoutError" in logged + assert "/mnt/oss" not in logged + assert "customer-bucket" not in logged + assert "sensitive-session-id" not in logged + + +def test_load_context_snapshot_failure_logs_sanitized_warning(monkeypatch: pytest.MonkeyPatch) -> None: + class ExplodingPersistence: + def load_context(self, context_id: str): + raise OSError(f"failed for {context_id} at /tmp/secret/path") + + warning_calls = [] + store = A2ATaskStore(metrics=NoOpA2AMetrics(), idle_timeout_seconds=60, persistence=ExplodingPersistence()) + + monkeypatch.setattr("iac_code.a2a.task_store.logger.warning", lambda *args, **kwargs: warning_calls.append(args)) + monkeypatch.setattr( + "iac_code.a2a.task_store.logger.exception", + lambda *args, **kwargs: pytest.fail("context snapshot load should not log traceback"), + ) + + assert store._load_context_snapshot("ctx-secret") is None + + assert warning_calls + template, error_type = warning_calls[0] + assert "context %s" not in template + assert error_type == "OSError" + + @pytest.mark.asyncio async def test_context_runtime_factory_runs_outside_mutation_lock() -> None: store = A2ATaskStore(metrics=NoOpA2AMetrics(), idle_timeout_seconds=60, cleanup_interval_seconds=300) @@ -672,6 +727,72 @@ async def test_save_updates_existing_executor_record_state_in_persistence(tmp_pa assert snapshot.state == "completed" +@pytest.mark.asyncio +async def test_save_updates_session_task_snapshot_after_restart_without_contexts(monkeypatch, tmp_path) -> None: + config_dir = tmp_path / "config" + cwd = tmp_path / "workspace" + cwd.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) + persistence = A2APersistenceStore(config_dir / "a2a") + writer = A2ATaskStore(metrics=NoOpA2AMetrics(), persistence=persistence) + context = await writer.get_or_create_context(context_id="ctx-1", cwd=str(cwd), runtime_factory=lambda sid: object()) + await writer.get_or_create_task(task_id="task-1", context_id="ctx-1") + session_task_path = SessionStorage().session_dir(str(cwd), context.session_id) / "a2a" / "task.json" + assert json.loads(session_task_path.read_text(encoding="utf-8"))["state"] == "submitted" + + restarted = A2ATaskStore(metrics=NoOpA2AMetrics(), persistence=persistence) + await restarted.save(sdk_task("task-1", context_id="ctx-1", state=TaskState.TASK_STATE_CANCELED, updated_at=2)) + + session_task = json.loads(session_task_path.read_text(encoding="utf-8")) + assert session_task["state"] == "canceled" + assert persistence.load_task("task-1").state == "canceled" + + +@pytest.mark.asyncio +async def test_session_snapshots_are_written_without_global_persistence(monkeypatch, tmp_path) -> None: + config_dir = tmp_path / "config" + cwd = tmp_path / "workspace" + cwd.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) + store = A2ATaskStore(metrics=NoOpA2AMetrics(), persistence=None) + + context = await store.get_or_create_context(context_id="ctx-1", cwd=str(cwd), runtime_factory=lambda sid: object()) + await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + + session_dir = SessionStorage().session_dir(str(cwd), context.session_id) + session_context = json.loads((session_dir / "a2a" / "context.json").read_text(encoding="utf-8")) + session_task = json.loads((session_dir / "a2a" / "task.json").read_text(encoding="utf-8")) + assert session_context["context_id"] == "ctx-1" + assert session_context["session_id"] == context.session_id + assert session_task["task_id"] == "task-1" + assert session_task["context_id"] == "ctx-1" + assert not (config_dir / "a2a" / "tasks" / "task-1.json").exists() + + +@pytest.mark.asyncio +async def test_session_snapshots_refuse_symlinked_a2a_dir(monkeypatch, tmp_path) -> None: + config_dir = tmp_path / "config" + cwd = tmp_path / "workspace" + cwd.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) + store = A2ATaskStore(metrics=NoOpA2AMetrics(), persistence=None) + + context = await store.get_or_create_context(context_id="ctx-1", cwd=str(cwd), runtime_factory=lambda sid: object()) + task = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + session_dir = SessionStorage().session_dir(str(cwd), context.session_id) + shutil.rmtree(session_dir / "a2a") + outside = tmp_path / "outside-a2a" + outside.mkdir() + _symlink_or_skip(outside, session_dir / "a2a", target_is_directory=True) + task.state = "working" + + store.mirror_task(task) + store.mirror_context(context) + + assert not (outside / "task.json").exists() + assert not (outside / "context.json").exists() + + @pytest.mark.asyncio async def test_mirror_task_updates_internal_record_timestamp(tmp_path) -> None: persistence = A2APersistenceStore(tmp_path) diff --git a/tests/acp/test_sessions.py b/tests/acp/test_sessions.py index 66c52f5e..a4357bd0 100644 --- a/tests/acp/test_sessions.py +++ b/tests/acp/test_sessions.py @@ -6,6 +6,7 @@ import json import os import time +from unittest.mock import patch import acp import acp.schema @@ -15,6 +16,7 @@ from iac_code.acp.session import ACPSession, _current_turn_id from iac_code.acp.state import TurnState from iac_code.agent.message import Message, TextBlock +from iac_code.services.session_metadata import SessionMetadata, write_session_metadata from iac_code.services.session_storage import SessionStorage from iac_code.types.stream_events import MessageEndEvent, TextDeltaEvent, Usage from iac_code.utils.project_paths import format_resume_command @@ -124,6 +126,155 @@ async def test_touch_refreshes_last_active_preventing_cleanup() -> None: assert "touched-1" not in expired +@pytest.mark.asyncio +async def test_prompt_backs_up_successful_turn(monkeypatch: pytest.MonkeyPatch) -> None: + from iac_code.services.session_backup import BackupReason + + class LoopWithSession: + _cwd = "/repo" + _session_storage = object() + + async def run_streaming(self, prompt: str): + yield TextDeltaEvent(text="ok") + yield MessageEndEvent(stop_reason="stop", usage=Usage()) + + calls = [] + + class FakeBackupService: + def __init__(self, *, session_storage=None): + self.session_storage = session_storage + + def backup_session(self, cwd, session_id, *, reason, critical): + calls.append((self.session_storage, cwd, session_id, reason, critical)) + + monkeypatch.setattr("iac_code.acp.session.SessionBackupService", FakeBackupService, raising=False) + session = ACPSession("acp-session", LoopWithSession(), FakeConn()) + + response = await session.prompt([acp.schema.TextContentBlock(type="text", text="hello")]) + + assert response.stop_reason == "end_turn" + assert calls == [(LoopWithSession._session_storage, "/repo", "acp-session", BackupReason.NORMAL_TURN_END, False)] + + +@pytest.mark.asyncio +async def test_prompt_logs_non_critical_backup_result_failure(monkeypatch: pytest.MonkeyPatch) -> None: + from iac_code.services.session_backup import BackupReason, BackupResult + + class LoopWithSession: + _cwd = "/repo" + _session_storage = object() + + async def run_streaming(self, prompt: str): + yield TextDeltaEvent(text="ok") + yield MessageEndEvent(stop_reason="stop", usage=Usage()) + + class FakeBackupService: + def __init__(self, *, session_storage=None): + self.session_storage = session_storage + + def backup_session(self, cwd, session_id, *, reason, critical): + assert (self.session_storage, cwd, session_id, reason, critical) == ( + LoopWithSession._session_storage, + "/repo", + "acp-session", + BackupReason.NORMAL_TURN_END, + False, + ) + return BackupResult(enabled=True, succeeded=False, error="[PATH]", retry_count=2) + + monkeypatch.setattr("iac_code.acp.session.SessionBackupService", FakeBackupService, raising=False) + session = ACPSession("acp-session", LoopWithSession(), FakeConn()) + + with patch("iac_code.acp.session.logger.warning") as warning: + response = await session.prompt([acp.schema.TextContentBlock(type="text", text="hello")]) + + assert response.stop_reason == "end_turn" + warning.assert_called_once_with( + "ACP session backup failed (reason=%s, retry_count=%s): %s", + "normal_turn_end", + 2, + "[PATH]", + ) + + +@pytest.mark.asyncio +async def test_prompt_backup_exception_warning_does_not_include_exception_message( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from iac_code.services.session_backup import BackupReason + + class LoopWithSession: + _cwd = "/repo" + _session_storage = object() + + async def run_streaming(self, prompt: str): + yield TextDeltaEvent(text="ok") + yield MessageEndEvent(stop_reason="stop", usage=Usage()) + + class FakeBackupService: + def __init__(self, *, session_storage=None): + self.session_storage = session_storage + + def backup_session(self, cwd, session_id, *, reason, critical): + assert (self.session_storage, cwd, session_id, reason, critical) == ( + LoopWithSession._session_storage, + "/repo", + "acp-session", + BackupReason.NORMAL_TURN_END, + False, + ) + raise RuntimeError("/mnt/oss/customer-bucket/acp-session source failed") + + monkeypatch.setattr("iac_code.acp.session.SessionBackupService", FakeBackupService, raising=False) + session = ACPSession("acp-session", LoopWithSession(), FakeConn()) + + with patch("iac_code.acp.session.logger.warning") as warning: + response = await session.prompt([acp.schema.TextContentBlock(type="text", text="hello")]) + + assert response.stop_reason == "end_turn" + warning.assert_called_once_with( + "ACP session backup failed (reason=%s, retry_count=%s, error_type=%s)", + "normal_turn_end", + 0, + "RuntimeError", + ) + assert "/mnt/oss/customer-bucket" not in " ".join(str(arg) for arg in warning.call_args.args) + + +@pytest.mark.asyncio +async def test_prompt_backs_up_successful_slash_command_turn(monkeypatch: pytest.MonkeyPatch) -> None: + from iac_code.services.session_backup import BackupReason + + class LoopWithSession: + _cwd = "/repo" + _session_storage = object() + + class FakeSlashRegistry: + def is_slash_command(self, text: str) -> bool: + return True + + async def execute(self, text: str, agent_loop, **context): + return "Renamed session to deploy-prod" + + calls = [] + + class FakeBackupService: + def __init__(self, *, session_storage=None): + self.session_storage = session_storage + + def backup_session(self, cwd, session_id, *, reason, critical): + calls.append((self.session_storage, cwd, session_id, reason, critical)) + + monkeypatch.setattr("iac_code.acp.session.ACPSlashRegistry", FakeSlashRegistry) + monkeypatch.setattr("iac_code.acp.session.SessionBackupService", FakeBackupService, raising=False) + session = ACPSession("acp-session", LoopWithSession(), FakeConn()) + + response = await session.prompt([acp.schema.TextContentBlock(type="text", text="/rename deploy-prod")]) + + assert response.stop_reason == "end_turn" + assert calls == [(LoopWithSession._session_storage, "/repo", "acp-session", BackupReason.NORMAL_TURN_END, False)] + + @pytest.mark.asyncio async def test_cleanup_loop_exception_does_not_crash_service() -> None: """Scenario 16: Exception in cleanup loop is logged but does not stop the server.""" @@ -599,6 +750,29 @@ async def test_resume_from_storage(monkeypatch: pytest.MonkeyPatch, tmp_path) -> assert len(ctx.loaded_messages) == 1 +@pytest.mark.asyncio +async def test_resume_metadata_only_v2_session(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + _patch_resume_server(monkeypatch, session_id="metadata-only-session") + monkeypatch.setattr("iac_code.utils.project_paths.get_config_dir", lambda: tmp_path) + storage = SessionStorage() + session_dir = storage.session_dir("/tmp", "metadata-only-session") + write_session_metadata( + session_dir, + SessionMetadata(session_id="metadata-only-session", cwd="/tmp", layout_version=2), + ) + (session_dir / "a2a").mkdir() + + conn = _RecordingFakeConn() + server = ACPServer() + server.on_connect(conn) + + result = await server.resume_session(cwd="/tmp", session_id="metadata-only-session") + + assert isinstance(result, acp.schema.ResumeSessionResponse) + resumed_session = server.sessions["metadata-only-session"] + assert resumed_session.agent_loop.context_manager.loaded_messages == [] + + @pytest.mark.asyncio async def test_resume_session_accepts_name(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: """Resuming by session name resolves to the persisted session id.""" diff --git a/tests/agent/test_agent_loop_new.py b/tests/agent/test_agent_loop_new.py index a476471f..caed5e94 100644 --- a/tests/agent/test_agent_loop_new.py +++ b/tests/agent/test_agent_loop_new.py @@ -1,5 +1,6 @@ import asyncio import json +from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, Mock, patch @@ -1298,6 +1299,88 @@ async def test_replace_session_reloads_usage_totals(self, mock_provider, mock_re assert loop.get_session_usage().output_tokens == 8 assert loop.get_session_usage().total_tokens == 15 + async def test_replace_session_resets_transcript_usage_store( + self, mock_provider, mock_registry, tmp_path, monkeypatch + ): + from iac_code.services.session_usage import SessionUsageStore + + cwd = "/tmp/status-project" + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + transcript_usage_path = ( + tmp_path / "root-session" / "pipeline" / "transcripts" / "transcript_att_0001" / "usage.jsonl" + ) + transcript_store = SessionUsageStore(path_provider=lambda _cwd, _session_id: transcript_usage_path) + transcript_store.append(cwd, "transcript_att_0001", Usage(input_tokens=1, output_tokens=2)) + normal_store = SessionUsageStore() + normal_store.append(cwd, "normal-session", Usage(input_tokens=7, output_tokens=8)) + + loop = AgentLoop( + provider_manager=mock_provider, + system_prompt="test", + tool_registry=mock_registry, + session_id="transcript_att_0001", + cwd=cwd, + session_usage_store=transcript_store, + root_session_id="root-session", + transcript_id="transcript_att_0001", + result_storage_dir=tmp_path + / "root-session" + / "pipeline" + / "transcripts" + / "transcript_att_0001" + / "tool-results", + audit_log_path=tmp_path + / "root-session" + / "pipeline" + / "transcripts" + / "transcript_att_0001" + / "permission-audit.jsonl", + ) + + assert loop.get_session_usage().total_tokens == 3 + + loop.replace_session("normal-session", resume_messages=None) + loop._record_session_usage(Usage(input_tokens=2, output_tokens=3)) + + assert loop.session_id == "normal-session" + assert loop.get_session_usage().total_tokens == 20 + assert transcript_store.load(cwd, "transcript_att_0001").total_tokens == 3 + assert normal_store.load(cwd, "normal-session").total_tokens == 20 + + async def test_replace_session_uses_v2_session_tool_results(self, mock_provider, mock_registry, tmp_path): + from iac_code.services.session_metadata import ( + SESSION_LAYOUT_VERSION_V2, + SessionMetadata, + write_session_metadata, + ) + from iac_code.services.session_storage import SessionStorage + + cwd = str(tmp_path / "workspace") + session_storage = SessionStorage(projects_dir=tmp_path / "projects") + old_session_dir = session_storage.session_dir(cwd, "old-session") + new_session_dir = session_storage.session_dir(cwd, "new-session") + write_session_metadata( + old_session_dir, + SessionMetadata(session_id="old-session", cwd=cwd, layout_version=SESSION_LAYOUT_VERSION_V2), + ) + write_session_metadata( + new_session_dir, + SessionMetadata(session_id="new-session", cwd=cwd, layout_version=SESSION_LAYOUT_VERSION_V2), + ) + loop = AgentLoop( + provider_manager=mock_provider, + system_prompt="test", + tool_registry=mock_registry, + session_storage=session_storage, + session_id="old-session", + cwd=cwd, + result_storage_dir=old_session_dir / "tool-results", + ) + + loop.replace_session("new-session", resume_messages=None) + + assert Path(loop._result_storage._storage_dir) == new_session_dir / "tool-results" + async def test_refresh_session_usage_reloads_external_usage_totals(self, mock_provider, mock_registry, tmp_path): from iac_code.services.session_usage import SessionUsageStore diff --git a/tests/agent/test_permission_audit_integration.py b/tests/agent/test_permission_audit_integration.py index 4204b90b..6a344691 100644 --- a/tests/agent/test_permission_audit_integration.py +++ b/tests/agent/test_permission_audit_integration.py @@ -204,6 +204,42 @@ async def test_agent_loop_prompt_event_carries_internal_audit_context(tmp_path): } +@pytest.mark.asyncio +async def test_agent_loop_prompt_event_carries_transcript_audit_context(tmp_path): + settings = PermissionAuditSettings(max_file_bytes=123, max_files=2) + metadata = _audit_metadata(scope="once", rule_source=None, rule=None, reason_type="needs_prompt") + transcript_dir = tmp_path / "root-session" / "pipeline" / "transcripts" / "transcript_att_0001" + audit_log_path = transcript_dir / "permission-audit.jsonl" + provider = FakeProvider([_tool_turn(), _text_turn("done")]) + registry = ToolRegistry() + registry.register(FakePermissionTool(PermissionResult(behavior="ask", audit=metadata))) + loop = AgentLoop( + provider_manager=provider, + system_prompt="test", + tool_registry=registry, + cwd=str(tmp_path), + max_turns=2, + session_id="transcript_att_0001", + root_session_id="root-session", + transcript_id="transcript_att_0001", + audit_log_path=audit_log_path, + permission_context=ToolPermissionContext(cwd=str(tmp_path), audit_settings=settings), + ) + + events = await _collect_events(loop, "run fake tool", permission_handler=Mock(return_value=False)) + + [prompt] = _permission_requests(events) + assert prompt.audit_context == { + "session_id": "transcript_att_0001", + "root_session_id": "root-session", + "transcript_id": "transcript_att_0001", + "cwd": str(tmp_path), + "settings": settings, + "metadata": metadata, + "audit_log_path": str(audit_log_path), + } + + @pytest.mark.asyncio async def test_agent_loop_bash_ask_rule_prompt_carries_rule_audit_context(tmp_path): settings = PermissionAuditSettings(max_file_bytes=123, max_files=2) diff --git a/tests/agent/test_permission_scenarios.py b/tests/agent/test_permission_scenarios.py index 1388a798..aa016624 100644 --- a/tests/agent/test_permission_scenarios.py +++ b/tests/agent/test_permission_scenarios.py @@ -10,12 +10,17 @@ import pytest from iac_code.agent.agent_loop import AgentLoop +from iac_code.commands.registry import CommandRegistry, PromptCommand +from iac_code.skills.frontmatter import SkillFrontmatter +from iac_code.skills.skill_definition import SkillDefinition +from iac_code.skills.skill_tool import SkillTool from iac_code.tools.base import ToolRegistry from iac_code.types.permissions import ( PermissionMode, PermissionRuleValue, ToolPermissionContext, ) +from iac_code.types.skill_source import SkillSource from iac_code.types.stream_events import ( MessageEndEvent, MessageStartEvent, @@ -85,6 +90,21 @@ def _read_file_turn(tool_use_id: str, path: str, *, text: str = "") -> list: return events +def _skill_turn(tool_use_id: str, skill: str, *, args: str = "", text: str = "") -> list: + """Build a fake LLM turn that calls the skill tool.""" + events = [MessageStartEvent(message_id=f"msg-{tool_use_id}")] + if text: + events.append(TextDeltaEvent(text=text)) + events.extend( + [ + ToolUseStartEvent(tool_use_id=tool_use_id, name="skill"), + ToolUseEndEvent(tool_use_id=tool_use_id, name="skill", input={"skill": skill, "args": args}), + MessageEndEvent(stop_reason="tool_use", usage=Usage()), + ] + ) + return events + + def _text_turn(text: str) -> list: """Build a fake LLM turn that just responds with text (no tool calls).""" return [ @@ -120,6 +140,19 @@ def _permission_events(events) -> list[PermissionRequestEvent]: return [e for e in events if isinstance(e, PermissionRequestEvent)] +def _skill_command(name: str, *, skill_root: str) -> PromptCommand: + frontmatter = SkillFrontmatter(description="Demo skill") + skill = SkillDefinition( + name=name, + description="Demo skill", + frontmatter=frontmatter, + content="Read the referenced file.", + source=SkillSource.BUNDLED, + skill_root=skill_root, + ) + return PromptCommand(name=name, description="Demo skill", skill=skill, source=SkillSource.BUNDLED) + + class TestSingleCommandPermissionScenarios: """Single-turn scenarios: verify ask/allow/deny for individual commands.""" @@ -204,6 +237,45 @@ async def test_session_trusted_read_roots_do_not_change_relative_read_lookup(sel assert any(expected_error in result.result for result in results) assert not any("Session reference content" in result.result for result in results) + @pytest.mark.asyncio + async def test_inline_skill_root_is_trusted_for_followup_file_reads(self, tmp_path): + """Loading an inline skill should trust its own reference files for subsequent read_file calls.""" + project = tmp_path / "project" + skill_root = tmp_path / "skill" + reference = skill_root / "references" / "ros-template.md" + project.mkdir() + reference.parent.mkdir(parents=True) + reference.write_text("Skill reference content", encoding="utf-8") + + command_registry = CommandRegistry() + command_registry.register(_skill_command("demo-skill", skill_root=str(skill_root))) + + registry = ToolRegistry() + registry.register_default_tools() + registry.register(SkillTool(command_registry=command_registry, session_id="sess-1")) + + provider = FakeProvider( + [ + _skill_turn("skill-1", "demo-skill"), + _read_file_turn("read-1", str(reference)), + _text_turn("done"), + ] + ) + loop = AgentLoop( + provider_manager=provider, + system_prompt="test", + tool_registry=registry, + cwd=str(project), + max_turns=3, + permission_context=ToolPermissionContext(cwd=str(project)), + ) + + events = await _collect_events(loop, "load skill and read reference") + + assert not _has_permission_request(events) + results = _tool_results(events) + assert any("Skill reference content" in result.result for result in results) + @pytest.mark.asyncio async def test_curl_requires_permission(self): """curl should prompt for permission.""" diff --git a/tests/cli/test_headless.py b/tests/cli/test_headless.py index 317a7315..2863b6be 100644 --- a/tests/cli/test_headless.py +++ b/tests/cli/test_headless.py @@ -146,6 +146,136 @@ async def test_headless_closes_agent_runtime_after_run(monkeypatch): runtime.aclose.assert_awaited_once() +@pytest.mark.asyncio +async def test_headless_backs_up_successful_turn(monkeypatch): + from iac_code.services.session_backup import BackupReason + + runner = _make_runner() + events = [MessageEndEvent(stop_reason="end_turn", usage=Usage())] + mock_loop = AsyncMock() + mock_loop.run_streaming = lambda prompt: _fake_stream(*events) + mock_loop._cwd = "/repo" + mock_loop._session_id = "session-headless" + mock_loop._session_storage = object() + runtime = SimpleNamespace( + agent_loop=mock_loop, + session_id="session-headless", + mcp_config_warnings=[], + aclose=AsyncMock(), + ) + calls = [] + + class FakeBackupService: + def __init__(self, *, session_storage=None): + self.session_storage = session_storage + + def backup_session(self, cwd, session_id, *, reason, critical): + calls.append((self.session_storage, cwd, session_id, reason, critical)) + + monkeypatch.setattr("iac_code.services.agent_factory.create_agent_runtime", lambda options: runtime) + monkeypatch.setattr("iac_code.cli.headless.SessionBackupService", FakeBackupService, raising=False) + + exit_code = await runner.run("test prompt") + + assert exit_code == EXIT_OK + assert calls == [(mock_loop._session_storage, "/repo", "session-headless", BackupReason.NORMAL_TURN_END, False)] + + +@pytest.mark.asyncio +async def test_headless_logs_non_critical_backup_result_failure(monkeypatch): + from iac_code.services.session_backup import BackupReason, BackupResult + + runner = _make_runner() + events = [MessageEndEvent(stop_reason="end_turn", usage=Usage())] + mock_loop = AsyncMock() + mock_loop.run_streaming = lambda prompt: _fake_stream(*events) + mock_loop._cwd = "/repo" + mock_loop._session_id = "session-headless" + mock_loop._session_storage = object() + runtime = SimpleNamespace( + agent_loop=mock_loop, + session_id="session-headless", + mcp_config_warnings=[], + aclose=AsyncMock(), + ) + + class FakeBackupService: + def __init__(self, *, session_storage=None): + self.session_storage = session_storage + + def backup_session(self, cwd, session_id, *, reason, critical): + assert (self.session_storage, cwd, session_id, reason, critical) == ( + mock_loop._session_storage, + "/repo", + "session-headless", + BackupReason.NORMAL_TURN_END, + False, + ) + return BackupResult(enabled=True, succeeded=False, error="[PATH]", retry_count=2) + + monkeypatch.setattr("iac_code.services.agent_factory.create_agent_runtime", lambda options: runtime) + monkeypatch.setattr("iac_code.cli.headless.SessionBackupService", FakeBackupService, raising=False) + + with patch("iac_code.cli.headless.logger.warning") as warning: + exit_code = await runner.run("test prompt") + + assert exit_code == EXIT_OK + warning.assert_called_once_with( + "Headless session backup failed (reason={}, retry_count={}): {}", + "normal_turn_end", + 2, + "[PATH]", + ) + + +@pytest.mark.asyncio +async def test_headless_backup_exception_warning_does_not_include_exception_message(monkeypatch): + from iac_code.services.session_backup import BackupReason + + runner = _make_runner() + events = [MessageEndEvent(stop_reason="end_turn", usage=Usage())] + mock_loop = AsyncMock() + mock_loop.run_streaming = lambda prompt: _fake_stream(*events) + mock_loop._cwd = "/repo" + mock_loop._session_id = "session-headless" + mock_loop._session_storage = object() + runtime = SimpleNamespace( + agent_loop=mock_loop, + session_id="session-headless", + mcp_config_warnings=[], + aclose=AsyncMock(), + ) + + class FakeBackupService: + def __init__(self, *, session_storage=None): + self.session_storage = session_storage + + def backup_session(self, cwd, session_id, *, reason, critical): + assert (self.session_storage, cwd, session_id, reason, critical) == ( + mock_loop._session_storage, + "/repo", + "session-headless", + BackupReason.NORMAL_TURN_END, + False, + ) + raise RuntimeError("/mnt/oss/customer-bucket/session source failed") + + monkeypatch.setattr("iac_code.services.agent_factory.create_agent_runtime", lambda options: runtime) + monkeypatch.setattr("iac_code.cli.headless.SessionBackupService", FakeBackupService, raising=False) + + with patch("iac_code.cli.headless.logger.warning") as warning: + exit_code = await runner.run("test prompt") + + assert exit_code == EXIT_OK + warning.assert_called_once_with( + "Headless session backup failed (reason={}, retry_count={}, error_type={})", + "normal_turn_end", + 0, + "RuntimeError", + ) + assert "/mnt/oss/customer-bucket" not in " ".join(str(arg) for arg in warning.call_args.args) + + @pytest.mark.asyncio async def test_json_output(): """TextDeltaEvent + MessageEndEvent produces valid JSON output and exit code 0.""" diff --git a/tests/integration/test_session_runtime_backup_paths.py b/tests/integration/test_session_runtime_backup_paths.py new file mode 100644 index 00000000..678b4a56 --- /dev/null +++ b/tests/integration/test_session_runtime_backup_paths.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +import base64 +import json +from pathlib import Path +from unittest.mock import Mock + +import pytest + +from iac_code.a2a.artifacts import artifact_store_for_session +from iac_code.agent.message import Message +from iac_code.mcp.output import convert_mcp_tool_result +from iac_code.pipeline.engine.transcript_storage import PipelineTranscriptStorage +from iac_code.services.permissions.audit import ( + PermissionAuditRecord, + PermissionAuditSettings, + emit_permission_audit, +) +from iac_code.services.session_backup import BackupReason, SessionBackupService +from iac_code.services.session_layout import SessionPaths +from iac_code.services.session_metadata import SESSION_LAYOUT_VERSION_V2 +from iac_code.services.session_storage import SessionStorage +from iac_code.services.session_usage import SessionUsageStore +from iac_code.types.stream_events import Usage +from iac_code.utils.image.pasted_content import PastedContent +from iac_code.utils.image.store import ImageStore + + +def _read_json(path: Path) -> dict[str, object]: + return json.loads(path.read_text(encoding="utf-8")) + + +def _assert_same_file(source_root: Path, mirror_root: Path, relative: str, expected: bytes | None = None) -> None: + source = source_root / relative + mirror = mirror_root / relative + assert source.is_file(), relative + assert mirror.is_file(), relative + assert mirror.read_bytes() == source.read_bytes() + if expected is not None: + assert mirror.read_bytes() == expected + + +def test_v2_session_runtime_paths_are_session_owned_and_backup_mirrored( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_dir = tmp_path / "config" + backup_root = tmp_path / "backup" + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + monkeypatch.setattr("iac_code.services.permissions.audit.log_event", Mock()) + + cwd = str(tmp_path / "repo") + session_id = "session-e2e" + transcript_id = "transcript_att_0001" + storage = SessionStorage(projects_dir=config_dir / "projects") + storage.append(cwd, session_id, Message(role="user", content="create a stack"), git_branch="main") + + session_dir = storage.session_dir(cwd, session_id) + session_paths = SessionPaths.require_supported(session_dir) + assert storage.read_metadata(cwd, session_id).layout_version == SESSION_LAYOUT_VERSION_V2 + + usage_store = SessionUsageStore(path_provider=lambda _cwd, _session_id: session_paths.usage_path) + assert usage_store.append(cwd, session_id, Usage(input_tokens=11, output_tokens=7), provider="test", model="m") + + assert emit_permission_audit( + PermissionAuditRecord( + session_id=session_id, + cwd=cwd, + tool_name="aliyun_ros_stack", + tool_use_id="tool-root", + decision="allow", + scope="once", + source="test", + ), + settings=PermissionAuditSettings(max_file_bytes=1024, max_files=1), + ) + + stored_image = ImageStore(session_id, session_root=session_dir).store( + PastedContent( + id=1, + type="image", + content=base64.b64encode(b"root-image").decode("ascii"), + media_type="image/png", + ) + ) + assert stored_image == str(session_paths.image_cache_dir / "1.png") + + root_mcp_result = convert_mcp_tool_result( + { + "content": [ + { + "type": "image", + "data": base64.b64encode(b"root-tool-result").decode("ascii"), + "mimeType": "image/png", + } + ] + }, + server_name="ros", + tool_name="render_template", + session_id=session_id, + session_dir=session_dir, + ) + root_tool_artifact = Path(root_mcp_result.metadata["mcp"]["artifacts"][0]["path"]) + + transcript_storage = PipelineTranscriptStorage(session_dir / "pipeline") + transcript_storage.append(cwd, transcript_id, Message(role="assistant", content="step output")) + transcript_dir = session_paths.transcript_dir(transcript_id) + transcript_usage_store = SessionUsageStore(path_provider=lambda _cwd, _session_id: transcript_dir / "usage.jsonl") + assert transcript_usage_store.append( + cwd, + transcript_id, + Usage(input_tokens=3, output_tokens=5), + provider="test", + model="step-model", + ) + assert emit_permission_audit( + PermissionAuditRecord( + session_id=transcript_id, + cwd=cwd, + tool_name="write_file", + tool_use_id="tool-transcript", + decision="allow", + scope="once", + source="test", + audit_log_path=str(transcript_dir / "permission-audit.jsonl"), + ), + settings=PermissionAuditSettings(max_file_bytes=1024, max_files=1), + ) + transcript_mcp_result = convert_mcp_tool_result( + { + "content": [ + { + "type": "resource", + "resource": { + "uri": "file:///tmp/template.json", + "mimeType": "application/json", + "blob": base64.b64encode(b'{"ok": true}').decode("ascii"), + }, + } + ] + }, + server_name="ros", + tool_name="export", + session_id=transcript_id, + session_dir=transcript_dir, + ) + transcript_tool_artifact = Path(transcript_mcp_result.metadata["mcp"]["artifacts"][0]["path"]) + + artifact_metadata = artifact_store_for_session(session_dir).save_text( + filename="stack-output.txt", + content="stack-id: s-123", + media_type="text/plain", + ) + a2a_artifact_path = session_paths.a2a_artifacts_dir / artifact_metadata.artifact_id / "stack-output.txt" + + # These two files are executor-owned snapshots. This test writes the files directly to cover + # session backup mirror shape without coupling to the full A2A task-store/executor flow. + session_paths.a2a_dir.mkdir(parents=True, exist_ok=True) + session_paths.a2a_task_path.write_text( + json.dumps({"task_id": "task-1", "state": "completed"}, sort_keys=True), + encoding="utf-8", + ) + session_paths.a2a_context_path.write_text( + json.dumps({"context_id": "ctx-1", "session_id": session_id}, sort_keys=True), + encoding="utf-8", + ) + + result = SessionBackupService(session_storage=storage, retry_delays=()).backup_session( + cwd, + session_id, + reason=BackupReason.TERMINAL, + critical=True, + ) + + mirror = backup_root / "projects" / session_dir.parent.name / session_id + assert result.enabled is True + assert result.source == session_dir + assert result.destination == mirror + + _assert_same_file(session_dir, mirror, "metadata.json") + assert _read_json(mirror / "metadata.json")["layout_version"] == SESSION_LAYOUT_VERSION_V2 + _assert_same_file(session_dir, mirror, "session.jsonl") + _assert_same_file(session_dir, mirror, "usage.jsonl") + _assert_same_file(session_dir, mirror, "permission-audit.jsonl") + _assert_same_file(session_dir, mirror, "image-cache/1.png", b"root-image") + _assert_same_file(session_dir, mirror, root_tool_artifact.relative_to(session_dir).as_posix(), b"root-tool-result") + _assert_same_file(session_dir, mirror, f"pipeline/transcripts/{transcript_id}/session.jsonl") + _assert_same_file(session_dir, mirror, f"pipeline/transcripts/{transcript_id}/usage.jsonl") + _assert_same_file(session_dir, mirror, f"pipeline/transcripts/{transcript_id}/permission-audit.jsonl") + _assert_same_file( + session_dir, + mirror, + transcript_tool_artifact.relative_to(session_dir).as_posix(), + b'{"ok": true}', + ) + _assert_same_file(session_dir, mirror, "a2a/task.json") + _assert_same_file(session_dir, mirror, "a2a/context.json") + _assert_same_file(session_dir, mirror, a2a_artifact_path.relative_to(session_dir).as_posix(), b"stack-id: s-123") + + assert not (mirror / ".backup-state.json").exists() + assert not (mirror / ".backup-lock").exists() + source_marker = _read_json(session_dir / ".backup-state.json") + assert source_marker["status"] == "succeeded" + assert source_marker["reason"] == "terminal" + + assert not (config_dir / "tool-results" / session_id / root_tool_artifact.name).exists() + assert not (config_dir / "image-cache" / session_id / "1.png").exists() + assert not (config_dir / "a2a" / "artifacts" / artifact_metadata.artifact_id / "stack-output.txt").exists() + legacy_transcript_artifact = ( + config_dir + / "projects" + / session_dir.parent.name + / transcript_id + / "tool-results" + / transcript_tool_artifact.name + ) + assert not legacy_transcript_artifact.exists() diff --git a/tests/mcp/test_output.py b/tests/mcp/test_output.py index 95729a78..fdef6ef9 100644 --- a/tests/mcp/test_output.py +++ b/tests/mcp/test_output.py @@ -1,7 +1,18 @@ import base64 from pathlib import Path +import pytest + from iac_code.mcp.output import convert_mcp_tool_result +from iac_code.services.session_layout import UnsupportedSessionLayoutError +from iac_code.services.session_metadata import SessionMetadata, write_session_metadata + + +def _symlink_or_skip(target: Path, link: Path, *, target_is_directory: bool = False) -> None: + try: + link.symlink_to(target, target_is_directory=target_is_directory) + except (NotImplementedError, OSError) as exc: + pytest.skip(f"symlink creation unsupported: {exc}") def test_convert_mcp_result_includes_text_structured_content_and_meta(monkeypatch, tmp_path: Path) -> None: @@ -108,6 +119,72 @@ def test_convert_mcp_result_stores_binary_content_without_exposing_base64(monkey assert artifacts[1]["uri"] == "file:///tmp/archive.bin" +def test_convert_mcp_result_stores_binary_content_under_session_dir(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + image_data = base64.b64encode(b"session-png").decode("ascii") + session_dir = tmp_path / "sessions" / "session-1" + + result = convert_mcp_tool_result( + {"content": [{"type": "image", "data": image_data, "mimeType": "image/png"}]}, + server_name="ros/server", + tool_name="render template", + session_id="session-1", + session_dir=session_dir, + ) + + artifacts = result.metadata["mcp"]["artifacts"] + assert len(artifacts) == 1 + artifact_path = Path(artifacts[0]["path"]) + assert artifact_path.parent == session_dir / "tool-results" / "mcp" / "ros-server" / "render-template" + assert artifact_path.read_bytes() == b"session-png" + assert not (tmp_path / "config" / "tool-results" / "session-1").exists() + + +def test_convert_mcp_result_rejects_symlink_session_tool_results(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + session_id = "session-1" + session_dir = tmp_path / "sessions" / session_id + write_session_metadata(session_dir, SessionMetadata(session_id=session_id, cwd=str(tmp_path), layout_version=2)) + outside = tmp_path / "outside-tool-results" + outside.mkdir() + _symlink_or_skip(outside, session_dir / "tool-results", target_is_directory=True) + image_data = base64.b64encode(b"session-png").decode("ascii") + + with pytest.raises(UnsupportedSessionLayoutError, match="session-owned path"): + convert_mcp_tool_result( + {"content": [{"type": "image", "data": image_data, "mimeType": "image/png"}]}, + server_name="ros", + tool_name="render", + session_id=session_id, + session_dir=session_dir, + ) + + assert list(outside.iterdir()) == [] + + +def test_convert_mcp_result_rejects_future_session_layout(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + session_id = "future-session" + session_dir = tmp_path / "sessions" / session_id + write_session_metadata( + session_dir, + SessionMetadata(session_id=session_id, cwd=str(tmp_path), layout_version=99), + ) + image_data = base64.b64encode(b"future-png").decode("ascii") + + with pytest.raises(UnsupportedSessionLayoutError): + convert_mcp_tool_result( + {"content": [{"type": "image", "data": image_data, "mimeType": "image/png"}]}, + server_name="ros", + tool_name="render", + session_id=session_id, + session_dir=session_dir, + ) + + assert not (tmp_path / "config" / "tool-results" / session_id).exists() + assert not (session_dir / "tool-results").exists() + + def test_convert_mcp_is_error_maps_to_tool_result_error(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) diff --git a/tests/mcp/test_runtime_integration.py b/tests/mcp/test_runtime_integration.py index a0bcfa47..ea8d5eef 100644 --- a/tests/mcp/test_runtime_integration.py +++ b/tests/mcp/test_runtime_integration.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import base64 import threading from pathlib import Path from typing import Any @@ -190,6 +191,42 @@ async def test_runtime_mcp_list_changed_refreshes_registered_tools(monkeypatch, assert runtime.tool_registry.get("mcp__ros__apply") is not None +@pytest.mark.asyncio +async def test_runtime_mcp_tools_changed_uses_session_dir_for_binary_output(monkeypatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + manager = FakeMCPManager() + + runtime = create_agent_runtime( + AgentFactoryOptions( + model="qwen3.7-max", + session_id="session-1", + cwd=str(tmp_path), + mcp_configs=[{"name": "ros", "command": "uvx"}], + mcp_manager_factory=lambda configs, roots: manager, + ) + ) + + manager._tools = [ + MCPToolRecord( + server_name="ros", + tool_name="render", + public_name="mcp__ros__render", + input_schema={"type": "object"}, + ) + ] + + await manager.listeners[0]("ros", "tools") + + tool = runtime.tool_registry.get("mcp__ros__render") + result = await tool.execute(tool_input={}, context=ToolContext()) + + session_dir = runtime.agent_loop._session_storage.session_dir(str(tmp_path), "session-1") + artifact_path = result.metadata["mcp"]["artifacts"][0]["path"] + assert artifact_path.startswith(str(session_dir / "tool-results" / "mcp" / "ros" / "render")) + assert not (tmp_path / "config" / "tool-results" / "session-1").exists() + + @pytest.mark.asyncio async def test_runtime_mcp_resources_changed_unregisters_resource_tools_when_empty(monkeypatch, tmp_path: Path) -> None: monkeypatch.chdir(tmp_path) @@ -552,6 +589,17 @@ async def reconnect(self, server_name: str) -> None: def add_change_listener(self, listener) -> None: self.listeners.append(listener) + async def call_tool(self, *args, **kwargs): + return { + "content": [ + { + "type": "image", + "data": base64.b64encode(b"runtime-png").decode("ascii"), + "mimeType": "image/png", + } + ] + } + async def read_resource(self, uri: str, server_name: str | None = None): return ( server_name or "ros", diff --git a/tests/pipeline/engine/test_pipeline_observability.py b/tests/pipeline/engine/test_pipeline_observability.py index e8edb61b..08f5f894 100644 --- a/tests/pipeline/engine/test_pipeline_observability.py +++ b/tests/pipeline/engine/test_pipeline_observability.py @@ -872,6 +872,49 @@ def test_telemetry_failure_is_swallowed(caplog): assert "Pipeline telemetry emission failed" in caplog.text +def test_pipeline_backup_telemetry_signal_names_are_pinned() -> None: + assert Events.PIPELINE_BACKUP_SUCCEEDED == "iac.pipeline.backup.succeeded" + assert Events.PIPELINE_BACKUP_FAILED == "iac.pipeline.backup.failed" + assert Events.PIPELINE_BACKUP_BLOCKED == "iac.pipeline.backup.blocked" + assert Metrics.PIPELINE_BACKUP_SUCCEEDED_COUNT == "iac.pipeline.backup.succeeded.count" + assert Metrics.PIPELINE_BACKUP_FAILED_COUNT == "iac.pipeline.backup.failed.count" + assert Metrics.PIPELINE_BACKUP_BLOCKED_COUNT == "iac.pipeline.backup.blocked.count" + + +def test_pipeline_backup_succeeded_failed_and_blocked_emit_events_and_metrics() -> None: + obs = PipelineObservability(pipeline_name="selling", session_id="sid", cwd="/repo") + + with ( + patch("iac_code.pipeline.engine.observability.log_event") as log_event, + patch("iac_code.pipeline.engine.observability.add_metric") as add_metric, + ): + obs.backup_succeeded(reason="terminal", step_id="deploy", critical=True, retry_count=2) + obs.backup_failed(reason="terminal", step_id="deploy", critical=True, retry_count=2) + obs.backup_blocked(reason="terminal", step_id="deploy", recoverable=True, persisted=False) + + assert [call.args[0] for call in log_event.call_args_list] == [ + Events.PIPELINE_BACKUP_SUCCEEDED, + Events.PIPELINE_BACKUP_FAILED, + Events.PIPELINE_BACKUP_BLOCKED, + ] + assert log_event.call_args_list[0].args[1] == { + "pipeline_name": "selling", + "session_id": "sid", + "cwd_present": True, + "step_id": "deploy", + "reason": "terminal", + "critical": True, + "retry_count": 2, + } + assert log_event.call_args_list[1].args[1]["retry_count"] == 2 + assert log_event.call_args_list[2].args[1]["persisted"] is False + assert [call.args[0] for call in add_metric.call_args_list] == [ + Metrics.PIPELINE_BACKUP_SUCCEEDED_COUNT, + Metrics.PIPELINE_BACKUP_FAILED_COUNT, + Metrics.PIPELINE_BACKUP_BLOCKED_COUNT, + ] + + def test_safe_span_falls_back_to_nullcontext(): obs = PipelineObservability(pipeline_name="selling", session_id="sid", cwd="/repo") diff --git a/tests/pipeline/engine/test_pipeline_runner.py b/tests/pipeline/engine/test_pipeline_runner.py index f00c47bf..f2492c80 100644 --- a/tests/pipeline/engine/test_pipeline_runner.py +++ b/tests/pipeline/engine/test_pipeline_runner.py @@ -17,6 +17,7 @@ from iac_code.pipeline.engine.state_machine import StateMachine from iac_code.pipeline.engine.transcript_storage import PipelineTranscriptStorage from iac_code.pipeline.engine.types import StepResult, StepStatus +from iac_code.services.session_backup import BackupReason, BackupResult, SessionBackupBlocked from iac_code.services.session_storage import SessionStorage from iac_code.types.stream_events import ResourceObservedEvent @@ -37,6 +38,22 @@ def session_path(self, cwd, session_id): return self._path +class OrderingSessionStorage(FakeSessionStorage): + def __init__(self, order_log: list[str]): + super().__init__() + self.order_log = order_log + + def append_meta(self, cwd, session_id, meta): + super().append_meta(cwd, session_id, meta) + meta_type = meta.get("type") + if meta_type == "pipeline_step_complete": + self.order_log.append(f"append:{meta_type}:{meta.get('step_id')}") + elif meta_type == "pipeline_rollback": + self.order_log.append(f"append:{meta_type}:{meta.get('from')}->{meta.get('to')}") + else: + self.order_log.append(f"append:{meta_type}") + + class FailingAppendMetaSessionStorage(FakeSessionStorage): def __init__(self, fail_types: set[str] | None = None): super().__init__() @@ -87,6 +104,11 @@ async def save_failed( ): self.calls.append(("failed", current_step, state_machine_snapshot["current_index"], reason)) + async def save_backup_blocked( + self, current_step, state_machine_snapshot, context_snapshot, pipeline_identity, reason=None, **kwargs + ): + self.calls.append(("backup_blocked", current_step, state_machine_snapshot["current_index"], reason)) + async def save_rollback( self, from_step, to_step, reason, state_machine_snapshot, context_snapshot, pipeline_identity, **kwargs ): @@ -192,7 +214,50 @@ async def save_failed( ) -def _build_two_step_runner(tmp_path, *, auto_advance_first=True, storage=None): +class RecordingBackupService: + def __init__( + self, + *, + block_on: BackupReason | None = None, + order_log: list[str] | None = None, + on_backup=None, + retry_count: int = 0, + ): + self.block_on = block_on + self.order_log = order_log + self.on_backup = on_backup + self.retry_count = retry_count + self.calls = [] + + def backup_session(self, cwd, session_id, *, reason, critical): + if self.order_log is not None: + self.order_log.append(f"backup:{reason.value}") + self.calls.append( + { + "cwd": cwd, + "session_id": session_id, + "reason": reason, + "critical": critical, + } + ) + if self.on_backup is not None: + self.on_backup(reason) + if reason == self.block_on: + raise SessionBackupBlocked( + "backup path /Users/alice/.iac-code/token failed access_key_secret=abc123", + retry_count=self.retry_count, + ) + return BackupResult(enabled=True, retry_count=self.retry_count) + + +def _build_two_step_runner( + tmp_path, + *, + auto_advance_first=True, + storage=None, + backup_service=None, + resume_from_sidecar=False, +): (tmp_path / "prompts").mkdir(exist_ok=True) (tmp_path / "prompts" / "a.md").write_text("A", encoding="utf-8") (tmp_path / "prompts" / "b.md").write_text("B", encoding="utf-8") @@ -226,10 +291,12 @@ def _build_two_step_runner(tmp_path, *, auto_advance_first=True, storage=None): session_storage=storage or FakeSessionStorage(), session_id="test", cwd=str(tmp_path), + backup_service=backup_service, + resume_from_sidecar=resume_from_sidecar, ) -def _build_parallel_runner(tmp_path, *, storage=None): +def _build_parallel_runner(tmp_path, *, storage=None, backup_service=None): (tmp_path / "prompts").mkdir(exist_ok=True) (tmp_path / "prompts" / "arch.md").write_text("Arch", encoding="utf-8") (tmp_path / "prompts" / "eval.md").write_text("Eval", encoding="utf-8") @@ -279,6 +346,7 @@ def _build_parallel_runner(tmp_path, *, storage=None): session_storage=storage or FakeSessionStorage(), session_id="test123", cwd=str(tmp_path), + backup_service=backup_service, ) runner.context.set_conclusion( "architecture", @@ -317,6 +385,44 @@ def test_rollback_to_same_step_creates_new_attempt(tmp_path): assert second["transcript_id"] == "transcript_att_0002" +def test_pipeline_runner_uses_legacy_placeholder_sidecar_for_legacy_session_file(tmp_path): + storage = SessionStorage(tmp_path / "config") + legacy_path = storage.legacy_session_path(str(tmp_path), "test") + legacy_path.parent.mkdir(parents=True) + legacy_path.write_text('{"role":"user","content":"legacy","cwd":"/repo"}\n', encoding="utf-8") + + runner = _build_two_step_runner(tmp_path, storage=storage) + + placeholder_dir = storage.session_dir(str(tmp_path), "test") + assert runner.session is not None + assert runner.session.session_dir == placeholder_dir / "pipeline" + assert isinstance(runner._transcript_storage, PipelineTranscriptStorage) + assert legacy_path.exists() + assert not (legacy_path.parent / "test" / "pipeline").exists() + + +def test_pipeline_runner_does_not_restore_metadata_less_directory_sidecar(tmp_path): + storage = SessionStorage(tmp_path / "config") + session_dir = storage.session_dir(str(tmp_path), "test") + session_dir.mkdir(parents=True) + (session_dir / "session.jsonl").write_text('{"role":"user","content":"legacy","cwd":"/repo"}\n', encoding="utf-8") + sidecar = PipelineSession(session_dir / "pipeline") + sidecar.save_running_sync( + "a", + {"current_index": 0, "steps": [{"id": "a"}, {"id": "b"}]}, + {}, + {"name": "test"}, + reason="legacy sidecar", + ) + + runner = _build_two_step_runner(tmp_path, storage=storage, resume_from_sidecar=True) + + assert runner.session is None + assert runner.sidecar_status is None + assert runner.sidecar_restore_result is None + assert runner._transcript_storage is None + + @pytest.mark.asyncio async def test_runner_passes_attempt_transcript_to_step_executor(tmp_path, monkeypatch): runner = _build_two_step_runner(tmp_path) @@ -348,6 +454,501 @@ async def fake_execute( assert captured["transcript_id"] == "transcript_att_0001" +@pytest.mark.asyncio +async def test_critical_backup_runs_after_completed_steps_and_terminal(tmp_path): + def assert_terminal_sidecar_saved(reason): + if reason == BackupReason.TERMINAL: + assert any(call[0] == "completed" for call in runner.session.calls) + + backup = RecordingBackupService(on_backup=assert_terminal_sidecar_saved) + runner = _build_two_step_runner(tmp_path, backup_service=backup) + runner.session = RecordingPipelineSession() + + async def fake_execute(step, context, session_id, user_message=None, **kwargs): + conclusion = {"value": step.step_id} + context.set_conclusion(step.conclusion_field, conclusion) + yield StepResult(step_id=step.step_id, status=StepStatus.COMPLETED, conclusion=conclusion) + + runner._step_executor.execute = fake_execute + + events = [event async for event in runner._continue_from_current()] + + assert [call["reason"] for call in backup.calls] == [ + BackupReason.PIPELINE_STEP_COMPLETED, + BackupReason.PIPELINE_STEP_COMPLETED, + BackupReason.TERMINAL, + ] + assert all(call["critical"] is True for call in backup.calls) + assert all(call["cwd"] == str(tmp_path) and call["session_id"] == "test" for call in backup.calls) + assert any( + isinstance(event, PipelineEvent) and event.type == PipelineEventType.PIPELINE_COMPLETED for event in events + ) + + +@pytest.mark.asyncio +async def test_step_complete_metadata_is_appended_before_step_backup(tmp_path): + order: list[str] = [] + backup = RecordingBackupService(order_log=order) + storage = OrderingSessionStorage(order) + runner = _build_two_step_runner(tmp_path, storage=storage, backup_service=backup) + + async def fake_execute(step, context, session_id, user_message=None, **kwargs): + conclusion = {"value": step.step_id} + context.set_conclusion(step.conclusion_field, conclusion) + yield StepResult(step_id=step.step_id, status=StepStatus.COMPLETED, conclusion=conclusion) + + runner._step_executor.execute = fake_execute + + async for _event in runner._continue_from_current(): + pass + + assert order[:5] == [ + "append:pipeline_step_complete:a", + "backup:pipeline_step_completed", + "append:pipeline_step_complete:b", + "backup:pipeline_step_completed", + "backup:terminal", + ] + + +@pytest.mark.asyncio +async def test_rollback_metadata_is_appended_before_rollback_backup(tmp_path): + order: list[str] = [] + backup = RecordingBackupService(order_log=order) + storage = OrderingSessionStorage(order) + runner = _build_two_step_runner(tmp_path, storage=storage, backup_service=backup) + requested_rollback = False + + async def fake_execute(step, context, session_id, user_message=None, **kwargs): + nonlocal requested_rollback + conclusion = {"value": step.step_id} + context.set_conclusion(step.conclusion_field, conclusion) + rollback_request = None + if step.step_id == "b" and not requested_rollback: + requested_rollback = True + rollback_request = ("a", "retry") + yield StepResult( + step_id=step.step_id, + status=StepStatus.COMPLETED, + conclusion=conclusion, + rollback_request=rollback_request, + ) + + runner._step_executor.execute = fake_execute + + async for _event in runner._continue_from_current(): + pass + + step_b_meta = order.index("append:pipeline_step_complete:b") + rollback_meta = order.index("append:pipeline_rollback:b->a") + pre_rollback_step_backups = [item for item in order[:rollback_meta] if item == "backup:pipeline_step_completed"] + rollback_backup = next( + index for index, item in enumerate(order) if index > rollback_meta and item == "backup:pipeline_step_completed" + ) + assert pre_rollback_step_backups == ["backup:pipeline_step_completed"] + assert step_b_meta < rollback_meta < rollback_backup + + +@pytest.mark.asyncio +async def test_backup_after_step_records_success_metric(tmp_path): + backup = RecordingBackupService(retry_count=1) + runner = _build_two_step_runner(tmp_path, backup_service=backup) + runner._observability.backup_succeeded = MagicMock() + + async def fake_execute(step, context, session_id, user_message=None, **kwargs): + conclusion = {"value": step.step_id} + context.set_conclusion(step.conclusion_field, conclusion) + yield StepResult(step_id=step.step_id, status=StepStatus.COMPLETED, conclusion=conclusion) + + runner._step_executor.execute = fake_execute + + [event async for event in runner._continue_from_current()] + + assert ( + call( + step_id="a", + reason=BackupReason.PIPELINE_STEP_COMPLETED.value, + critical=True, + retry_count=1, + ) + in runner._observability.backup_succeeded.call_args_list + ) + + +@pytest.mark.asyncio +async def test_backup_blocked_after_step_yields_recoverable_event_without_failed_completion(tmp_path): + backup = RecordingBackupService(block_on=BackupReason.PIPELINE_STEP_COMPLETED, retry_count=2) + runner = _build_two_step_runner(tmp_path, backup_service=backup) + runner.session = RecordingPipelineSession() + runner._observability.step_completed = MagicMock() + runner._observability.funnel_step = MagicMock() + runner._observability.backup_blocked = MagicMock() + runner._observability.backup_failed = MagicMock() + + async def fake_execute(step, context, session_id, user_message=None, **kwargs): + conclusion = {"value": step.step_id} + context.set_conclusion(step.conclusion_field, conclusion) + yield StepResult(step_id=step.step_id, status=StepStatus.COMPLETED, conclusion=conclusion) + + runner._step_executor.execute = fake_execute + + events = [event async for event in runner._continue_from_current()] + + blocked_events = [ + event for event in events if isinstance(event, PipelineEvent) and event.type == PipelineEventType.BACKUP_BLOCKED + ] + assert len(blocked_events) == 1 + assert blocked_events[0].step_id == "a" + assert blocked_events[0].data["reason"] == BackupReason.PIPELINE_STEP_COMPLETED.value + assert blocked_events[0].data["recoverable"] is True + assert "/Users/alice" not in blocked_events[0].data["error"] + assert "abc123" not in blocked_events[0].data["error"] + assert not any(isinstance(event, PipelineEvent) and event.type == PipelineEventType.STEP_FAILED for event in events) + assert not any( + isinstance(event, PipelineEvent) and event.type == PipelineEventType.PIPELINE_COMPLETED for event in events + ) + runner._observability.step_completed.assert_not_called() + runner._observability.funnel_step.assert_not_called() + runner._observability.backup_blocked.assert_called_once_with( + step_id="a", + reason=BackupReason.PIPELINE_STEP_COMPLETED.value, + recoverable=True, + persisted=True, + ) + runner._observability.backup_failed.assert_called_once_with( + step_id="a", + reason=BackupReason.PIPELINE_STEP_COMPLETED.value, + critical=True, + retry_count=2, + ) + + +@pytest.mark.asyncio +async def test_backup_blocked_sidecar_persist_failure_does_not_publish_recoverable_event(tmp_path, caplog): + backup = RecordingBackupService(block_on=BackupReason.PIPELINE_STEP_COMPLETED) + runner = _build_two_step_runner(tmp_path, backup_service=backup) + caplog.set_level(logging.WARNING, logger="iac_code.pipeline.engine.pipeline_runner") + + class FailingBackupBlockedSession(RecordingPipelineSession): + async def save_backup_blocked(self, *args, **kwargs): + raise OSError("sidecar locked at /mnt/oss/customer-bucket/sensitive-session-id/pipeline/meta.yaml") + + runner.session = FailingBackupBlockedSession() + runner._observability.backup_blocked = MagicMock() + runner._observability.backup_failed = MagicMock() + + async def fake_execute(step, context, session_id, user_message=None, **kwargs): + conclusion = {"value": step.step_id} + context.set_conclusion(step.conclusion_field, conclusion) + yield StepResult(step_id=step.step_id, status=StepStatus.COMPLETED, conclusion=conclusion) + + runner._step_executor.execute = fake_execute + + events = [event async for event in runner._continue_from_current()] + + assert not any( + isinstance(event, PipelineEvent) and event.type == PipelineEventType.BACKUP_BLOCKED for event in events + ) + assert any( + isinstance(event, PipelineEvent) + and event.type == PipelineEventType.STEP_FAILED + and event.data["error_details"]["type"] == "PipelineStatePersistenceError" + for event in events + ) + runner._observability.backup_blocked.assert_called_once_with( + step_id="a", + reason=BackupReason.PIPELINE_STEP_COMPLETED.value, + recoverable=False, + persisted=False, + ) + runner._observability.backup_failed.assert_called_once_with( + step_id="a", + reason=BackupReason.PIPELINE_STEP_COMPLETED.value, + critical=True, + retry_count=0, + ) + messages = [ + record.getMessage() + for record in caplog.records + if "Failed to persist pipeline backup_blocked sidecar state" in record.getMessage() + ] + assert messages + assert "session_id=" not in messages[0] + assert "/mnt/oss" not in messages[0] + assert "customer-bucket" not in messages[0] + assert "sensitive-session-id" not in messages[0] + + +@pytest.mark.asyncio +async def test_backup_blocked_missing_sidecar_does_not_publish_recoverable_event(tmp_path): + backup = RecordingBackupService(block_on=BackupReason.PIPELINE_STEP_COMPLETED) + runner = _build_two_step_runner(tmp_path, backup_service=backup) + runner.session = None + runner._observability.backup_blocked = MagicMock() + runner._observability.backup_failed = MagicMock() + + async def fake_execute(step, context, session_id, user_message=None, **kwargs): + conclusion = {"value": step.step_id} + context.set_conclusion(step.conclusion_field, conclusion) + yield StepResult(step_id=step.step_id, status=StepStatus.COMPLETED, conclusion=conclusion) + + runner._step_executor.execute = fake_execute + + events = [event async for event in runner._continue_from_current()] + + assert not any( + isinstance(event, PipelineEvent) and event.type == PipelineEventType.BACKUP_BLOCKED for event in events + ) + assert any( + isinstance(event, PipelineEvent) + and event.type == PipelineEventType.STEP_FAILED + and event.data["error_details"]["type"] == "PipelineStatePersistenceError" + for event in events + ) + runner._observability.backup_blocked.assert_called_once_with( + step_id="a", + reason=BackupReason.PIPELINE_STEP_COMPLETED.value, + recoverable=False, + persisted=False, + ) + runner._observability.backup_failed.assert_called_once_with( + step_id="a", + reason=BackupReason.PIPELINE_STEP_COMPLETED.value, + critical=True, + retry_count=0, + ) + + +@pytest.mark.asyncio +async def test_terminal_backup_blocked_stops_before_pipeline_completed_event(tmp_path): + backup = RecordingBackupService(block_on=BackupReason.TERMINAL) + runner = _build_two_step_runner(tmp_path, backup_service=backup) + runner.session = RecordingPipelineSession() + + async def fake_execute(step, context, session_id, user_message=None, **kwargs): + conclusion = {"value": step.step_id} + context.set_conclusion(step.conclusion_field, conclusion) + yield StepResult(step_id=step.step_id, status=StepStatus.COMPLETED, conclusion=conclusion) + + runner._step_executor.execute = fake_execute + + events = [event async for event in runner._continue_from_current()] + + completed_step_ids = [ + event.step_id + for event in events + if isinstance(event, PipelineEvent) and event.type == PipelineEventType.STEP_COMPLETED + ] + assert completed_step_ids == ["a"] + blocked_events = [ + event for event in events if isinstance(event, PipelineEvent) and event.type == PipelineEventType.BACKUP_BLOCKED + ] + assert len(blocked_events) == 1 + assert blocked_events[0].data["reason"] == BackupReason.TERMINAL.value + assert not any( + isinstance(event, PipelineEvent) and event.type == PipelineEventType.PIPELINE_COMPLETED for event in events + ) + assert any(call[0] == "completed" for call in runner.session.calls) + assert any(call[0] == "backup_blocked" and call[3] == BackupReason.TERMINAL.value for call in runner.session.calls) + + +@pytest.mark.asyncio +async def test_input_required_backup_blocks_before_user_input_required_event(tmp_path): + backup = RecordingBackupService(block_on=BackupReason.INPUT_REQUIRED) + runner = _build_two_step_runner(tmp_path, auto_advance_first=False, backup_service=backup) + runner.session = RecordingPipelineSession() + runner._observability.user_input_required = MagicMock() + runner._observability.funnel_step = MagicMock() + + async def fake_execute(step, context, session_id, user_message=None, **kwargs): + conclusion = {"user_prompt": "choose", "options": ["one"]} + context.set_conclusion(step.conclusion_field, conclusion) + yield StepResult(step_id=step.step_id, status=StepStatus.COMPLETED, conclusion=conclusion) + + runner._step_executor.execute = fake_execute + + events = [event async for event in runner._continue_from_current()] + + assert [call["reason"] for call in backup.calls] == [ + BackupReason.PIPELINE_STEP_COMPLETED, + BackupReason.INPUT_REQUIRED, + ] + blocked_events = [ + event for event in events if isinstance(event, PipelineEvent) and event.type == PipelineEventType.BACKUP_BLOCKED + ] + assert len(blocked_events) == 1 + assert blocked_events[0].step_id == "a" + assert blocked_events[0].data["reason"] == BackupReason.INPUT_REQUIRED.value + assert not any( + isinstance(event, PipelineEvent) and event.type == PipelineEventType.USER_INPUT_REQUIRED for event in events + ) + runner._observability.user_input_required.assert_not_called() + assert not any( + call.kwargs.get("status") == "waiting_input" for call in runner._observability.funnel_step.call_args_list + ) + assert not any( + isinstance(event, PipelineEvent) and event.type == PipelineEventType.PIPELINE_COMPLETED for event in events + ) + + +@pytest.mark.asyncio +async def test_failed_step_terminal_backup_blocks_before_failure_events(tmp_path): + backup = RecordingBackupService(block_on=BackupReason.TERMINAL) + runner = _build_two_step_runner(tmp_path, backup_service=backup) + runner.session = RecordingPipelineSession() + runner._observability.step_failed = MagicMock() + runner._observability.funnel_step = MagicMock() + runner._observability.backup_blocked = MagicMock() + + async def fake_execute(step, context, session_id, user_message=None, **kwargs): + yield StepResult(step_id=step.step_id, status=StepStatus.FAILED, error="boom") + + runner._step_executor.execute = fake_execute + + events = [event async for event in runner._continue_from_current()] + + blocked_events = [ + event for event in events if isinstance(event, PipelineEvent) and event.type == PipelineEventType.BACKUP_BLOCKED + ] + assert len(blocked_events) == 1 + assert blocked_events[0].data["reason"] == BackupReason.TERMINAL.value + assert not any(isinstance(event, PipelineEvent) and event.type == PipelineEventType.STEP_FAILED for event in events) + assert not any( + isinstance(event, PipelineEvent) and event.type == PipelineEventType.PIPELINE_COMPLETED for event in events + ) + runner._observability.step_failed.assert_not_called() + assert not any(call.kwargs.get("status") == "failed" for call in runner._observability.funnel_step.call_args_list) + runner._observability.backup_blocked.assert_called_once_with( + step_id="a", + reason=BackupReason.TERMINAL.value, + recoverable=True, + persisted=True, + ) + assert any(call[0] == "failed" for call in runner.session.calls) + assert any(call[0] == "backup_blocked" and call[3] == BackupReason.TERMINAL.value for call in runner.session.calls) + + +@pytest.mark.asyncio +async def test_parallel_exception_terminal_backup_blocks_before_failure_events(tmp_path): + backup = RecordingBackupService(block_on=BackupReason.TERMINAL) + runner = _build_parallel_runner(tmp_path, backup_service=backup) + runner.session = RecordingPipelineSession() + runner._observability.step_failed = MagicMock() + runner._observability.funnel_step = MagicMock() + + async def exploding_parallel(*_args, **_kwargs): + raise RuntimeError("parallel boom") + yield + + runner._execute_parallel_sub_pipeline = exploding_parallel + + events = [event async for event in runner._continue_from_current()] + + blocked_events = [ + event for event in events if isinstance(event, PipelineEvent) and event.type == PipelineEventType.BACKUP_BLOCKED + ] + assert len(blocked_events) == 1 + assert blocked_events[0].data["reason"] == BackupReason.TERMINAL.value + assert not any(isinstance(event, PipelineEvent) and event.type == PipelineEventType.STEP_FAILED for event in events) + assert not any( + isinstance(event, PipelineEvent) and event.type == PipelineEventType.PIPELINE_COMPLETED for event in events + ) + runner._observability.step_failed.assert_not_called() + assert not any(call.kwargs.get("status") == "failed" for call in runner._observability.funnel_step.call_args_list) + assert any(call[0] == "failed" for call in runner.session.calls) + + +@pytest.mark.asyncio +async def test_invalid_rollback_terminal_backup_blocks_before_failure_events(tmp_path): + backup = RecordingBackupService(block_on=BackupReason.TERMINAL) + runner = _build_two_step_runner(tmp_path, backup_service=backup) + runner.session = RecordingPipelineSession() + runner._observability.step_failed = MagicMock() + runner._observability.funnel_step = MagicMock() + + async def fake_execute(step, context, session_id, user_message=None, **kwargs): + conclusion = {"value": step.step_id} + context.set_conclusion(step.conclusion_field, conclusion) + yield StepResult( + step_id=step.step_id, + status=StepStatus.COMPLETED, + conclusion=conclusion, + rollback_request=("missing-step", "retry"), + ) + + runner._step_executor.execute = fake_execute + + events = [event async for event in runner._continue_from_current()] + + blocked_events = [ + event for event in events if isinstance(event, PipelineEvent) and event.type == PipelineEventType.BACKUP_BLOCKED + ] + assert len(blocked_events) == 1 + assert blocked_events[0].data["reason"] == BackupReason.TERMINAL.value + assert not any( + isinstance(event, PipelineEvent) and event.type == PipelineEventType.STEP_COMPLETED for event in events + ) + assert not any(isinstance(event, PipelineEvent) and event.type == PipelineEventType.STEP_FAILED for event in events) + assert not any( + isinstance(event, PipelineEvent) and event.type == PipelineEventType.PIPELINE_COMPLETED for event in events + ) + runner._observability.step_failed.assert_not_called() + assert not any(call.kwargs.get("status") == "failed" for call in runner._observability.funnel_step.call_args_list) + assert any(call[0] == "failed" for call in runner.session.calls) + + +@pytest.mark.asyncio +async def test_invalid_rollback_terminal_backup_runs_after_failed_sidecar(tmp_path): + def assert_failed_sidecar_saved(reason): + if reason == BackupReason.TERMINAL: + assert any(call[0] == "failed" for call in runner.session.calls) + + backup = RecordingBackupService(on_backup=assert_failed_sidecar_saved) + runner = _build_two_step_runner(tmp_path, backup_service=backup) + runner.session = RecordingPipelineSession() + + async def fake_execute(step, context, session_id, user_message=None, **kwargs): + conclusion = {"value": step.step_id} + context.set_conclusion(step.conclusion_field, conclusion) + yield StepResult( + step_id=step.step_id, + status=StepStatus.COMPLETED, + conclusion=conclusion, + rollback_request=("missing-step", "retry"), + ) + + runner._step_executor.execute = fake_execute + + events = [event async for event in runner._continue_from_current()] + + assert any(isinstance(event, PipelineEvent) and event.type == PipelineEventType.STEP_FAILED for event in events) + assert any(call[0] == "failed" for call in runner.session.calls) + + +@pytest.mark.asyncio +async def test_backup_env_with_conflicting_backup_root_yields_recoverable_block(tmp_path, monkeypatch): + storage = SessionStorage(tmp_path / "config") + storage.ensure_v2_session_dir_for_new_session(str(tmp_path), "test") + backup_root = tmp_path / "backups" + backup_root.write_text("not a directory", encoding="utf-8") + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + runner = _build_two_step_runner(tmp_path, storage=storage) + runner.session = RecordingPipelineSession() + + async def fake_execute(step, context, session_id, user_message=None, **kwargs): + conclusion = {"value": step.step_id} + context.set_conclusion(step.conclusion_field, conclusion) + yield StepResult(step_id=step.step_id, status=StepStatus.COMPLETED, conclusion=conclusion) + + runner._step_executor.execute = fake_execute + + events = [event async for event in runner._continue_from_current()] + + assert any(isinstance(event, PipelineEvent) and event.type == PipelineEventType.BACKUP_BLOCKED for event in events) + assert not any(isinstance(event, PipelineEvent) and event.type == PipelineEventType.STEP_FAILED for event in events) + + @pytest.mark.asyncio async def test_auto_advance_saves_next_step_after_advance(tmp_path): runner = _build_two_step_runner(tmp_path) @@ -1333,6 +1934,47 @@ def test_context_deps_keys_match_step_conclusion_fields(self): class TestParallelSubPipelineStep: + @pytest.mark.asyncio + async def test_sub_pipeline_backup_blocked_surfaces_as_parent_backup_blocked(self, tmp_path, monkeypatch): + backup = RecordingBackupService(block_on=BackupReason.PIPELINE_STEP_COMPLETED) + runner = _build_parallel_runner(tmp_path, backup_service=backup) + runner.session = RecordingPipelineSession() + + class FakeStepExecutor: + current_agent_loop = None + + async def execute(self, step, context, session_id, **kwargs): + conclusion = {"value": step.step_id} + context.set_conclusion(step.conclusion_field, conclusion) + yield StepResult(step_id=step.step_id, status=StepStatus.COMPLETED, conclusion=conclusion) + + monkeypatch.setattr( + "iac_code.pipeline.engine.sub_pipeline_executor.SubPipelineExecutor._make_step_executor", + lambda _self: FakeStepExecutor(), + ) + + events = [event async for event in runner._continue_from_current()] + + blocked_events = [ + event + for event in events + if isinstance(event, PipelineEvent) and event.type == PipelineEventType.BACKUP_BLOCKED + ] + assert len(blocked_events) == 1 + assert blocked_events[0].step_id == "eval" + assert blocked_events[0].data["reason"] == BackupReason.PIPELINE_STEP_COMPLETED.value + assert not any( + isinstance(event, PipelineEvent) + and event.type in {PipelineEventType.SUB_STEP_FAILED, PipelineEventType.STEP_FAILED} + for event in events + ) + assert not any( + isinstance(event, PipelineEvent) + and event.type == PipelineEventType.PIPELINE_COMPLETED + and event.data.get("failed", False) + for event in events + ) + @pytest.mark.asyncio async def test_parallel_step_invokes_sub_pipeline_executor(self, tmp_path): """parallel_sub_pipeline step should fan-out to SubPipelineExecutor.""" @@ -2973,6 +3615,39 @@ async def mock_execute(step, context, session_id, *, user_message=None, **kwargs assert len(started) == 1 assert started[0].data["name"] == "intent_parsing" + @pytest.mark.asyncio + async def test_exit_condition_terminal_backup_runs_after_completed_sidecar(self, tmp_path): + self._write_pipeline_with_exit_condition(tmp_path) + + def assert_completed_sidecar_saved(reason): + if reason == BackupReason.TERMINAL: + assert any(call[0] == "completed" for call in runner.session.calls) + + backup = RecordingBackupService(on_backup=assert_completed_sidecar_saved) + runner = PipelineRunner( + pipeline_dir=tmp_path, + provider_manager=MagicMock(), + base_tool_registry=MagicMock(), + session_storage=FakeSessionStorage(), + session_id="test123", + backup_service=backup, + ) + runner.session = RecordingPipelineSession() + + async def mock_execute(step, context, session_id, *, user_message=None, **kwargs): + conclusion = {"is_infra_intent": False, "rejection_reason": "not infra"} + context.set_conclusion(step.conclusion_field, conclusion) + yield StepResult(step_id=step.step_id, status=StepStatus.COMPLETED, conclusion=conclusion) + + runner._step_executor.execute = mock_execute + + events = [event async for event in runner.run("帮我写个Python脚本")] + + assert any( + isinstance(event, PipelineEvent) and event.type == PipelineEventType.PIPELINE_COMPLETED for event in events + ) + assert any(call[0] == "completed" for call in runner.session.calls) + @pytest.mark.asyncio async def test_no_exit_when_condition_not_met(self, tmp_path): from iac_code.pipeline.engine.types import StepResult, StepStatus diff --git a/tests/pipeline/engine/test_pipeline_runner_sidecar_path.py b/tests/pipeline/engine/test_pipeline_runner_sidecar_path.py index 41a8f952..b97c132a 100644 --- a/tests/pipeline/engine/test_pipeline_runner_sidecar_path.py +++ b/tests/pipeline/engine/test_pipeline_runner_sidecar_path.py @@ -11,6 +11,23 @@ from iac_code.agent.message import ImageBlock, Message, TextBlock, ToolResultBlock from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType from iac_code.pipeline.engine.types import StepResult, StepStatus +from iac_code.services.session_metadata import SESSION_LAYOUT_VERSION_V2, SessionMetadata, write_session_metadata +from iac_code.services.session_storage import SessionStorage +from iac_code.utils import project_paths + + +def _symlink_or_skip(target: Path, link: Path, *, target_is_directory: bool = False) -> None: + try: + link.symlink_to(target, target_is_directory=target_is_directory) + except (NotImplementedError, OSError) as exc: + pytest.skip(f"symlink creation unsupported: {exc}") + + +def _seed_minimal_pipeline_sidecar(session_dir: Path) -> Path: + pipeline_dir = session_dir / "pipeline" + pipeline_dir.mkdir(parents=True, exist_ok=True) + (pipeline_dir / "meta.yaml").write_text("status: running\n", encoding="utf-8") + return pipeline_dir def _stub_session_storage(tmp_path: Path): @@ -19,6 +36,7 @@ def _stub_session_storage(tmp_path: Path): sessions_root = tmp_path / "projects" / "proj" sessions_root.mkdir(parents=True, exist_ok=True) storage.session_dir.side_effect = lambda cwd, sid: sessions_root / sid + storage.v2_session_dir.side_effect = lambda cwd, sid: sessions_root / sid storage.session_path.side_effect = lambda cwd, sid: sessions_root / sid / "session.jsonl" return storage @@ -52,6 +70,259 @@ def _build_runner(tmp_path: Path, *, resume_from_sidecar: bool = False): ) +def _pipeline_dir(tmp_path: Path) -> Path: + pipeline_dir = tmp_path / "pipe-real-storage" + pipeline_dir.mkdir(exist_ok=True) + (pipeline_dir / "pipeline.yaml").write_text( + yaml.dump( + { + "name": "t", + "context_dependencies": {"x": []}, + "steps": [{"id": "s1", "conclusion_field": "x", "forward": None, "prompt": "prompts/s1.md"}], + } + ), + encoding="utf-8", + ) + prompts_dir = pipeline_dir / "prompts" + prompts_dir.mkdir(exist_ok=True) + (prompts_dir / "s1.md").write_text("step 1", encoding="utf-8") + return pipeline_dir + + +def test_runner_creates_v2_metadata_before_session_sidecar(tmp_path: Path) -> None: + from iac_code.pipeline.engine.pipeline_runner import PipelineRunner + + storage = SessionStorage(projects_dir=tmp_path / "projects") + + runner = PipelineRunner( + pipeline_dir=_pipeline_dir(tmp_path), + provider_manager=MagicMock(), + base_tool_registry=MagicMock(), + session_storage=storage, + session_id="sess123", + cwd="/proj", + ) + + metadata = storage.read_metadata("/proj", "sess123") + assert metadata is not None + assert metadata.layout_version == SESSION_LAYOUT_VERSION_V2 + assert runner.session is not None + assert runner.session.session_dir == storage.session_dir("/proj", "sess123") / "pipeline" + + +def test_runner_uses_writable_legacy_sidecar_placeholder_for_legacy_file(tmp_path: Path) -> None: + from iac_code.pipeline.engine.pipeline_runner import PipelineRunner + + storage = SessionStorage(projects_dir=tmp_path / "projects") + legacy_path = storage.legacy_session_path("/proj", "sess123") + legacy_path.parent.mkdir(parents=True) + legacy_path.write_text('{"role":"user","content":"legacy"}\n', encoding="utf-8") + + runner = PipelineRunner( + pipeline_dir=_pipeline_dir(tmp_path), + provider_manager=MagicMock(), + base_tool_registry=MagicMock(), + session_storage=storage, + session_id="sess123", + cwd="/proj", + ) + + placeholder_dir = storage.session_dir("/proj", "sess123") + assert runner.session is not None + assert runner.session.session_dir == placeholder_dir / "pipeline" + runner._save_running_sync("s1", reason="first legacy pipeline state") + assert legacy_path.exists() + assert (placeholder_dir / "pipeline" / "meta.yaml").exists() + assert not (legacy_path.parent / "sess123" / "pipeline").exists() + + +@pytest.mark.parametrize("sidecar_file", [None, "usage.jsonl", "permission-audit.jsonl"]) +def test_runner_uses_legacy_sidecar_placeholder_for_non_pipeline_sidecar_shadow( + tmp_path: Path, + sidecar_file: str | None, +) -> None: + from iac_code.pipeline.engine.pipeline_runner import PipelineRunner + + storage = SessionStorage(projects_dir=tmp_path / "projects") + legacy_path = storage.legacy_session_path("/proj", "sess123") + legacy_path.parent.mkdir(parents=True) + legacy_path.write_text('{"role":"user","content":"legacy"}\n', encoding="utf-8") + shadow_dir = legacy_path.parent / "sess123" + shadow_dir.mkdir() + if sidecar_file is not None: + (shadow_dir / sidecar_file).write_text("", encoding="utf-8") + + runner = PipelineRunner( + pipeline_dir=_pipeline_dir(tmp_path), + provider_manager=MagicMock(), + base_tool_registry=MagicMock(), + session_storage=storage, + session_id="sess123", + cwd="/proj", + ) + + placeholder_dir = storage._legacy_sidecar_placeholder_dir(legacy_path) + assert runner.session is not None + assert runner.session.session_dir == placeholder_dir / "pipeline" + runner._save_running_sync("s1", reason="legacy pipeline state") + assert (placeholder_dir / "pipeline" / "meta.yaml").exists() + assert not (shadow_dir / "pipeline").exists() + + +def test_runner_restores_existing_sidecar_for_legacy_flat_session(tmp_path: Path) -> None: + from iac_code.pipeline.engine.pipeline_runner import PipelineRunner + + storage = SessionStorage(projects_dir=tmp_path / "projects") + legacy_path = storage.legacy_session_path("/proj", "sess123") + legacy_path.parent.mkdir(parents=True) + legacy_path.write_text('{"role":"user","content":"legacy","cwd":"/proj"}\n', encoding="utf-8") + pipeline_dir = _pipeline_dir(tmp_path) + sidecar_dir = legacy_path.parent / "sess123" / "pipeline" + sidecar_dir.mkdir(parents=True) + (sidecar_dir / "meta.yaml").write_text( + yaml.dump( + { + "pipeline_name": "t", + "status": "running", + "current_step": "s1", + "step_ids": ["s1"], + "sub_pipeline_step_ids": {}, + "pipeline_fingerprint": hashlib.sha256((pipeline_dir / "pipeline.yaml").read_bytes()).hexdigest(), + "state_machine": { + "current_index": 0, + "rollback_count": 0, + "interrupt_rollback_count": 0, + "step_statuses": {"s1": "running"}, + }, + "updated_at": 0.0, + "reason": "seeded legacy sidecar", + } + ), + encoding="utf-8", + ) + (sidecar_dir / "context.yaml").write_text(yaml.dump({}), encoding="utf-8") + + runner = PipelineRunner( + pipeline_dir=pipeline_dir, + provider_manager=MagicMock(), + base_tool_registry=MagicMock(), + session_storage=storage, + session_id="sess123", + cwd="/proj", + resume_from_sidecar=True, + ) + + assert runner.session is not None + assert runner.session.session_dir == sidecar_dir + assert runner.sidecar_restore_result is not None + assert runner.sidecar_restore_result.ok is True + assert runner.state_machine.current_step.step_id == "s1" + + +def test_runner_restores_long_cwd_legacy_sidecar_despite_current_metadata_shadow(tmp_path: Path) -> None: + from iac_code.pipeline.engine.pipeline_runner import PipelineRunner + + cwd = "x" * (project_paths.MAX_SANITIZED_LENGTH + 50) + projects_dir = tmp_path / "projects" + current_project_dir, legacy_project_dir = project_paths.project_dir_candidates(cwd, projects_dir) + session_id = "sess123" + write_session_metadata( + current_project_dir / session_id, + SessionMetadata(session_id=session_id, cwd=cwd, layout_version=SESSION_LAYOUT_VERSION_V2), + ) + storage = SessionStorage(projects_dir=projects_dir) + pipeline_dir = _pipeline_dir(tmp_path) + sidecar_dir = legacy_project_dir / session_id / "pipeline" + sidecar_dir.mkdir(parents=True) + (sidecar_dir / "meta.yaml").write_text( + yaml.dump( + { + "pipeline_name": "t", + "status": "running", + "current_step": "s1", + "step_ids": ["s1"], + "sub_pipeline_step_ids": {}, + "pipeline_fingerprint": hashlib.sha256((pipeline_dir / "pipeline.yaml").read_bytes()).hexdigest(), + "state_machine": { + "current_index": 0, + "rollback_count": 0, + "interrupt_rollback_count": 0, + "step_statuses": {"s1": "running"}, + }, + "updated_at": 0.0, + "reason": "seeded legacy sidecar", + } + ), + encoding="utf-8", + ) + (sidecar_dir / "context.yaml").write_text(yaml.dump({}), encoding="utf-8") + + runner = PipelineRunner( + pipeline_dir=pipeline_dir, + provider_manager=MagicMock(), + base_tool_registry=MagicMock(), + session_storage=storage, + session_id=session_id, + cwd=cwd, + resume_from_sidecar=True, + ) + + assert runner.session is not None + assert runner.session.session_dir == sidecar_dir + assert runner.sidecar_restore_result is not None + assert runner.sidecar_restore_result.ok is True + assert runner.state_machine.current_step.step_id == "s1" + assert not (current_project_dir / session_id / "pipeline").exists() + + +def test_writable_pipeline_sidecar_allows_usage_lock_file(tmp_path: Path) -> None: + from iac_code.pipeline.engine.pipeline_runner import PipelineRunner + + session_dir = tmp_path / "session" + session_dir.mkdir() + (session_dir / "usage.jsonl").write_text("", encoding="utf-8") + (session_dir / ".usage.jsonl.lock").write_text("", encoding="utf-8") + + assert PipelineRunner._writable_pipeline_sidecar_session_dir(session_dir) == session_dir + + +def test_existing_pipeline_sidecar_rejects_symlinked_session_root(tmp_path: Path) -> None: + from iac_code.pipeline.engine.pipeline_runner import PipelineRunner + + external_session_dir = tmp_path / "external-session" + _seed_minimal_pipeline_sidecar(external_session_dir) + symlinked_session_dir = tmp_path / "session-link" + _symlink_or_skip(external_session_dir, symlinked_session_dir, target_is_directory=True) + + assert PipelineRunner._existing_pipeline_sidecar_session_dir(symlinked_session_dir) is None + + +def test_existing_pipeline_sidecar_rejects_symlinked_pipeline_dir(tmp_path: Path) -> None: + from iac_code.pipeline.engine.pipeline_runner import PipelineRunner + + session_dir = tmp_path / "session" + session_dir.mkdir() + external_pipeline_dir = tmp_path / "external-pipeline" + external_pipeline_dir.mkdir() + (external_pipeline_dir / "meta.yaml").write_text("status: running\n", encoding="utf-8") + _symlink_or_skip(external_pipeline_dir, session_dir / "pipeline", target_is_directory=True) + + assert PipelineRunner._existing_pipeline_sidecar_session_dir(session_dir) is None + + +def test_existing_pipeline_sidecar_rejects_symlinked_marker_file(tmp_path: Path) -> None: + from iac_code.pipeline.engine.pipeline_runner import PipelineRunner + + session_dir = tmp_path / "session" + pipeline_dir = session_dir / "pipeline" + pipeline_dir.mkdir(parents=True) + external_marker = tmp_path / "external-meta.yaml" + external_marker.write_text("status: running\n", encoding="utf-8") + _symlink_or_skip(external_marker, pipeline_dir / "meta.yaml") + + assert PipelineRunner._existing_pipeline_sidecar_session_dir(session_dir) is None + + def _build_two_step_runner(tmp_path: Path, *, resume_from_sidecar: bool = False, max_rollbacks: int = 3): pipeline_dir = tmp_path / "pipe" pipeline_dir.mkdir(exist_ok=True) diff --git a/tests/pipeline/engine/test_session.py b/tests/pipeline/engine/test_session.py index 9b051002..e3d3f25e 100644 --- a/tests/pipeline/engine/test_session.py +++ b/tests/pipeline/engine/test_session.py @@ -350,6 +350,23 @@ def test_terminal_status_restore_does_not_log_warning(self, session, caplog, sta if record.name == "iac_code.pipeline.engine.session" and record.levelno >= logging.WARNING ] + def test_backup_blocked_status_restore_does_not_look_terminal(self, session, caplog): + sm_snap = {"current_index": 1, "rollback_count": 0, "interrupt_rollback_count": 0, "step_statuses": {}} + session.save_backup_blocked_sync("confirm", sm_snap, {}, _identity(), reason="terminal") + caplog.set_level(logging.WARNING, logger="iac_code.pipeline.engine.session") + + restored = session.restore_sync(_identity()) + + assert session.has_resumable_status() is True + assert restored.ok is True + assert restored.status == "backup_blocked" + assert restored.reason == "terminal" + assert not [ + record + for record in caplog.records + if record.name == "iac_code.pipeline.engine.session" and record.levelno >= logging.WARNING + ] + def test_identity_mismatch_restore_does_not_log_warning(self, session, caplog): sm_snap = {"current_index": 0, "rollback_count": 0, "interrupt_rollback_count": 0, "step_statuses": {}} session.save_running_sync("intent", sm_snap, {}, _identity(fingerprint="old")) diff --git a/tests/pipeline/engine/test_sub_pipeline_executor.py b/tests/pipeline/engine/test_sub_pipeline_executor.py index 8f0eccf5..e83dd4b6 100644 --- a/tests/pipeline/engine/test_sub_pipeline_executor.py +++ b/tests/pipeline/engine/test_sub_pipeline_executor.py @@ -12,6 +12,7 @@ from iac_code.pipeline.engine.step_spec import LoadedPipeline, StepSpec, SubPipelineSpec from iac_code.pipeline.engine.sub_pipeline_executor import SubPipelineExecutor, SubPipelineResult from iac_code.pipeline.engine.types import StepResult, StepStatus +from iac_code.services.session_backup import BackupReason, SessionBackupBlocked from iac_code.tools.base import ToolRegistry from iac_code.types.stream_events import ToolResultEvent, ToolUseEndEvent, ToolUseStartEvent @@ -43,6 +44,43 @@ def _make_sub_spec() -> SubPipelineSpec: ) +def _make_single_step_sub_spec() -> SubPipelineSpec: + return SubPipelineSpec( + name="evaluate_candidate", + steps=[ + StepSpec( + step_id="template_generating", + conclusion_field="template", + forward=None, + prompt_file="prompts/template.md", + skill="iac_aliyun", + context_fields=["candidate", "intent"], + ) + ], + max_rollbacks=2, + iterate_over="architecture.candidates", + context_fields_from_parent=["intent"], + ) + + +class RecordingBackupService: + def __init__(self, *, block_on: BackupReason | None = None): + self.block_on = block_on + self.calls = [] + + def backup_session(self, cwd, session_id, *, reason, critical): + self.calls.append( + { + "cwd": cwd, + "session_id": session_id, + "reason": reason, + "critical": critical, + } + ) + if reason == self.block_on: + raise SessionBackupBlocked("cannot mirror /Users/alice/.iac-code access_key_secret=abc123") + + class TestSubPipelineResult: def test_success_result_to_dict(self): result = SubPipelineResult( @@ -85,6 +123,105 @@ def test_success_result_no_error_key(self): class TestSubPipelineExecutor: + @pytest.mark.asyncio + async def test_execute_streaming_does_not_backup_after_sub_step_completion(self, tmp_path, monkeypatch): + (tmp_path / "prompts").mkdir(exist_ok=True) + (tmp_path / "prompts" / "template.md").write_text("Generate template", encoding="utf-8") + pipeline = LoadedPipeline( + name="test", + steps=[], + context_dependencies={"intent": []}, + max_rollbacks=3, + skills={"iac_aliyun": "# IaC Skill"}, + ) + parent_ctx = PipelineContext({"intent": []}) + parent_ctx.set_conclusion("intent", {"type": "test"}) + backup = RecordingBackupService() + + class FakeStepExecutor: + current_agent_loop = None + + async def execute(self, step, context, session_id, **kwargs): + yield StepResult(step_id=step.step_id, status=StepStatus.COMPLETED, conclusion={"body": "ok"}) + + executor = SubPipelineExecutor( + provider_manager=MagicMock(), + base_tool_registry=ToolRegistry(), + pipeline=pipeline, + pipeline_dir=tmp_path, + cwd=str(tmp_path), + backup_service=backup, + ) + monkeypatch.setattr(executor, "_make_step_executor", lambda: FakeStepExecutor()) + + events = [ + event + async for event in executor.execute_streaming( + sub_spec=_make_single_step_sub_spec(), + candidate={"name": "Plan A"}, + candidate_index=0, + parent_context=parent_ctx, + session_id="test_session", + ) + ] + + assert backup.calls == [] + assert any( + isinstance(event, PipelineEvent) and event.type == PipelineEventType.SUB_STEP_COMPLETED for event in events + ) + + @pytest.mark.asyncio + async def test_execute_streaming_does_not_run_sub_step_backup_that_can_block(self, tmp_path, monkeypatch): + (tmp_path / "prompts").mkdir(exist_ok=True) + (tmp_path / "prompts" / "template.md").write_text("Generate template", encoding="utf-8") + pipeline = LoadedPipeline( + name="test", + steps=[], + context_dependencies={"intent": []}, + max_rollbacks=3, + skills={"iac_aliyun": "# IaC Skill"}, + ) + parent_ctx = PipelineContext({"intent": []}) + parent_ctx.set_conclusion("intent", {"type": "test"}) + backup = RecordingBackupService(block_on=BackupReason.PIPELINE_STEP_COMPLETED) + + class FakeStepExecutor: + current_agent_loop = None + + async def execute(self, step, context, session_id, **kwargs): + yield StepResult(step_id=step.step_id, status=StepStatus.COMPLETED, conclusion={"body": "ok"}) + + executor = SubPipelineExecutor( + provider_manager=MagicMock(), + base_tool_registry=ToolRegistry(), + pipeline=pipeline, + pipeline_dir=tmp_path, + cwd=str(tmp_path), + backup_service=backup, + ) + monkeypatch.setattr(executor, "_make_step_executor", lambda: FakeStepExecutor()) + events = [ + event + async for event in executor.execute_streaming( + sub_spec=_make_single_step_sub_spec(), + candidate={"name": "Plan A"}, + candidate_index=0, + parent_context=parent_ctx, + session_id="test_session", + ) + ] + + assert backup.calls == [] + assert not any( + isinstance(event, PipelineEvent) and event.type == PipelineEventType.SUB_STEP_FAILED for event in events + ) + assert not any( + isinstance(event, PipelineEvent) + and event.type == PipelineEventType.SUB_PIPELINE_COMPLETED + and event.data.get("failed", False) + for event in events + ) + @pytest.mark.asyncio async def test_execute_streaming_resumes_current_sub_step_with_attempt_data(self, tmp_path, monkeypatch): """Resume starts at the recorded sub-step and passes attempt/transcript state through.""" diff --git a/tests/pipeline/engine/test_transcript_storage.py b/tests/pipeline/engine/test_transcript_storage.py index 23b0575e..d76b3f13 100644 --- a/tests/pipeline/engine/test_transcript_storage.py +++ b/tests/pipeline/engine/test_transcript_storage.py @@ -2,10 +2,87 @@ import json from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest from iac_code import __version__ +from iac_code.agent.agent_loop import AgentLoop from iac_code.agent.message import ImageBlock, Message, TextBlock, ToolUseBlock +from iac_code.pipeline.engine.context import PipelineContext +from iac_code.pipeline.engine.pipeline_runner import PipelineRunner +from iac_code.pipeline.engine.step_executor import StepExecutor +from iac_code.pipeline.engine.step_spec import LoadedPipeline, StepSpec from iac_code.pipeline.engine.transcript_storage import PipelineTranscriptStorage +from iac_code.services.session_layout import SessionPaths, UnsupportedSessionLayoutError +from iac_code.services.session_metadata import SESSION_LAYOUT_VERSION_V2, SessionMetadata, write_session_metadata +from iac_code.services.session_storage import SessionStorage +from iac_code.services.session_usage import SessionUsageStore +from iac_code.tools.base import Tool, ToolContext, ToolRegistry, ToolResult +from iac_code.types.permissions import PermissionAuditMetadata, PermissionResult, ToolPermissionContext +from iac_code.types.stream_events import ( + MessageEndEvent, + MessageStartEvent, + PermissionRequestEvent, + ToolUseEndEvent, + ToolUseStartEvent, + Usage, +) + + +def _symlink_or_skip(target: Path, link: Path, *, target_is_directory: bool = False) -> None: + try: + link.symlink_to(target, target_is_directory=target_is_directory) + except (NotImplementedError, OSError) as exc: + pytest.skip(f"symlink creation unsupported: {exc}") + + +class _FakeProvider: + def __init__(self) -> None: + self._call_count = 0 + + def get_model_name(self) -> str: + return "fake-model" + + async def stream(self, messages, system, tools=None, max_tokens=8192): + self._call_count += 1 + if self._call_count > 1: + yield MessageStartEvent(message_id="msg-2") + yield MessageEndEvent(stop_reason="end_turn", usage=Usage()) + return + yield MessageStartEvent(message_id="msg-1") + yield ToolUseStartEvent(tool_use_id="tool-1", name="fake_permission") + yield ToolUseEndEvent(tool_use_id="tool-1", name="fake_permission", input={"payload": "value"}) + yield MessageEndEvent(stop_reason="tool_use", usage=Usage()) + + +class _FakePermissionTool(Tool): + @property + def name(self) -> str: + return "fake_permission" + + @property + def description(self) -> str: + return "Fake permission-controlled tool" + + @property + def input_schema(self) -> dict[str, Any]: + return {"type": "object", "properties": {"payload": {"type": "string"}}} + + async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + return ToolResult.success("executed") + + async def check_permissions(self, input: dict, context=None) -> PermissionResult: + return PermissionResult( + behavior="ask", + audit=PermissionAuditMetadata( + scope="once", + source="permission_pipeline", + reason_type="needs_prompt", + operation={"product": "ROS", "action": "CreateStack"}, + ), + ) def test_append_and_load_roundtrip(tmp_path: Path): @@ -54,6 +131,21 @@ def test_transcript_lives_inside_sidecar(tmp_path: Path): ) +def test_transcript_refuses_dangling_symlinked_metadata_before_fallback(tmp_path: Path): + session_dir = tmp_path / "session" + session_dir.mkdir() + _symlink_or_skip(tmp_path / "missing-metadata.json", session_dir / "metadata.json") + outside = tmp_path / "outside-pipeline" + outside.mkdir() + _symlink_or_skip(outside, session_dir / "pipeline", target_is_directory=True) + storage = PipelineTranscriptStorage(session_dir / "pipeline") + + with pytest.raises(UnsupportedSessionLayoutError, match="Unsupported session metadata"): + storage.append("/repo", "transcript_att_0001", Message(role="user", content="hello")) + + assert not (outside / "transcripts" / "transcript_att_0001" / "session.jsonl").exists() + + def test_load_skips_lite_meta_rows(tmp_path: Path): storage = PipelineTranscriptStorage(tmp_path / "pipeline") path = storage.session_path("/repo", "transcript_att_0001") @@ -142,6 +234,33 @@ def test_exists_tracks_session_path(tmp_path: Path): assert storage.exists("/repo", "transcript_att_0001") is True +def test_load_ignores_symlinked_transcript_leaf(tmp_path: Path): + storage = PipelineTranscriptStorage(tmp_path / "pipeline") + path = storage.session_path("/repo", "transcript_att_0001") + path.parent.mkdir(parents=True) + outside = tmp_path / "outside-session.jsonl" + outside.write_text('{"role":"user","content":"outside"}\n', encoding="utf-8") + _symlink_or_skip(outside, path) + + assert storage.exists("/repo", "transcript_att_0001") is False + assert storage.load("/repo", "transcript_att_0001") == [] + + +def test_load_reraises_regular_transcript_leaf_read_errors(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + storage = PipelineTranscriptStorage(tmp_path / "pipeline") + path = storage.session_path("/repo", "transcript_att_0001") + path.parent.mkdir(parents=True) + path.write_text('{"role":"user","content":"hello"}\n', encoding="utf-8") + + def fail_open(*_args, **_kwargs): + raise PermissionError("locked") + + monkeypatch.setattr("iac_code.pipeline.engine.transcript_storage.open_text_no_follow", fail_open) + + with pytest.raises(PermissionError, match="locked"): + storage.load("/repo", "transcript_att_0001") + + def test_repair_interrupted_delegates_to_session_storage(tmp_path: Path): storage = PipelineTranscriptStorage(tmp_path / "pipeline") messages = [ @@ -166,3 +285,154 @@ def test_rejects_unsafe_transcript_id(tmp_path: Path): assert "unsafe transcript id" in str(exc) else: raise AssertionError("unsafe transcript id was accepted") + + +def test_agent_loop_accepts_transcript_runtime_paths(tmp_path: Path): + transcript_dir = tmp_path / "pipeline" / "transcripts" / "transcript_att_0001" + usage_path = transcript_dir / "usage.jsonl" + result_storage_dir = transcript_dir / "tool-results" + audit_log_path = transcript_dir / "permission-audit.jsonl" + + loop = AgentLoop( + provider_manager=SimpleNamespace(get_model_name=lambda: "fake-model"), + system_prompt="system", + tool_registry=ToolRegistry(), + session_id="transcript_att_0001", + root_session_id="root-session", + transcript_id="transcript_att_0001", + result_storage_dir=result_storage_dir, + audit_log_path=audit_log_path, + session_usage_store=SessionUsageStore(path_provider=lambda _cwd, _session_id: usage_path), + cwd="/repo", + ) + + assert loop.session_id == "transcript_att_0001" + assert loop._root_session_id == "root-session" + assert loop._transcript_id == "transcript_att_0001" + assert loop._audit_log_path == str(audit_log_path) + assert loop._result_storage._storage_dir == str(result_storage_dir) + assert loop._session_usage_store.append("/repo", loop.session_id, Usage(input_tokens=1, output_tokens=2)) + assert usage_path.exists() + + +def test_pipeline_runner_loads_legacy_transcript_session_when_no_v2_sidecar(tmp_path: Path): + storage = SessionStorage(projects_dir=tmp_path / "projects") + legacy_path = storage.legacy_session_path("/repo", "transcript_att_0001") + legacy_path.parent.mkdir(parents=True) + legacy_path.write_text( + '{"role":"assistant","content":[{"type":"tool_use","id":"tu_1","name":"complete_step","input":{}}]}\n', + encoding="utf-8", + ) + runner = PipelineRunner.__new__(PipelineRunner) + runner._cwd = "/repo" + runner._transcript_storage = None + runner._session_storage = storage + + messages = runner._load_repaired_resume_messages("transcript_att_0001") + + assert messages is not None + assert [message.role for message in messages] == ["assistant", "user"] + + +@pytest.mark.asyncio +async def test_step_executor_routes_transcript_runtime_paths(tmp_path: Path): + (tmp_path / "prompts").mkdir() + (tmp_path / "prompts" / "step.md").write_text("Run step.", encoding="utf-8") + root_storage = SessionStorage(projects_dir=tmp_path / "projects") + root_session_dir = root_storage.session_dir("/repo", "root-session") + write_session_metadata( + root_session_dir, + SessionMetadata(session_id="root-session", cwd="/repo", layout_version=SESSION_LAYOUT_VERSION_V2), + ) + transcript_storage = PipelineTranscriptStorage(tmp_path / "detached-pipeline") + registry = ToolRegistry() + registry.register(_FakePermissionTool()) + step = StepSpec(step_id="step", conclusion_field="out", forward=None, prompt_file="prompts/step.md") + pipeline = LoadedPipeline( + name="test", + steps=[step], + context_dependencies={"out": []}, + max_rollbacks=1, + skills={}, + ) + executor = StepExecutor( + provider_manager=_FakeProvider(), + base_tool_registry=registry, + pipeline=pipeline, + pipeline_dir=tmp_path, + session_storage=transcript_storage, + root_session_storage=root_storage, + cwd="/repo", + permission_context_getter=lambda: ToolPermissionContext(cwd="/repo"), + ) + + agent_context = executor.build_agent_loop_context( + step, + PipelineContext({"out": []}), + "root-session", + transcript_id="transcript_att_0001", + ) + + loop = agent_context.agent_loop + assert loop is not None + transcript_dir = SessionPaths.from_session_dir(root_session_dir).transcript_dir("transcript_att_0001") + assert loop.session_id == "transcript_att_0001" + assert loop._root_session_id == "root-session" + assert loop._transcript_id == "transcript_att_0001" + assert loop._result_storage._storage_dir == str(transcript_dir / "tool-results") + assert loop._audit_log_path == str(transcript_dir / "permission-audit.jsonl") + assert loop._session_usage_store.append("/repo", loop.session_id, Usage(input_tokens=3, output_tokens=4)) + assert (transcript_dir / "usage.jsonl").exists() + assert not (tmp_path / "detached-pipeline" / "transcripts" / "transcript_att_0001" / "usage.jsonl").exists() + + permission_events = [] + async for event in loop.run_streaming("run fake tool"): + if isinstance(event, PermissionRequestEvent): + permission_events.append(event) + event.response_future.set_result(False) + + [permission_event] = permission_events + assert permission_event.audit_context["root_session_id"] == "root-session" + assert permission_event.audit_context["transcript_id"] == "transcript_att_0001" + assert permission_event.audit_context["audit_log_path"] == str(transcript_dir / "permission-audit.jsonl") + + +def test_step_executor_keeps_legacy_root_on_legacy_runtime_paths(tmp_path: Path): + (tmp_path / "prompts").mkdir() + (tmp_path / "prompts" / "step.md").write_text("Run step.", encoding="utf-8") + root_storage = SessionStorage(projects_dir=tmp_path / "projects") + root_session_dir = root_storage.session_dir("/repo", "root-session") + root_session_dir.mkdir(parents=True) + registry = ToolRegistry() + registry.register(_FakePermissionTool()) + step = StepSpec(step_id="step", conclusion_field="out", forward=None, prompt_file="prompts/step.md") + pipeline = LoadedPipeline( + name="test", + steps=[step], + context_dependencies={"out": []}, + max_rollbacks=1, + skills={}, + ) + executor = StepExecutor( + provider_manager=_FakeProvider(), + base_tool_registry=registry, + pipeline=pipeline, + pipeline_dir=tmp_path, + session_storage=root_storage, + root_session_storage=root_storage, + cwd="/repo", + permission_context_getter=lambda: ToolPermissionContext(cwd="/repo"), + ) + + agent_context = executor.build_agent_loop_context( + step, + PipelineContext({"out": []}), + "root-session", + transcript_id="transcript_att_0001", + ) + + loop = agent_context.agent_loop + assert loop is not None + assert Path(loop._result_storage._storage_dir).parts[-2:] == ("tool-results", "transcript_att_0001") + assert loop._audit_log_path is None + assert not (root_session_dir / "pipeline" / "transcripts" / "transcript_att_0001").exists() diff --git a/tests/services/permissions/test_audit.py b/tests/services/permissions/test_audit.py index 61020874..953c309f 100644 --- a/tests/services/permissions/test_audit.py +++ b/tests/services/permissions/test_audit.py @@ -6,6 +6,7 @@ import pytest +import iac_code.services.permissions.audit as audit_module from iac_code.services.permissions.audit import ( PermissionAuditRecord, build_display_tool_input, @@ -18,6 +19,8 @@ is_permission_audit_non_read_only, sanitize_free_text, ) +from iac_code.services.session_layout import UnsupportedSessionLayoutError +from iac_code.services.session_metadata import SessionMetadata, write_session_metadata from iac_code.types.permissions import PermissionAuditMetadata, PermissionAuditSettings from iac_code.utils.project_paths import sanitize_path @@ -48,6 +51,76 @@ def _has_truncated_object(value: object) -> bool: return False +def test_emit_permission_audit_log_failure_warning_redacts_session_paths( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + session_id = "sensitive-session-id" + session_dir = tmp_path / "projects" / "sensitive-project" / session_id + session_dir.mkdir(parents=True) + (session_dir / "metadata.json").write_text("{not-json", encoding="utf-8") + warning = Mock() + monkeypatch.setattr(audit_module.logger, "warning", warning) + + written = emit_permission_audit( + PermissionAuditRecord( + session_id=session_id, + tool_name="bash", + tool_use_id="toolu_1", + decision="allow", + scope="session", + source="unit_test", + audit_log_path=str(session_dir / "permission-audit.jsonl"), + ) + ) + + assert written is False + warning.assert_called_once() + logged = " ".join(str(arg) for arg in warning.call_args.args) + assert str(tmp_path) not in logged + assert "sensitive-project" not in logged + assert session_id not in logged + assert "UnsupportedSessionLayoutError" in logged + + +def test_emit_permission_audit_log_failure_warning_omits_mounted_config_paths( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session_id = "sensitive-session-id" + warning = Mock() + monkeypatch.setattr(audit_module.logger, "warning", warning) + monkeypatch.setattr( + audit_module, + "_log_path", + Mock( + side_effect=UnsupportedSessionLayoutError( + "Unsupported session metadata: " + "/mnt/oss/customer-bucket/projects/sensitive-project/" + f"{session_id}/metadata.json" + ) + ), + ) + + written = emit_permission_audit( + PermissionAuditRecord( + session_id=session_id, + tool_name="bash", + tool_use_id="toolu_1", + decision="allow", + scope="session", + source="unit_test", + ) + ) + + assert written is False + warning.assert_called_once() + logged = " ".join(str(arg) for arg in warning.call_args.args) + assert "/mnt/oss" not in logged + assert "customer-bucket" not in logged + assert "sensitive-project" not in logged + assert session_id not in logged + assert "UnsupportedSessionLayoutError" in logged + + def test_fingerprint_is_stable_and_short() -> None: assert fingerprint_text("secret-value") == fingerprint_text("secret-value") assert fingerprint_text("secret-value").startswith("sha256:") @@ -171,6 +244,53 @@ def test_emit_permission_audit_preserves_field_shape_fingerprint_keys( assert field_key in row["input_summary"]["params_field_shapes"] +def test_emit_permission_audit_uses_direct_log_path_from_record( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("iac_code.services.permissions.audit.log_event", Mock()) + session_dir = tmp_path / "session" + write_session_metadata(session_dir, SessionMetadata(session_id="session", cwd="/repo", layout_version=2)) + log_path = session_dir / "pipeline" / "transcripts" / "transcript_1" / "permission-audit.jsonl" + record = PermissionAuditRecord( + session_id="transcript_1", + cwd="/repo", + tool_name="write_file", + tool_use_id="tool-1", + decision="allow", + scope="once", + source="test", + audit_log_path=str(log_path), + ) + + assert emit_permission_audit(record, settings=PermissionAuditSettings(max_file_bytes=1024, max_files=2)) + + assert log_path.exists() + assert not (tmp_path / "projects").exists() + + +def test_emit_permission_audit_refuses_direct_log_path_outside_supported_session( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + telemetry = Mock() + monkeypatch.setattr("iac_code.services.permissions.audit.log_event", telemetry) + record = PermissionAuditRecord( + session_id="s1", + cwd="/repo", + tool_name="write_file", + tool_use_id="tool-1", + decision="allow", + scope="once", + source="test", + audit_log_path=str(tmp_path / "outside" / "permission-audit.jsonl"), + ) + + assert emit_permission_audit(record, settings=PermissionAuditSettings(max_file_bytes=1024, max_files=2)) is False + assert not (tmp_path / "outside" / "permission-audit.jsonl").exists() + telemetry.assert_not_called() + + def test_emit_permission_audit_sanitizes_raw_field_shape_keys( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -505,6 +625,41 @@ def fail_append(*args, **kwargs): telemetry.assert_not_called() +@pytest.mark.parametrize("existing_log", [False, True]) +def test_emit_permission_audit_refuses_future_layout_session_without_mutating_log( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + existing_log: bool, +) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path)) + telemetry = Mock() + monkeypatch.setattr("iac_code.services.permissions.audit.log_event", telemetry) + cwd = "/home/workspace/context-future-layout" + session_id = "future-layout" + session_dir = _session_audit_log_path(tmp_path, cwd, session_id).parent + write_session_metadata(session_dir, SessionMetadata(session_id=session_id, cwd=cwd, layout_version=99)) + log_path = session_dir / "permission-audit.jsonl" + if existing_log: + log_path.write_text('{"old":true}\n', encoding="utf-8") + before = log_path.read_text(encoding="utf-8") if log_path.exists() else None + record = PermissionAuditRecord( + session_id=session_id, + cwd=cwd, + tool_name="bash", + tool_use_id="tu-future-layout", + decision="allow", + scope="once", + source="permission_pipeline", + ) + + assert emit_permission_audit(record, settings=PermissionAuditSettings(max_file_bytes=1024, max_files=2)) is False + telemetry.assert_not_called() + if before is None: + assert not log_path.exists() + else: + assert log_path.read_text(encoding="utf-8") == before + + def test_emit_permission_audit_clamps_excessive_max_files_before_rotation( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/services/permissions/test_trusted_roots.py b/tests/services/permissions/test_trusted_roots.py index 8c494f48..6e17a936 100644 --- a/tests/services/permissions/test_trusted_roots.py +++ b/tests/services/permissions/test_trusted_roots.py @@ -17,6 +17,43 @@ def test_build_session_trusted_read_directories_uses_session_artifact_dirs(monke ] +def test_build_session_trusted_read_directories_includes_session_scoped_artifact_dirs(monkeypatch, tmp_path): + monkeypatch.setattr("iac_code.config.get_config_dir", lambda: tmp_path / ".iac-code") + + from iac_code.services.permissions.trusted_roots import build_session_trusted_read_directories + + session_dir = tmp_path / "sessions" / "abc123" + + roots = build_session_trusted_read_directories("abc123", session_dir=session_dir) + + assert roots == [ + str(Path(tmp_path / ".iac-code" / "tool-results" / "abc123")), + str(Path(tmp_path / ".iac-code" / "image-cache" / "abc123")), + str(session_dir / "tool-results"), + str(session_dir / "image-cache"), + ] + + +def test_build_session_trusted_read_directories_rejects_symlinked_session_artifact_dir(monkeypatch, tmp_path): + monkeypatch.setattr("iac_code.config.get_config_dir", lambda: tmp_path / ".iac-code") + + from iac_code.services.permissions.trusted_roots import build_session_trusted_read_directories + + session_dir = tmp_path / "sessions" / "abc123" + session_dir.mkdir(parents=True) + outside = tmp_path / "outside" + outside.mkdir() + try: + (session_dir / "tool-results").symlink_to(outside, target_is_directory=True) + except (OSError, NotImplementedError): + pytest.skip("symlink creation is not available on this platform") + + roots = build_session_trusted_read_directories("abc123", session_dir=session_dir) + + assert str(session_dir / "tool-results") not in roots + assert str(session_dir / "image-cache") in roots + + def test_build_session_trusted_read_directories_returns_empty_for_falsey_session_id(): from iac_code.services.permissions.trusted_roots import build_session_trusted_read_directories diff --git a/tests/services/test_agent_factory.py b/tests/services/test_agent_factory.py index e57ce949..7b8f8f7e 100644 --- a/tests/services/test_agent_factory.py +++ b/tests/services/test_agent_factory.py @@ -1,6 +1,18 @@ from __future__ import annotations +import base64 +from pathlib import Path + +import pytest + from iac_code.services.agent_factory import AgentFactoryOptions, AgentRuntime, create_agent_runtime +from iac_code.services.session_metadata import ( + SESSION_LAYOUT_VERSION_V2, + SessionMetadata, + read_session_metadata, + write_session_metadata, +) +from iac_code.services.session_storage import SessionStorage def _current_time_line(prompt: str) -> str: @@ -61,8 +73,140 @@ def test_create_agent_runtime_adds_session_trusted_read_directories(tmp_path, mo ) roots = runtime.agent_loop._permission_context.trusted_read_directories + session_dir = runtime.agent_loop._session_storage.session_dir(str(tmp_path), "session-42") assert str(tmp_path / "config" / "tool-results" / "session-42") in roots assert str(tmp_path / "config" / "image-cache" / "session-42") in roots + assert str(session_dir / "tool-results") in roots + assert str(session_dir / "image-cache") in roots + + +def test_create_agent_runtime_result_storage_uses_v2_session_dir(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + session_id = "session-results" + storage = SessionStorage() + session_dir = storage.session_dir(str(tmp_path), session_id) + write_session_metadata( + session_dir, + SessionMetadata(session_id=session_id, cwd=str(tmp_path), layout_version=SESSION_LAYOUT_VERSION_V2), + ) + + runtime = create_agent_runtime( + AgentFactoryOptions( + model="qwen3.7-max", + session_id=session_id, + cwd=str(tmp_path), + ) + ) + + assert Path(runtime.agent_loop._result_storage._storage_dir) == session_dir / "tool-results" + + +def test_create_agent_runtime_prepares_new_session_as_v2_before_first_turn(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + session_id = "new-session-results" + storage = SessionStorage() + session_dir = storage.session_dir(str(tmp_path), session_id) + + runtime = create_agent_runtime( + AgentFactoryOptions( + model="qwen3.7-max", + session_id=session_id, + cwd=str(tmp_path), + ) + ) + + assert Path(runtime.agent_loop._result_storage._storage_dir) == session_dir / "tool-results" + metadata = read_session_metadata(session_dir) + assert metadata is not None + assert metadata.layout_version == SESSION_LAYOUT_VERSION_V2 + + +def test_create_agent_runtime_result_storage_uses_legacy_global_dir_without_session_metadata( + tmp_path, + monkeypatch, +) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + session_id = "legacy-session-results" + storage = SessionStorage() + session_dir = storage.session_dir(str(tmp_path), session_id) + session_dir.mkdir(parents=True) + (session_dir / "session.jsonl").write_text("", encoding="utf-8") + + runtime = create_agent_runtime( + AgentFactoryOptions( + model="qwen3.7-max", + session_id=session_id, + cwd=str(tmp_path), + ) + ) + + assert Path(runtime.agent_loop._result_storage._storage_dir) == tmp_path / "config" / "tool-results" / session_id + assert Path(runtime.agent_loop._result_storage._storage_dir) != session_dir / "tool-results" + + +@pytest.mark.asyncio +async def test_create_agent_runtime_mcp_binary_output_uses_session_dir(tmp_path, monkeypatch): + from iac_code.mcp.types import MCPToolRecord + from iac_code.tools.base import ToolContext + + class BinaryMCPManager: + async def connect_all(self) -> None: + pass + + def list_tools(self) -> list[MCPToolRecord]: + return [ + MCPToolRecord( + server_name="ros", + tool_name="render", + public_name="mcp__ros__render", + input_schema={"type": "object"}, + ) + ] + + def list_resources(self) -> list: + return [] + + def list_prompts(self) -> list: + return [] + + def list_connections(self) -> list: + return [] + + def needs_auth_servers(self) -> list[str]: + return [] + + async def call_tool(self, *args, **kwargs): + return { + "content": [ + { + "type": "image", + "data": base64.b64encode(b"factory-png").decode("ascii"), + "mimeType": "image/png", + } + ] + } + + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + manager = BinaryMCPManager() + + runtime = create_agent_runtime( + AgentFactoryOptions( + model="qwen3.7-max", + session_id="session-1", + cwd=str(tmp_path), + mcp_configs=[{"name": "ros", "command": "uvx"}], + mcp_manager_factory=lambda configs, roots: manager, + ) + ) + + tool = runtime.tool_registry.get("mcp__ros__render") + result = await tool.execute(tool_input={}, context=ToolContext()) + + session_dir = runtime.agent_loop._session_storage.session_dir(str(tmp_path), "session-1") + artifact_path = result.metadata["mcp"]["artifacts"][0]["path"] + assert artifact_path.startswith(str(session_dir / "tool-results" / "mcp" / "ros" / "render")) + assert not (tmp_path / "config" / "tool-results" / "session-1").exists() def test_create_agent_runtime_all_fields_populated(tmp_path, monkeypatch) -> None: diff --git a/tests/services/test_session_backup.py b/tests/services/test_session_backup.py new file mode 100644 index 00000000..4d4320f2 --- /dev/null +++ b/tests/services/test_session_backup.py @@ -0,0 +1,1643 @@ +import json +import os +import sys +from pathlib import Path + +import pytest + +from iac_code.services.session_backup import ( + BackupReason, + SessionBackupBlocked, + SessionBackupError, + SessionBackupService, +) +from iac_code.services.session_metadata import SESSION_LAYOUT_VERSION_V2, SessionMetadata, write_session_metadata +from iac_code.services.session_storage import SessionStorage + + +def _symlink_or_skip(target: Path, link: Path, *, target_is_directory: bool = False) -> None: + try: + link.symlink_to(target, target_is_directory=target_is_directory) + except (NotImplementedError, OSError) as exc: + pytest.skip(f"symlink creation unsupported: {exc}") + + +def _read_backup_marker(session_dir: Path) -> dict[str, object]: + return json.loads((session_dir / ".backup-state.json").read_text(encoding="utf-8")) + + +def _create_v2_session_dir(storage: SessionStorage, cwd: str, session_id: str) -> Path: + session_dir = storage.session_dir(cwd, session_id) + write_session_metadata( + session_dir, + SessionMetadata(session_id=session_id, cwd=cwd, layout_version=SESSION_LAYOUT_VERSION_V2), + ) + return session_dir + + +def test_backup_disabled_when_env_unset(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.delenv("IAC_CODE_CONFIG_BACKUP_DIR", raising=False) + storage = SessionStorage(projects_dir=tmp_path / "projects") + service = SessionBackupService(storage) + + result = service.backup_session("/repo", "s1", reason=BackupReason.NORMAL_TURN_END, critical=False) + + assert result.enabled is False + assert result.copied_files == 0 + + +def test_backup_disabled_when_env_unset_does_not_resolve_session_dir(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("IAC_CODE_CONFIG_BACKUP_DIR", raising=False) + + class RaisingStorage: + def session_dir(self, _cwd: str, _session_id: str) -> Path: + raise AssertionError("session_dir should not be called when backup is disabled") + + result = SessionBackupService(RaisingStorage()).backup_session( + "/repo", + "s1", + reason=BackupReason.NORMAL_TURN_END, + critical=False, + ) + + assert result.enabled is False + + +def test_backup_disabled_when_env_unset_does_not_validate_legacy_session_id(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("IAC_CODE_CONFIG_BACKUP_DIR", raising=False) + + result = SessionBackupService().backup_session( + "/repo", + "../legacy-session", + reason=BackupReason.NORMAL_TURN_END, + critical=False, + ) + + assert result.enabled is False + + +@pytest.mark.parametrize( + "raw_backup_root", + [ + "relative-backup", + pytest.param(r"C:\backup", marks=pytest.mark.skipif(os.name == "nt", reason="valid on Windows")), + r"C:", + r"C:\\", + r"\\server\share", + ], +) +def test_backup_rejects_non_absolute_or_windows_root_backup_dir( + monkeypatch: pytest.MonkeyPatch, + raw_backup_root: str, +) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", raw_backup_root) + + with pytest.raises(SessionBackupError, match="backup root"): + SessionBackupService()._backup_root() + + +@pytest.mark.skipif(os.name != "nt", reason="Windows path semantics only") +def test_backup_accepts_windows_absolute_backup_dir(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", r"C:\backup") + + assert SessionBackupService()._backup_root() == Path(r"C:\backup") + + +@pytest.mark.parametrize( + "session_id", + ["/tmp/s1", "../s1", "a/b", r"a\b", ".", "..", "", "CON", "NUL.txt", "COM1", "LPT9.log", "s1.", "s1 "], +) +def test_backup_rejects_unsafe_session_id_before_storage_call( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + session_id: str, +) -> None: + backup_root = tmp_path / "backup" + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + calls = [] + + class RecordingStorage: + def session_dir(self, cwd: str, requested_session_id: str) -> Path: + calls.append((cwd, requested_session_id)) + raise AssertionError("session_dir should not be called for unsafe session_id") + + with pytest.raises(SessionBackupError, match="unsafe session_id"): + SessionBackupService(RecordingStorage()).backup_session( + "/repo", + session_id, + reason=BackupReason.NORMAL_TURN_END, + critical=True, + ) + + assert calls == [] + assert not backup_root.exists() + + +def test_backup_mirrors_session_and_excludes_local_markers(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + backup_root = tmp_path / "backup" + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + (session_dir / "session.jsonl").write_text("one\n", encoding="utf-8") + (session_dir / "permission-audit.jsonl").write_text("audit\n", encoding="utf-8") + (session_dir / "usage.jsonl").write_text("usage\n", encoding="utf-8") + (session_dir / ".backup-state.json").write_text("local\n", encoding="utf-8") + (session_dir / ".backup-lock").write_text("lock\n", encoding="utf-8") + (session_dir / ".session.jsonl.lock").write_text("session lock\n", encoding="utf-8") + (session_dir / ".permission-audit.jsonl.lock").write_text("audit lock\n", encoding="utf-8") + (session_dir / ".usage.jsonl.lock").write_text("usage lock\n", encoding="utf-8") + + result = SessionBackupService(session_storage=storage).backup_session( + "/repo", + "s1", + reason=BackupReason.PIPELINE_STEP_COMPLETED, + critical=True, + ) + + mirror = backup_root / "projects" / session_dir.parent.name / "s1" + assert result.enabled is True + assert (mirror / "session.jsonl").read_text(encoding="utf-8") == "one\n" + assert (mirror / "permission-audit.jsonl").read_text(encoding="utf-8") == "audit\n" + assert (mirror / "usage.jsonl").read_text(encoding="utf-8") == "usage\n" + assert not (mirror / ".backup-state.json").exists() + assert not (mirror / ".backup-lock").exists() + assert not (mirror / ".session.jsonl.lock").exists() + assert not (mirror / ".permission-audit.jsonl.lock").exists() + assert not (mirror / ".usage.jsonl.lock").exists() + marker = _read_backup_marker(session_dir) + assert marker["status"] == "succeeded" + assert marker["reason"] == "pipeline_step_completed" + assert "error" not in marker + + +def test_backup_deletes_stale_mirror_files(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + backup_root = tmp_path / "backup" + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + (session_dir / "session.jsonl").write_text("fresh\n", encoding="utf-8") + mirror = backup_root / "projects" / session_dir.parent.name / "s1" + mirror.mkdir(parents=True) + (mirror / "stale.txt").write_text("old\n", encoding="utf-8") + + SessionBackupService(session_storage=storage).backup_session( + "/repo", + "s1", + reason=BackupReason.TERMINAL, + critical=True, + ) + + assert not (mirror / "stale.txt").exists() + assert (mirror / "session.jsonl").read_text(encoding="utf-8") == "fresh\n" + + +def test_backup_deletes_stale_symlink_to_directory(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + backup_root = tmp_path / "backup" + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + (session_dir / "session.jsonl").write_text("fresh\n", encoding="utf-8") + mirror = backup_root / "projects" / session_dir.parent.name / "s1" + mirror.mkdir(parents=True) + outside_dir = tmp_path / "outside" + outside_dir.mkdir() + stale_link = mirror / "stale-link" + _symlink_or_skip(outside_dir, stale_link, target_is_directory=True) + + SessionBackupService(session_storage=storage).backup_session( + "/repo", + "s1", + reason=BackupReason.TERMINAL, + critical=True, + ) + + assert not stale_link.is_symlink() + assert (mirror / "session.jsonl").read_text(encoding="utf-8") == "fresh\n" + assert outside_dir.exists() + + +def test_backup_lock_rejects_source_reparse_point(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + service = SessionBackupService() + session_dir = tmp_path / "session" + session_dir.mkdir() + + monkeypatch.setattr(service, "_is_reparse_point", lambda path: path == session_dir) + + with pytest.raises(SessionBackupError, match="session source"): + with service._session_backup_lock(session_dir): + pass + + assert not (session_dir / ".backup-lock").exists() + + +def test_backup_lock_rejects_lock_file_reparse_point(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + service = SessionBackupService() + session_dir = tmp_path / "session" + session_dir.mkdir() + lock_path = session_dir / ".backup-lock" + lock_path.write_text("", encoding="utf-8") + + monkeypatch.setattr(service, "_is_reparse_point", lambda path: path == lock_path) + + with pytest.raises(SessionBackupError, match="backup lock"): + with service._session_backup_lock(session_dir): + pass + + +def test_backup_lock_uses_msvcrt_on_windows(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + from iac_code.services import session_backup as session_backup_module + + class FakeMsvcrt: + LK_LOCK = 1 + LK_UNLCK = 2 + + def __init__(self) -> None: + self.calls: list[tuple[int, int]] = [] + + def locking(self, _fd: int, mode: int, nbytes: int) -> None: + self.calls.append((mode, nbytes)) + + fake_msvcrt = FakeMsvcrt() + opened: list[tuple[Path, int, int]] = [] + service = SessionBackupService(session_storage=object()) + monkeypatch.setitem(sys.modules, "msvcrt", fake_msvcrt) + monkeypatch.setattr(session_backup_module.os, "name", "nt", raising=False) + + def fake_open_no_follow(path: Path, flags: int, mode: int) -> int: + opened.append((path, flags, mode)) + return os.open(path, flags, mode) + + monkeypatch.setattr(service, "_open_no_follow_fd", fake_open_no_follow) + + session_dir = tmp_path / "session" + session_dir.mkdir() + + with service._session_backup_lock(session_dir): + assert (session_dir / ".backup-lock").exists() + + assert fake_msvcrt.calls == [(fake_msvcrt.LK_LOCK, 1), (fake_msvcrt.LK_UNLCK, 1)] + assert opened == [(session_dir / ".backup-lock", os.O_RDWR | os.O_CREAT, 0o600)] + + +def test_canonical_windows_path_text_normalizes_verbatim_prefixes() -> None: + assert SessionBackupService._canonical_windows_path_text(r"\\?\C:\work\repo") == r"c:\work\repo" + assert SessionBackupService._canonical_windows_path_text(r"\\?\UNC\server\share\repo") == r"\\server\share\repo" + + +def test_backup_windows_no_follow_rejects_reparse_attribute_from_handle( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + import ctypes + import types + + class FakeFunction: + def __init__(self, result: object = True, *, reparse_info: bool = False) -> None: + self.result = result + self.reparse_info = reparse_info + self.calls: list[tuple[object, ...]] = [] + self.argtypes = None + self.restype = None + + def __call__(self, *args): + self.calls.append(args) + if self.reparse_info: + args[1]._obj.dwFileAttributes = 0x400 + return self.result + + class FakeKernel32: + def __init__(self) -> None: + self.CreateFileW = FakeFunction(result=1234) + self.GetFileInformationByHandle = FakeFunction(reparse_info=True) + self.CloseHandle = FakeFunction(result=True) + + fake_kernel32 = FakeKernel32() + fake_msvcrt = types.SimpleNamespace(open_osfhandle=lambda *_args: pytest.fail("open_osfhandle called")) + monkeypatch.setitem(sys.modules, "msvcrt", fake_msvcrt) + monkeypatch.setattr(ctypes, "WinDLL", lambda *_args, **_kwargs: fake_kernel32, raising=False) + + with pytest.raises(SessionBackupError, match="regular file"): + SessionBackupService()._open_windows_no_follow_fd(tmp_path / "session.jsonl", os.O_RDONLY, 0o600) + + assert fake_kernel32.GetFileInformationByHandle.calls + assert fake_kernel32.CloseHandle.calls == [(1234,)] + + +def test_backup_rejects_windows_physical_alias_overlap(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + from iac_code.services import session_backup as session_backup_module + + source = tmp_path / "config" / "projects" / "repo" / "s1" + source.mkdir(parents=True) + write_session_metadata( + source, + SessionMetadata(session_id="s1", cwd="/repo", layout_version=SESSION_LAYOUT_VERSION_V2), + ) + backup_root = tmp_path / "backup-alias" + destination = backup_root / "projects" / source.parent.name / "s1" + service = SessionBackupService(session_storage=object()) + + physical_paths = { + source: r"\\?\C:\work\repo\.iac-code\projects\repo\s1", + backup_root: r"\\?\C:\work\repo\.iac-code\projects\repo\s1\backup", + destination: r"\\?\C:\work\repo\.iac-code\projects\repo\s1\backup\projects\repo\s1", + } + + monkeypatch.setattr(session_backup_module.os, "name", "nt", raising=False) + monkeypatch.setattr(service, "_windows_physical_path_text", lambda path: physical_paths[path]) + + with pytest.raises(SessionBackupError, match="overlaps session source"): + service._validate_mirror_paths(source, destination, backup_root) + + +def test_backup_rejects_windows_physical_source_inside_backup_root( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from iac_code.services import session_backup as session_backup_module + + source = tmp_path / "config" / "projects" / "repo" / "s1" + source.mkdir(parents=True) + write_session_metadata( + source, + SessionMetadata(session_id="s1", cwd="/repo", layout_version=SESSION_LAYOUT_VERSION_V2), + ) + backup_root = tmp_path / "backup-alias" + destination = backup_root / "projects" / source.parent.name / "s1" + service = SessionBackupService(session_storage=object()) + + physical_paths = { + source: r"\\?\C:\work\repo\.iac-code\projects\repo\s1", + backup_root: r"\\?\C:\work\repo\.iac-code", + destination: r"\\?\C:\work\repo\.iac-code\projects\repo\s1", + } + + monkeypatch.setattr(session_backup_module.os, "name", "nt", raising=False) + monkeypatch.setattr(service, "_windows_physical_path_text", lambda path: physical_paths[path]) + + with pytest.raises(SessionBackupError, match="overlaps session source"): + service._validate_mirror_paths(source, destination, backup_root) + + +def test_windows_physical_path_sets_win32_signatures(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + import ctypes + + class FakeFunction: + def __init__(self, result=None) -> None: + self.result = result + self.calls = [] + self.argtypes = None + self.restype = None + + def __call__(self, *args): + self.calls.append(args) + if len(args) >= 2 and hasattr(args[1], "value"): + args[1].value = r"\\?\C:\real\session" + return len(args[1].value) + return self.result + + class FakeKernel32: + def __init__(self) -> None: + self.CreateFileW = FakeFunction(result=1234567890123) + self.GetFinalPathNameByHandleW = FakeFunction() + self.CloseHandle = FakeFunction(result=True) + + fake_kernel32 = FakeKernel32() + monkeypatch.setattr(ctypes, "WinDLL", lambda *_args, **_kwargs: fake_kernel32, raising=False) + + physical = SessionBackupService._windows_existing_physical_path_text(tmp_path) + + assert physical == r"\\?\C:\real\session" + assert fake_kernel32.CreateFileW.argtypes is not None + assert fake_kernel32.CreateFileW.restype is not None + assert fake_kernel32.GetFinalPathNameByHandleW.argtypes is not None + assert fake_kernel32.GetFinalPathNameByHandleW.restype is not None + assert fake_kernel32.CloseHandle.argtypes is not None + assert fake_kernel32.CloseHandle.restype is not None + assert fake_kernel32.CloseHandle.calls == [(1234567890123,)] + + +def test_unlink_uses_rmdir_for_windows_directory_symlink(monkeypatch: pytest.MonkeyPatch) -> None: + from iac_code.services import session_backup as session_backup_module + + class FakeDirectorySymlink: + def __init__(self) -> None: + self.rmdir_called = False + self.unlink_called = False + + def is_symlink(self) -> bool: + return True + + def is_dir(self) -> bool: + return True + + def rmdir(self) -> None: + self.rmdir_called = True + + def unlink(self) -> None: + self.unlink_called = True + + fake_path = FakeDirectorySymlink() + monkeypatch.setattr(session_backup_module.os, "name", "nt", raising=False) + + SessionBackupService(session_storage=object())._unlink(fake_path) # type: ignore[arg-type] + + assert fake_path.rmdir_called is True + assert fake_path.unlink_called is False + + +def test_unlink_uses_lstat_directory_attribute_for_broken_windows_symlink( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from iac_code.services import session_backup as session_backup_module + + class FakeStat: + st_file_attributes = getattr(os.stat_result, "FILE_ATTRIBUTE_DIRECTORY", 0x10) + + class FakeBrokenDirectorySymlink: + def __init__(self) -> None: + self.rmdir_called = False + self.unlink_called = False + + def is_symlink(self) -> bool: + return True + + def stat(self, *, follow_symlinks: bool = True): + assert follow_symlinks is False + return FakeStat() + + def is_dir(self) -> bool: + return False + + def rmdir(self) -> None: + self.rmdir_called = True + + def unlink(self) -> None: + self.unlink_called = True + + fake_path = FakeBrokenDirectorySymlink() + monkeypatch.setattr(session_backup_module.os, "name", "nt", raising=False) + + SessionBackupService(session_storage=object())._unlink(fake_path) # type: ignore[arg-type] + + assert fake_path.rmdir_called is True + assert fake_path.unlink_called is False + + +def test_backup_deletes_stale_broken_symlink(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + backup_root = tmp_path / "backup" + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + (session_dir / "session.jsonl").write_text("fresh\n", encoding="utf-8") + mirror = backup_root / "projects" / session_dir.parent.name / "s1" + mirror.mkdir(parents=True) + stale_link = mirror / "broken-link" + _symlink_or_skip(tmp_path / "missing-target", stale_link) + + SessionBackupService(session_storage=storage).backup_session( + "/repo", + "s1", + reason=BackupReason.TERMINAL, + critical=True, + ) + + assert not stale_link.is_symlink() + assert (mirror / "session.jsonl").read_text(encoding="utf-8") == "fresh\n" + + +def test_backup_deletes_stale_non_regular_destination_entry(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + if not hasattr(os, "mkfifo"): + pytest.skip("os.mkfifo is unavailable") + backup_root = tmp_path / "backup" + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + (session_dir / "session.jsonl").write_text("fresh\n", encoding="utf-8") + mirror = backup_root / "projects" / session_dir.parent.name / "s1" + mirror.mkdir(parents=True) + stale_fifo = mirror / "stale.pipe" + try: + os.mkfifo(stale_fifo) + except OSError as exc: + pytest.skip(f"fifo creation unsupported: {exc}") + + SessionBackupService(session_storage=storage).backup_session( + "/repo", + "s1", + reason=BackupReason.TERMINAL, + critical=True, + ) + + assert not stale_fifo.exists() + assert (mirror / "session.jsonl").read_text(encoding="utf-8") == "fresh\n" + + +def test_backup_skips_source_symlink_to_external_file(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + backup_root = tmp_path / "backup" + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + (session_dir / "session.jsonl").write_text("fresh\n", encoding="utf-8") + external_file = tmp_path / "external-secret.txt" + external_file.write_text("secret\n", encoding="utf-8") + _symlink_or_skip(external_file, session_dir / "linked-secret.txt") + mirror = backup_root / "projects" / session_dir.parent.name / "s1" + mirror.mkdir(parents=True) + (mirror / "linked-secret.txt").write_text("stale\n", encoding="utf-8") + + SessionBackupService(session_storage=storage).backup_session( + "/repo", + "s1", + reason=BackupReason.TERMINAL, + critical=True, + ) + + assert not (mirror / "linked-secret.txt").exists() + assert external_file.read_text(encoding="utf-8") == "secret\n" + + +def test_backup_copy_file_refuses_symlink_source(tmp_path: Path) -> None: + external_file = tmp_path / "external-secret.txt" + external_file.write_text("secret\n", encoding="utf-8") + source_link = tmp_path / "session" / "linked-secret.txt" + source_link.parent.mkdir() + _symlink_or_skip(external_file, source_link) + destination = tmp_path / "mirror" / "linked-secret.txt" + + with pytest.raises(SessionBackupError, match="regular file"): + SessionBackupService(session_storage=object())._copy_file(source_link, destination) + + assert not destination.exists() + assert external_file.read_text(encoding="utf-8") == "secret\n" + + +def test_backup_skips_source_symlink_to_external_directory(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + backup_root = tmp_path / "backup" + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + (session_dir / "session.jsonl").write_text("fresh\n", encoding="utf-8") + external_dir = tmp_path / "external-dir" + external_dir.mkdir() + (external_dir / "secret.txt").write_text("secret\n", encoding="utf-8") + _symlink_or_skip(external_dir, session_dir / "linked-dir", target_is_directory=True) + + SessionBackupService(session_storage=storage).backup_session( + "/repo", + "s1", + reason=BackupReason.TERMINAL, + critical=True, + ) + + mirror = backup_root / "projects" / session_dir.parent.name / "s1" + assert not (mirror / "linked-dir").exists() + assert external_dir.exists() + assert (external_dir / "secret.txt").read_text(encoding="utf-8") == "secret\n" + + +def test_backup_skips_broken_source_symlink(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + backup_root = tmp_path / "backup" + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + (session_dir / "session.jsonl").write_text("fresh\n", encoding="utf-8") + _symlink_or_skip(tmp_path / "missing-source-target", session_dir / "broken-link") + + SessionBackupService(session_storage=storage).backup_session( + "/repo", + "s1", + reason=BackupReason.TERMINAL, + critical=True, + ) + + mirror = backup_root / "projects" / session_dir.parent.name / "s1" + assert not (mirror / "broken-link").exists() + + +def test_backup_rejects_symlinked_source_session_dir( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + backup_root = tmp_path / "backup" + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = storage.session_dir("/repo", "s1") + session_dir.parent.mkdir(parents=True) + external_session = tmp_path / "external-session" + external_session.mkdir() + write_session_metadata( + external_session, + SessionMetadata(session_id="s1", cwd="/repo", layout_version=SESSION_LAYOUT_VERSION_V2), + ) + (external_session / "secret.txt").write_text("secret\n", encoding="utf-8") + _symlink_or_skip(external_session, session_dir, target_is_directory=True) + + with pytest.raises(SessionBackupBlocked, match="session source"): + SessionBackupService(session_storage=storage, retry_delays=()).backup_session( + "/repo", + "s1", + reason=BackupReason.INPUT_REQUIRED, + critical=True, + ) + + mirror = backup_root / "projects" / session_dir.parent.name / "s1" + assert not (mirror / "secret.txt").exists() + assert (external_session / "secret.txt").read_text(encoding="utf-8") == "secret\n" + + +def test_backup_rejects_reparse_point_source_ancestry( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + backup_root = tmp_path / "backup" + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + (session_dir / "session.jsonl").write_text("fresh\n", encoding="utf-8") + service = SessionBackupService(session_storage=storage, retry_delays=()) + monkeypatch.setattr(service, "_is_reparse_point", lambda path: Path(path) == session_dir.parent) + + with pytest.raises(SessionBackupBlocked, match="session source"): + service.backup_session( + "/repo", + "s1", + reason=BackupReason.INPUT_REQUIRED, + critical=True, + ) + + mirror = backup_root / "projects" / session_dir.parent.name / "s1" + assert not (mirror / "session.jsonl").exists() + assert not (session_dir / ".backup-state.json").exists() + assert not (session_dir / ".backup-lock").exists() + + +def test_backup_skips_non_regular_source_entry(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + if not hasattr(os, "mkfifo"): + pytest.skip("os.mkfifo is unavailable") + backup_root = tmp_path / "backup" + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + (session_dir / "session.jsonl").write_text("fresh\n", encoding="utf-8") + fifo_path = session_dir / "event.pipe" + try: + os.mkfifo(fifo_path) + except OSError as exc: + pytest.skip(f"fifo creation unsupported: {exc}") + mirror = backup_root / "projects" / session_dir.parent.name / "s1" + mirror.mkdir(parents=True) + (mirror / "event.pipe").write_text("stale\n", encoding="utf-8") + + SessionBackupService(session_storage=storage).backup_session( + "/repo", + "s1", + reason=BackupReason.TERMINAL, + critical=True, + ) + + assert not (mirror / "event.pipe").exists() + assert (mirror / "session.jsonl").read_text(encoding="utf-8") == "fresh\n" + + +def test_backup_failed_marker_sanitizes_raw_errors(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + backup_root = tmp_path / "backup-secret-token" + backup_root.write_text("not a directory", encoding="utf-8") + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + service = SessionBackupService(session_storage=storage, retry_delays=()) + + result = service.backup_session( + "/repo", + "s1", + reason=BackupReason.NORMAL_TURN_END, + critical=False, + ) + + marker = _read_backup_marker(session_dir) + assert result.succeeded is False + assert result.error is not None + assert str(tmp_path) not in result.error + assert str(tmp_path) not in marker["error"] + assert "backup-secret-token" not in result.error + assert "backup-secret-token" not in marker["error"] + + +def test_backup_error_redacts_arbitrary_mount_paths(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + backup_root = tmp_path / "backup" + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + service = SessionBackupService(session_storage=storage, retry_delays=()) + + def fail_with_mount_path(*_args, **_kwargs) -> None: + raise SessionBackupError("copy failed for /mnt/oss/customer-bucket/tenant-a/session/s1") + + monkeypatch.setattr(service, "_validate_mirror_paths", fail_with_mount_path) + + result = service.backup_session( + "/repo", + "s1", + reason=BackupReason.NORMAL_TURN_END, + critical=False, + ) + + marker = _read_backup_marker(session_dir) + assert result.succeeded is False + assert result.error is not None + for public_error in (result.error, str(marker["error"])): + assert "/mnt/oss" not in public_error + assert "customer-bucket" not in public_error + assert "tenant-a" not in public_error + + +def test_backup_error_redacts_windows_paths_with_spaces() -> None: + error = SessionBackupService._public_error_text( + SessionBackupError( + r"copy failed for C:\Users\Alice\OneDrive - Org\Backup Root\secret.txt" + r" and \\server\secret share\tenant-a\file.json" + ) + ) + + assert "OneDrive" not in error + assert "Org" not in error + assert "Backup Root" not in error + assert "secret share" not in error + assert "tenant-a" not in error + + +def test_backup_replaces_stale_file_with_source_directory(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + backup_root = tmp_path / "backup" + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + (session_dir / "foo").mkdir() + (session_dir / "foo" / "child.txt").write_text("child\n", encoding="utf-8") + mirror = backup_root / "projects" / session_dir.parent.name / "s1" + mirror.mkdir(parents=True) + (mirror / "foo").write_text("stale file\n", encoding="utf-8") + + SessionBackupService(session_storage=storage).backup_session( + "/repo", + "s1", + reason=BackupReason.TERMINAL, + critical=True, + ) + + assert (mirror / "foo").is_dir() + assert (mirror / "foo" / "child.txt").read_text(encoding="utf-8") == "child\n" + + +def test_backup_replaces_stale_root_file_with_session_directory( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + backup_root = tmp_path / "backup" + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + (session_dir / "session.jsonl").write_text("fresh\n", encoding="utf-8") + mirror = backup_root / "projects" / session_dir.parent.name / "s1" + mirror.parent.mkdir(parents=True) + mirror.write_text("stale file\n", encoding="utf-8") + + SessionBackupService(session_storage=storage).backup_session( + "/repo", + "s1", + reason=BackupReason.TERMINAL, + critical=True, + ) + + assert mirror.is_dir() + assert (mirror / "session.jsonl").read_text(encoding="utf-8") == "fresh\n" + + +def test_backup_replaces_stale_directory_with_source_file(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + backup_root = tmp_path / "backup" + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + (session_dir / "foo").write_text("fresh file\n", encoding="utf-8") + mirror = backup_root / "projects" / session_dir.parent.name / "s1" + (mirror / "foo").mkdir(parents=True) + (mirror / "foo" / "old.txt").write_text("old\n", encoding="utf-8") + + SessionBackupService(session_storage=storage).backup_session( + "/repo", + "s1", + reason=BackupReason.TERMINAL, + critical=True, + ) + + assert (mirror / "foo").is_file() + assert (mirror / "foo").read_text(encoding="utf-8") == "fresh file\n" + + +def test_backup_replaces_stale_fifo_with_source_file(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + if not hasattr(os, "mkfifo"): + pytest.skip("os.mkfifo is unavailable") + backup_root = tmp_path / "backup" + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + source_file = session_dir / "foo" + source_file.write_text("", encoding="utf-8") + mirror = backup_root / "projects" / session_dir.parent.name / "s1" + mirror.mkdir(parents=True) + mirror_fifo = mirror / "foo" + try: + os.mkfifo(mirror_fifo) + except OSError as exc: + pytest.skip(f"fifo creation unsupported: {exc}") + fifo_stat = mirror_fifo.stat() + os.utime(source_file, ns=(fifo_stat.st_atime_ns, fifo_stat.st_mtime_ns)) + + SessionBackupService(session_storage=storage).backup_session( + "/repo", + "s1", + reason=BackupReason.TERMINAL, + critical=True, + ) + + assert (mirror / "foo").is_file() + assert (mirror / "foo").read_text(encoding="utf-8") == "" + + +def test_backup_root_inside_session_dir_blocks_without_recursive_mirror( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + (session_dir / "session.jsonl").write_text("fresh\n", encoding="utf-8") + backup_root = session_dir / "backup" + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + + with pytest.raises(SessionBackupBlocked, match="session source"): + SessionBackupService(session_storage=storage, retry_delays=()).backup_session( + "/repo", + "s1", + reason=BackupReason.INPUT_REQUIRED, + critical=True, + ) + + mirror = backup_root / "projects" / session_dir.parent.name / "s1" + assert not backup_root.exists() + assert not mirror.exists() + assert not (backup_root / "projects" / session_dir.parent.name / "s1" / "backup" / "projects").exists() + + +def test_backup_rejects_source_inside_planned_destination( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + backup_root = tmp_path / "backup" + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + source = backup_root / "projects" / "s1" / "s1" / "source" + source.mkdir(parents=True) + source_file = source / "session.jsonl" + source_file.write_text("fresh\n", encoding="utf-8") + + class FakeStorage: + def session_dir(self, _cwd: str, _session_id: str) -> Path: + return source + + with pytest.raises(SessionBackupBlocked, match="backup destination"): + SessionBackupService(session_storage=FakeStorage(), retry_delays=()).backup_session( + "/repo", + "s1", + reason=BackupReason.INPUT_REQUIRED, + critical=True, + ) + + assert source_file.read_text(encoding="utf-8") == "fresh\n" + + +def test_backup_rejects_backup_root_containing_session_source( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + (session_dir / "session.jsonl").write_text("fresh\n", encoding="utf-8") + backup_root = session_dir.parent.parent + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + + with pytest.raises(SessionBackupBlocked, match="backup destination"): + SessionBackupService(session_storage=storage, retry_delays=()).backup_session( + "/repo", + "s1", + reason=BackupReason.INPUT_REQUIRED, + critical=True, + ) + + mirror = backup_root / "projects" / session_dir.parent.name / "s1" + assert not (mirror / "session.jsonl").exists() + + +def test_backup_rejects_destination_ancestry_symlink_outside_backup_root( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + backup_root = tmp_path / "backup" + backup_root.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + _symlink_or_skip(outside, backup_root / "projects", target_is_directory=True) + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + (session_dir / "session.jsonl").write_text("fresh\n", encoding="utf-8") + + with pytest.raises(SessionBackupBlocked, match="destination ancestry"): + SessionBackupService(session_storage=storage, retry_delays=()).backup_session( + "/repo", + "s1", + reason=BackupReason.INPUT_REQUIRED, + critical=True, + ) + + assert not (outside / session_dir.parent.name / "s1" / "session.jsonl").exists() + + +def test_backup_rejects_destination_ancestry_symlink_inside_backup_root( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + backup_root = tmp_path / "backup" + backup_root.mkdir() + real_projects = backup_root / "real_projects" + real_projects.mkdir() + _symlink_or_skip(real_projects, backup_root / "projects", target_is_directory=True) + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + (session_dir / "session.jsonl").write_text("fresh\n", encoding="utf-8") + + with pytest.raises(SessionBackupBlocked, match="destination ancestry"): + SessionBackupService(session_storage=storage, retry_delays=()).backup_session( + "/repo", + "s1", + reason=BackupReason.INPUT_REQUIRED, + critical=True, + ) + + assert not (real_projects / session_dir.parent.name / "s1" / "session.jsonl").exists() + + +def test_backup_accepts_symlinked_configured_backup_root( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + backup_target = tmp_path / "backup-target" + backup_target.mkdir() + backup_link = tmp_path / "backup-link" + _symlink_or_skip(backup_target, backup_link, target_is_directory=True) + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_link)) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + (session_dir / "session.jsonl").write_text("fresh\n", encoding="utf-8") + + result = SessionBackupService(session_storage=storage, retry_delays=()).backup_session( + "/repo", + "s1", + reason=BackupReason.INPUT_REQUIRED, + critical=True, + ) + + assert result.succeeded is True + assert (backup_target / "projects" / session_dir.parent.name / "s1" / "session.jsonl").read_text( + encoding="utf-8" + ) == "fresh\n" + + +def test_backup_accepts_symlinked_backup_root_ancestor( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + ancestor_target = tmp_path / "ancestor-target" + ancestor_target.mkdir() + ancestor_link = tmp_path / "ancestor-link" + _symlink_or_skip(ancestor_target, ancestor_link, target_is_directory=True) + backup_root = ancestor_link / "backup" + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + (session_dir / "session.jsonl").write_text("fresh\n", encoding="utf-8") + + result = SessionBackupService(session_storage=storage, retry_delays=()).backup_session( + "/repo", + "s1", + reason=BackupReason.INPUT_REQUIRED, + critical=True, + ) + + assert result.succeeded is True + assert (ancestor_target / "backup" / "projects" / session_dir.parent.name / "s1" / "session.jsonl").read_text( + encoding="utf-8" + ) == "fresh\n" + + +def test_backup_heals_leaf_destination_session_symlink( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + backup_root = tmp_path / "backup" + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + (session_dir / "session.jsonl").write_text("fresh\n", encoding="utf-8") + mirror = backup_root / "projects" / session_dir.parent.name / "s1" + mirror.parent.mkdir(parents=True) + outside_target = tmp_path / "outside-target" + outside_target.mkdir() + _symlink_or_skip(outside_target, mirror, target_is_directory=True) + + SessionBackupService(session_storage=storage, retry_delays=()).backup_session( + "/repo", + "s1", + reason=BackupReason.TERMINAL, + critical=True, + ) + + assert mirror.is_dir() + assert not mirror.is_symlink() + assert (mirror / "session.jsonl").read_text(encoding="utf-8") == "fresh\n" + assert not (outside_target / "session.jsonl").exists() + + +def test_backup_uses_local_lock_and_excludes_it(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + backup_root = tmp_path / "backup" + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + (session_dir / "session.jsonl").write_text("fresh\n", encoding="utf-8") + service = SessionBackupService(session_storage=storage) + original_mirror = service._mirror + + def assert_lock_exists(source: Path, destination: Path): + assert (session_dir / ".backup-lock").exists() + return original_mirror(source, destination) + + monkeypatch.setattr(service, "_mirror", assert_lock_exists) + + service.backup_session("/repo", "s1", reason=BackupReason.TERMINAL, critical=True) + + mirror = backup_root / "projects" / session_dir.parent.name / "s1" + assert (session_dir / ".backup-lock").exists() + assert not (mirror / ".backup-lock").exists() + + +def test_backup_fsyncs_structural_metadata_changes(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + backup_root = tmp_path / "backup" + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + (session_dir / "new_dir").mkdir() + (session_dir / "new_dir" / "child.txt").write_text("child\n", encoding="utf-8") + (session_dir / "dir_conflict").write_text("fresh file\n", encoding="utf-8") + mirror = backup_root / "projects" / session_dir.parent.name / "s1" + mirror.mkdir(parents=True) + (mirror / "stale.txt").write_text("stale\n", encoding="utf-8") + (mirror / "dir_conflict").mkdir() + (mirror / "dir_conflict" / "old.txt").write_text("old\n", encoding="utf-8") + (mirror / "empty").mkdir() + fsync_calls: list[Path] = [] + monkeypatch.setattr("iac_code.services.session_backup.fsync_parent_dir", fsync_calls.append) + + SessionBackupService(session_storage=storage).backup_session( + "/repo", + "s1", + reason=BackupReason.TERMINAL, + critical=True, + ) + + assert mirror in fsync_calls + assert mirror / "new_dir" in fsync_calls + assert mirror / "stale.txt" in fsync_calls + assert mirror / "dir_conflict" in fsync_calls + assert mirror / "empty" in fsync_calls + + +def test_failed_marker_for_mirror_failure_is_written_while_lock_is_held( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + if os.name == "nt": + pytest.skip("fcntl lock assertion is POSIX-only") + import fcntl + + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(tmp_path / "backup")) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + (session_dir / "session.jsonl").write_text("one\n", encoding="utf-8") + service = SessionBackupService(session_storage=storage, retry_delays=()) + original_write_marker = service._write_marker + failed_marker_lock_states: list[bool] = [] + + def fail_mirror(*_args, **_kwargs): + raise OSError("mirror failed") + + def assert_lock_state_for_marker( + source: Path, + *, + reason: BackupReason, + status: str, + error: str | None, + **kwargs, + ) -> None: + if status == "failed": + with (source / ".backup-lock").open("a+b") as lock_file: + try: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + failed_marker_lock_states.append(True) + else: + failed_marker_lock_states.append(False) + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) + original_write_marker(source, reason=reason, status=status, error=error, **kwargs) + + monkeypatch.setattr(service, "_mirror", fail_mirror) + monkeypatch.setattr(service, "_write_marker", assert_lock_state_for_marker) + + with pytest.raises(SessionBackupBlocked, match="mirror failed"): + service.backup_session("/repo", "s1", reason=BackupReason.INPUT_REQUIRED, critical=True) + + assert failed_marker_lock_states == [True] + + +def test_critical_backup_retries_then_blocks(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(tmp_path / "backup")) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + (session_dir / "session.jsonl").write_text("one\n", encoding="utf-8") + calls = {"count": 0} + + service = SessionBackupService(storage, (0, 0)) + + def fail_copy(*_args, **_kwargs): + calls["count"] += 1 + raise OSError("copy failed") + + monkeypatch.setattr(service, "_copy_file", fail_copy) + + with pytest.raises(SessionBackupBlocked, match="copy failed"): + service.backup_session("/repo", "s1", reason=BackupReason.INPUT_REQUIRED, critical=True) + + assert calls["count"] == 3 + marker = _read_backup_marker(session_dir) + assert marker["status"] == "failed" + assert marker["reason"] == "input_required" + assert "copy failed" in str(marker["error"]) + + +def test_critical_backup_lock_failure_retries_then_blocks( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(tmp_path / "backup")) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + (session_dir / "session.jsonl").write_text("one\n", encoding="utf-8") + (session_dir / ".backup-lock").mkdir() + sleep_calls: list[float] = [] + monkeypatch.setattr("iac_code.services.session_backup.time.sleep", sleep_calls.append) + + with pytest.raises(SessionBackupBlocked, match=".backup-lock"): + SessionBackupService(session_storage=storage, retry_delays=(0, 0)).backup_session( + "/repo", + "s1", + reason=BackupReason.INPUT_REQUIRED, + critical=True, + ) + + assert sleep_calls == [0, 0] + marker = _read_backup_marker(session_dir) + assert marker["status"] == "failed" + assert marker["reason"] == "input_required" + assert ".backup-lock" in str(marker["error"]) + + +def test_critical_backup_lock_symlink_retries_then_blocks_without_following_target( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(tmp_path / "backup")) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + (session_dir / "session.jsonl").write_text("one\n", encoding="utf-8") + external_lock = tmp_path / "external-lock" + external_lock.write_text("external\n", encoding="utf-8") + _symlink_or_skip(external_lock, session_dir / ".backup-lock") + + with pytest.raises(SessionBackupBlocked, match="backup lock"): + SessionBackupService(session_storage=storage, retry_delays=()).backup_session( + "/repo", + "s1", + reason=BackupReason.INPUT_REQUIRED, + critical=True, + ) + + assert external_lock.read_text(encoding="utf-8") == "external\n" + marker = _read_backup_marker(session_dir) + assert marker["status"] == "failed" + assert marker["reason"] == "input_required" + assert "backup lock" in str(marker["error"]) + + +def test_non_critical_backup_lock_failure_returns_enabled_result( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(tmp_path / "backup")) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + (session_dir / "session.jsonl").write_text("one\n", encoding="utf-8") + (session_dir / ".backup-lock").mkdir() + + result = SessionBackupService(session_storage=storage, retry_delays=(0, 0)).backup_session( + "/repo", + "s1", + reason=BackupReason.NORMAL_TURN_END, + critical=False, + ) + + assert result.enabled is True + assert result.succeeded is False + assert result.error is not None + assert result.retry_count == 2 + marker = _read_backup_marker(session_dir) + assert marker["status"] == "failed" + assert marker["reason"] == "normal_turn_end" + assert marker["retry_count"] == 2 + assert marker["attempt"] == 3 + assert marker["exhausted"] is True + assert ".backup-lock" in str(marker["error"]) + + +def test_non_critical_backup_root_failure_returns_enabled_result( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(tmp_path / "backup")) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + (session_dir / "session.jsonl").write_text("one\n", encoding="utf-8") + calls = {"count": 0} + service = SessionBackupService(session_storage=storage, retry_delays=(0, 0)) + + def fail_backup_root() -> Path: + calls["count"] += 1 + raise OSError("backup root failed") + + monkeypatch.setattr(service, "_backup_root", fail_backup_root) + + result = service.backup_session("/repo", "s1", reason=BackupReason.NORMAL_TURN_END, critical=False) + + assert result.enabled is True + assert result.succeeded is False + assert result.error == "backup root failed" + assert result.retry_count == 2 + assert calls["count"] == 3 + marker = _read_backup_marker(session_dir) + assert marker["status"] == "failed" + assert marker["reason"] == "normal_turn_end" + assert marker["retry_count"] == 2 + assert marker["attempt"] == 3 + assert marker["exhausted"] is True + assert "backup root failed" in str(marker["error"]) + + +def test_missing_unmarked_source_blocks_when_critical_and_does_not_create_dirs( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + backup_root = tmp_path / "backup" + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = storage.session_dir("/repo", "missing") + + with pytest.raises(SessionBackupBlocked, match="supported session layout"): + SessionBackupService(session_storage=storage, retry_delays=(0, 0)).backup_session( + "/repo", + "missing", + reason=BackupReason.INPUT_REQUIRED, + critical=True, + ) + + mirror = backup_root / "projects" / session_dir.parent.name / "missing" + assert not session_dir.exists() + assert not mirror.exists() + + +def test_missing_unmarked_source_non_critical_skips_without_creating_dirs( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + backup_root = tmp_path / "backup" + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = storage.session_dir("/repo", "missing") + + result = SessionBackupService(session_storage=storage, retry_delays=(0, 0)).backup_session( + "/repo", + "missing", + reason=BackupReason.NORMAL_TURN_END, + critical=False, + ) + + mirror = backup_root / "projects" / session_dir.parent.name / "missing" + assert result.enabled is False + assert not session_dir.exists() + assert not mirror.exists() + + +def test_non_critical_backup_reports_unsupported_layout_without_raising( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + backup_root = tmp_path / "backup" + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = storage.session_dir("/repo", "future") + write_session_metadata(session_dir, SessionMetadata(session_id="future", cwd="/repo", layout_version=99)) + + result = SessionBackupService(session_storage=storage, retry_delays=()).backup_session( + "/repo", + "future", + reason=BackupReason.NORMAL_TURN_END, + critical=False, + ) + + assert result.enabled is True + assert result.succeeded is False + assert "Unsupported session layout version" in str(result.error) + assert not (backup_root / "projects" / session_dir.parent.name / "future").exists() + + +def test_critical_backup_converts_unsupported_layout_to_blocked( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + backup_root = tmp_path / "backup" + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = storage.session_dir("/repo", "future") + write_session_metadata(session_dir, SessionMetadata(session_id="future", cwd="/repo", layout_version=99)) + + with pytest.raises(SessionBackupBlocked, match="Unsupported session layout version"): + SessionBackupService(session_storage=storage, retry_delays=()).backup_session( + "/repo", + "future", + reason=BackupReason.INPUT_REQUIRED, + critical=True, + ) + + assert not (backup_root / "projects" / session_dir.parent.name / "future").exists() + + +def test_critical_backup_blocks_legacy_file_session_without_mutating( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + backup_root = tmp_path / "backup" + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + legacy_path = storage.legacy_session_path("/repo", "legacy") + legacy_path.parent.mkdir(parents=True) + legacy_path.write_text('{"role":"user","content":"old"}\n', encoding="utf-8") + + with pytest.raises(SessionBackupBlocked, match="supported session layout"): + SessionBackupService(session_storage=storage, retry_delays=()).backup_session( + "/repo", + "legacy", + reason=BackupReason.INPUT_REQUIRED, + critical=True, + ) + + session_dir = storage.session_dir("/repo", "legacy") + mirror = backup_root / "projects" / legacy_path.parent.name / "legacy" + assert not session_dir.exists() + assert not mirror.exists() + + +def test_critical_backup_blocks_unmarked_directory_session_without_mutating( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + backup_root = tmp_path / "backup" + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = storage.session_dir("/repo", "legacy-dir") + session_dir.mkdir(parents=True) + (session_dir / "session.jsonl").write_text('{"role":"user","content":"old"}\n', encoding="utf-8") + + with pytest.raises(SessionBackupBlocked, match="supported session layout"): + SessionBackupService(session_storage=storage, retry_delays=()).backup_session( + "/repo", + "legacy-dir", + reason=BackupReason.INPUT_REQUIRED, + critical=True, + ) + + mirror = backup_root / "projects" / session_dir.parent.name / "legacy-dir" + assert not (session_dir / ".backup-state.json").exists() + assert not (session_dir / ".backup-lock").exists() + assert not mirror.exists() + + +def test_tmp_named_session_files_are_mirrored(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + backup_root = tmp_path / "backup" + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + nested_tmp = session_dir / "nested.tmp" + nested_tmp.mkdir(parents=True) + (nested_tmp / "inside.txt").write_text("keep nested\n", encoding="utf-8") + tool_results = session_dir / "tool-results" + tool_results.mkdir(parents=True) + (tool_results / "foo.tmp").write_text("keep tmp file\n", encoding="utf-8") + (session_dir / "session.jsonl").write_text("keep\n", encoding="utf-8") + + SessionBackupService(session_storage=storage).backup_session( + "/repo", + "s1", + reason=BackupReason.PIPELINE_STEP_COMPLETED, + critical=True, + ) + + mirror = backup_root / "projects" / session_dir.parent.name / "s1" + assert (mirror / "session.jsonl").read_text(encoding="utf-8") == "keep\n" + assert (mirror / "nested.tmp" / "inside.txt").read_text(encoding="utf-8") == "keep nested\n" + assert (mirror / "tool-results" / "foo.tmp").read_text(encoding="utf-8") == "keep tmp file\n" + + +def test_backup_skips_reparse_point_like_source_directories( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + backup_root = tmp_path / "backup" + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + (session_dir / "session.jsonl").write_text("fresh\n", encoding="utf-8") + junction_like = session_dir / "junction" + junction_like.mkdir() + (junction_like / "outside.txt").write_text("must not copy\n", encoding="utf-8") + service = SessionBackupService(session_storage=storage) + monkeypatch.setattr(service, "_is_reparse_point", lambda path: Path(path) == junction_like) + + service.backup_session("/repo", "s1", reason=BackupReason.PIPELINE_STEP_COMPLETED, critical=True) + + mirror = backup_root / "projects" / session_dir.parent.name / "s1" + assert (mirror / "session.jsonl").read_text(encoding="utf-8") == "fresh\n" + assert not (mirror / "junction").exists() + + +def test_backup_deletes_stale_reparse_point_like_destination_entry( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + backup_root = tmp_path / "backup" + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + (session_dir / "session.jsonl").write_text("fresh\n", encoding="utf-8") + mirror = backup_root / "projects" / session_dir.parent.name / "s1" + mirror.mkdir(parents=True) + stale_reparse = mirror / "stale-junction" + stale_reparse.mkdir() + service = SessionBackupService(session_storage=storage) + monkeypatch.setattr(service, "_is_reparse_point", lambda path: Path(path) == stale_reparse) + + service.backup_session("/repo", "s1", reason=BackupReason.PIPELINE_STEP_COMPLETED, critical=True) + + assert not stale_reparse.exists() + assert (mirror / "session.jsonl").read_text(encoding="utf-8") == "fresh\n" + + +def test_backup_rejects_reparse_point_like_destination_ancestry( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + backup_root = tmp_path / "backup" + projects_dir = backup_root / "projects" + projects_dir.mkdir(parents=True) + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + (session_dir / "session.jsonl").write_text("fresh\n", encoding="utf-8") + service = SessionBackupService(session_storage=storage, retry_delays=()) + monkeypatch.setattr(service, "_is_reparse_point", lambda path: Path(path) == projects_dir) + + with pytest.raises(SessionBackupBlocked, match="destination ancestry"): + service.backup_session("/repo", "s1", reason=BackupReason.PIPELINE_STEP_COMPLETED, critical=True) + + assert not (projects_dir / session_dir.parent.name / "s1" / "session.jsonl").exists() + + +def test_unlink_does_not_make_symlink_target_writable(tmp_path: Path) -> None: + target = tmp_path / "target.txt" + target.write_text("target\n", encoding="utf-8") + link = tmp_path / "link.txt" + _symlink_or_skip(target, link) + service = SessionBackupService() + calls: list[Path] = [] + service._make_writable = calls.append # type: ignore[method-assign] + + service._unlink(link) + + assert calls == [] + assert target.read_text(encoding="utf-8") == "target\n" + assert not link.exists() + + +def test_backup_uses_short_temp_prefix_for_long_file_names( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + backup_root = tmp_path / "backup" + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + long_name = "a" * 245 + (session_dir / long_name).write_text("long\n", encoding="utf-8") + + SessionBackupService(session_storage=storage, retry_delays=()).backup_session( + "/repo", + "s1", + reason=BackupReason.PIPELINE_STEP_COMPLETED, + critical=True, + ) + + mirror = backup_root / "projects" / session_dir.parent.name / "s1" + assert (mirror / long_name).read_text(encoding="utf-8") == "long\n" + + +def test_marker_write_failure_does_not_mask_original_backup_error( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(tmp_path / "backup")) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + (session_dir / "session.jsonl").write_text("one\n", encoding="utf-8") + service = SessionBackupService(session_storage=storage, retry_delays=()) + + def fail_copy(*_args, **_kwargs): + raise OSError("copy failed") + + def fail_marker(*_args, **_kwargs): + raise OSError("marker failed") + + monkeypatch.setattr(service, "_copy_file", fail_copy) + monkeypatch.setattr(service, "_write_marker", fail_marker) + + with pytest.raises(SessionBackupBlocked, match="copy failed"): + service.backup_session("/repo", "s1", reason=BackupReason.INPUT_REQUIRED, critical=True) + + +def test_critical_backup_blocked_does_not_chain_original_exception( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(tmp_path / "backup")) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + (session_dir / "session.jsonl").write_text("one\n", encoding="utf-8") + service = SessionBackupService(session_storage=storage, retry_delays=()) + + def fail_copy(*_args, **_kwargs): + raise OSError("copy failed at /private/mount/secret/session") + + monkeypatch.setattr(service, "_copy_file", fail_copy) + + with pytest.raises(SessionBackupBlocked) as exc_info: + service.backup_session("/repo", "s1", reason=BackupReason.INPUT_REQUIRED, critical=True) + + assert exc_info.value.__cause__ is None + assert "/private/mount" not in str(exc_info.value) + + +def test_critical_backup_blocked_exposes_retry_count( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(tmp_path / "backup")) + storage = SessionStorage(projects_dir=tmp_path / "config" / "projects") + session_dir = _create_v2_session_dir(storage, "/repo", "s1") + (session_dir / "session.jsonl").write_text("one\n", encoding="utf-8") + service = SessionBackupService(session_storage=storage, retry_delays=(0, 0)) + + def fail_copy(*_args, **_kwargs): + raise OSError("copy failed") + + monkeypatch.setattr(service, "_copy_file", fail_copy) + + with pytest.raises(SessionBackupBlocked) as exc_info: + service.backup_session("/repo", "s1", reason=BackupReason.INPUT_REQUIRED, critical=True) + + assert exc_info.value.retry_count == 2 + assert exc_info.value.result is not None + assert exc_info.value.result.retry_count == 2 + assert exc_info.value.result.succeeded is False diff --git a/tests/services/test_session_index.py b/tests/services/test_session_index.py index 1d395e3d..91835637 100644 --- a/tests/services/test_session_index.py +++ b/tests/services/test_session_index.py @@ -18,10 +18,17 @@ extract_last_json_string_field, read_lite_metadata, ) -from iac_code.services.session_metadata import SESSION_JSONL_FILENAME, SessionMetadata, write_session_metadata +from iac_code.services.session_metadata import ( + SESSION_JSONL_FILENAME, + SESSION_LAYOUT_VERSION_V2, + SESSION_METADATA_FILENAME, + SessionMetadata, + write_session_metadata, +) from iac_code.services.session_storage import SessionStorage from iac_code.services.session_usage import SessionUsageStore from iac_code.types.stream_events import Usage +from iac_code.utils import project_paths # --------------------------------------------------------------------------- # Field extraction helpers @@ -204,6 +211,18 @@ def test_list_all_projects_includes_everything(self, tmp_path): ids = {e.session_id for e in index.list_all_projects()} assert ids == {"id-a", "id-b"} + def test_list_all_projects_keeps_same_session_id_in_different_projects(self, tmp_path): + storage = SessionStorage(projects_dir=tmp_path) + storage.append("/a", "same-id", Message(role="user", content="from a"), git_branch=None) + storage.append("/b", "same-id", Message(role="user", content="from b"), git_branch=None) + + entries = SessionIndex(projects_dir=tmp_path).list_all_projects() + + assert sorted((entry.session_id, entry.cwd, entry.title) for entry in entries) == [ + ("same-id", "/a", "from a"), + ("same-id", "/b", "from b"), + ] + def test_list_all_projects_includes_legacy_sessions(self, tmp_path): storage = SessionStorage(projects_dir=tmp_path) legacy_path = storage.legacy_session_path("/legacy", "legacy-id") @@ -215,6 +234,26 @@ def test_list_all_projects_includes_legacy_sessions(self, tmp_path): assert [(e.session_id, e.cwd, e.title) for e in entries] == [("legacy-id", "/legacy", "old")] + def test_list_for_cwd_includes_metadata_only_v2_session(self, tmp_path): + storage = SessionStorage(projects_dir=tmp_path) + session_dir = storage.session_dir("/metadata-only", "metadata-only-id") + write_session_metadata( + session_dir, + SessionMetadata( + session_id="metadata-only-id", + cwd="/metadata-only", + name="waiting-pipeline", + layout_version=SESSION_LAYOUT_VERSION_V2, + ), + ) + (session_dir / "a2a").mkdir() + + entries = SessionIndex(projects_dir=tmp_path).list_for_cwd("/metadata-only") + + assert [(entry.session_id, entry.cwd, entry.title, entry.size_bytes) for entry in entries] == [ + ("metadata-only-id", "/metadata-only", "waiting-pipeline", 0) + ] + def test_directory_session_metadata_name_takes_precedence(self, tmp_path): storage = SessionStorage(projects_dir=tmp_path) storage.append("/p", "named", Message(role="user", content="first prompt"), git_branch=None) @@ -323,13 +362,7 @@ def test_directory_session_ignores_stale_metadata_session_id(self, tmp_path): SessionMetadata(session_id="stale", name="copied-name", cwd="/p", git_branch=None), ) - entry = SessionIndex(projects_dir=tmp_path).list_for_cwd("/p")[0] - - assert entry.session_id == "actual" - assert entry.name is None - assert entry.title == "first prompt" - assert entry.auto_title == "first prompt" - assert entry.is_legacy is False + assert SessionIndex(projects_dir=tmp_path).list_for_cwd("/p") == [] def test_duplicate_legacy_and_directory_session_id_prefers_directory(self, tmp_path): storage = SessionStorage(projects_dir=tmp_path) @@ -337,7 +370,7 @@ def test_duplicate_legacy_and_directory_session_id_prefers_directory(self, tmp_p legacy_path.parent.mkdir(parents=True, exist_ok=True) legacy_path.write_text('{"role":"user","content":"legacy","cwd":"/p"}\n', encoding="utf-8") - session_dir = storage.session_dir("/p", "same") + session_dir = legacy_path.parent / "same" session_dir.mkdir(parents=True, exist_ok=True) (session_dir / SESSION_JSONL_FILENAME).write_text( '{"role":"user","content":"directory","cwd":"/p"}\n', @@ -348,6 +381,138 @@ def test_duplicate_legacy_and_directory_session_id_prefers_directory(self, tmp_p assert [(entry.session_id, entry.title, entry.is_legacy) for entry in entries] == [("same", "directory", False)] + def test_duplicate_legacy_and_unsupported_directory_keeps_legacy(self, tmp_path): + storage = SessionStorage(projects_dir=tmp_path) + legacy_path = storage.legacy_session_path("/p", "same") + legacy_path.parent.mkdir(parents=True, exist_ok=True) + legacy_path.write_text('{"role":"user","content":"legacy","cwd":"/p"}\n', encoding="utf-8") + + session_dir = storage.session_dir("/p", "same") + session_dir.mkdir(parents=True, exist_ok=True) + (session_dir / SESSION_JSONL_FILENAME).write_text( + '{"role":"user","content":"future","cwd":"/p"}\n', + encoding="utf-8", + ) + (session_dir / SESSION_METADATA_FILENAME).write_text('{"layout_version":99}\n', encoding="utf-8") + + entries = SessionIndex(projects_dir=tmp_path).list_for_cwd("/p") + + assert [(entry.session_id, entry.title, entry.is_legacy) for entry in entries] == [("same", "legacy", True)] + + def test_duplicate_legacy_and_metadata_only_directory_keeps_legacy(self, tmp_path): + storage = SessionStorage(projects_dir=tmp_path) + legacy_path = storage.legacy_session_path("/p", "same") + legacy_path.parent.mkdir(parents=True, exist_ok=True) + legacy_path.write_text('{"role":"user","content":"legacy","cwd":"/p"}\n', encoding="utf-8") + write_session_metadata( + legacy_path.parent / "same", + SessionMetadata( + session_id="same", + cwd="/shadow", + name="shadow", + layout_version=SESSION_LAYOUT_VERSION_V2, + ), + ) + + entries = SessionIndex(projects_dir=tmp_path).list_for_cwd("/p") + + assert [(entry.session_id, entry.cwd, entry.title, entry.is_legacy) for entry in entries] == [ + ("same", "/p", "legacy", True) + ] + + def test_metadata_only_mismatched_session_id_is_ignored(self, tmp_path): + storage = SessionStorage(projects_dir=tmp_path) + write_session_metadata( + storage.session_dir("/p", "requested"), + SessionMetadata( + session_id="different", + cwd="/p", + name="wrong-id", + layout_version=SESSION_LAYOUT_VERSION_V2, + ), + ) + + index = SessionIndex(projects_dir=tmp_path) + + assert index.list_for_cwd("/p") == [] + assert index.list_all_projects() == [] + assert index.find_by_id_or_prefix("requested") is None + + def test_directory_session_mismatched_session_id_is_ignored(self, tmp_path): + storage = SessionStorage(projects_dir=tmp_path) + session_dir = storage.session_dir("/p", "requested-dir") + session_dir.mkdir(parents=True) + (session_dir / SESSION_JSONL_FILENAME).write_text( + '{"role":"user","content":"wrong","cwd":"/p"}\n', + encoding="utf-8", + ) + write_session_metadata( + session_dir, + SessionMetadata( + session_id="different", + cwd="/p", + name="wrong-id", + layout_version=SESSION_LAYOUT_VERSION_V2, + ), + ) + + index = SessionIndex(projects_dir=tmp_path) + + assert index.list_for_cwd("/p") == [] + assert index.list_all_projects() == [] + assert index.find_by_id_or_prefix("requested-dir") is None + + def test_list_for_cwd_merges_bounded_and_legacy_long_project_dirs(self, tmp_path): + cwd = "x" * (project_paths.MAX_SANITIZED_LENGTH + 50) + current_project_dir, legacy_project_dir = project_paths.project_dir_candidates(cwd, tmp_path) + legacy_project_dir.mkdir(parents=True) + (legacy_project_dir / "legacy-long.jsonl").write_text( + '{"role":"user","content":"old","cwd":"%s"}\n' % cwd, + encoding="utf-8", + ) + current_project_dir.mkdir(parents=True) + storage = SessionStorage(projects_dir=tmp_path) + storage.append(cwd, "new-long", Message(role="user", content="new"), git_branch=None) + + entries = SessionIndex(projects_dir=tmp_path).list_for_cwd(cwd) + + assert {entry.session_id: entry.title for entry in entries} == { + "legacy-long": "old", + "new-long": "new", + } + + def test_list_all_projects_ignores_metadata_only_shadow_with_stale_metadata_cwd(self, tmp_path): + cwd = "x" * (project_paths.MAX_SANITIZED_LENGTH + 50) + current_project_dir, legacy_project_dir = project_paths.project_dir_candidates(cwd, tmp_path) + legacy_project_dir.mkdir(parents=True) + legacy_path = legacy_project_dir / "legacy-long-stale-shadow.jsonl" + legacy_path.write_text( + '{"role":"user","content":"legacy","cwd":"%s"}\n' % cwd, + encoding="utf-8", + ) + shadow_dir = current_project_dir / "legacy-long-stale-shadow" + write_session_metadata( + shadow_dir, + SessionMetadata( + session_id="legacy-long-stale-shadow", + cwd="/stale-cwd", + name="shadow", + layout_version=SESSION_LAYOUT_VERSION_V2, + ), + ) + metadata_path = shadow_dir / SESSION_METADATA_FILENAME + os.utime(metadata_path, (metadata_path.stat().st_atime, legacy_path.stat().st_mtime + 100)) + + entries = [ + entry + for entry in SessionIndex(projects_dir=tmp_path).list_all_projects() + if entry.session_id == "legacy-long-stale-shadow" + ] + + assert len(entries) == 1 + assert entries[0].cwd == cwd + assert entries[0].title == "legacy" + def test_list_sorted_by_mtime_desc(self, tmp_path): storage = SessionStorage(projects_dir=tmp_path) storage.append("/p", "older", Message(role="user", content="o"), git_branch=None) diff --git a/tests/services/test_session_layout.py b/tests/services/test_session_layout.py new file mode 100644 index 00000000..6fe3d804 --- /dev/null +++ b/tests/services/test_session_layout.py @@ -0,0 +1,189 @@ +import json +from pathlib import Path + +import pytest + +from iac_code.services.session_layout import ( + SESSION_LAYOUT_VERSION_V2, + SessionPaths, + UnsupportedSessionLayoutError, + ensure_session_owned_dir, + is_supported_session_dir_for_id, + session_layout_version, +) +from iac_code.services.session_metadata import SESSION_METADATA_FILENAME, SessionMetadata, write_session_metadata + + +def _symlink_or_skip(target: Path, link: Path, *, target_is_directory: bool = False) -> None: + try: + link.symlink_to(target, target_is_directory=target_is_directory) + except (NotImplementedError, OSError) as exc: + pytest.skip(f"symlink creation unsupported: {exc}") + + +def test_missing_layout_version_is_legacy(tmp_path: Path) -> None: + session_dir = tmp_path / "projects" / "proj" / "s1" + write_session_metadata(session_dir, SessionMetadata(session_id="s1", cwd="/repo")) + + assert session_layout_version(session_dir) is None + + +def test_layout_v2_paths_are_session_scoped(tmp_path: Path) -> None: + session_dir = tmp_path / "projects" / "proj" / "s1" + write_session_metadata( + session_dir, + SessionMetadata(session_id="s1", cwd="/repo", layout_version=SESSION_LAYOUT_VERSION_V2), + ) + + paths = SessionPaths.from_session_dir(session_dir) + + assert paths.session_dir == session_dir + assert paths.image_cache_dir == session_dir / "image-cache" + assert paths.tool_results_dir == session_dir / "tool-results" + assert paths.usage_path == session_dir / "usage.jsonl" + assert paths.permission_audit_path == session_dir / "permission-audit.jsonl" + assert paths.a2a_artifacts_dir == session_dir / "a2a" / "artifacts" + assert paths.transcript_dir("transcript_att_0001") == ( + session_dir / "pipeline" / "transcripts" / "transcript_att_0001" + ) + assert paths.transcript_usage_path("transcript_att_0001") == ( + session_dir / "pipeline" / "transcripts" / "transcript_att_0001" / "usage.jsonl" + ) + assert paths.transcript_permission_audit_path("transcript_att_0001") == ( + session_dir / "pipeline" / "transcripts" / "transcript_att_0001" / "permission-audit.jsonl" + ) + assert paths.transcript_tool_results_dir("transcript_att_0001") == ( + session_dir / "pipeline" / "transcripts" / "transcript_att_0001" / "tool-results" + ) + + +@pytest.mark.parametrize( + "bad_id", + ["", ".", "..", "../x", "a/b", "a\\b", "bad id", "CON", "NUL.txt", "COM1", "LPT9.log", "step.", "step "], +) +def test_transcript_paths_reject_unsafe_ids(tmp_path: Path, bad_id: str) -> None: + paths = SessionPaths.from_session_dir(tmp_path / "projects" / "proj" / "s1") + + with pytest.raises(ValueError, match="unsafe transcript id"): + paths.transcript_dir(bad_id) + + +def test_unknown_layout_version_is_not_legacy(tmp_path: Path) -> None: + session_dir = tmp_path / "projects" / "proj" / "s1" + write_session_metadata(session_dir, SessionMetadata(session_id="s1", cwd="/repo", layout_version=99)) + + with pytest.raises(UnsupportedSessionLayoutError, match="Unsupported session layout version"): + SessionPaths.require_supported(session_dir) + + +def test_unknown_layout_version_with_valid_metadata_is_not_legacy(tmp_path: Path) -> None: + session_dir = tmp_path / "projects" / "proj" / "s1" + write_session_metadata(session_dir, SessionMetadata(session_id="s1", cwd="/repo", layout_version=99)) + + assert session_layout_version(session_dir) == 99 + with pytest.raises(UnsupportedSessionLayoutError, match="Unsupported session layout version"): + SessionPaths.require_supported(session_dir) + + +def test_ensure_session_owned_dir_rejects_symlink_leaf(tmp_path: Path) -> None: + session_dir = tmp_path / "projects" / "proj" / "s1" + write_session_metadata( + session_dir, + SessionMetadata(session_id="s1", cwd="/repo", layout_version=SESSION_LAYOUT_VERSION_V2), + ) + outside = tmp_path / "outside" + outside.mkdir() + _symlink_or_skip(outside, session_dir / "image-cache", target_is_directory=True) + + with pytest.raises(UnsupportedSessionLayoutError, match="session-owned path"): + ensure_session_owned_dir(session_dir, session_dir / "image-cache") + + +def test_ensure_session_owned_dir_rejects_symlink_parent(tmp_path: Path) -> None: + session_dir = tmp_path / "projects" / "proj" / "s1" + write_session_metadata( + session_dir, + SessionMetadata(session_id="s1", cwd="/repo", layout_version=SESSION_LAYOUT_VERSION_V2), + ) + outside = tmp_path / "outside" + outside.mkdir() + _symlink_or_skip(outside, session_dir / "pipeline", target_is_directory=True) + + with pytest.raises(UnsupportedSessionLayoutError, match="session-owned path"): + ensure_session_owned_dir(session_dir, session_dir / "pipeline" / "transcripts") + + +def test_ensure_session_owned_dir_rejects_reparse_point(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + import iac_code.services.session_layout as session_layout + + session_dir = tmp_path / "projects" / "proj" / "s1" + write_session_metadata( + session_dir, + SessionMetadata(session_id="s1", cwd="/repo", layout_version=SESSION_LAYOUT_VERSION_V2), + ) + target = session_dir / "tool-results" + target.mkdir() + + monkeypatch.setattr(session_layout, "_is_reparse_point", lambda path: path == target) + + with pytest.raises(UnsupportedSessionLayoutError, match="session-owned path"): + ensure_session_owned_dir(session_dir, target) + + +@pytest.mark.parametrize( + "metadata_text", + [ + "{not-json}\n", + json.dumps(["not", "an", "object"]) + "\n", + json.dumps({"layout_version": 2}) + "\n", + json.dumps({"layout_version": 99}) + "\n", + json.dumps({"layout_version": "2"}) + "\n", + ], +) +def test_invalid_layout_metadata_is_not_legacy(tmp_path: Path, metadata_text: str) -> None: + session_dir = tmp_path / "projects" / "proj" / "s1" + session_dir.mkdir(parents=True) + (session_dir / SESSION_METADATA_FILENAME).write_text(metadata_text, encoding="utf-8") + + with pytest.raises(UnsupportedSessionLayoutError, match="Unsupported session metadata"): + SessionPaths.require_supported(session_dir) + + +def test_metadata_symlink_is_unsupported_even_when_target_is_valid(tmp_path: Path) -> None: + session_dir = tmp_path / "projects" / "proj" / "s1" + session_dir.mkdir(parents=True) + target = tmp_path / "outside-metadata.json" + target.write_text( + json.dumps({"session_id": "s1", "layout_version": SESSION_LAYOUT_VERSION_V2}) + "\n", + encoding="utf-8", + ) + _symlink_or_skip(target, session_dir / SESSION_METADATA_FILENAME) + + with pytest.raises(UnsupportedSessionLayoutError, match="Unsupported session metadata"): + SessionPaths.require_supported(session_dir) + with pytest.raises(UnsupportedSessionLayoutError, match="Unsupported session metadata"): + is_supported_session_dir_for_id(session_dir, "s1") + + +def test_dangling_metadata_symlink_is_unsupported(tmp_path: Path) -> None: + session_dir = tmp_path / "projects" / "proj" / "s1" + session_dir.mkdir(parents=True) + _symlink_or_skip(tmp_path / "missing-metadata.json", session_dir / SESSION_METADATA_FILENAME) + + with pytest.raises(UnsupportedSessionLayoutError, match="Unsupported session metadata"): + SessionPaths.require_supported(session_dir) + + +def test_metadata_reparse_point_is_unsupported(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + import iac_code.services.session_metadata as session_metadata + + session_dir = tmp_path / "projects" / "proj" / "s1" + write_session_metadata( + session_dir, + SessionMetadata(session_id="s1", cwd="/repo", layout_version=SESSION_LAYOUT_VERSION_V2), + ) + metadata_path = session_dir / SESSION_METADATA_FILENAME + monkeypatch.setattr(session_metadata, "_is_reparse_point", lambda path: path == metadata_path) + + with pytest.raises(UnsupportedSessionLayoutError, match="Unsupported session metadata"): + SessionPaths.require_supported(session_dir) diff --git a/tests/services/test_session_storage.py b/tests/services/test_session_storage.py index 12cbf943..d21696bd 100644 --- a/tests/services/test_session_storage.py +++ b/tests/services/test_session_storage.py @@ -12,10 +12,18 @@ get_recalled_memory_files, ) from iac_code.pipeline.engine.cleanup import CLEANUP_PROMPT_METADATA_TYPE, create_cleanup_prompt_message -from iac_code.services.session_metadata import SESSION_JSONL_FILENAME, SESSION_METADATA_FILENAME +from iac_code.services.session_layout import UnsupportedSessionLayoutError +from iac_code.services.session_metadata import ( + SESSION_JSONL_FILENAME, + SESSION_LAYOUT_VERSION_V2, + SESSION_METADATA_FILENAME, + SessionMetadata, + write_session_metadata, +) from iac_code.services.session_storage import SessionStorage from iac_code.services.session_usage import SessionUsageStore from iac_code.types.stream_events import Usage +from iac_code.utils import project_paths CWD = "/tmp/proj-x" @@ -41,6 +49,13 @@ def sample_messages(): ] +def _symlink_or_skip(target, link, *, target_is_directory=False) -> None: + try: + link.symlink_to(target, target_is_directory=target_is_directory) + except (NotImplementedError, OSError) as exc: + pytest.skip(f"symlink creation unsupported: {exc}") + + class TestSessionStorage: def test_save_and_load_roundtrip(self, storage, sample_messages): storage.save(CWD, "s1", sample_messages, git_branch="main") @@ -74,6 +89,28 @@ def test_exists(self, storage): storage.append(CWD, "exists-id", Message(role="user", content="hi"), git_branch=None) assert storage.exists(CWD, "exists-id") + def test_metadata_only_v2_session_exists_and_loads_empty_history(self, storage): + session_dir = storage.ensure_v2_session_dir_for_new_session(CWD, "metadata-only", git_branch="main") + assert session_dir is not None + + assert storage.exists(CWD, "metadata-only") + assert storage.load(CWD, "metadata-only") == [] + + def test_metadata_only_v2_session_symlink_root_is_ignored(self, storage, tmp_path): + session_id = "metadata-only-symlink" + session_dir = storage.session_dir(CWD, session_id) + external_dir = tmp_path / "external-session" + write_session_metadata( + external_dir, + SessionMetadata(session_id=session_id, cwd=CWD, layout_version=SESSION_LAYOUT_VERSION_V2), + ) + session_dir.parent.mkdir(parents=True, exist_ok=True) + _symlink_or_skip(external_dir, session_dir, target_is_directory=True) + + assert storage.v2_session_dir(CWD, session_id) is None + assert storage.read_metadata(CWD, session_id) is None + assert storage.ensure_v2_session_dir_for_new_session(CWD, session_id, git_branch="main") is None + def test_meta_rows_skipped_on_load(self, storage): storage.append(CWD, "meta-test", Message(role="user", content="real"), git_branch=None) storage.append_meta(CWD, "meta-test", {"type": "last-prompt", "last_prompt": "real"}) @@ -193,6 +230,24 @@ def test_get_latest_session_anywhere(self, storage): result = storage.get_latest_session_anywhere() assert result == ("/tmp/b", "newer") + def test_get_latest_session_anywhere_ignores_older_unsupported_layout_candidate(self, storage): + import os + + future_dir = storage.session_dir("/tmp/a", "future-older") + future_dir.mkdir(parents=True) + future_path = future_dir / SESSION_JSONL_FILENAME + future_path.write_text('{"role":"user","content":"old","cwd":"/tmp/a"}\n', encoding="utf-8") + write_session_metadata( + future_dir, + SessionMetadata(session_id="future-older", cwd="/tmp/a", layout_version=99), + ) + storage.append("/tmp/b", "newer", Message(role="user", content="newer"), git_branch=None) + new_path = storage.session_path("/tmp/b", "newer") + os.utime(future_path, (future_path.stat().st_atime, 1000)) + os.utime(new_path, (new_path.stat().st_atime, 2000)) + + assert storage.get_latest_session_anywhere() == ("/tmp/b", "newer") + def test_cross_project_lookup_ignores_usage_sidecars(self, storage): import os @@ -264,6 +319,178 @@ def test_existing_legacy_session_stays_legacy_until_rename(storage): assert [m.role for m in storage.load(CWD, "legacy")] == ["user", "assistant"] +@pytest.mark.parametrize("metadata_kind", ["v2", "future", "invalid"]) +def test_legacy_file_wins_over_metadata_only_directory(storage, metadata_kind): + session_id = f"legacy-shadow-{metadata_kind}" + legacy_path = storage.legacy_session_path(CWD, session_id) + legacy_path.parent.mkdir(parents=True, exist_ok=True) + legacy_path.write_text('{"role":"user","content":"old"}\n', encoding="utf-8") + session_dir = legacy_path.parent / session_id + session_dir.mkdir(parents=True, exist_ok=True) + if metadata_kind == "invalid": + (session_dir / SESSION_METADATA_FILENAME).write_text("{not-json", encoding="utf-8") + else: + write_session_metadata( + session_dir, + SessionMetadata( + session_id=session_id, + cwd=CWD, + layout_version=SESSION_LAYOUT_VERSION_V2 if metadata_kind == "v2" else 99, + ), + ) + + assert storage.session_path(CWD, session_id) == legacy_path + assert storage.v2_session_dir(CWD, session_id) is None + assert storage.ensure_v2_session_dir_for_new_session(CWD, session_id, git_branch="main") is None + assert storage.read_metadata(CWD, session_id) is None + assert storage.session_dir(CWD, session_id) != session_dir + + storage.append(CWD, session_id, Message(role="assistant", content="next"), git_branch=None) + + assert [message.content for message in storage.load(CWD, session_id)] == ["old", "next"] + assert not (session_dir / SESSION_JSONL_FILENAME).exists() + + +def test_legacy_file_keeps_sidecar_only_directory_for_restore(storage): + session_id = "legacy-with-sidecars" + legacy_path = storage.legacy_session_path(CWD, session_id) + legacy_path.parent.mkdir(parents=True, exist_ok=True) + legacy_path.write_text('{"role":"user","content":"old"}\n', encoding="utf-8") + session_dir = legacy_path.parent / session_id + (session_dir / "pipeline").mkdir(parents=True) + (session_dir / "pipeline" / "meta.yaml").write_text("status: running\n", encoding="utf-8") + + assert storage.session_path(CWD, session_id) == legacy_path + assert storage.session_dir(CWD, session_id) == session_dir + + storage.append(CWD, session_id, Message(role="assistant", content="next"), git_branch=None) + + assert [message.content for message in storage.load(CWD, session_id)] == ["old", "next"] + assert not (session_dir / SESSION_JSONL_FILENAME).exists() + assert (session_dir / "pipeline" / "meta.yaml").exists() + + +def test_legacy_file_keeps_sidecar_directory_with_rotated_audit_and_lock(storage): + session_id = "legacy-with-rotated-audit" + legacy_path = storage.legacy_session_path(CWD, session_id) + legacy_path.parent.mkdir(parents=True, exist_ok=True) + legacy_path.write_text('{"role":"user","content":"old"}\n', encoding="utf-8") + session_dir = legacy_path.parent / session_id + (session_dir / "pipeline").mkdir(parents=True) + (session_dir / "pipeline" / "meta.yaml").write_text("status: running\n", encoding="utf-8") + (session_dir / "permission-audit.jsonl.1").write_text("", encoding="utf-8") + (session_dir / ".permission-audit.jsonl.lock").write_text("", encoding="utf-8") + + assert storage.session_path(CWD, session_id) == legacy_path + assert storage.session_dir(CWD, session_id) == session_dir + + +def test_legacy_file_keeps_direct_a2a_sidecar_directory_for_restore(storage): + session_id = "legacy-with-a2a-sidecar" + legacy_path = storage.legacy_session_path(CWD, session_id) + legacy_path.parent.mkdir(parents=True, exist_ok=True) + legacy_path.write_text('{"role":"user","content":"old"}\n', encoding="utf-8") + session_dir = legacy_path.parent / session_id + a2a_pipeline_dir = session_dir / "a2a" / "pipeline" + a2a_pipeline_dir.mkdir(parents=True) + (a2a_pipeline_dir / "a2a-events.jsonl").write_text("", encoding="utf-8") + + assert storage.session_path(CWD, session_id) == legacy_path + assert storage.session_dir(CWD, session_id) == session_dir + + +@pytest.mark.parametrize("sidecar_file", [None, "usage.jsonl", "permission-audit.jsonl"]) +def test_legacy_file_uses_placeholder_for_non_pipeline_sidecar_directory(storage, sidecar_file): + session_id = "legacy-non-pipeline-sidecars" if sidecar_file is None else f"legacy-{sidecar_file}" + legacy_path = storage.legacy_session_path(CWD, session_id) + legacy_path.parent.mkdir(parents=True, exist_ok=True) + legacy_path.write_text('{"role":"user","content":"old"}\n', encoding="utf-8") + session_dir = legacy_path.parent / session_id + session_dir.mkdir(parents=True) + if sidecar_file is not None: + (session_dir / sidecar_file).write_text("", encoding="utf-8") + + assert storage.session_path(CWD, session_id) == legacy_path + assert storage.session_dir(CWD, session_id) == storage._legacy_sidecar_placeholder_dir(legacy_path) + + +@pytest.mark.parametrize("metadata_kind", ["v2", "future", "invalid"]) +def test_rename_legacy_file_ignores_metadata_only_shadow_directory(storage, metadata_kind): + session_id = f"legacy-shadow-rename-{metadata_kind}" + legacy_path = storage.legacy_session_path(CWD, session_id) + legacy_path.parent.mkdir(parents=True, exist_ok=True) + legacy_path.write_text('{"role":"user","content":"old"}\n', encoding="utf-8") + session_dir = legacy_path.parent / session_id + session_dir.mkdir(parents=True, exist_ok=True) + if metadata_kind == "invalid": + (session_dir / SESSION_METADATA_FILENAME).write_text("{not-json", encoding="utf-8") + else: + write_session_metadata( + session_dir, + SessionMetadata( + session_id=session_id, + name="deploy-prod" if metadata_kind == "v2" else "shadow-name", + cwd=CWD, + layout_version=SESSION_LAYOUT_VERSION_V2 if metadata_kind == "v2" else 99, + ), + ) + + result = storage.rename_session(CWD, session_id, "deploy-prod", git_branch="main") + + metadata = storage.read_metadata(CWD, session_id) + assert result == "renamed" + assert not legacy_path.exists() + assert (session_dir / SESSION_JSONL_FILENAME).exists() + assert metadata is not None + assert metadata.name == "deploy-prod" + assert metadata.layout_version == SESSION_LAYOUT_VERSION_V2 + assert storage.load(CWD, session_id)[0].content == "old" + + +@pytest.mark.parametrize("has_jsonl", [False, True]) +@pytest.mark.parametrize("layout_version", [SESSION_LAYOUT_VERSION_V2, 99]) +def test_rename_refuses_mismatched_metadata_before_mutating(storage, has_jsonl, layout_version): + session_id = f"rename-conflict-{has_jsonl}-{layout_version}" + session_dir = storage.session_dir(CWD, session_id) + session_dir.mkdir(parents=True) + if has_jsonl: + (session_dir / SESSION_JSONL_FILENAME).write_text('{"role":"user","content":"wrong"}\n', encoding="utf-8") + write_session_metadata( + session_dir, + SessionMetadata( + session_id=f"different-{session_id}", + cwd=CWD, + name="shadow-name", + layout_version=layout_version, + ), + ) + metadata_before = (session_dir / SESSION_METADATA_FILENAME).read_text(encoding="utf-8") + + with pytest.raises(UnsupportedSessionLayoutError, match="Unsupported session metadata"): + storage.rename_session(CWD, session_id, "deploy-prod", git_branch="main") + + assert (session_dir / SESSION_METADATA_FILENAME).read_text(encoding="utf-8") == metadata_before + + +def test_rename_name_owner_ignores_mismatched_metadata(storage): + storage.append(CWD, "actual", Message(role="user", content="hello"), git_branch=None) + shadow_dir = storage.session_dir(CWD, "shadow") + shadow_dir.mkdir(parents=True) + (shadow_dir / SESSION_JSONL_FILENAME).write_text('{"role":"user","content":"shadow"}\n', encoding="utf-8") + write_session_metadata( + shadow_dir, + SessionMetadata( + session_id="different-shadow", + cwd=CWD, + name="deploy-prod", + layout_version=SESSION_LAYOUT_VERSION_V2, + ), + ) + + assert storage.rename_session(CWD, "actual", "deploy-prod", git_branch=None) == "renamed" + assert storage.read_metadata(CWD, "actual").name == "deploy-prod" + + def test_rename_legacy_session_migrates_to_directory(storage): legacy_path = storage.legacy_session_path(CWD, "legacy-rename") legacy_path.parent.mkdir(parents=True, exist_ok=True) @@ -280,6 +507,134 @@ def test_rename_legacy_session_migrates_to_directory(storage): assert storage.load(CWD, "legacy-rename")[0].content == "old" +def test_rename_legacy_session_merges_legacy_sidecar_placeholder(storage): + session_id = "legacy-rename-sidecars" + legacy_path = storage.legacy_session_path(CWD, session_id) + legacy_path.parent.mkdir(parents=True, exist_ok=True) + legacy_path.write_text('{"role":"user","content":"old"}\n', encoding="utf-8") + placeholder_dir = storage._legacy_sidecar_placeholder_dir(legacy_path) + (placeholder_dir / "pipeline").mkdir(parents=True) + (placeholder_dir / "pipeline" / "meta.yaml").write_text("status: running\n", encoding="utf-8") + (placeholder_dir / "permission-audit.jsonl").write_text('{"decision":"allow"}\n', encoding="utf-8") + + result = storage.rename_session(CWD, session_id, "deploy-prod", git_branch="main") + + session_dir = storage.session_dir(CWD, session_id) + assert result == "renamed" + assert not placeholder_dir.exists() + assert (session_dir / SESSION_JSONL_FILENAME).exists() + assert (session_dir / "pipeline" / "meta.yaml").read_text(encoding="utf-8") == "status: running\n" + assert (session_dir / "permission-audit.jsonl").read_text(encoding="utf-8") == '{"decision":"allow"}\n' + + +def test_rename_legacy_session_does_not_overwrite_existing_sidecar_child(storage): + session_id = "legacy-rename-sidecar-conflict" + legacy_path = storage.legacy_session_path(CWD, session_id) + legacy_path.parent.mkdir(parents=True, exist_ok=True) + legacy_path.write_text('{"role":"user","content":"old"}\n', encoding="utf-8") + session_dir = legacy_path.parent / session_id + (session_dir / "pipeline").mkdir(parents=True) + (session_dir / "pipeline" / "meta.yaml").write_text("status: existing\n", encoding="utf-8") + placeholder_dir = storage._legacy_sidecar_placeholder_dir(legacy_path) + (placeholder_dir / "pipeline").mkdir(parents=True) + (placeholder_dir / "pipeline" / "meta.yaml").write_text("status: placeholder\n", encoding="utf-8") + + result = storage.rename_session(CWD, session_id, "deploy-prod", git_branch="main") + + assert result == "renamed" + assert (session_dir / SESSION_JSONL_FILENAME).exists() + assert (session_dir / "pipeline" / "meta.yaml").read_text(encoding="utf-8") == "status: existing\n" + assert (placeholder_dir / "pipeline" / "meta.yaml").read_text(encoding="utf-8") == "status: placeholder\n" + + +def test_rename_legacy_session_merges_placeholder_despite_lock_and_unknown_leftovers(storage): + session_id = "legacy-rename-sidecar-lock" + legacy_path = storage.legacy_session_path(CWD, session_id) + legacy_path.parent.mkdir(parents=True, exist_ok=True) + legacy_path.write_text('{"role":"user","content":"old"}\n', encoding="utf-8") + placeholder_dir = storage._legacy_sidecar_placeholder_dir(legacy_path) + (placeholder_dir / "pipeline").mkdir(parents=True) + (placeholder_dir / "pipeline" / "meta.yaml").write_text("status: running\n", encoding="utf-8") + (placeholder_dir / "permission-audit.jsonl.1").write_text("rotated\n", encoding="utf-8") + (placeholder_dir / ".permission-audit.jsonl.lock").write_text("", encoding="utf-8") + (placeholder_dir / "unexpected.tmp").write_text("leftover\n", encoding="utf-8") + + result = storage.rename_session(CWD, session_id, "deploy-prod", git_branch="main") + + session_dir = storage.session_dir(CWD, session_id) + assert result == "renamed" + assert (session_dir / "pipeline" / "meta.yaml").read_text(encoding="utf-8") == "status: running\n" + assert (session_dir / "permission-audit.jsonl.1").read_text(encoding="utf-8") == "rotated\n" + assert (session_dir / ".permission-audit.jsonl.lock").exists() + assert not (placeholder_dir / "pipeline").exists() + assert (placeholder_dir / "unexpected.tmp").read_text(encoding="utf-8") == "leftover\n" + + +def test_rename_legacy_session_ignores_symlinked_sidecar_placeholder(storage, tmp_path): + session_id = "legacy-rename-sidecar-symlink" + legacy_path = storage.legacy_session_path(CWD, session_id) + legacy_path.parent.mkdir(parents=True, exist_ok=True) + legacy_path.write_text('{"role":"user","content":"old"}\n', encoding="utf-8") + external_dir = tmp_path / "external-sidecars" + (external_dir / "pipeline").mkdir(parents=True) + (external_dir / "pipeline" / "meta.yaml").write_text("status: external\n", encoding="utf-8") + placeholder_dir = storage._legacy_sidecar_placeholder_dir(legacy_path) + _symlink_or_skip(external_dir, placeholder_dir, target_is_directory=True) + + result = storage.rename_session(CWD, session_id, "deploy-prod", git_branch="main") + + session_dir = storage.session_dir(CWD, session_id) + assert result == "renamed" + assert not (session_dir / "pipeline").exists() + assert (external_dir / "pipeline" / "meta.yaml").read_text(encoding="utf-8") == "status: external\n" + + +def test_legacy_session_dir_ignores_symlinked_sidecar_placeholder(storage, tmp_path): + session_id = "legacy-sidecar-symlink" + legacy_path = storage.legacy_session_path(CWD, session_id) + legacy_path.parent.mkdir(parents=True, exist_ok=True) + legacy_path.write_text('{"role":"user","content":"legacy"}\n', encoding="utf-8") + external_dir = tmp_path / "external-sidecars" + (external_dir / "pipeline").mkdir(parents=True) + (external_dir / "pipeline" / "events.jsonl").write_text("[]", encoding="utf-8") + placeholder_dir = storage._legacy_sidecar_placeholder_dir(legacy_path) + _symlink_or_skip(external_dir, placeholder_dir, target_is_directory=True) + + session_dir = storage.session_dir(CWD, session_id) + + assert session_dir != placeholder_dir + assert session_dir.name == f"{placeholder_dir.name}.conflict-sidecars" + + +def test_legacy_session_dir_ignores_dangling_symlinked_sidecar_placeholder(storage, tmp_path): + session_id = "legacy-sidecar-dangling-symlink" + legacy_path = storage.legacy_session_path(CWD, session_id) + legacy_path.parent.mkdir(parents=True, exist_ok=True) + legacy_path.write_text('{"role":"user","content":"legacy"}\n', encoding="utf-8") + placeholder_dir = storage._legacy_sidecar_placeholder_dir(legacy_path) + _symlink_or_skip(tmp_path / "missing-sidecars", placeholder_dir, target_is_directory=True) + + session_dir = storage.session_dir(CWD, session_id) + + assert session_dir != placeholder_dir + assert session_dir.name == f"{placeholder_dir.name}.conflict-sidecars" + + +def test_legacy_session_dir_ignores_reparse_sidecar_placeholder(storage, monkeypatch): + session_id = "legacy-sidecar-reparse" + legacy_path = storage.legacy_session_path(CWD, session_id) + legacy_path.parent.mkdir(parents=True, exist_ok=True) + legacy_path.write_text('{"role":"user","content":"legacy"}\n', encoding="utf-8") + placeholder_dir = storage._legacy_sidecar_placeholder_dir(legacy_path) + placeholder_dir.mkdir() + monkeypatch.setattr(SessionStorage, "_is_reparse_point", staticmethod(lambda path: path == placeholder_dir)) + + session_dir = storage.session_dir(CWD, session_id) + + assert session_dir != placeholder_dir + assert session_dir.name == f"{placeholder_dir.name}.conflict-sidecars" + + def test_rename_rejects_same_project_duplicate_name(storage): storage.append(CWD, "one", Message(role="user", content="one"), git_branch=None) storage.append(CWD, "two", Message(role="user", content="two"), git_branch=None) @@ -363,3 +718,760 @@ def test_legacy_migration_keeps_directory_session_when_present(tmp_path): assert storage._ensure_directory_format("/tmp/project", "sid") == directory assert directory_path.read_text(encoding="utf-8") == '{"role":"user","content":"directory"}\n' + + +def test_new_directory_session_writes_layout_v2_metadata(storage): + storage.append(CWD, "layout-v2", Message(role="user", content="hello"), git_branch="main") + + metadata = storage.read_metadata(CWD, "layout-v2") + + assert metadata is not None + assert metadata.layout_version == 2 + assert metadata.cwd == CWD + assert metadata.git_branch == "main" + + +def test_write_session_metadata_uses_atomic_replace(monkeypatch, tmp_path): + import iac_code.services.session_metadata as session_metadata + + calls = [] + + def fake_atomic_write_text(path, content, *, encoding="utf-8", durable=True, replace_attempts=3): + calls.append((path, json.loads(content), encoding, durable, replace_attempts)) + path.write_text(content, encoding=encoding) + + monkeypatch.setattr(session_metadata, "atomic_write_text", fake_atomic_write_text, raising=False) + session_dir = tmp_path / "session" + + write_session_metadata( + session_dir, + SessionMetadata(session_id="s1", cwd=CWD, layout_version=SESSION_LAYOUT_VERSION_V2), + ) + + assert calls == [ + ( + session_dir / SESSION_METADATA_FILENAME, + {"session_id": "s1", "cwd": CWD, "schema_version": 1, "layout_version": SESSION_LAYOUT_VERSION_V2}, + "utf-8", + True, + 3, + ) + ] + + +def test_existing_legacy_directory_session_without_metadata_stays_legacy(storage): + session_dir = storage.session_dir(CWD, "legacy-dir") + session_dir.mkdir(parents=True) + (session_dir / SESSION_JSONL_FILENAME).write_text("", encoding="utf-8") + + storage.append(CWD, "legacy-dir", Message(role="user", content="hello"), git_branch="main") + + metadata = storage.read_metadata(CWD, "legacy-dir") + assert metadata is None + + +def test_append_into_empty_precreated_session_directory_writes_layout_v2_metadata(storage): + session_dir = storage.session_dir(CWD, "empty-precreated") + session_dir.mkdir(parents=True) + + storage.append(CWD, "empty-precreated", Message(role="user", content="hello"), git_branch="main") + + metadata = storage.read_metadata(CWD, "empty-precreated") + assert metadata is not None + assert metadata.layout_version == SESSION_LAYOUT_VERSION_V2 + + +def test_ensure_v2_session_dir_marks_empty_precreated_directory(storage): + session_dir = storage.session_dir(CWD, "empty-precreated") + session_dir.mkdir(parents=True) + + assert storage.ensure_v2_session_dir_for_new_session(CWD, "empty-precreated", git_branch="main") == session_dir + + metadata = storage.read_metadata(CWD, "empty-precreated") + assert metadata is not None + assert metadata.layout_version == SESSION_LAYOUT_VERSION_V2 + assert metadata.git_branch == "main" + + +def test_long_cwd_legacy_project_remains_readable_after_bounded_project_exists(tmp_path): + cwd = "x" * (project_paths.MAX_SANITIZED_LENGTH + 50) + current_project_dir, legacy_project_dir = project_paths.project_dir_candidates(cwd, tmp_path) + legacy_project_dir.mkdir(parents=True) + legacy_path = legacy_project_dir / "legacy-long.jsonl" + legacy_path.write_text('{"role":"user","content":"old","cwd":"%s"}\n' % cwd, encoding="utf-8") + current_project_dir.mkdir(parents=True) + storage = SessionStorage(projects_dir=tmp_path) + + loaded = storage.load(cwd, "legacy-long") + + assert [message.content for message in loaded] == ["old"] + assert storage.exists(cwd, "legacy-long") + + +def test_long_cwd_legacy_directory_session_dir_not_shadowed_by_new_sidecar(tmp_path): + cwd = "x" * (project_paths.MAX_SANITIZED_LENGTH + 50) + current_project_dir, legacy_project_dir = project_paths.project_dir_candidates(cwd, tmp_path) + current_sidecar_dir = current_project_dir / "legacy-dir" + (current_sidecar_dir / "pipeline").mkdir(parents=True) + (current_sidecar_dir / "pipeline" / "events.jsonl").write_text("", encoding="utf-8") + legacy_session_dir = legacy_project_dir / "legacy-dir" + legacy_session_dir.mkdir(parents=True) + (legacy_session_dir / SESSION_JSONL_FILENAME).write_text( + '{"role":"user","content":"old","cwd":"%s"}\n' % cwd, + encoding="utf-8", + ) + storage = SessionStorage(projects_dir=tmp_path) + + assert storage.session_dir(cwd, "legacy-dir") == legacy_session_dir + assert [message.content for message in storage.load(cwd, "legacy-dir")] == ["old"] + + +def test_long_cwd_legacy_sidecar_only_marked_before_bounded_project_shadow(tmp_path): + cwd = "x" * (project_paths.MAX_SANITIZED_LENGTH + 50) + current_project_dir, legacy_project_dir = project_paths.project_dir_candidates(cwd, tmp_path) + legacy_session_dir = legacy_project_dir / "legacy-sidecar-only" + (legacy_session_dir / "pipeline").mkdir(parents=True) + (legacy_session_dir / "pipeline" / "events.jsonl").write_text("", encoding="utf-8") + storage = SessionStorage(projects_dir=tmp_path) + + session_dir = storage.ensure_v2_session_dir_for_new_session(cwd, "legacy-sidecar-only", git_branch="main") + + assert session_dir == legacy_session_dir + assert (legacy_session_dir / SESSION_METADATA_FILENAME).exists() + assert not (current_project_dir / "legacy-sidecar-only" / SESSION_METADATA_FILENAME).exists() + + +def test_long_cwd_legacy_sidecar_state_wins_over_current_metadata_only_shadow(tmp_path): + cwd = "x" * (project_paths.MAX_SANITIZED_LENGTH + 50) + current_project_dir, legacy_project_dir = project_paths.project_dir_candidates(cwd, tmp_path) + session_id = "legacy-sidecar-shadow" + current_session_dir = current_project_dir / session_id + legacy_session_dir = legacy_project_dir / session_id + write_session_metadata( + current_session_dir, + SessionMetadata(session_id=session_id, cwd=cwd, layout_version=SESSION_LAYOUT_VERSION_V2), + ) + (legacy_session_dir / "pipeline").mkdir(parents=True) + (legacy_session_dir / "pipeline" / "events.jsonl").write_text("", encoding="utf-8") + storage = SessionStorage(projects_dir=tmp_path) + + assert storage.v2_session_dir(cwd, session_id) is None + assert storage.session_dir(cwd, session_id) == legacy_session_dir + assert storage.ensure_v2_session_dir_for_new_session(cwd, session_id, git_branch="main") == legacy_session_dir + assert storage.session_dir(cwd, session_id) == legacy_session_dir + + +def test_long_cwd_legacy_sidecar_state_wins_over_empty_current_directory(tmp_path): + cwd = "x" * (project_paths.MAX_SANITIZED_LENGTH + 50) + current_project_dir, legacy_project_dir = project_paths.project_dir_candidates(cwd, tmp_path) + session_id = "legacy-sidecar-empty-shadow" + current_session_dir = current_project_dir / session_id + legacy_session_dir = legacy_project_dir / session_id + current_session_dir.mkdir(parents=True) + (legacy_session_dir / "pipeline").mkdir(parents=True) + (legacy_session_dir / "pipeline" / "events.jsonl").write_text("", encoding="utf-8") + storage = SessionStorage(projects_dir=tmp_path) + + assert storage.session_dir(cwd, session_id) == legacy_session_dir + assert storage.ensure_v2_session_dir_for_new_session(cwd, session_id, git_branch="main") == legacy_session_dir + assert not (current_session_dir / SESSION_METADATA_FILENAME).exists() + + +def test_new_session_dir_uses_bounded_project_even_when_legacy_long_project_exists(tmp_path): + cwd = "x" * (project_paths.MAX_SANITIZED_LENGTH + 50) + current_project_dir, legacy_project_dir = project_paths.project_dir_candidates(cwd, tmp_path) + legacy_project_dir.mkdir(parents=True) + storage = SessionStorage(projects_dir=tmp_path) + + session_dir = storage.ensure_v2_session_dir_for_new_session(cwd, "new-long", git_branch="main") + + assert session_dir == current_project_dir / "new-long" + assert (session_dir / SESSION_METADATA_FILENAME).exists() + + +def test_append_new_session_uses_bounded_project_even_when_legacy_long_project_exists(tmp_path): + cwd = "x" * (project_paths.MAX_SANITIZED_LENGTH + 50) + current_project_dir, legacy_project_dir = project_paths.project_dir_candidates(cwd, tmp_path) + legacy_project_dir.mkdir(parents=True) + storage = SessionStorage(projects_dir=tmp_path) + + storage.append(cwd, "new-long-append", Message(role="user", content="new"), git_branch=None) + + assert storage.session_path(cwd, "new-long-append") == current_project_dir / "new-long-append" / "session.jsonl" + assert not (legacy_project_dir / "new-long-append" / "session.jsonl").exists() + + +def test_metadata_only_shadow_does_not_hide_legacy_file_for_cross_project_lookup(storage): + legacy_path = storage.legacy_session_path(CWD, "same-id-shadow") + legacy_path.parent.mkdir(parents=True, exist_ok=True) + legacy_path.write_text('{"role":"user","content":"legacy","cwd":"%s"}\n' % CWD, encoding="utf-8") + session_dir = legacy_path.parent / "same-id-shadow" + write_session_metadata( + session_dir, + SessionMetadata( + session_id="same-id-shadow", + cwd="/shadow", + name="shadow", + layout_version=SESSION_LAYOUT_VERSION_V2, + ), + ) + + assert storage.find_session_anywhere("same-id-shadow") == (CWD, legacy_path) + + +def test_cross_project_lookup_ignores_metadata_only_shadow_across_project_dir_candidates(storage): + cwd = "x" * (project_paths.MAX_SANITIZED_LENGTH + 50) + current_project_dir, legacy_project_dir = project_paths.project_dir_candidates(cwd, storage._projects_dir) + legacy_project_dir.mkdir(parents=True) + legacy_path = legacy_project_dir / "legacy-long-shadow.jsonl" + legacy_path.write_text('{"role":"user","content":"legacy","cwd":"%s"}\n' % cwd, encoding="utf-8") + shadow_dir = current_project_dir / "legacy-long-shadow" + write_session_metadata( + shadow_dir, + SessionMetadata( + session_id="legacy-long-shadow", + cwd=cwd, + name="shadow", + layout_version=SESSION_LAYOUT_VERSION_V2, + ), + ) + + assert storage.find_session_anywhere("legacy-long-shadow") == (cwd, legacy_path) + + +def test_cross_project_lookup_ignores_metadata_only_shadow_with_stale_metadata_cwd(storage): + cwd = "x" * (project_paths.MAX_SANITIZED_LENGTH + 50) + current_project_dir, legacy_project_dir = project_paths.project_dir_candidates(cwd, storage._projects_dir) + legacy_project_dir.mkdir(parents=True) + legacy_path = legacy_project_dir / "legacy-long-stale-shadow.jsonl" + legacy_path.write_text('{"role":"user","content":"legacy","cwd":"%s"}\n' % cwd, encoding="utf-8") + shadow_dir = current_project_dir / "legacy-long-stale-shadow" + write_session_metadata( + shadow_dir, + SessionMetadata( + session_id="legacy-long-stale-shadow", + cwd="/stale-cwd", + name="shadow", + layout_version=SESSION_LAYOUT_VERSION_V2, + ), + ) + + assert storage.find_session_anywhere("legacy-long-stale-shadow") == (cwd, legacy_path) + + +def test_latest_session_ignores_metadata_only_shadow_when_same_id_legacy_exists(storage): + import os + + legacy_path = storage.legacy_session_path(CWD, "same-id-latest-shadow") + legacy_path.parent.mkdir(parents=True, exist_ok=True) + legacy_path.write_text('{"role":"user","content":"legacy","cwd":"%s"}\n' % CWD, encoding="utf-8") + session_dir = legacy_path.parent / "same-id-latest-shadow" + write_session_metadata( + session_dir, + SessionMetadata( + session_id="same-id-latest-shadow", + cwd="/shadow", + name="shadow", + layout_version=SESSION_LAYOUT_VERSION_V2, + ), + ) + metadata_path = session_dir / SESSION_METADATA_FILENAME + os.utime(metadata_path, (metadata_path.stat().st_atime, legacy_path.stat().st_mtime + 100)) + + assert storage.get_latest_session_anywhere() == (CWD, "same-id-latest-shadow") + + +def test_latest_session_ignores_metadata_only_shadow_across_project_dir_candidates(storage): + import os + + cwd = "x" * (project_paths.MAX_SANITIZED_LENGTH + 50) + current_project_dir, legacy_project_dir = project_paths.project_dir_candidates(cwd, storage._projects_dir) + legacy_project_dir.mkdir(parents=True) + legacy_path = legacy_project_dir / "latest-long-shadow.jsonl" + legacy_path.write_text('{"role":"user","content":"legacy","cwd":"%s"}\n' % cwd, encoding="utf-8") + shadow_dir = current_project_dir / "latest-long-shadow" + write_session_metadata( + shadow_dir, + SessionMetadata( + session_id="latest-long-shadow", + cwd=cwd, + name="shadow", + layout_version=SESSION_LAYOUT_VERSION_V2, + ), + ) + metadata_path = shadow_dir / SESSION_METADATA_FILENAME + os.utime(metadata_path, (metadata_path.stat().st_atime, legacy_path.stat().st_mtime + 100)) + + assert storage.get_latest_session_anywhere() == (cwd, "latest-long-shadow") + + +def test_latest_session_ignores_metadata_only_shadow_with_stale_metadata_cwd(storage): + import os + + cwd = "x" * (project_paths.MAX_SANITIZED_LENGTH + 50) + current_project_dir, legacy_project_dir = project_paths.project_dir_candidates(cwd, storage._projects_dir) + legacy_project_dir.mkdir(parents=True) + legacy_path = legacy_project_dir / "latest-long-stale-shadow.jsonl" + legacy_path.write_text('{"role":"user","content":"legacy","cwd":"%s"}\n' % cwd, encoding="utf-8") + shadow_dir = current_project_dir / "latest-long-stale-shadow" + write_session_metadata( + shadow_dir, + SessionMetadata( + session_id="latest-long-stale-shadow", + cwd="/stale-cwd", + name="shadow", + layout_version=SESSION_LAYOUT_VERSION_V2, + ), + ) + metadata_path = shadow_dir / SESSION_METADATA_FILENAME + os.utime(metadata_path, (metadata_path.stat().st_atime, legacy_path.stat().st_mtime + 100)) + + assert storage.get_latest_session_anywhere() == (cwd, "latest-long-stale-shadow") + + +def test_latest_session_keeps_metadata_only_when_same_id_legacy_is_in_different_project(storage): + import os + + legacy_path = storage.legacy_session_path("/legacy-project", "same-id-cross-project") + legacy_path.parent.mkdir(parents=True, exist_ok=True) + legacy_path.write_text('{"role":"user","content":"legacy","cwd":"/legacy-project"}\n', encoding="utf-8") + session_dir = storage.ensure_v2_session_dir_for_new_session( + "/metadata-project", + "same-id-cross-project", + git_branch="main", + ) + assert session_dir is not None + metadata_path = session_dir / SESSION_METADATA_FILENAME + os.utime(metadata_path, (metadata_path.stat().st_atime, legacy_path.stat().st_mtime + 100)) + + assert storage.get_latest_session_anywhere() == ("/metadata-project", "same-id-cross-project") + + +def test_metadata_only_mismatched_session_id_is_ignored_by_cross_project_lookup(storage): + session_dir = storage.session_dir(CWD, "requested") + write_session_metadata( + session_dir, + SessionMetadata( + session_id="different", + cwd=CWD, + name="wrong-id", + layout_version=SESSION_LAYOUT_VERSION_V2, + ), + ) + + assert storage.find_session_anywhere("requested") is None + assert storage.get_latest_session_anywhere() is None + assert storage.exists(CWD, "requested") is False + + +def test_metadata_only_mismatched_future_session_id_is_ignored(storage): + session_dir = storage.session_dir(CWD, "requested-future") + write_session_metadata( + session_dir, + SessionMetadata( + session_id="different-future", + cwd=CWD, + name="wrong-id", + layout_version=99, + ), + ) + + assert storage.exists(CWD, "requested-future") is False + assert storage.read_metadata(CWD, "requested-future") is None + assert storage.find_session_anywhere("requested-future") is None + + +def test_directory_session_mismatched_metadata_is_not_current_session(storage): + session_dir = storage.session_dir(CWD, "requested-dir") + session_dir.mkdir(parents=True) + (session_dir / SESSION_JSONL_FILENAME).write_text('{"role":"user","content":"wrong"}\n', encoding="utf-8") + write_session_metadata( + session_dir, + SessionMetadata( + session_id="different-dir", + cwd=CWD, + layout_version=SESSION_LAYOUT_VERSION_V2, + ), + ) + + assert storage.exists(CWD, "requested-dir") is False + assert storage.load(CWD, "requested-dir") == [] + assert storage.v2_session_dir(CWD, "requested-dir") is None + assert storage.session_path(CWD, "requested-dir") != session_dir / SESSION_JSONL_FILENAME + assert storage.session_dir(CWD, "requested-dir") != session_dir + assert storage.find_session_anywhere("requested-dir") is None + assert storage.get_latest_session_anywhere() is None + + +def test_directory_session_mismatched_future_metadata_is_ignored(storage): + session_dir = storage.session_dir(CWD, "requested-dir-future") + session_dir.mkdir(parents=True) + (session_dir / SESSION_JSONL_FILENAME).write_text('{"role":"user","content":"wrong"}\n', encoding="utf-8") + write_session_metadata( + session_dir, + SessionMetadata( + session_id="different-dir-future", + cwd=CWD, + layout_version=99, + ), + ) + + assert storage.exists(CWD, "requested-dir-future") is False + assert storage.load(CWD, "requested-dir-future") == [] + assert storage.find_session_anywhere("requested-dir-future") is None + assert storage.get_latest_session_anywhere() is None + + +@pytest.mark.parametrize("layout_version", [SESSION_LAYOUT_VERSION_V2, 99]) +def test_write_refuses_mismatched_metadata_before_mutating(storage, layout_version): + session_dir = storage.session_dir(CWD, f"requested-write-{layout_version}") + session_dir.mkdir(parents=True) + write_session_metadata( + session_dir, + SessionMetadata( + session_id=f"different-write-{layout_version}", + cwd=CWD, + layout_version=layout_version, + ), + ) + metadata_before = (session_dir / SESSION_METADATA_FILENAME).read_text(encoding="utf-8") + + with pytest.raises(UnsupportedSessionLayoutError, match="Unsupported session metadata"): + storage.append( + CWD, + f"requested-write-{layout_version}", + Message(role="assistant", content="new"), + git_branch="main", + ) + + assert not (session_dir / SESSION_JSONL_FILENAME).exists() + assert (session_dir / SESSION_METADATA_FILENAME).read_text(encoding="utf-8") == metadata_before + + +def test_write_refuses_invalid_metadata_only_before_mutating(storage): + session_dir = storage.session_dir(CWD, "invalid-metadata-write") + session_dir.mkdir(parents=True) + metadata_path = session_dir / SESSION_METADATA_FILENAME + metadata_path.write_text('{"name":"missing-session-id"}\n', encoding="utf-8") + + with pytest.raises(UnsupportedSessionLayoutError, match="Unsupported session metadata"): + storage.append(CWD, "invalid-metadata-write", Message(role="assistant", content="new"), git_branch="main") + + assert not (session_dir / SESSION_JSONL_FILENAME).exists() + assert metadata_path.read_text(encoding="utf-8") == '{"name":"missing-session-id"}\n' + + +def test_write_refuses_symlinked_metadata_before_mutating(storage, tmp_path): + session_id = "symlinked-metadata-write" + session_dir = storage.session_dir(CWD, session_id) + session_dir.mkdir(parents=True) + target = tmp_path / "outside-metadata.json" + target.write_text( + '{"session_id":"symlinked-metadata-write","layout_version":2}\n', + encoding="utf-8", + ) + metadata_path = session_dir / SESSION_METADATA_FILENAME + _symlink_or_skip(target, metadata_path) + + with pytest.raises(UnsupportedSessionLayoutError, match="Unsupported session metadata"): + storage.append(CWD, session_id, Message(role="assistant", content="new"), git_branch="main") + + assert metadata_path.is_symlink() + assert not (session_dir / SESSION_JSONL_FILENAME).exists() + + +def test_usage_load_reads_legacy_long_project_after_bounded_project_exists(tmp_path): + cwd = "x" * (project_paths.MAX_SANITIZED_LENGTH + 50) + current_project_dir, legacy_project_dir = project_paths.project_dir_candidates(cwd, tmp_path) + usage_dir = legacy_project_dir / "sid" + usage_dir.mkdir(parents=True) + (usage_dir / "usage.jsonl").write_text( + '{"type":"usage","input_tokens":7,"output_tokens":3}\n', + encoding="utf-8", + ) + current_project_dir.mkdir(parents=True) + store = SessionUsageStore(projects_dir=tmp_path) + + totals = store.load(cwd, "sid") + + assert totals.input_tokens == 7 + assert totals.output_tokens == 3 + + +def test_usage_append_writes_existing_legacy_long_v2_session_dir_after_bounded_project_exists(tmp_path): + cwd = "x" * (project_paths.MAX_SANITIZED_LENGTH + 50) + current_project_dir, legacy_project_dir = project_paths.project_dir_candidates(cwd, tmp_path) + session_dir = legacy_project_dir / "sid" + session_dir.mkdir(parents=True) + write_session_metadata( + session_dir, + SessionMetadata(session_id="sid", cwd=cwd, layout_version=SESSION_LAYOUT_VERSION_V2), + ) + (session_dir / SESSION_JSONL_FILENAME).write_text('{"role":"user","content":"old"}\n', encoding="utf-8") + current_project_dir.mkdir(parents=True) + store = SessionUsageStore(projects_dir=tmp_path) + + assert store.append(cwd, "sid", Usage(input_tokens=4, output_tokens=2)) + + assert (session_dir / "usage.jsonl").exists() + assert not (current_project_dir / "sid" / "usage.jsonl").exists() + + +def test_ensure_v2_session_dir_marks_sidecar_only_directory(storage): + session_dir = storage.session_dir(CWD, "sidecar-only") + (session_dir / "pipeline").mkdir(parents=True) + (session_dir / "pipeline" / "events.jsonl").write_text("", encoding="utf-8") + + assert storage.ensure_v2_session_dir_for_new_session(CWD, "sidecar-only", git_branch="main") == session_dir + + metadata = storage.read_metadata(CWD, "sidecar-only") + assert metadata is not None + assert metadata.layout_version == SESSION_LAYOUT_VERSION_V2 + assert metadata.git_branch == "main" + + +def test_sidecar_only_directory_without_metadata_is_not_resumable(storage): + session_dir = storage.session_dir(CWD, "sidecar-only-unmarked") + (session_dir / "pipeline").mkdir(parents=True) + (session_dir / "pipeline" / "events.jsonl").write_text("", encoding="utf-8") + + assert storage.exists(CWD, "sidecar-only-unmarked") is False + + +def test_ensure_v2_session_dir_marks_session_owned_runtime_sidecar_directory(storage): + session_dir = storage.session_dir(CWD, "runtime-sidecars") + session_dir.mkdir(parents=True) + (session_dir / "usage.jsonl").write_text("", encoding="utf-8") + (session_dir / ".usage.jsonl.lock").write_text("", encoding="utf-8") + (session_dir / "permission-audit.jsonl").write_text("", encoding="utf-8") + (session_dir / "tool-results").mkdir() + (session_dir / "image-cache").mkdir() + + assert storage.ensure_v2_session_dir_for_new_session(CWD, "runtime-sidecars", git_branch="main") == session_dir + + metadata = storage.read_metadata(CWD, "runtime-sidecars") + assert metadata is not None + assert metadata.layout_version == SESSION_LAYOUT_VERSION_V2 + + +@pytest.mark.parametrize( + ("name", "is_dir"), + [ + ("usage.jsonl", True), + (".usage.jsonl.lock", True), + ("permission-audit.jsonl", True), + ("tool-results", False), + ("image-cache", False), + ], +) +def test_ensure_v2_session_dir_rejects_sidecar_name_with_wrong_type(storage, name, is_dir): + session_id = f"bad-sidecar-{name}" + session_dir = storage.session_dir(CWD, session_id) + session_dir.mkdir(parents=True) + path = session_dir / name + if is_dir: + path.mkdir() + else: + path.write_text("", encoding="utf-8") + + assert storage.ensure_v2_session_dir_for_new_session(CWD, session_id, git_branch="main") is None + assert storage.read_metadata(CWD, session_id) is None + + +@pytest.mark.parametrize("operation", ["append", "append_meta", "save"]) +def test_write_paths_refuse_unsupported_layout_before_mutating(storage, operation): + session_dir = storage.session_dir(CWD, f"future-{operation}") + session_dir.mkdir(parents=True) + session_path = session_dir / SESSION_JSONL_FILENAME + metadata_path = session_dir / SESSION_METADATA_FILENAME + session_path.write_text('{"role":"user","content":"old"}\n', encoding="utf-8") + write_session_metadata( + session_dir, + SessionMetadata(session_id=f"future-{operation}", cwd=CWD, layout_version=99), + ) + before_session = session_path.read_text(encoding="utf-8") + before_metadata = metadata_path.read_text(encoding="utf-8") + + with pytest.raises(UnsupportedSessionLayoutError, match="Unsupported session layout version"): + if operation == "append": + storage.append(CWD, f"future-{operation}", Message(role="assistant", content="new"), git_branch="main") + elif operation == "append_meta": + storage.append_meta(CWD, f"future-{operation}", {"type": "last-prompt", "last_prompt": "new"}) + else: + storage.save(CWD, f"future-{operation}", [Message(role="user", content="new")], git_branch="main") + + assert session_path.read_text(encoding="utf-8") == before_session + assert metadata_path.read_text(encoding="utf-8") == before_metadata + + +@pytest.mark.parametrize("operation", ["append", "append_meta", "save"]) +def test_write_paths_refuse_metadata_only_unsupported_layout_before_mutating(storage, operation): + session_dir = storage.session_dir(CWD, f"future-metadata-only-{operation}") + session_dir.mkdir(parents=True) + session_path = session_dir / SESSION_JSONL_FILENAME + metadata_path = session_dir / SESSION_METADATA_FILENAME + write_session_metadata( + session_dir, + SessionMetadata(session_id=f"future-metadata-only-{operation}", cwd=CWD, layout_version=99), + ) + before_metadata = metadata_path.read_text(encoding="utf-8") + + with pytest.raises(UnsupportedSessionLayoutError, match="Unsupported session layout version"): + if operation == "append": + storage.append( + CWD, + f"future-metadata-only-{operation}", + Message(role="assistant", content="new"), + git_branch="main", + ) + elif operation == "append_meta": + storage.append_meta( + CWD, + f"future-metadata-only-{operation}", + {"type": "last-prompt", "last_prompt": "new"}, + ) + else: + storage.save( + CWD, + f"future-metadata-only-{operation}", + [Message(role="user", content="new")], + git_branch="main", + ) + + assert not session_path.exists() + assert metadata_path.read_text(encoding="utf-8") == before_metadata + + +@pytest.mark.parametrize("operation", ["load", "exists"]) +def test_read_paths_refuse_unsupported_layout_before_reading(storage, operation): + session_dir = storage.session_dir(CWD, f"future-read-{operation}") + session_dir.mkdir(parents=True) + (session_dir / SESSION_JSONL_FILENAME).write_text('{"role":"user","content":"old"}\n', encoding="utf-8") + write_session_metadata( + session_dir, + SessionMetadata(session_id=f"future-read-{operation}", cwd=CWD, layout_version=99), + ) + + with pytest.raises(UnsupportedSessionLayoutError, match="Unsupported session layout version"): + if operation == "load": + storage.load(CWD, f"future-read-{operation}") + else: + storage.exists(CWD, f"future-read-{operation}") + + +def test_find_session_anywhere_refuses_unsupported_directory_layout(storage): + session_dir = storage.session_dir(CWD, "future-find") + session_dir.mkdir(parents=True) + (session_dir / SESSION_JSONL_FILENAME).write_text( + '{"role":"user","content":"old","cwd":"/tmp/proj-x"}\n', + encoding="utf-8", + ) + write_session_metadata( + session_dir, + SessionMetadata(session_id="future-find", cwd=CWD, layout_version=99), + ) + + with pytest.raises(UnsupportedSessionLayoutError, match="Unsupported session layout version"): + storage.find_session_anywhere("future-find") + + +def test_find_session_anywhere_prefers_v2_metadata_cwd(storage): + session_dir = storage.session_dir("/metadata-cwd", "metadata-cwd-session") + session_dir.mkdir(parents=True) + (session_dir / SESSION_JSONL_FILENAME).write_text('{"role":"user","content":"legacy row"}\n', encoding="utf-8") + write_session_metadata( + session_dir, + SessionMetadata( + session_id="metadata-cwd-session", + cwd="/metadata-cwd", + layout_version=SESSION_LAYOUT_VERSION_V2, + ), + ) + + found = storage.find_session_anywhere("metadata-cwd-session") + + assert found == ("/metadata-cwd", session_dir / SESSION_JSONL_FILENAME) + + +def test_get_latest_session_anywhere_uses_metadata_only_v2_session(storage): + session_dir = storage.ensure_v2_session_dir_for_new_session( + "/metadata-only-latest", + "latest-meta", + git_branch="main", + ) + assert session_dir is not None + + assert storage.get_latest_session_anywhere() == ("/metadata-only-latest", "latest-meta") + + +def test_get_latest_session_anywhere_refuses_unsupported_directory_layout(storage): + session_dir = storage.session_dir(CWD, "future-latest") + session_dir.mkdir(parents=True) + (session_dir / SESSION_JSONL_FILENAME).write_text( + '{"role":"user","content":"old","cwd":"/tmp/proj-x"}\n', + encoding="utf-8", + ) + write_session_metadata( + session_dir, + SessionMetadata(session_id="future-latest", cwd=CWD, layout_version=99), + ) + + with pytest.raises(UnsupportedSessionLayoutError, match="Unsupported session layout version"): + storage.get_latest_session_anywhere() + + +def test_rename_session_preserves_layout_version_for_v2_sessions(storage): + storage.append(CWD, "rename-v2", Message(role="user", content="hello"), git_branch="main") + + result = storage.rename_session(CWD, "rename-v2", "deploy-prod", git_branch="feature") + + metadata = storage.read_metadata(CWD, "rename-v2") + assert result == "renamed" + assert metadata is not None + assert metadata.name == "deploy-prod" + assert metadata.layout_version == SESSION_LAYOUT_VERSION_V2 + + +def test_rename_new_directory_session_writes_layout_v2_metadata(storage): + result = storage.rename_session(CWD, "rename-new", "deploy-prod", git_branch="main") + + metadata = storage.read_metadata(CWD, "rename-new") + assert result == "renamed" + assert metadata is not None + assert metadata.name == "deploy-prod" + assert metadata.layout_version == SESSION_LAYOUT_VERSION_V2 + + +def test_rename_session_refuses_unsupported_layout_before_modifying_metadata(storage): + session_dir = storage.session_dir(CWD, "future-rename") + session_dir.mkdir(parents=True) + (session_dir / SESSION_JSONL_FILENAME).write_text('{"role":"user","content":"old"}\n', encoding="utf-8") + write_session_metadata( + session_dir, + SessionMetadata(session_id="future-rename", name="old-name", cwd=CWD, layout_version=99), + ) + metadata_path = session_dir / SESSION_METADATA_FILENAME + before_metadata = metadata_path.read_text(encoding="utf-8") + + with pytest.raises(UnsupportedSessionLayoutError, match="Unsupported session layout version"): + storage.rename_session(CWD, "future-rename", "deploy-prod", git_branch="main") + + assert metadata_path.read_text(encoding="utf-8") == before_metadata + + +def test_rename_session_refuses_metadata_only_unsupported_layout_before_mutating(storage): + session_dir = storage.session_dir(CWD, "future-metadata-only-rename") + session_dir.mkdir(parents=True) + session_path = session_dir / SESSION_JSONL_FILENAME + metadata_path = session_dir / SESSION_METADATA_FILENAME + write_session_metadata( + session_dir, + SessionMetadata(session_id="future-metadata-only-rename", name="old-name", cwd=CWD, layout_version=99), + ) + before_metadata = metadata_path.read_text(encoding="utf-8") + + with pytest.raises(UnsupportedSessionLayoutError, match="Unsupported session layout version"): + storage.rename_session(CWD, "future-metadata-only-rename", "deploy-prod", git_branch="main") + + assert not session_path.exists() + assert metadata_path.read_text(encoding="utf-8") == before_metadata diff --git a/tests/services/test_session_usage.py b/tests/services/test_session_usage.py index a507f30f..f2c918b3 100644 --- a/tests/services/test_session_usage.py +++ b/tests/services/test_session_usage.py @@ -1,11 +1,30 @@ import json +from pathlib import Path +import pytest + +from iac_code.services.session_layout import UnsupportedSessionLayoutError +from iac_code.services.session_metadata import ( + SESSION_JSONL_FILENAME, + SESSION_LAYOUT_VERSION_V2, + SESSION_METADATA_FILENAME, + SessionMetadata, + write_session_metadata, +) from iac_code.services.session_usage import SessionUsageStore, SessionUsageTotals from iac_code.types.stream_events import Usage +from iac_code.utils import project_paths CWD = "/tmp/status-project" +def _symlink_or_skip(target: Path, link: Path, *, target_is_directory: bool = False) -> None: + try: + link.symlink_to(target, target_is_directory=target_is_directory) + except (NotImplementedError, OSError) as exc: + pytest.skip(f"symlink creation unsupported: {exc}") + + def test_totals_adds_usage_and_tracks_record_count() -> None: totals = SessionUsageTotals() @@ -122,3 +141,163 @@ def test_load_reads_new_and_legacy_sidecars(tmp_path) -> None: assert totals.cache_creation_input_tokens == 7 assert totals.total_tokens == 15 assert totals.recorded_events == 2 + + +def test_usage_store_accepts_direct_path_provider(tmp_path: Path) -> None: + path = tmp_path / "session" / "pipeline" / "transcripts" / "transcript_1" / "usage.jsonl" + store = SessionUsageStore(path_provider=lambda _cwd, _session_id: path) + + assert store.append("/repo", "transcript_1", Usage(input_tokens=3, output_tokens=4), provider="p", model="m") + + assert path.exists() + assert store.load("/repo", "transcript_1").total_tokens == 7 + + +def test_usage_store_with_direct_path_provider_ignores_legacy_path(tmp_path: Path) -> None: + direct_path = tmp_path / "transcript" / "usage.jsonl" + legacy_store = SessionUsageStore(projects_dir=tmp_path) + legacy_path = legacy_store.legacy_path_for("/repo", "transcript_1") + legacy_path.parent.mkdir(parents=True) + legacy_path.write_text('{"type":"usage","version":1,"input_tokens":50,"output_tokens":60}\n', encoding="utf-8") + store = SessionUsageStore(path_provider=lambda _cwd, _session_id: direct_path) + + totals = store.load("/repo", "transcript_1") + + assert totals.total_tokens == 0 + assert totals.recorded_events == 0 + + +def test_load_skips_mismatched_directory_session_usage(tmp_path) -> None: + store = SessionUsageStore(projects_dir=tmp_path) + path = store.path_for(CWD, "mismatched") + session_dir = path.parent + session_dir.mkdir(parents=True) + (session_dir / SESSION_JSONL_FILENAME).write_text('{"role":"user","content":"wrong"}\n', encoding="utf-8") + write_session_metadata( + session_dir, + SessionMetadata( + session_id="different", + cwd=CWD, + layout_version=SESSION_LAYOUT_VERSION_V2, + ), + ) + path.write_text('{"type":"usage","version":1,"input_tokens":50,"output_tokens":60}\n', encoding="utf-8") + + totals = store.load(CWD, "mismatched") + + assert totals.total_tokens == 0 + assert totals.recorded_events == 0 + + +def test_append_refuses_mismatched_directory_session_usage(tmp_path) -> None: + store = SessionUsageStore(projects_dir=tmp_path) + path = store.path_for(CWD, "mismatched-write") + session_dir = path.parent + session_dir.mkdir(parents=True) + (session_dir / SESSION_METADATA_FILENAME).write_text('{"session_id":"different"}\n', encoding="utf-8") + + with pytest.raises(UnsupportedSessionLayoutError, match="Unsupported session metadata"): + store.append(CWD, "mismatched-write", Usage(input_tokens=1), provider="p", model="m") + + assert not path.exists() + + +def test_usage_refuses_and_ignores_symlinked_metadata(tmp_path) -> None: + store = SessionUsageStore(projects_dir=tmp_path) + session_id = "symlinked-metadata-usage" + project_dir = project_paths.project_dir_candidates(CWD, tmp_path)[0] + session_dir = project_dir / session_id + session_dir.mkdir(parents=True) + target = tmp_path / "outside-metadata.json" + target.write_text( + '{"session_id":"symlinked-metadata-usage","layout_version":2}\n', + encoding="utf-8", + ) + _symlink_or_skip(target, session_dir / SESSION_METADATA_FILENAME) + usage_path = session_dir / "usage.jsonl" + + with pytest.raises(UnsupportedSessionLayoutError, match="Unsupported session metadata"): + store.append(CWD, session_id, Usage(input_tokens=1), provider="p", model="m") + + assert not usage_path.exists() + usage_path.write_text('{"type":"usage","version":1,"input_tokens":50,"output_tokens":60}\n', encoding="utf-8") + totals = store.load(CWD, session_id) + assert totals.total_tokens == 0 + assert totals.recorded_events == 0 + + +def test_usage_direct_path_provider_refuses_symlinked_metadata(tmp_path) -> None: + session_dir = tmp_path / "session" + session_dir.mkdir() + target = tmp_path / "outside-metadata.json" + target.write_text( + '{"session_id":"direct-usage","layout_version":2}\n', + encoding="utf-8", + ) + _symlink_or_skip(target, session_dir / SESSION_METADATA_FILENAME) + usage_path = session_dir / "usage.jsonl" + store = SessionUsageStore(path_provider=lambda _cwd, _session_id: usage_path) + + with pytest.raises(UnsupportedSessionLayoutError, match="Unsupported session metadata"): + store.append("/repo", "direct-usage", Usage(input_tokens=1), provider="p", model="m") + + assert not usage_path.exists() + usage_path.write_text('{"type":"usage","version":1,"input_tokens":50,"output_tokens":60}\n', encoding="utf-8") + totals = store.load("/repo", "direct-usage") + assert totals.total_tokens == 0 + assert totals.recorded_events == 0 + + +def test_usage_direct_path_provider_refuses_symlinked_usage_leaf(tmp_path) -> None: + session_dir = tmp_path / "session" + write_session_metadata( + session_dir, + SessionMetadata( + session_id="direct-usage", + cwd="/repo", + layout_version=SESSION_LAYOUT_VERSION_V2, + ), + ) + usage_path = session_dir / "usage.jsonl" + outside = tmp_path / "outside-usage.jsonl" + outside_content = '{"type":"usage","version":1,"input_tokens":50,"output_tokens":60}\n' + outside.write_text(outside_content, encoding="utf-8") + _symlink_or_skip(outside, usage_path) + store = SessionUsageStore(path_provider=lambda _cwd, _session_id: usage_path) + + with pytest.raises(OSError, match="symlink|reparse"): + store.append("/repo", "direct-usage", Usage(input_tokens=1), provider="p", model="m") + + assert outside.read_text(encoding="utf-8") == outside_content + totals = store.load("/repo", "direct-usage") + assert totals.total_tokens == 0 + assert totals.recorded_events == 0 + + +def test_usage_prefers_legacy_flat_over_metadata_shadow_across_project_dir_candidates(tmp_path) -> None: + cwd = "x" * (project_paths.MAX_SANITIZED_LENGTH + 50) + current_project_dir, legacy_project_dir = project_paths.project_dir_candidates(cwd, tmp_path) + legacy_project_dir.mkdir(parents=True) + legacy_session = legacy_project_dir / "long-shadow.jsonl" + legacy_session.write_text('{"role":"user","content":"legacy","cwd":"%s"}\n' % cwd, encoding="utf-8") + legacy_usage = legacy_project_dir / "long-shadow.usage.jsonl" + legacy_usage.write_text('{"type":"usage","version":1,"input_tokens":3,"output_tokens":4}\n', encoding="utf-8") + shadow_dir = current_project_dir / "long-shadow" + write_session_metadata( + shadow_dir, + SessionMetadata( + session_id="long-shadow", + cwd=cwd, + layout_version=SESSION_LAYOUT_VERSION_V2, + ), + ) + shadow_usage = shadow_dir / "usage.jsonl" + shadow_usage.write_text('{"type":"usage","version":1,"input_tokens":50,"output_tokens":60}\n', encoding="utf-8") + store = SessionUsageStore(projects_dir=tmp_path) + + assert store.path_for(cwd, "long-shadow") == legacy_usage + totals = store.load(cwd, "long-shadow") + + assert totals.input_tokens == 3 + assert totals.output_tokens == 4 + assert totals.recorded_events == 1 diff --git a/tests/test_i18n.py b/tests/test_i18n.py index c2269918..f6587af7 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -64,6 +64,8 @@ "Cost estimation", "Current step", "Complete step", + "backup unavailable", + "Pipeline backup is blocked; the pipeline is paused and recoverable: {error}", "Ask user question", "Show architecture diagram", "Show candidate details", @@ -71,6 +73,19 @@ 'Displayed details for "{candidate_name}".', } +SESSION_BACKUP_USER_VISIBLE_MSGIDS = { + "A2A pipeline sidecar owner is unavailable", + "A2A pipeline sidecar restore failed: status={status}, reason={reason}", + "Failed to persist A2A pipeline snapshot", + "Session backup requires a supported session layout.", + "Unrepairable A2A pipeline journal tail", + "backup root", + "invalid session layout version", + "invalid session metadata", + "session source", + "unknown", +} + def _get_all_msgids_from_pot(pot_file: Path) -> set[str]: """Extract all msgids from a .pot template file. @@ -410,6 +425,27 @@ def test_pipeline_user_visible_translations_are_complete(): assert not errors, "\n".join(errors) +@pytest.mark.skipif(sys.platform == "win32", reason="messages.pot not generated on Windows") +def test_session_backup_labels_are_translatable(): + """Session backup validation errors interpolate labels, so guard against raw English labels.""" + assert POT_FILE.exists(), f"POT file not found at {POT_FILE}" + pot_msgids = _get_all_msgids_from_pot(POT_FILE) + missing_from_pot = SESSION_BACKUP_USER_VISIBLE_MSGIDS - pot_msgids + assert not missing_from_pot, "Session backup msgids missing from messages.pot: {}".format(sorted(missing_from_pot)) + + errors = [] + for lang_dir in _discover_language_dirs(): + translations = _get_all_translations_from_po(lang_dir / "LC_MESSAGES" / "messages.po") + for msgid in sorted(SESSION_BACKUP_USER_VISIBLE_MSGIDS): + msgstr = translations.get(msgid, "").strip() + if not msgstr: + errors.append(f"{lang_dir.name}: missing translation for {msgid!r}") + elif msgstr == msgid: + errors.append(f"{lang_dir.name}: untranslated placeholder for {msgid!r}") + + assert not errors, "\n".join(errors) + + @pytest.mark.skipif(sys.platform == "win32", reason="messages.pot not generated on Windows") def test_aliyun_credential_labels_are_translatable(): """Aliyun auth menu labels come from data tables, so guard against dynamic gettext misses.""" diff --git a/tests/test_services/test_telemetry/test_metrics.py b/tests/test_services/test_telemetry/test_metrics.py index ced1f3e5..1b5f545e 100644 --- a/tests/test_services/test_telemetry/test_metrics.py +++ b/tests/test_services/test_telemetry/test_metrics.py @@ -38,6 +38,9 @@ def test_metric_names_include_pipeline_metrics(): M.PIPELINE_CANDIDATE_SUCCESS_COUNT, M.PIPELINE_CANDIDATE_FAILED_COUNT, M.PIPELINE_FUNNEL_STEP_COUNT, + M.PIPELINE_BACKUP_SUCCEEDED_COUNT, + M.PIPELINE_BACKUP_FAILED_COUNT, + M.PIPELINE_BACKUP_BLOCKED_COUNT, } assert expected.issubset(set(METRIC_NAMES)) @@ -105,3 +108,6 @@ def test_pipeline_count_metrics_are_counters(): assert M.PIPELINE_CANDIDATE_SUCCESS_COUNT in counter_names assert M.PIPELINE_CANDIDATE_FAILED_COUNT in counter_names assert M.PIPELINE_FUNNEL_STEP_COUNT in counter_names + assert M.PIPELINE_BACKUP_SUCCEEDED_COUNT in counter_names + assert M.PIPELINE_BACKUP_FAILED_COUNT in counter_names + assert M.PIPELINE_BACKUP_BLOCKED_COUNT in counter_names diff --git a/tests/test_website_session_backup_docs.py b/tests/test_website_session_backup_docs.py new file mode 100644 index 00000000..f29c0a10 --- /dev/null +++ b/tests/test_website_session_backup_docs.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +from pathlib import Path + +PROJECT_ROOT = Path(__file__).parent.parent +WEBSITE_ROOT = PROJECT_ROOT / "website" + +LOCALE_DOC_ROOTS = [ + WEBSITE_ROOT / "docs", + WEBSITE_ROOT / "i18n" / "zh-Hans" / "docusaurus-plugin-content-docs" / "current", + WEBSITE_ROOT / "i18n" / "ja" / "docusaurus-plugin-content-docs" / "current", + WEBSITE_ROOT / "i18n" / "fr" / "docusaurus-plugin-content-docs" / "current", + WEBSITE_ROOT / "i18n" / "de" / "docusaurus-plugin-content-docs" / "current", + WEBSITE_ROOT / "i18n" / "es" / "docusaurus-plugin-content-docs" / "current", + WEBSITE_ROOT / "i18n" / "pt" / "docusaurus-plugin-content-docs" / "current", +] + + +def test_website_documents_session_backup_in_all_locales() -> None: + missing: list[str] = [] + checks = { + "configuration/environment-variables.md": [ + "IAC_CODE_CONFIG_BACKUP_DIR", + "/projects///", + "normal_turn_end", + "critical", + "%VAR%", + "$env:VAR", + "UNC", + ".backup-lock", + "reparse", + ], + "configuration/runtime-configuration.md": [ + "/projects///", + "/projects///metadata.json", + "/projects///session.jsonl", + "/projects///usage.jsonl", + "/projects///permission-audit.jsonl", + "/projects///a2a/task.json", + "/projects///a2a/context.json", + "/projects///a2a/artifacts/", + "/projects///a2a/pipeline/", + "/projects///a2a/cleanup-deferred-prompts.json", + "/projects///pipeline/meta.yaml", + "/projects///pipeline/context.yaml", + "/projects///pipeline/events.jsonl", + "/projects///pipeline/display.jsonl", + "/projects///pipeline/transcripts//", + "/projects///pipeline/transcripts//session.jsonl", + "/projects///pipeline/transcripts//usage.jsonl", + "/projects///pipeline/transcripts//permission-audit.jsonl", + "/projects///pipeline/transcripts//tool-results/", + "layout_version", + ".backup-state.json", + "image-cache/", + "tool-results/", + ], + "a2a/protocol-reference.md": [ + "backup_blocked", + "backupBlocked", + "metadata.iac_code.pipeline.eventType", + "backup_committed", + "committedEventId", + "committedEventType", + "committedSequence", + "TASK_STATE_INPUT_REQUIRED", + "normal_turn_end", + "pipeline_step_completed", + "input_required", + "pipeline_handoff_ready", + "waiting_input", + "terminal", + "handoff_ready", + ], + "automation/non-interactive-mode.md": [ + "IAC_CODE_CONFIG_BACKUP_DIR", + "normal_turn_end", + "warning", + ".backup-state.json", + ], + "acp/protocol-reference.md": [ + "IAC_CODE_CONFIG_BACKUP_DIR", + "normal_turn_end", + "warning", + ".backup-state.json", + ], + "automation/pipeline-mode.md": [ + "backup_blocked", + "backup_committed", + "committedEventId", + "committedEventType", + "committedSequence", + "input_required", + "waiting_input", + "terminal", + "pipeline_handoff_ready", + "parallel_sub_pipeline", + ], + "a2a/overview.md": [ + "IAC_CODE_CONFIG_BACKUP_DIR", + "/a2a/tasks", + "/a2a/contexts", + ], + "mcp/capabilities.md": [ + "tool-results/mcp", + "/projects///tool-results", + "/tool-results/", + ], + "mcp/troubleshooting.md": [ + "tool-results/mcp", + "/projects///tool-results", + "/tool-results/", + ], + } + + for root in LOCALE_DOC_ROOTS: + for relative_path, needles in checks.items(): + path = root / relative_path + if not path.exists(): + missing.append(f"{path.relative_to(PROJECT_ROOT)}: missing file") + continue + text = path.read_text(encoding="utf-8") + for needle in needles: + if needle not in text: + missing.append(f"{path.relative_to(PROJECT_ROOT)}: missing {needle!r}") + + assert not missing, "\n".join(missing) + + +def test_session_backup_docs_do_not_promise_warning_response_fields() -> None: + forbidden = { + "automation/non-interactive-mode.md": [ + "return a `warning`", + "returns a `warning`", + "devuelve un `warning`", + "renvoie un `warning`", + "retorna um `warning`", + "liefert ein `warning`", + "`warning` を返し", + "返回 `warning`", + ], + "acp/protocol-reference.md": [ + "complete with a `warning`", + "completes with a `warning`", + "completarse con un `warning`", + "terminer avec un `warning`", + "concluir com um `warning`", + "mit einem `warning` abgeschlossen", + "`warning` 付きで完了", + "带 `warning` 完成", + ], + } + violations: list[str] = [] + + for root in LOCALE_DOC_ROOTS: + for relative_path, phrases in forbidden.items(): + path = root / relative_path + text = path.read_text(encoding="utf-8") + for phrase in phrases: + if phrase in text: + violations.append(f"{path.relative_to(PROJECT_ROOT)}: forbidden {phrase!r}") + + assert not violations, "\n".join(violations) + + +def test_session_backup_docs_do_not_use_legacy_audit_or_lock_locations() -> None: + forbidden = [ + "/logs/permission-audit.jsonl", + "logs/permission-audit.jsonl", + "directory locks", + "目录锁", + "ディレクトリロック", + "verrous de répertoire", + "bloqueos de directorio", + "locks de diretório", + "权限审计记录仍位于 `/logs/`", + "権限監査レコードは `/logs/` に残ります", + ] + violations: list[str] = [] + + for root in LOCALE_DOC_ROOTS: + for path in root.rglob("*.md"): + text = path.read_text(encoding="utf-8") + for phrase in forbidden: + if phrase in text: + violations.append(f"{path.relative_to(PROJECT_ROOT)}: forbidden {phrase!r}") + + assert not violations, "\n".join(violations) + + +def test_backup_committed_docs_scope_terminal_and_handoff_publications() -> None: + required_protocol_tokens = [ + "`pipeline_step_completed`", + "`input_required`", + "`waiting_input`", + "`terminal`", + "`handoff_ready`", + "`pipeline_handoff_ready`", + "`backup_committed`", + "`committedEventId`", + "`committedEventType`", + "`committedSequence`", + ] + required_pipeline_tokens = [ + "`backup_blocked`", + "`backup_committed`", + "`committedEventId`", + "`committedEventType`", + "`committedSequence`", + "`pipeline_handoff_ready`", + "terminal", + ] + missing: list[str] = [] + + for root in LOCALE_DOC_ROOTS: + protocol = (root / "a2a" / "protocol-reference.md").read_text(encoding="utf-8") + pipeline_mode = (root / "automation" / "pipeline-mode.md").read_text(encoding="utf-8") + for token in required_protocol_tokens: + if token not in protocol: + path = (root / "a2a" / "protocol-reference.md").relative_to(PROJECT_ROOT) + missing.append(f"{path}: missing {token}") + for token in required_pipeline_tokens: + if token not in pipeline_mode: + path = (root / "automation" / "pipeline-mode.md").relative_to(PROJECT_ROOT) + missing.append(f"{path}: missing {token}") + + assert not missing, "\n".join(missing) diff --git a/tests/tools/test_result_storage.py b/tests/tools/test_result_storage.py index b1d34c46..a4f3b2fa 100644 --- a/tests/tools/test_result_storage.py +++ b/tests/tools/test_result_storage.py @@ -4,9 +4,18 @@ import pytest +from iac_code.services.session_layout import SESSION_LAYOUT_VERSION_V2, UnsupportedSessionLayoutError +from iac_code.services.session_metadata import SessionMetadata, write_session_metadata from iac_code.tools.result_storage import ResultStorage +def _symlink_or_skip(target: Path, link: Path, *, target_is_directory: bool = False) -> None: + try: + link.symlink_to(target, target_is_directory=target_is_directory) + except (NotImplementedError, OSError) as exc: + pytest.skip(f"symlink creation unsupported: {exc}") + + @pytest.fixture def storage(tmp_path): return ResultStorage(storage_dir=str(tmp_path), max_inline_chars=100, preview_chars=50) @@ -59,6 +68,54 @@ def test_externalized_file_cannot_escape_storage_dir(self, tmp_path, tool_use_id assert not (tmp_path / "escape.txt").exists() assert file_path.name.endswith(".txt") + def test_session_owned_storage_refuses_symlinked_tool_results_dir(self, tmp_path): + session_dir = tmp_path / "session" + write_session_metadata( + session_dir, + SessionMetadata(session_id="session", cwd="/repo", layout_version=SESSION_LAYOUT_VERSION_V2), + ) + outside = tmp_path / "outside" + outside.mkdir() + _symlink_or_skip(outside, session_dir / "tool-results", target_is_directory=True) + storage = ResultStorage(storage_dir=str(session_dir / "tool-results"), max_inline_chars=1) + + with pytest.raises(UnsupportedSessionLayoutError, match="Unsafe session-owned path"): + storage.process(tool_use_id="tool-1", content="long output") + + assert not (outside / "tool-1.txt").exists() + + def test_session_owned_storage_refuses_symlinked_result_leaf(self, tmp_path): + session_dir = tmp_path / "session" + write_session_metadata( + session_dir, + SessionMetadata(session_id="session", cwd="/repo", layout_version=SESSION_LAYOUT_VERSION_V2), + ) + storage_dir = session_dir / "tool-results" + storage_dir.mkdir() + outside = tmp_path / "outside-tool-result.txt" + outside.write_text("outside content", encoding="utf-8") + _symlink_or_skip(outside, storage_dir / "tool-1.txt") + storage = ResultStorage(storage_dir=str(storage_dir), max_inline_chars=1) + + with pytest.raises(OSError, match="symlink|reparse"): + storage.process(tool_use_id="tool-1", content="long output") + + assert outside.read_text(encoding="utf-8") == "outside content" + + def test_session_owned_storage_refuses_dangling_symlinked_metadata_before_fallback(self, tmp_path): + session_dir = tmp_path / "session" + session_dir.mkdir() + _symlink_or_skip(tmp_path / "missing-metadata.json", session_dir / "metadata.json") + outside = tmp_path / "outside" + outside.mkdir() + _symlink_or_skip(outside, session_dir / "tool-results", target_is_directory=True) + storage = ResultStorage(storage_dir=str(session_dir / "tool-results"), max_inline_chars=1) + + with pytest.raises(UnsupportedSessionLayoutError, match="Unsupported session metadata"): + storage.process(tool_use_id="tool-1", content="long output") + + assert not (outside / "tool-1.txt").exists() + def test_externalized_file_content(self, storage): content = "line\n" * 100 result = storage.process(tool_use_id="t3", content=content) diff --git a/tests/ui/test_repl_handle_chat_continue.py b/tests/ui/test_repl_handle_chat_continue.py index 128cee0a..f20bb2f9 100644 --- a/tests/ui/test_repl_handle_chat_continue.py +++ b/tests/ui/test_repl_handle_chat_continue.py @@ -2,11 +2,13 @@ from __future__ import annotations -from unittest.mock import AsyncMock, MagicMock +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, Mock import pytest from iac_code.pipeline.config import RunMode +from iac_code.services.session_backup import BackupReason @pytest.mark.asyncio @@ -36,6 +38,7 @@ async def test_handle_chat_continue_works_in_normal_mode(monkeypatch): """Normal mode (no IAC_CODE_MODE set): _handle_chat_continue runs the agent loop.""" monkeypatch.delenv("IAC_CODE_MODE", raising=False) + from iac_code.ui.renderer import StreamingOutputResult from iac_code.ui.repl import InlineREPL repl = InlineREPL.__new__(InlineREPL) @@ -46,9 +49,180 @@ async def test_handle_chat_continue_works_in_normal_mode(monkeypatch): repl._agent_loop.context_manager = MagicMock(get_messages=MagicMock(return_value=[])) repl._agent_loop.stamp_last_turn_elapsed = MagicMock() repl.renderer = MagicMock() - repl.renderer.run_streaming_output = AsyncMock(return_value=0.0) + repl.renderer.run_streaming_output = AsyncMock(return_value=StreamingOutputResult(elapsed=0.0)) repl.renderer._last_streaming_errors = [] + repl._backup_service = SimpleNamespace(backup_session=Mock()) + repl._original_cwd = "/repo" + repl._session_id = "session-1" repl._streaming_error_log = [] await repl._handle_chat_continue() repl._agent_loop.run_streaming.assert_called_once() + repl._backup_service.backup_session.assert_called_once_with( + "/repo", + "session-1", + reason=BackupReason.NORMAL_TURN_END, + critical=False, + ) + + +@pytest.mark.asyncio +async def test_handle_chat_continue_interrupted_result_does_not_backup(monkeypatch): + monkeypatch.delenv("IAC_CODE_MODE", raising=False) + + from iac_code.ui.renderer import StreamingOutputResult + from iac_code.ui.repl import InlineREPL + + repl = InlineREPL.__new__(InlineREPL) + repl._runtime_mode = RunMode.NORMAL + repl.store = MagicMock() + repl._agent_loop = MagicMock() + repl._agent_loop.run_streaming = MagicMock(return_value=[]) + repl._agent_loop.context_manager = MagicMock(get_messages=MagicMock(return_value=[])) + repl._agent_loop.stamp_last_turn_elapsed = MagicMock() + repl.renderer = MagicMock() + repl.renderer.run_streaming_output = AsyncMock( + return_value=StreamingOutputResult( + elapsed=0.2, + queued_inputs=["next turn"], + draft_input="half typed", + interrupted=True, + ) + ) + repl.renderer._last_streaming_errors = [] + repl._backup_service = SimpleNamespace(backup_session=Mock()) + repl._original_cwd = "/repo" + repl._session_id = "session-1" + repl._streaming_error_log = [] + repl._block_if_cleanup_ledger_unreadable = Mock(return_value=False) + repl._prune_cleanup_prompts_if_no_pending_cleanup = Mock() + + queued_inputs = await repl._handle_chat_continue() + + assert queued_inputs == ["next turn"] + assert repl._streaming_draft_input == "half typed" + repl._backup_service.backup_session.assert_not_called() + repl._prune_cleanup_prompts_if_no_pending_cleanup.assert_called_once_with() + repl.store.set_state.assert_any_call(is_busy=False) + + +@pytest.mark.asyncio +async def test_cleanup_continue_backs_up_after_prune_and_state_reset(tmp_path, monkeypatch): + """A normal cleanup continuation is also a normal agent-loop turn end.""" + monkeypatch.delenv("IAC_CODE_MODE", raising=False) + + from iac_code.pipeline.engine.cleanup import CleanupPrompt + from iac_code.ui.renderer import StreamingOutputResult + from iac_code.ui.repl import InlineREPL + + async def events(): + if False: + yield None + + order: list[str] = [] + repl = InlineREPL.__new__(InlineREPL) + repl._original_cwd = "/repo" + repl._session_id = "session-1" + repl._pipeline_cleanup_ledger_path = None + repl._backup_service = SimpleNamespace( + backup_session=Mock(side_effect=lambda *args, **kwargs: order.append("backup")) + ) + repl.store = SimpleNamespace(set_state=Mock(side_effect=lambda **kwargs: order.append(f"busy={kwargs['is_busy']}"))) + repl.renderer = SimpleNamespace( + print_system_message=Mock(), + prompt_permission=AsyncMock(), + run_streaming_output=AsyncMock(return_value=StreamingOutputResult(elapsed=1.2)), + _last_streaming_errors=[], + ) + repl._agent_loop = SimpleNamespace( + continue_streaming=Mock(return_value=events()), + stamp_last_turn_elapsed=Mock(side_effect=lambda elapsed: order.append("stamp")), + context_manager=SimpleNamespace( + add_raw_message=Mock(return_value=SimpleNamespace()), + get_messages=Mock(return_value=[]), + ), + ) + repl._session_storage = SimpleNamespace(append=Mock()) + repl.current_git_branch = Mock(return_value=None) + repl._wrap_cleanup_observer = Mock(side_effect=lambda stream, ledger=None: stream) + repl._prune_cleanup_prompts_if_no_pending_cleanup = Mock(side_effect=lambda ledger=None: order.append("prune")) + repl._streaming_error_log = [] + + cleanup_prompt = CleanupPrompt(resources=[], prompt="cleanup now", status_message="cleanup status") + ledger = SimpleNamespace( + path=tmp_path / "cleanup.yaml", + load_failed=Mock(return_value=False), + build_pending_prompt=Mock(return_value=cleanup_prompt), + record_prompt_queued=Mock(), + ) + + assert await repl._start_pipeline_cleanup_from_ledger(ledger) is True + + repl._backup_service.backup_session.assert_called_once_with( + "/repo", + "session-1", + reason=BackupReason.NORMAL_TURN_END, + critical=False, + ) + assert order == ["busy=True", "stamp", "prune", "busy=False", "backup"] + + +@pytest.mark.asyncio +async def test_cleanup_continue_interrupted_result_does_not_backup(tmp_path, monkeypatch): + monkeypatch.delenv("IAC_CODE_MODE", raising=False) + + from iac_code.pipeline.engine.cleanup import CleanupPrompt + from iac_code.ui.renderer import StreamingOutputResult + from iac_code.ui.repl import InlineREPL + + async def events(): + if False: + yield None + + repl = InlineREPL.__new__(InlineREPL) + repl._original_cwd = "/repo" + repl._session_id = "session-1" + repl._pipeline_cleanup_ledger_path = None + repl._backup_service = SimpleNamespace(backup_session=Mock()) + repl.store = SimpleNamespace(set_state=Mock()) + repl.renderer = SimpleNamespace( + print_system_message=Mock(), + prompt_permission=AsyncMock(), + run_streaming_output=AsyncMock( + return_value=StreamingOutputResult( + elapsed=0.2, + queued_inputs=["next turn"], + draft_input="half typed", + interrupted=True, + ) + ), + _last_streaming_errors=[], + ) + repl._agent_loop = SimpleNamespace( + continue_streaming=Mock(return_value=events()), + stamp_last_turn_elapsed=Mock(), + context_manager=SimpleNamespace( + add_raw_message=Mock(return_value=SimpleNamespace()), + get_messages=Mock(return_value=[]), + ), + ) + repl._session_storage = SimpleNamespace(append=Mock()) + repl.current_git_branch = Mock(return_value=None) + repl._wrap_cleanup_observer = Mock(side_effect=lambda stream, ledger=None: stream) + repl._prune_cleanup_prompts_if_no_pending_cleanup = Mock() + repl._streaming_error_log = [] + + cleanup_prompt = CleanupPrompt(resources=[], prompt="cleanup now", status_message="cleanup status") + ledger = SimpleNamespace( + path=tmp_path / "cleanup.yaml", + load_failed=Mock(return_value=False), + build_pending_prompt=Mock(return_value=cleanup_prompt), + record_prompt_queued=Mock(), + ) + + assert await repl._start_pipeline_cleanup_from_ledger(ledger) is True + + assert repl._streaming_draft_input == "next turn\nhalf typed" + repl._backup_service.backup_session.assert_not_called() + repl._prune_cleanup_prompts_if_no_pending_cleanup.assert_called_once_with(ledger) + repl.store.set_state.assert_any_call(is_busy=False) diff --git a/tests/ui/test_repl_integration.py b/tests/ui/test_repl_integration.py index 34d82c33..d6e22dcd 100644 --- a/tests/ui/test_repl_integration.py +++ b/tests/ui/test_repl_integration.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import re import subprocess import sys @@ -11,6 +12,7 @@ import pytest +from iac_code.services.session_backup import BackupReason from iac_code.services.update_checker import PendingUpdate from iac_code.ui.components.select import SelectLayout from iac_code.utils.project_paths import format_resume_command @@ -69,6 +71,50 @@ def test_init_creates_provider_manager(self, mock_mm, mock_ss, mock_pm): repl = InlineREPL(model="claude-sonnet-4-6") assert hasattr(repl, "_provider_manager") + @patch("iac_code.ui.repl.SessionBackupService") + @patch("iac_code.ui.repl.ProviderManager") + @patch("iac_code.ui.repl.SessionStorage") + @patch("iac_code.ui.repl.MemoryManager") + def test_init_default_backup_service_reuses_session_storage(self, mock_mm, mock_ss, mock_pm, mock_backup): + from iac_code.ui.repl import InlineREPL + + repl = InlineREPL(model="claude-sonnet-4-6") + + mock_backup.assert_called_once_with(session_storage=mock_ss.return_value) + assert repl._backup_service is mock_backup.return_value + + @patch("iac_code.ui.repl.SessionBackupService") + @patch("iac_code.ui.repl.ProviderManager") + @patch("iac_code.ui.repl.SessionStorage") + @patch("iac_code.ui.repl.MemoryManager") + def test_init_keeps_injected_backup_service(self, mock_mm, mock_ss, mock_pm, mock_backup): + from iac_code.ui.repl import InlineREPL + + backup_service = Mock() + + repl = InlineREPL(model="claude-sonnet-4-6", backup_service=backup_service) + + mock_backup.assert_not_called() + assert repl._backup_service is backup_service + + @patch("iac_code.ui.repl.SessionBackupService") + @patch("iac_code.ui.repl.ProviderManager") + @patch("iac_code.ui.repl.SessionStorage") + @patch("iac_code.ui.repl.MemoryManager") + def test_init_keeps_falsey_injected_backup_service(self, mock_mm, mock_ss, mock_pm, mock_backup): + from iac_code.ui.repl import InlineREPL + + class FalseyBackupService: + def __bool__(self) -> bool: + return False + + backup_service = FalseyBackupService() + + repl = InlineREPL(model="claude-sonnet-4-6", backup_service=backup_service) + + mock_backup.assert_not_called() + assert repl._backup_service is backup_service + @patch("iac_code.ui.repl.ProviderManager") @patch("iac_code.ui.repl.SessionStorage") @patch("iac_code.ui.repl.MemoryManager") @@ -333,6 +379,24 @@ def test_insert_text_delegates_to_prompt_input(): repl._prompt_input.insert_text.assert_called_once_with("hello from history") +def test_session_dir_for_artifacts_returns_none_for_legacy_directory_without_layout(tmp_path, monkeypatch): + from iac_code.services.session_storage import SessionStorage + from iac_code.ui.repl import InlineREPL + + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + cwd = str(tmp_path) + storage = SessionStorage() + session_dir = storage.session_dir(cwd, "legacy-session") + session_dir.mkdir(parents=True) + repl = InlineREPL.__new__(InlineREPL) + repl._session_storage = storage + repl._original_cwd = cwd + repl._session_id = "legacy-session" + + assert repl._session_dir_for_artifacts() is None + assert repl._result_storage_dir_for_session() is None + + def test_history_search_uses_agent_context_messages(): from iac_code.agent.message import Message from iac_code.state.app_state import AppState @@ -468,6 +532,9 @@ async def events(): stamp_last_turn_elapsed=Mock(), context_manager=SimpleNamespace(get_messages=Mock(return_value=[])), ) + repl._backup_service = SimpleNamespace(backup_session=Mock()) + repl._original_cwd = "/repo" + repl._session_id = "session-1" repl._streaming_error_log = [] queued_inputs = await repl._handle_chat("first turn") @@ -479,6 +546,179 @@ async def events(): _, renderer_kwargs = repl.renderer.run_streaming_output.call_args assert renderer_kwargs["streaming_input"] is not None repl.renderer.record_user_turn.assert_called_once_with("first turn") + repl._backup_service.backup_session.assert_called_once_with( + "/repo", + "session-1", + reason=BackupReason.NORMAL_TURN_END, + critical=False, + ) + + +@pytest.mark.asyncio +async def test_handle_chat_backup_failure_warns_and_preserves_queued_inputs(): + from iac_code.ui.renderer import StreamingOutputResult + from iac_code.ui.repl import InlineREPL + + async def events(): + if False: + yield None + + def raise_backup(*args, **kwargs): + raise RuntimeError("/mnt/oss/customer-bucket/repl-session source failed") + + repl = InlineREPL.__new__(InlineREPL) + repl.store = SimpleNamespace(set_state=Mock()) + repl.renderer = SimpleNamespace( + record_user_turn=Mock(), + run_streaming_output=AsyncMock(return_value=StreamingOutputResult(elapsed=0.2, queued_inputs=["next turn"])), + prompt_permission=AsyncMock(), + _last_streaming_errors=[], + ) + repl._agent_loop = SimpleNamespace( + run_streaming=Mock(return_value=events()), + stamp_last_turn_elapsed=Mock(), + context_manager=SimpleNamespace(get_messages=Mock(return_value=[])), + ) + repl._backup_service = SimpleNamespace(backup_session=Mock(side_effect=raise_backup)) + repl._original_cwd = "/repo" + repl._session_id = "session-1" + repl._streaming_error_log = [] + + with patch("iac_code.ui.repl.logger.warning") as warning: + queued_inputs = await repl._handle_chat("first turn") + + assert queued_inputs == ["next turn"] + repl.store.set_state.assert_any_call(is_busy=False) + warning.assert_called_once() + assert warning.call_args.args == ( + "Normal REPL session backup failed (reason={}, retry_count={}, error_type={})", + "normal_turn_end", + 0, + "RuntimeError", + ) + assert "/mnt/oss/customer-bucket" not in " ".join(str(arg) for arg in warning.call_args.args) + + +@pytest.mark.asyncio +async def test_handle_chat_non_critical_backup_result_failure_warns(): + from iac_code.services.session_backup import BackupResult + from iac_code.ui.renderer import StreamingOutputResult + from iac_code.ui.repl import InlineREPL + + async def events(): + if False: + yield None + + repl = InlineREPL.__new__(InlineREPL) + repl.store = SimpleNamespace(set_state=Mock()) + repl.renderer = SimpleNamespace( + record_user_turn=Mock(), + run_streaming_output=AsyncMock(return_value=StreamingOutputResult(elapsed=0.2, queued_inputs=[])), + prompt_permission=AsyncMock(), + _last_streaming_errors=[], + ) + repl._agent_loop = SimpleNamespace( + run_streaming=Mock(return_value=events()), + stamp_last_turn_elapsed=Mock(), + context_manager=SimpleNamespace(get_messages=Mock(return_value=[])), + ) + repl._backup_service = SimpleNamespace( + backup_session=Mock(return_value=BackupResult(enabled=True, succeeded=False, error="[PATH]", retry_count=2)) + ) + repl._original_cwd = "/repo" + repl._session_id = "session-1" + repl._streaming_error_log = [] + + with patch("iac_code.ui.repl.logger.warning") as warning: + queued_inputs = await repl._handle_chat("first turn") + + assert queued_inputs == [] + warning.assert_called_once() + assert warning.call_args.args == ( + "Normal REPL session backup failed (reason={}, retry_count={}): {}", + "normal_turn_end", + 2, + "[PATH]", + ) + + +@pytest.mark.asyncio +async def test_handle_chat_interrupted_result_does_not_backup(): + from iac_code.ui.renderer import StreamingOutputResult + from iac_code.ui.repl import InlineREPL + + async def events(): + if False: + yield None + + repl = InlineREPL.__new__(InlineREPL) + repl.store = SimpleNamespace(set_state=Mock()) + repl.renderer = SimpleNamespace( + record_user_turn=Mock(), + run_streaming_output=AsyncMock( + return_value=StreamingOutputResult( + elapsed=0.2, + queued_inputs=["next turn"], + draft_input="half typed", + interrupted=True, + ) + ), + prompt_permission=AsyncMock(), + _last_streaming_errors=[], + ) + repl._agent_loop = SimpleNamespace( + run_streaming=Mock(return_value=events()), + stamp_last_turn_elapsed=Mock(), + context_manager=SimpleNamespace(get_messages=Mock(return_value=[])), + ) + repl._backup_service = SimpleNamespace(backup_session=Mock()) + repl._original_cwd = "/repo" + repl._session_id = "session-1" + repl._streaming_error_log = [] + repl._prune_cleanup_prompts_if_no_pending_cleanup = Mock() + + queued_inputs = await repl._handle_chat("first turn") + + assert queued_inputs == ["next turn"] + assert repl._streaming_draft_input == "half typed" + repl._backup_service.backup_session.assert_not_called() + repl._prune_cleanup_prompts_if_no_pending_cleanup.assert_called_once_with() + repl.store.set_state.assert_any_call(is_busy=False) + + +@pytest.mark.parametrize("exc_type", [asyncio.CancelledError, KeyboardInterrupt]) +@pytest.mark.asyncio +async def test_handle_chat_propagates_interrupts_without_warning_or_backup(exc_type): + from iac_code.ui.repl import InlineREPL + + async def events(): + if False: + yield None + + repl = InlineREPL.__new__(InlineREPL) + repl.store = SimpleNamespace(set_state=Mock()) + repl.renderer = SimpleNamespace( + record_user_turn=Mock(), + run_streaming_output=AsyncMock(side_effect=exc_type()), + prompt_permission=AsyncMock(), + _last_streaming_errors=[], + ) + repl._agent_loop = SimpleNamespace( + run_streaming=Mock(return_value=events()), + stamp_last_turn_elapsed=Mock(), + context_manager=SimpleNamespace(get_messages=Mock(return_value=[])), + ) + repl._backup_service = SimpleNamespace(backup_session=Mock()) + repl._original_cwd = "/repo" + repl._session_id = "session-1" + repl._streaming_error_log = [] + + with patch("iac_code.ui.repl.logger.opt") as logger_opt, pytest.raises(exc_type): + await repl._handle_chat("first turn") + + repl.store.set_state.assert_any_call(is_busy=False) + repl._backup_service.backup_session.assert_not_called() + logger_opt.assert_not_called() def test_normalize_streaming_output_result_includes_draft_input(): @@ -1013,14 +1253,26 @@ def test_swap_session_clears_stale_cleanup_ledger_path_before_pruning(tmp_path: def test_swap_session_refreshes_session_trusted_read_directories(monkeypatch, tmp_path): + from iac_code.mcp.types import MCPToolRecord + from iac_code.services.agent_factory import _sync_mcp_tool_registry + from iac_code.services.session_metadata import SESSION_LAYOUT_VERSION_V2, SessionMetadata, write_session_metadata from iac_code.state.app_state import AppState + from iac_code.tools.base import ToolRegistry from iac_code.types.permissions import ToolPermissionContext from iac_code.ui.repl import InlineREPL + from iac_code.utils.image.pasted_content import PastedContent monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + sessions_root = tmp_path / "sessions" + write_session_metadata( + sessions_root / "new", + SessionMetadata(session_id="new", cwd=str(tmp_path), layout_version=SESSION_LAYOUT_VERSION_V2), + ) old_roots = [ str(tmp_path / "config" / "tool-results" / "old"), str(tmp_path / "config" / "image-cache" / "old"), + str(sessions_root / "old" / "tool-results"), + str(sessions_root / "old" / "image-cache"), ] custom_root = "/custom/trusted" permission_context = ToolPermissionContext( @@ -1034,9 +1286,31 @@ def test_swap_session_refreshes_session_trusted_read_directories(monkeypatch, tm repl._session_storage = SimpleNamespace( load=Mock(return_value=[]), repair_interrupted=Mock(return_value=[]), + session_dir=lambda _cwd, session_id: sessions_root / session_id, ) repl._agent_loop = SimpleNamespace(replace_session=Mock()) repl._load_current_session_name = Mock(return_value=None) + repl.tool_registry = ToolRegistry() + repl._mcp_manager = SimpleNamespace( + list_tools=Mock( + return_value=[ + MCPToolRecord( + server_name="ros", + tool_name="render", + public_name="mcp__ros__render", + input_schema={"type": "object"}, + ) + ] + ), + list_resources=Mock(return_value=[]), + ) + repl._registered_mcp_tool_names = _sync_mcp_tool_registry( + repl.tool_registry, + repl._mcp_manager, + "old", + set(), + session_dir=sessions_root / "old", + ) repl.store = SimpleNamespace( get_state=Mock( return_value=AppState(model="test-model", cwd=str(tmp_path), permission_context=permission_context) @@ -1050,9 +1324,18 @@ def test_swap_session_refreshes_session_trusted_read_directories(monkeypatch, tm roots = permission_context.trusted_read_directories assert old_roots[0] not in roots assert old_roots[1] not in roots + assert old_roots[2] not in roots + assert old_roots[3] not in roots assert str(tmp_path / "config" / "tool-results" / "new") in roots assert str(tmp_path / "config" / "image-cache" / "new") in roots + assert str(sessions_root / "new" / "tool-results") in roots + assert str(sessions_root / "new" / "image-cache") in roots assert custom_root in roots + stored_path = repl._image_store.store(PastedContent(id=1, type="image", content="aGk=", media_type="image/png")) + assert stored_path == str(sessions_root / "new" / "image-cache" / "1.png") + mcp_tool = repl.tool_registry.get("mcp__ros__render") + assert mcp_tool._session_id == "new" + assert mcp_tool._session_dir == sessions_root / "new" def test_extract_last_user_text_skips_recalled_memory_message(): @@ -1179,6 +1462,68 @@ def test_resolve_session_id_continue_cross_project_raises_with_hint(): assert format_resume_command("/elsewhere/repo", "latest-id") in str(exc_info.value) +@patch("iac_code.ui.repl.AgentLoop") +@patch("iac_code.ui.repl.ProviderManager") +@patch("iac_code.ui.repl.SessionStorage") +@patch("iac_code.ui.repl.MemoryManager") +def test_continue_without_history_prepares_new_session_layout( + mock_mm, + mock_ss, + mock_pm, + mock_agent_loop, + monkeypatch, + tmp_path, +): + from iac_code.services.session_metadata import SESSION_LAYOUT_VERSION_V2, SessionMetadata, write_session_metadata + from iac_code.ui.repl import InlineREPL + + session_id = "fresh-continue-session" + sessions_root = tmp_path / "sessions" + session_dir = sessions_root / session_id + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setattr("uuid.uuid4", lambda: session_id) + storage = mock_ss.return_value + storage.get_latest_session_anywhere.return_value = None + storage.load.return_value = [] + storage.repair_interrupted.return_value = [] + storage.session_dir.side_effect = lambda _cwd, selected_session_id: sessions_root / selected_session_id + + def ensure_v2_session(cwd, selected_session_id, **_kwargs): + write_session_metadata( + sessions_root / selected_session_id, + SessionMetadata(session_id=selected_session_id, cwd=cwd, layout_version=SESSION_LAYOUT_VERSION_V2), + ) + + storage.ensure_v2_session_dir_for_new_session.side_effect = ensure_v2_session + storage.v2_session_dir.side_effect = lambda _cwd, selected_session_id: ( + sessions_root / selected_session_id if storage.ensure_v2_session_dir_for_new_session.called else None + ) + + repl = InlineREPL(model="test-model", resume_session_id=True) + + assert repl.session_id == session_id + assert storage.ensure_v2_session_dir_for_new_session.call_args.args[:2] == (str(tmp_path), session_id) + assert mock_agent_loop.call_args.kwargs["result_storage_dir"] == session_dir / "tool-results" + + +def test_prepare_pipeline_session_layout_uses_pipeline_cwd() -> None: + from iac_code.ui.repl import InlineREPL + + storage = Mock() + repl = InlineREPL.__new__(InlineREPL) + repl._session_storage = storage + repl.current_git_branch = Mock(return_value="feature") + + repl._prepare_pipeline_session_layout("/pipeline-workspace", "session-1") + + storage.ensure_v2_session_dir_for_new_session.assert_called_once_with( + "/pipeline-workspace", + "session-1", + git_branch="feature", + ) + + def test_cross_project_message_uses_windows_resume_command(monkeypatch): import iac_code.utils.project_paths as project_paths from iac_code.ui.repl import InlineREPL @@ -1190,6 +1535,71 @@ def test_cross_project_message_uses_windows_resume_command(monkeypatch): assert r'cd /d "C:\Users\Me\iac repo & unsafe" && iac-code --resume "abc & unsafe"' in message +@patch("iac_code.ui.repl.AgentLoop") +@patch("iac_code.ui.repl.ProviderManager") +@patch("iac_code.ui.repl.SessionStorage") +@patch("iac_code.ui.repl.MemoryManager") +def test_inline_repl_agent_loop_uses_session_tool_results( + mock_mm, + mock_ss, + mock_pm, + mock_agent_loop, + monkeypatch, + tmp_path, +): + from iac_code.services.session_metadata import SESSION_LAYOUT_VERSION_V2, SessionMetadata, write_session_metadata + from iac_code.ui.repl import InlineREPL + + session_id = "session-v2" + sessions_root = tmp_path / "sessions" + session_dir = sessions_root / session_id + write_session_metadata( + session_dir, + SessionMetadata(session_id=session_id, cwd=str(tmp_path), layout_version=SESSION_LAYOUT_VERSION_V2), + ) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("uuid.uuid4", lambda: session_id) + mock_ss.return_value.load.return_value = [] + mock_ss.return_value.repair_interrupted.return_value = [] + mock_ss.return_value.session_dir.side_effect = lambda _cwd, selected_session_id: sessions_root / selected_session_id + + repl = InlineREPL(model="test-model") + + assert repl.session_id == session_id + assert mock_agent_loop.call_args.kwargs["result_storage_dir"] == session_dir / "tool-results" + + +@patch("iac_code.ui.repl.AgentLoop") +@patch("iac_code.ui.repl.ProviderManager") +@patch("iac_code.ui.repl.SessionStorage") +@patch("iac_code.ui.repl.MemoryManager") +def test_inline_repl_agent_loop_uses_legacy_tool_results_without_session_metadata( + mock_mm, + mock_ss, + mock_pm, + mock_agent_loop, + monkeypatch, + tmp_path, +): + from iac_code.ui.repl import InlineREPL + + session_id = "legacy-session" + sessions_root = tmp_path / "sessions" + session_dir = sessions_root / session_id + session_dir.mkdir(parents=True) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setattr("uuid.uuid4", lambda: session_id) + mock_ss.return_value.load.return_value = [] + mock_ss.return_value.repair_interrupted.return_value = [] + mock_ss.return_value.session_dir.side_effect = lambda _cwd, selected_session_id: sessions_root / selected_session_id + + repl = InlineREPL(model="test-model") + + assert repl.session_id == session_id + assert mock_agent_loop.call_args.kwargs["result_storage_dir"] is None + + @patch("iac_code.ui.repl.ProviderManager") @patch("iac_code.ui.repl.SessionStorage") @patch("iac_code.ui.repl.MemoryManager") diff --git a/tests/ui/test_repl_pipeline_display_replay.py b/tests/ui/test_repl_pipeline_display_replay.py index 3f16db13..7f9cfa9b 100644 --- a/tests/ui/test_repl_pipeline_display_replay.py +++ b/tests/ui/test_repl_pipeline_display_replay.py @@ -354,6 +354,57 @@ async def fake_render(event_stream, progress_bar_fn=None): assert seen[2].data["options"][0]["name"] == "低成本方案" +@pytest.mark.asyncio +async def test_resume_waiting_candidate_selection_does_not_rerender_recursive_backup_blocked(): + from iac_code.ui.repl import InlineREPL + + backup_event = PipelineEvent( + type=PipelineEventType.BACKUP_BLOCKED, + step_id="confirm_and_select", + timestamp=time.time(), + data={"reason": "pipeline_step_completed", "error": "backup unavailable", "recoverable": True}, + ) + repl = InlineREPL.__new__(InlineREPL) + repl._pipeline = MagicMock() + repl._pipeline_display_current_step_id = None + repl._render_pipeline_event = MagicMock() + repl._load_pipeline_display_replay_model = MagicMock( + return_value=DisplayReplayModel( + pipeline_name="selling", + attempts=[ + DisplayAttempt( + step_id="confirm_and_select", + attempt_no=1, + status="waiting_input", + ui_mode="candidate_selection", + candidate_selection=DisplayCandidateSelection( + state="waiting", + prompt="请选择一个方案", + options=[{"name": "低成本方案", "summary": "单 ECS", "candidate_index": 0}], + candidates={}, + ), + ) + ], + ) + ) + + async def fake_render(event_stream, progress_bar_fn=None): + async for _event in event_stream: + pass + repl._render_pipeline_event(backup_event) + return backup_event + + repl._render_candidate_selection_tabs = fake_render + + result = await repl._resume_waiting_candidate_selection_from_sidecar() + + backup_render_calls = [ + call_item for call_item in repl._render_pipeline_event.call_args_list if call_item.args[0] is backup_event + ] + assert result is backup_event + assert len(backup_render_calls) == 1 + + def test_load_pipeline_display_replay_model_allows_completed_terminal_status(tmp_path, monkeypatch): from iac_code.pipeline.engine.session import PipelineIdentity, PipelineSession from iac_code.services.session_storage import SessionStorage diff --git a/tests/ui/test_repl_pipeline_handoff.py b/tests/ui/test_repl_pipeline_handoff.py index a8513d82..c6d2cb49 100644 --- a/tests/ui/test_repl_pipeline_handoff.py +++ b/tests/ui/test_repl_pipeline_handoff.py @@ -1728,6 +1728,48 @@ async def stream(): repl._restart_pipeline_stream_after_interrupt.assert_not_awaited() +@pytest.mark.asyncio +async def test_outer_stream_does_not_rerender_recursive_candidate_selection_backup_blocked(): + from iac_code.ui.repl import InlineREPL + + backup_event = PipelineEvent( + type=PipelineEventType.BACKUP_BLOCKED, + step_id="select", + timestamp=time.time(), + data={"reason": "pipeline_step_completed", "error": "backup unavailable", "recoverable": True}, + ) + repl = InlineREPL.__new__(InlineREPL) + repl._pipeline = MagicMock() + repl._pipeline_step_names = [] + repl._pipeline_completed_indices = set() + repl._update_pipeline_state_from_event = MagicMock() + repl._render_pipeline_event = MagicMock() + repl._restart_pipeline_stream_after_interrupt = AsyncMock(return_value=_empty_stream()) + + async def fake_candidate_selection_tabs(_event_stream, progress_bar_fn=None): + repl._render_pipeline_event(backup_event) + return backup_event + + repl._render_candidate_selection_tabs = AsyncMock(side_effect=fake_candidate_selection_tabs) + + async def stream(): + yield PipelineEvent( + type=PipelineEventType.STEP_STARTED, + step_id="select", + timestamp=time.time(), + data={"index": 1, "total": 1, "ui_mode": "candidate_selection"}, + ) + + result = await repl._render_pipeline_stream(stream()) + + backup_render_calls = [ + call_item for call_item in repl._render_pipeline_event.call_args_list if call_item.args[0] is backup_event + ] + assert result is backup_event + assert len(backup_render_calls) == 1 + repl._restart_pipeline_stream_after_interrupt.assert_not_awaited() + + @pytest.mark.asyncio async def test_outer_stream_returns_parallel_tabs_terminal_event(): from iac_code.ui.repl import InlineREPL diff --git a/tests/ui/test_repl_pipeline_sidecar_restore.py b/tests/ui/test_repl_pipeline_sidecar_restore.py index f37899d8..583da05c 100644 --- a/tests/ui/test_repl_pipeline_sidecar_restore.py +++ b/tests/ui/test_repl_pipeline_sidecar_restore.py @@ -15,6 +15,7 @@ def repl_for_sidecar_restore(tmp_path): repl = InlineREPL.__new__(InlineREPL) repl._pipeline = None repl._pipeline_waiting_input = False + repl._pipeline_backup_blocked = False repl._pipeline_state_persistence_failed = False repl._pipeline_state_persistence_warning_rendered = False repl._session_id = "sid" @@ -176,6 +177,28 @@ def test_finalize_persistence_failure_event_does_not_mark_user_aborted(repl_for_ ) +def test_finalize_backup_blocked_keeps_pipeline_recoverable(repl_for_sidecar_restore): + event = PipelineEvent( + type=PipelineEventType.BACKUP_BLOCKED, + step_id="collect", + timestamp=1.0, + data={"reason": "pipeline_step_completed", "error": "backup unavailable", "recoverable": True}, + ) + pipeline = MagicMock() + pipeline.sidecar_status = None + pipeline.state_machine.is_complete = False + pipeline.mark_user_aborted = MagicMock() + repl_for_sidecar_restore._pipeline = pipeline + repl_for_sidecar_restore._pipeline_waiting_input = False + + repl_for_sidecar_restore._record_pipeline_display_event(event) + repl_for_sidecar_restore._finalize_pipeline_after_render(None) + + assert repl_for_sidecar_restore._pipeline is pipeline + assert repl_for_sidecar_restore._pipeline_backup_blocked is True + pipeline.mark_user_aborted.assert_not_called() + + def test_finalize_user_abort_persistence_failure_keeps_pipeline_paused(repl_for_sidecar_restore): pipeline = MagicMock() pipeline.sidecar_status = None @@ -586,6 +609,29 @@ async def test_restored_running_routes_to_continue_without_user_prompt(monkeypat pipeline.resume.assert_not_called() +@pytest.mark.asyncio +async def test_restored_backup_blocked_routes_to_continue(monkeypatch, repl_for_sidecar_restore): + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + pipeline = MagicMock() + pipeline.restore_from_sidecar = AsyncMock(return_value=MagicMock(ok=True, status="backup_blocked", reason=None)) + pipeline.continue_from_sidecar = MagicMock(return_value=_empty_stream()) + pipeline.run = MagicMock(return_value=_empty_stream()) + pipeline.resume = MagicMock(return_value=_empty_stream()) + pipeline.state_machine.is_complete = False + pipeline.sidecar_status = "backup_blocked" + pipeline.mark_user_aborted = MagicMock() + _seed_sidecar(repl_for_sidecar_restore, "backup_blocked") + + with patch("iac_code.pipeline.create_pipeline", return_value=pipeline): + await repl_for_sidecar_restore._handle_pipeline_chat("retry backup") + + pipeline.continue_from_sidecar.assert_called_once_with( + user_input=PipelineUserInput(content="retry backup", display_text="retry backup", has_images=False) + ) + pipeline.run.assert_not_called() + pipeline.resume.assert_not_called() + + @pytest.mark.asyncio async def test_restored_running_uses_pipeline_working_directory(monkeypatch, tmp_path, repl_for_sidecar_restore): pipeline_cwd = tmp_path / "pipeline-cwd" diff --git a/tests/utils/image/test_store.py b/tests/utils/image/test_store.py index 9de65473..dabf24a3 100644 --- a/tests/utils/image/test_store.py +++ b/tests/utils/image/test_store.py @@ -1,12 +1,22 @@ +import hashlib import sys from pathlib import Path import pytest +from iac_code.services.session_layout import UnsupportedSessionLayoutError +from iac_code.services.session_metadata import SessionMetadata, write_session_metadata from iac_code.utils.image.pasted_content import PastedContent from iac_code.utils.image.store import ImageStore, cleanup_old_image_caches +def _symlink_or_skip(target: Path, link: Path, *, target_is_directory: bool = False) -> None: + try: + link.symlink_to(target, target_is_directory=target_is_directory) + except (NotImplementedError, OSError) as exc: + pytest.skip(f"symlink creation unsupported: {exc}") + + def test_store_writes_per_session_file_with_0o600(tmp_path, monkeypatch): monkeypatch.setattr("iac_code.utils.image.store._get_base_dir", lambda: tmp_path / "image-cache") store = ImageStore(session_id="sess-a") @@ -24,6 +34,168 @@ def test_store_writes_per_session_file_with_0o600(tmp_path, monkeypatch): assert stat.S_IMODE(p.stat().st_mode) == 0o600 +def test_store_with_session_root_writes_under_session_image_cache(tmp_path, monkeypatch): + monkeypatch.setattr("iac_code.utils.image.store._get_base_dir", lambda: tmp_path / "config" / "image-cache") + session_root = tmp_path / "sessions" / "sess-a" + store = ImageStore(session_id="sess-a", session_root=session_root) + + path = store.store(PastedContent(id=7, type="image", content="aGVsbG8=", media_type="image/png")) + + assert path == str(session_root / "image-cache" / "7.png") + assert Path(path).read_bytes() == b"hello" + assert not (tmp_path / "config" / "image-cache" / "sess-a").exists() + + +def test_store_with_session_root_rejects_symlink_image_cache(tmp_path, monkeypatch): + monkeypatch.setattr("iac_code.utils.image.store._get_base_dir", lambda: tmp_path / "config" / "image-cache") + session_root = tmp_path / "sessions" / "sess-a" + write_session_metadata(session_root, SessionMetadata(session_id="sess-a", cwd=str(tmp_path), layout_version=2)) + outside = tmp_path / "outside-cache" + outside.mkdir() + _symlink_or_skip(outside, session_root / "image-cache", target_is_directory=True) + store = ImageStore(session_id="sess-a", session_root=session_root) + + with pytest.raises(UnsupportedSessionLayoutError, match="session-owned path"): + store.store(PastedContent(id=7, type="image", content="aGVsbG8=", media_type="image/png")) + + assert list(outside.iterdir()) == [] + + +def test_store_with_future_session_root_raises_without_global_fallback(tmp_path, monkeypatch): + monkeypatch.setattr("iac_code.utils.image.store._get_base_dir", lambda: tmp_path / "config" / "image-cache") + session_root = tmp_path / "sessions" / "sess-a" + write_session_metadata( + session_root, + SessionMetadata(session_id="sess-a", cwd=str(tmp_path), layout_version=99), + ) + store = ImageStore(session_id="sess-a", session_root=session_root) + + with pytest.raises(UnsupportedSessionLayoutError): + store.store(PastedContent(id=7, type="image", content="aGVsbG8=", media_type="image/png")) + + assert not (session_root / "image-cache").exists() + assert not (tmp_path / "config" / "image-cache" / "sess-a").exists() + + +def test_store_with_session_root_discovers_legacy_cached_file(tmp_path, monkeypatch): + legacy_dir = tmp_path / "config" / "image-cache" / "sess-a" + legacy_dir.mkdir(parents=True) + legacy_path = legacy_dir / "7.png" + legacy_path.write_bytes(b"legacy") + monkeypatch.setattr("iac_code.utils.image.store._get_base_dir", lambda: tmp_path / "config" / "image-cache") + store = ImageStore(session_id="sess-a", session_root=tmp_path / "sessions" / "sess-a") + + assert store.get_path(7) == str(legacy_path) + + +def test_store_with_session_root_prefers_session_scoped_file_over_legacy(tmp_path, monkeypatch): + legacy_dir = tmp_path / "config" / "image-cache" / "sess-a" + session_cache_dir = tmp_path / "sessions" / "sess-a" / "image-cache" + legacy_dir.mkdir(parents=True) + session_cache_dir.mkdir(parents=True) + legacy_path = legacy_dir / "7.png" + session_path = session_cache_dir / "7.png" + legacy_path.write_bytes(b"legacy") + session_path.write_bytes(b"session") + monkeypatch.setattr("iac_code.utils.image.store._get_base_dir", lambda: tmp_path / "config" / "image-cache") + store = ImageStore(session_id="sess-a", session_root=tmp_path / "sessions" / "sess-a") + + assert store.get_path(7) == str(session_path) + + +def test_get_path_with_session_root_rejects_symlink_image_cache_read(tmp_path, monkeypatch): + monkeypatch.setattr("iac_code.utils.image.store._get_base_dir", lambda: tmp_path / "config" / "image-cache") + session_root = tmp_path / "sessions" / "sess-a" + write_session_metadata(session_root, SessionMetadata(session_id="sess-a", cwd=str(tmp_path), layout_version=2)) + outside = tmp_path / "outside-cache" + outside.mkdir() + (outside / "7.png").write_bytes(b"outside") + _symlink_or_skip(outside, session_root / "image-cache", target_is_directory=True) + store = ImageStore(session_id="sess-a", session_root=session_root) + + assert store.get_path(7) is None + + +def test_get_path_with_session_root_rejects_symlink_image_file(tmp_path, monkeypatch): + monkeypatch.setattr("iac_code.utils.image.store._get_base_dir", lambda: tmp_path / "config" / "image-cache") + session_root = tmp_path / "sessions" / "sess-a" + write_session_metadata(session_root, SessionMetadata(session_id="sess-a", cwd=str(tmp_path), layout_version=2)) + image_cache = session_root / "image-cache" + image_cache.mkdir() + outside = tmp_path / "outside.png" + outside.write_bytes(b"outside") + _symlink_or_skip(outside, image_cache / "7.png") + store = ImageStore(session_id="sess-a", session_root=session_root) + + assert store.get_path(7) is None + + +def test_store_with_session_root_next_image_id_counts_session_and_legacy_files(tmp_path, monkeypatch): + legacy_dir = tmp_path / "config" / "image-cache" / "sess-a" + session_cache_dir = tmp_path / "sessions" / "sess-a" / "image-cache" + legacy_dir.mkdir(parents=True) + session_cache_dir.mkdir(parents=True) + (legacy_dir / "7.png").write_bytes(b"legacy") + (session_cache_dir / "3.png").write_bytes(b"session") + monkeypatch.setattr("iac_code.utils.image.store._get_base_dir", lambda: tmp_path / "config" / "image-cache") + store = ImageStore(session_id="sess-a", session_root=tmp_path / "sessions" / "sess-a") + + assert store.next_image_id() == 8 + + +def test_next_image_id_with_session_root_rejects_symlink_image_cache_read(tmp_path, monkeypatch): + legacy_dir = tmp_path / "config" / "image-cache" / "sess-a" + legacy_dir.mkdir(parents=True) + (legacy_dir / "7.png").write_bytes(b"legacy") + monkeypatch.setattr("iac_code.utils.image.store._get_base_dir", lambda: tmp_path / "config" / "image-cache") + session_root = tmp_path / "sessions" / "sess-a" + write_session_metadata(session_root, SessionMetadata(session_id="sess-a", cwd=str(tmp_path), layout_version=2)) + outside = tmp_path / "outside-cache" + outside.mkdir() + (outside / "99.png").write_bytes(b"outside") + _symlink_or_skip(outside, session_root / "image-cache", target_is_directory=True) + store = ImageStore(session_id="sess-a", session_root=session_root) + + assert store.next_image_id() == 8 + + +def test_next_image_id_with_session_root_rejects_symlink_image_file(tmp_path, monkeypatch): + legacy_dir = tmp_path / "config" / "image-cache" / "sess-a" + legacy_dir.mkdir(parents=True) + (legacy_dir / "7.png").write_bytes(b"legacy") + monkeypatch.setattr("iac_code.utils.image.store._get_base_dir", lambda: tmp_path / "config" / "image-cache") + session_root = tmp_path / "sessions" / "sess-a" + write_session_metadata(session_root, SessionMetadata(session_id="sess-a", cwd=str(tmp_path), layout_version=2)) + image_cache = session_root / "image-cache" + image_cache.mkdir() + outside = tmp_path / "99.png" + outside.write_bytes(b"outside") + _symlink_or_skip(outside, image_cache / "99.png") + store = ImageStore(session_id="sess-a", session_root=session_root) + + assert store.next_image_id() == 8 + + +def test_store_block_with_session_root_replaces_symlink_image_file(tmp_path, monkeypatch): + monkeypatch.setattr("iac_code.utils.image.store._get_base_dir", lambda: tmp_path / "config" / "image-cache") + session_root = tmp_path / "sessions" / "sess-a" + write_session_metadata(session_root, SessionMetadata(session_id="sess-a", cwd=str(tmp_path), layout_version=2)) + image_cache = session_root / "image-cache" + image_cache.mkdir() + outside = tmp_path / "outside.png" + outside.write_bytes(b"outside") + digest = hashlib.sha256("aGVsbG8=".encode()).hexdigest()[:32] + image_path = image_cache / f"block-{digest}.png" + _symlink_or_skip(outside, image_path) + block = type("ImageBlock", (), {"data": "aGVsbG8=", "media_type": "image/png"})() + store = ImageStore(session_id="sess-a", session_root=session_root) + + assert store.store_block(block) == str(image_path) + assert image_path.read_bytes() == b"hello" + assert not image_path.is_symlink() + assert outside.read_bytes() == b"outside" + + def test_get_path_discovers_cached_file_after_store_recreated(tmp_path, monkeypatch): monkeypatch.setattr("iac_code.utils.image.store._get_base_dir", lambda: tmp_path / "image-cache") first = ImageStore(session_id="sess-a") diff --git a/tests/utils/test_project_paths.py b/tests/utils/test_project_paths.py index 91a03d95..73f198e6 100644 --- a/tests/utils/test_project_paths.py +++ b/tests/utils/test_project_paths.py @@ -10,6 +10,7 @@ find_git_worktree_root, format_resume_command, get_git_branch, + get_project_dir, same_project_path, sanitize_path, ) @@ -26,7 +27,7 @@ def test_preserves_alphanumerics(self): def test_long_path_gets_hash_suffix(self): original = "x" * (MAX_SANITIZED_LENGTH + 50) result = sanitize_path(original) - # Length should be MAX_SANITIZED_LENGTH + dash + hash + # Legacy sanitizer behavior is retained for non-session callers. assert len(result) > MAX_SANITIZED_LENGTH assert result.startswith("x" * MAX_SANITIZED_LENGTH) assert "-" in result[MAX_SANITIZED_LENGTH:] @@ -44,6 +45,27 @@ def test_empty_string(self): assert sanitize_path("") == "" +def test_get_project_dir_prefers_existing_legacy_long_directory(tmp_path, monkeypatch): + import iac_code.utils.project_paths as project_paths + + cwd = "x" * (MAX_SANITIZED_LENGTH + 50) + legacy_name = project_paths._legacy_sanitize_path(cwd) + legacy_dir = tmp_path / "projects" / legacy_name + legacy_dir.mkdir(parents=True) + monkeypatch.setattr("iac_code.utils.project_paths.get_config_dir", lambda: tmp_path) + + assert get_project_dir(cwd) == legacy_dir + + +def test_get_project_dir_uses_bounded_component_for_new_long_directory(tmp_path, monkeypatch): + cwd = "x" * (MAX_SANITIZED_LENGTH + 50) + monkeypatch.setattr("iac_code.utils.project_paths.get_config_dir", lambda: tmp_path) + + project_dir = get_project_dir(cwd) + + assert len(project_dir.name) <= MAX_SANITIZED_LENGTH + + class TestProjectPathComparison: def test_windows_drive_case_and_separators_match(self): assert same_project_path(r"C:\Users\Me\Repo", "c:/Users/Me/Repo") diff --git a/tests/utils/test_state_io.py b/tests/utils/test_state_io.py index efe212f5..a5f5e2c2 100644 --- a/tests/utils/test_state_io.py +++ b/tests/utils/test_state_io.py @@ -12,7 +12,20 @@ import pytest -from iac_code.utils.state_io import append_jsonl_locked, atomic_write_json, atomic_write_text, safe_replace +from iac_code.utils.state_io import ( + append_jsonl_locked, + atomic_write_json, + atomic_write_text, + safe_replace, + write_text_no_follow, +) + + +def _symlink_or_skip(target: Path, link: Path, *, target_is_directory: bool = False) -> None: + try: + link.symlink_to(target, target_is_directory=target_is_directory) + except (NotImplementedError, OSError) as exc: + pytest.skip(f"symlink creation unsupported: {exc}") def test_atomic_write_text_replaces_file_and_removes_temp(tmp_path: Path) -> None: @@ -25,6 +38,15 @@ def test_atomic_write_text_replaces_file_and_removes_temp(tmp_path: Path) -> Non assert not list(tmp_path.glob(".state.txt.*.tmp")) +def test_write_text_no_follow_truncates_existing_file(tmp_path: Path) -> None: + path = tmp_path / "state.txt" + path.write_text("longer old content", encoding="utf-8") + + write_text_no_follow(path, "short", encoding="utf-8") + + assert path.read_text(encoding="utf-8") == "short" + + def test_atomic_write_json_fails_without_overwriting_target(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: path = tmp_path / "state.json" path.write_text('{"ok": true}\n', encoding="utf-8") @@ -61,6 +83,32 @@ def test_append_jsonl_locked_writes_one_complete_line_per_record(tmp_path: Path) assert [json.loads(line) for line in lines] == [{"a": 1}, {"b": 2}] +def test_append_jsonl_locked_refuses_symlink_leaf(tmp_path: Path) -> None: + outside = tmp_path / "outside.jsonl" + outside.write_text("outside\n", encoding="utf-8") + path = tmp_path / "session.jsonl" + _symlink_or_skip(outside, path) + + with pytest.raises(OSError): + append_jsonl_locked(path, [{"a": 1}], durable=False) + + assert outside.read_text(encoding="utf-8") == "outside\n" + + +def test_cross_process_append_lock_refuses_symlink_lock_leaf(tmp_path: Path) -> None: + from iac_code.utils import state_io + + outside = tmp_path / "outside.lock" + outside.write_text("outside\n", encoding="utf-8") + _symlink_or_skip(outside, tmp_path / ".session.jsonl.lock") + + with pytest.raises(OSError): + with state_io.cross_process_append_lock(tmp_path / "session.jsonl"): + pass + + assert outside.read_text(encoding="utf-8") == "outside\n" + + def test_append_jsonl_rotating_locked_rotates_before_append(tmp_path: Path) -> None: from iac_code.utils.state_io import append_jsonl_rotating_locked @@ -73,6 +121,20 @@ def test_append_jsonl_rotating_locked_rotates_before_append(tmp_path: Path) -> N assert (tmp_path / "permission-audit.jsonl.1").exists() +def test_append_jsonl_rotating_locked_refuses_symlink_leaf(tmp_path: Path) -> None: + from iac_code.utils.state_io import append_jsonl_rotating_locked + + outside = tmp_path / "outside.jsonl" + outside.write_text("outside\n", encoding="utf-8") + path = tmp_path / "permission-audit.jsonl" + _symlink_or_skip(outside, path) + + with pytest.raises(OSError): + append_jsonl_rotating_locked(path, [{"created": True}], max_file_bytes=1024, max_files=2) + + assert outside.read_text(encoding="utf-8") == "outside\n" + + def test_append_jsonl_rotating_locked_rotates_when_pending_record_would_exceed_limit(tmp_path: Path) -> None: from iac_code.utils.state_io import append_jsonl_rotating_locked @@ -241,6 +303,14 @@ def fail_flock(fd: int, operation: int) -> None: assert not path.exists() +def test_cross_process_append_lock_is_public_alias(tmp_path: Path) -> None: + from iac_code.utils import state_io + + assert state_io._cross_process_append_lock is state_io.cross_process_append_lock + with state_io.cross_process_append_lock(tmp_path / "session.jsonl"): + pass + + def test_windows_append_lock_seeks_before_lock_and_unlock(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: from iac_code.utils import state_io @@ -259,7 +329,7 @@ def fileno(self) -> int: def seek(self, offset: int) -> None: events.append(("seek", offset)) - def fake_open(self: Path, mode: str = "r", *args: object, **kwargs: object) -> FakeLockFile: + def fake_open_lock(_path: Path) -> FakeLockFile: return FakeLockFile() def fake_locking(fd: int, mode: int, nbytes: int) -> None: @@ -268,7 +338,7 @@ def fake_locking(fd: int, mode: int, nbytes: int) -> None: fake_msvcrt = types.SimpleNamespace(LK_LOCK=1, LK_UNLCK=2, locking=fake_locking) monkeypatch.setattr("iac_code.utils.state_io.sys.platform", "win32") monkeypatch.setitem(sys.modules, "msvcrt", fake_msvcrt) - monkeypatch.setattr(Path, "open", fake_open) + monkeypatch.setattr(state_io, "_open_lock_binary", fake_open_lock) with state_io._cross_process_append_lock(tmp_path / "session.jsonl"): events.append(("yield", 0)) @@ -282,6 +352,46 @@ def fake_locking(fd: int, mode: int, nbytes: int) -> None: ] +def test_windows_no_follow_rejects_reparse_attribute_from_handle( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + import ctypes + + from iac_code.utils import state_io + + class FakeFunction: + def __init__(self, result: object = True, *, reparse_info: bool = False) -> None: + self.result = result + self.reparse_info = reparse_info + self.calls: list[tuple[object, ...]] = [] + self.argtypes = None + self.restype = None + + def __call__(self, *args): + self.calls.append(args) + if self.reparse_info: + args[1]._obj.dwFileAttributes = 0x400 + return self.result + + class FakeKernel32: + def __init__(self) -> None: + self.CreateFileW = FakeFunction(result=1234) + self.GetFileInformationByHandle = FakeFunction(reparse_info=True) + self.CloseHandle = FakeFunction(result=True) + + fake_kernel32 = FakeKernel32() + fake_msvcrt = types.SimpleNamespace(open_osfhandle=lambda *_args: pytest.fail("open_osfhandle called")) + monkeypatch.setitem(sys.modules, "msvcrt", fake_msvcrt) + monkeypatch.setattr(ctypes, "WinDLL", lambda *_args, **_kwargs: fake_kernel32, raising=False) + + with pytest.raises(OSError, match="symlink or reparse"): + state_io._open_windows_no_follow(tmp_path / "state.jsonl", os.O_RDONLY, 0o600) + + assert fake_kernel32.GetFileInformationByHandle.calls + assert fake_kernel32.CloseHandle.calls == [(1234,)] + + def test_parent_directory_fsync_is_best_effort(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: path = tmp_path / "state.txt" calls: list[int] = [] diff --git a/website/docs/a2a/overview.md b/website/docs/a2a/overview.md index 27769866..e0aeeddb 100644 --- a/website/docs/a2a/overview.md +++ b/website/docs/a2a/overview.md @@ -72,3 +72,8 @@ iac-code supports A2A server mode over HTTP JSON-RPC/REST and several optional t - Push delivery is at-least-once for Redis-backed queues; callback receivers must handle duplicates and enforce their own endpoint-side authorization policy. Tool permission requests are rejected automatically in A2A server mode unless `auto-approve-permissions` or an explicit permission rule allows them. Permission decisions are audited locally; any allow decision that requires an audit record fails closed if that record cannot be persisted. Protected Alibaba Cloud write APIs still require exact per-API authorization outside blanket bypass modes. Run unauthenticated A2A mode only in trusted local environments or protect it with Bearer token, Basic auth, or API key authentication. + + +## Persistence and Session Backup + +A2A task and context indexes remain in the shared runtime area, for example `/a2a/tasks` and `/a2a/contexts`, so they can be mounted or synchronized independently from per-session data. When `IAC_CODE_CONFIG_BACKUP_DIR` is configured, session-scoped A2A snapshots and artifacts inside v2 sessions are mirrored with the session backup, while the shared task/context indexes should be provided by their own mounted storage. diff --git a/website/docs/a2a/protocol-reference.md b/website/docs/a2a/protocol-reference.md index 2bcffe34..0c69777b 100644 --- a/website/docs/a2a/protocol-reference.md +++ b/website/docs/a2a/protocol-reference.md @@ -352,7 +352,7 @@ Task and context IDs must be non-empty, at most 128 characters, and contain only | `TASK_STATE_CANCELED` | Cancellation was requested and applied | | `TASK_STATE_FAILED` | The task failed validation or execution | -iac-code uses `TASK_STATE_INPUT_REQUIRED` as the normal completed state because the context remains available for follow-up messages. +iac-code uses `TASK_STATE_INPUT_REQUIRED` as the normal completed state because the context remains available for follow-up messages. Ordinary A2A turns treat `TASK_STATE_INPUT_REQUIRED` as this normal completion state; their successful turn-end backup is the non-blocking `normal_turn_end` checkpoint. If an ordinary A2A terminal or cancellation publication is blocked by a critical backup gate, task metadata exposes `metadata.iac_code.backupBlocked` with `reason`, `error`, and `recoverable`; clients should wait for recovery before sending the next turn. Pipeline mode publishes backup gate failures as pipeline events, so clients should inspect `metadata.iac_code.pipeline.eventType == "backup_blocked"` and the event data. When `IAC_CODE_CONFIG_BACKUP_DIR` is enabled in pipeline mode, completed step checkpoints with reason `pipeline_step_completed`, `input_required`, `waiting_input`, `terminal`, and `handoff_ready` are held behind the critical backup gate. The `terminal` reason protects terminal publications, and the `handoff_ready` reason protects the `pipeline_handoff_ready` event type. For terminal and `pipeline_handoff_ready` committed publications, iac-code persists a `backup_committed` pipeline event whose data contains `committedEventId`, `committedEventType`, and `committedSequence`; clients can pair it with the protected event before treating the publication as recoverable after restart. ## Streaming Updates diff --git a/website/docs/acp/protocol-reference.md b/website/docs/acp/protocol-reference.md index 8ad08bca..ee0df093 100644 --- a/website/docs/acp/protocol-reference.md +++ b/website/docs/acp/protocol-reference.md @@ -27,6 +27,12 @@ Sessions can also be loaded from history (`session/load`) or resumed (`session/r --- +## Session Backups + +ACP sessions use the same v2 session backup behavior as interactive and headless runs. When `IAC_CODE_CONFIG_BACKUP_DIR` is set, a normal prompt completion records a non-blocking `normal_turn_end` backup; if that backup fails, the failure is logged as a `warning` and recorded in `.backup-state.json`, while the final response still completes without a promised warning field. Pipeline mode has its own critical backup gates. + +--- + ## Methods ### initialize diff --git a/website/docs/automation/non-interactive-mode.md b/website/docs/automation/non-interactive-mode.md index a2481d63..7be4556e 100644 --- a/website/docs/automation/non-interactive-mode.md +++ b/website/docs/automation/non-interactive-mode.md @@ -39,6 +39,10 @@ Supported output formats are: | `json` | A single JSON result for callers that parse the final response. | | `stream-json` | Streaming JSON events for callers that process incremental progress. | +## Session Backups + +When `IAC_CODE_CONFIG_BACKUP_DIR` is set, non-interactive runs mirror the v2 session at key checkpoints. The ordinary end-of-turn checkpoint uses `normal_turn_end`; backup failures at that point are logged as a `warning` and recorded in `.backup-state.json` without failing the completed response or adding a warning field to the final output. Pipeline mode has its own critical backup gates. + ## Permission Control in Automation When running non-interactively, use `--permission-mode` to control how the agent handles tool approvals: diff --git a/website/docs/automation/pipeline-mode.md b/website/docs/automation/pipeline-mode.md index 60d68250..f329de15 100644 --- a/website/docs/automation/pipeline-mode.md +++ b/website/docs/automation/pipeline-mode.md @@ -99,3 +99,8 @@ ACP does not currently support Pipeline mode. `--prompt` / [Non-interactive Mode - Pipeline mode requires the interactive REPL. `--prompt` is rejected when `IAC_CODE_MODE=pipeline`. - Pipeline mode supports text input. Images pasted into the REPL are ignored while the pipeline is active. - Mid-pipeline shell escapes, skill triggers, and most slash commands are restricted unless the pipeline definition explicitly allows them. Basic commands such as `/help`, `/status`, `/resume`, and `/exit` remain available. + + +## Backup Checkpoints + +Pipeline mode performs critical backup checkpoints after completed agent-loop steps and before externally visible waiting, `pipeline_handoff_ready`, or terminal states. If a critical backup cannot finish, the pipeline emits `backup_blocked` and pauses in a recoverable state instead of publishing `input_required`, `waiting_input`, `pipeline_handoff_ready`, or terminal completion first. For A2A observers, terminal and `pipeline_handoff_ready` protected publications are followed by a `backup_committed` event with `committedEventId`, `committedEventType`, and `committedSequence` once the mirror is durable. `parallel_sub_pipeline` child-step progress is not backed up independently while sibling sub-pipelines may still be running; the parent checkpoint captures the stable aggregate state. diff --git a/website/docs/configuration/environment-variables.md b/website/docs/configuration/environment-variables.md index 7ef10eb8..d8c4ec05 100644 --- a/website/docs/configuration/environment-variables.md +++ b/website/docs/configuration/environment-variables.md @@ -52,7 +52,7 @@ See [Alibaba Cloud Credentials](./alibaba-cloud-credentials.md) for more details | Variable | Description | |---|---| | `IAC_CODE_CONFIG_DIR` | Override the runtime configuration directory (default `~/.iac-code/`); supports `~` and `$VAR` expansion. All persisted artifacts (credentials, settings, history, projects, image cache, skills, telemetry, etc.) follow it | -| `IAC_CODE_LOG_DIR` | Override the local startup/debug log directory (default `/logs/`); supports `~` and `$VAR` expansion. Permission audit records still stay under `/logs/permission-audit.jsonl` | +| `IAC_CODE_LOG_DIR` | Override the local startup/debug log directory (default `/logs/`); supports `~` and `$VAR` expansion. Permission audit records follow the session layout and are not moved by this variable | | `IAC_CODE_PERMISSION_AUDIT_INCLUDE_TOOL_INPUT` | Override `permissions.audit.include_tool_input`; set to `1` / `true` / `yes` / `on` to include shape-only tool input in permission audit records, using type/length/fingerprint instead of raw business payload strings and fingerprinting non-whitelisted field names | | `IAC_CODE_ENV` | Deployment environment label (default: `production`) | | `IAC_CODE_TENANT_ID` | Tenant identifier for telemetry; auto-prefixed with `iac_tenant_` if not already | @@ -60,3 +60,10 @@ See [Alibaba Cloud Credentials](./alibaba-cloud-credentials.md) for more details | `IAC_CODE_A2A_PUSH_KEYRING` | Environment-managed encrypted push secret keyring for A2A (JSON format) | | `OTEL_EXPORTER_OTLP_ENDPOINT` | Standard OpenTelemetry endpoint; when set, enables OTLP export | | `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` | Capture GenAI message/tool content on spans: `SPAN_ONLY`, `EVENT_ONLY`, `SPAN_AND_EVENT` | + + +## Session Backup + +| Variable | Description | +|---|---| +| `IAC_CODE_CONFIG_BACKUP_DIR` | Optional session backup directory; supports `~` and `$VAR` expansion, and `%VAR%` expansion on Windows. In PowerShell, pass a concrete path or let the shell expand `$env:VAR` before starting `iac-code`. In sandbox deployments this is commonly an OSS-mounted path, but it must be independent from and not overlap `IAC_CODE_CONFIG_DIR` or any session source, and should be low latency enough for critical checkpoints. UNC paths, mapped drives, and mounted OSS paths must preserve `.backup-lock` file locking, atomic replace semantics, and file metadata well enough for incremental mirroring; avoid symlink, junction, or reparse-point ancestry for the active session source, backup root, and mirrored sessions. When enabled, checkpoints mirror each v2 session to `/projects///` with the same directory shape as the active session; `.backup-state.json` and `.backup-lock` stay local and are not copied. Normal chat turn-end backups use `normal_turn_end` and do not block the response; only `critical=true` checkpoint failures block publication. Shared A2A task/context indexes can be mounted separately. | diff --git a/website/docs/configuration/runtime-configuration.md b/website/docs/configuration/runtime-configuration.md index fbf68789..fe9c627e 100644 --- a/website/docs/configuration/runtime-configuration.md +++ b/website/docs/configuration/runtime-configuration.md @@ -19,7 +19,7 @@ The runtime directory defaults to: ~/.iac-code/ ``` -You can relocate it by setting the `IAC_CODE_CONFIG_DIR` environment variable (supports `~` and `$VAR` expansion). When set, every persisted artifact — credentials, settings, history, `projects/`, `image-cache/`, `tool-results/`, `memory/`, `a2a/`, `telemetry/`, `skills/` — follows the new location. Startup/debug logs default to `/logs/` but can be moved separately with `IAC_CODE_LOG_DIR`; permission audit records stay under `/logs/`. +You can relocate it by setting the `IAC_CODE_CONFIG_DIR` environment variable (supports `~` and `$VAR` expansion). When set, every persisted artifact — credentials, settings, history, `projects/`, `image-cache/`, `tool-results/`, `memory/`, `a2a/`, `telemetry/`, `skills/` — follows the new location. Startup/debug logs default to `/logs/` but can be moved separately with `IAC_CODE_LOG_DIR`; permission audit records stay with their owning session. Common files: @@ -156,13 +156,13 @@ ROA-style requests are treated as read-only only when the method is `GET` and th ### Permission Audit Log -Permission decisions that cross user prompts, tool-cache boundaries, automation approval, or resolver approval are appended to: +Permission decisions that cross user prompts, tool-cache boundaries, automation approval, or resolver approval are appended to the active session audit log: ```text -/logs/permission-audit.jsonl +/projects///permission-audit.jsonl ``` -By default, this is `~/.iac-code/logs/permission-audit.jsonl`. The permission audit log follows `IAC_CODE_CONFIG_DIR`; `IAC_CODE_LOG_DIR` only moves startup/debug logs. The audit writer appends JSONL records with file locking, rotates the file, and restricts local file permissions where the operating system supports it. Routine read-only auto-allow decisions may be omitted, but denials, prompts, cached decisions, automation approvals, resolver approvals, and other audited permission boundaries are recorded. +Pipeline step transcripts write their own audit records under `/projects///pipeline/transcripts//permission-audit.jsonl`. Permission audit logs follow the session layout under `IAC_CODE_CONFIG_DIR`; `IAC_CODE_LOG_DIR` only moves startup/debug logs and does not move permission audit records. The audit writer appends JSONL records with file locking, rotates the file, and restricts local file permissions where the operating system supports it. Routine read-only auto-allow decisions may be omitted, but denials, prompts, cached decisions, automation approvals, resolver approvals, and other audited permission boundaries are recorded. Audit settings are configured under `permissions.audit`: @@ -173,3 +173,34 @@ Audit settings are configured under `permissions.audit`: | `max_files` | `5` | Number of rotated audit files to keep. Values above the built-in maximum are clamped. | If any allow decision that requires an audit record cannot be persisted to the audit log, IaC Code fails closed and denies the action instead of executing it without an audit trail. + + +## Session Layout and Backup State + +New sessions use a metadata `layout_version` marker so runtime artifacts can stay scoped to the owning session. Session-owned files include `image-cache/`, `tool-results/`, A2A snapshots, pipeline transcript runtime data, and `.backup-state.json`, which records the last backup reason and status; `.backup-state.json` and `.backup-lock` stay local to the active session and are excluded from backup mirrors. Legacy sessions without a `layout_version` marker remain readable but do not receive new session-scoped artifact directories; sessions with unsupported layout versions are rejected. + +Key v2 session paths are: + +```text +/projects/// +/projects///metadata.json +/projects///session.jsonl +/projects///usage.jsonl +/projects///permission-audit.jsonl +/projects///image-cache/ +/projects///tool-results/ +/projects///a2a/task.json +/projects///a2a/context.json +/projects///a2a/artifacts/ +/projects///a2a/pipeline/ +/projects///a2a/cleanup-deferred-prompts.json +/projects///pipeline/meta.yaml +/projects///pipeline/context.yaml +/projects///pipeline/events.jsonl +/projects///pipeline/display.jsonl +/projects///pipeline/transcripts// +/projects///pipeline/transcripts//session.jsonl +/projects///pipeline/transcripts//usage.jsonl +/projects///pipeline/transcripts//permission-audit.jsonl +/projects///pipeline/transcripts//tool-results/ +``` diff --git a/website/docs/mcp/capabilities.md b/website/docs/mcp/capabilities.md index 2d8d29db..addeec46 100644 --- a/website/docs/mcp/capabilities.md +++ b/website/docs/mcp/capabilities.md @@ -41,7 +41,13 @@ IaC Code converts MCP content blocks into model-visible text: | `resource_link` | Rendered as a resource link with URI and MIME type. | | Image, audio, and blob data | Stored as private artifact files and referenced by artifact id. | -Binary artifacts are stored below: +Binary artifacts are stored in the session-owned MCP tool-results directory for v2 sessions: + +```text +/projects///tool-results/mcp/// +``` + +Legacy sessions without a supported layout marker continue to use: ```text /tool-results//mcp/// diff --git a/website/docs/mcp/troubleshooting.md b/website/docs/mcp/troubleshooting.md index 489f965a..1fb9d857 100644 --- a/website/docs/mcp/troubleshooting.md +++ b/website/docs/mcp/troubleshooting.md @@ -145,7 +145,13 @@ Runtime logs default to: or `IAC_CODE_LOG_DIR` when set. -MCP binary artifacts from tool results are stored under: +MCP binary artifacts from tool results are stored under the session-owned directory for v2 sessions: + +```text +/projects///tool-results/mcp/ +``` + +Legacy sessions without a supported layout marker use: ```text /tool-results//mcp/ diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/overview.md b/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/overview.md index 24237af0..eea87614 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/overview.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/overview.md @@ -72,3 +72,8 @@ iac-code unterstuetzt A2A-Servermodus ueber HTTP JSON-RPC/REST und mehrere optio - Push-Zustellung ist fuer Redis-gestuetzte Queues mindestens einmal; Callback-Empfaenger muessen Duplikate behandeln und ihre eigene autorisierungsseitige Endpoint-Policy erzwingen. Tool-Berechtigungsanfragen werden im A2A-Servermodus automatisch abgelehnt, sofern sie nicht durch `auto-approve-permissions` oder eine explizite Berechtigungsregel erlaubt werden. Berechtigungsentscheidungen werden lokal auditiert; jede Allow-Entscheidung, die einen Auditdatensatz erfordert, schlaegt fail-closed fehl, wenn dieser Datensatz nicht persistiert werden kann. Geschuetzte Alibaba-Cloud-Schreib-APIs erfordern ausserhalb pauschaler Bypass-Modi weiterhin eine exakte Autorisierung pro API. Fuehren Sie den unauthentifizierten A2A-Modus nur in vertrauenswuerdigen lokalen Umgebungen aus oder schuetzen Sie ihn mit Bearer-Token-, Basic-Auth- oder API-Key-Authentifizierung. + + +## Persistenz und Sitzungsbackup + +A2A task/context-Indizes bleiben im gemeinsamen Runtime-Bereich, zum Beispiel `/a2a/tasks` und `/a2a/contexts`, damit sie unabhaengig von sitzungsbezogenen Daten gemountet oder synchronisiert werden koennen. Wenn `IAC_CODE_CONFIG_BACKUP_DIR` konfiguriert ist, werden A2A-Snapshots und Artefakte innerhalb von v2-Sitzungen mit dem Sitzungsbackup gespiegelt; die gemeinsamen task/context-Indizes sollten ueber eigenen gemounteten Speicher bereitgestellt werden. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md b/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md index 9fac3d45..98017417 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md @@ -332,7 +332,7 @@ Task- und Kontext-IDs muessen nicht leer sein, hoechstens 128 Zeichen haben und | `TASK_STATE_CANCELED` | Abbruch wurde angefordert und angewendet | | `TASK_STATE_FAILED` | Der Task ist bei Validierung oder Ausfuehrung fehlgeschlagen | -iac-code verwendet `TASK_STATE_INPUT_REQUIRED` als normalen abgeschlossenen Zustand, da der Kontext fuer Follow-up-Nachrichten verfuegbar bleibt. +iac-code verwendet `TASK_STATE_INPUT_REQUIRED` als normalen abgeschlossenen Zustand, da der Kontext fuer Follow-up-Nachrichten verfuegbar bleibt. Normale A2A-Turns behandeln `TASK_STATE_INPUT_REQUIRED` als diesen normalen Abschlusszustand; ihr erfolgreicher Turn-End-Backup ist der nicht blockierende `normal_turn_end`-Checkpoint. Wenn eine normale A2A-Terminal- oder Abbruchpublikation durch eine kritische Backup-Sperre blockiert wird, enthalten die Task-Metadaten `metadata.iac_code.backupBlocked` mit `reason`, `error` und `recoverable`; Clients sollten auf Wiederherstellung warten, bevor sie den naechsten Turn senden. Der Pipeline-Modus veroeffentlicht Fehler dieser Backup-Sperre als Pipeline-Ereignisse, daher sollten Clients `metadata.iac_code.pipeline.eventType == "backup_blocked"` und die Ereignisdaten pruefen. Bei aktiviertem `IAC_CODE_CONFIG_BACKUP_DIR` im Pipeline-Modus warten Publikationen mit Grund `pipeline_step_completed`, `input_required`, `waiting_input`, `terminal` und `handoff_ready` hinter der kritischen Backup-Sperre. Der Grund `terminal` schuetzt Terminal-Publikationen, und der Grund `handoff_ready` schuetzt den Ereignistyp `pipeline_handoff_ready`. Fuer committed Terminal- und `pipeline_handoff_ready`-Publikationen persistiert iac-code ein `backup_committed`-Pipeline-Ereignis mit `committedEventId`, `committedEventType` und `committedSequence`; Clients koennen es mit dem geschuetzten Ereignis paaren, bevor sie die Publikation nach einem Neustart als wiederherstellbar behandeln. ## Streaming-Aktualisierungen diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/acp/protocol-reference.md b/website/i18n/de/docusaurus-plugin-content-docs/current/acp/protocol-reference.md index 850decfe..dc50d68c 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/acp/protocol-reference.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/acp/protocol-reference.md @@ -27,6 +27,12 @@ Sitzungen koennen auch aus dem Verlauf geladen (`session/load`) oder fortgesetzt --- +## Sitzungsbackups + +ACP-Sitzungen verwenden dasselbe v2-Sitzungsbackup-Verhalten wie interaktive und headless-Laeufe. Wenn `IAC_CODE_CONFIG_BACKUP_DIR` gesetzt ist, zeichnet ein normaler Prompt-Abschluss ein nicht blockierendes `normal_turn_end`-Backup auf; wenn dieses Backup fehlschlaegt, wird der Fehler als `warning` protokolliert und in `.backup-state.json` erfasst, waehrend die finale Antwort ohne zugesichertes warning-Feld abgeschlossen wird. Der Pipeline-Modus hat eigene kritische Backup-Gates. + +--- + ## Methoden ### initialize diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md b/website/i18n/de/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md index 598c5e52..89c3a26d 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md @@ -39,6 +39,10 @@ Unterstützte Ausgabeformate sind: | `json` | Ein einzelnes JSON-Ergebnis für Aufrufer, die die endgültige Antwort parsen. | | `stream-json` | Streaming-JSON-Ereignisse für Aufrufer, die inkrementellen Fortschritt verarbeiten. | +## Sitzungsbackups + +Wenn `IAC_CODE_CONFIG_BACKUP_DIR` gesetzt ist, spiegeln nicht-interaktive Laeufe die v2-Sitzung an wichtigen Checkpoints. Der normale Turn-End-Checkpoint verwendet `normal_turn_end`; Backupfehler an diesem Punkt werden als `warning` protokolliert und in `.backup-state.json` erfasst, ohne die abgeschlossene Antwort fehlschlagen zu lassen oder dem finalen Output ein warning-Feld hinzuzufuegen. Der Pipeline-Modus hat eigene kritische Backup-Gates. + ## Berechtigungssteuerung in der Automatisierung Verwenden Sie beim nicht-interaktiven Ausführen `--permission-mode`, um zu steuern, wie der Agent Werkzeuggenehmigungen behandelt: diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/automation/pipeline-mode.md b/website/i18n/de/docusaurus-plugin-content-docs/current/automation/pipeline-mode.md index d8a22891..9e06c5ba 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/automation/pipeline-mode.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/automation/pipeline-mode.md @@ -99,3 +99,8 @@ ACP unterstützt den Pipeline-Modus derzeit nicht. `--prompt` / der [nicht inter - Der Pipeline-Modus benötigt die interaktive REPL. `--prompt` wird abgelehnt, wenn `IAC_CODE_MODE=pipeline` gesetzt ist. - Der Pipeline-Modus unterstützt Texteingaben. In die REPL eingefügte Bilder werden ignoriert, solange die Pipeline aktiv ist. - Während einer Pipeline sind Shell-Escapes, Skill-Trigger und die meisten Slash-Befehle eingeschränkt, sofern die Pipeline-Definition sie nicht ausdrücklich erlaubt. Grundlegende Befehle wie `/help`, `/status`, `/resume` und `/exit` bleiben verfügbar. + + +## Backup-Checkpoints + +Der Pipeline-Modus fuehrt kritische Backup-Checkpoints nach abgeschlossenen Agent-Loop-Steps und vor extern sichtbaren Wartezustaenden, `pipeline_handoff_ready` oder Endzustaenden aus. Wenn ein kritisches Backup nicht abgeschlossen werden kann, gibt die Pipeline `backup_blocked` aus und pausiert in einem wiederherstellbaren Zustand, statt zuerst `input_required`, `waiting_input`, `pipeline_handoff_ready` oder einen Endzustand zu veroeffentlichen. Fuer A2A-Beobachter folgt auf terminale und `pipeline_handoff_ready`-geschuetzte Publikationen ein `backup_committed`-Ereignis mit `committedEventId`, `committedEventType` und `committedSequence`, sobald der Mirror dauerhaft ist. Fortschritt aus `parallel_sub_pipeline`-Kind-Steps wird nicht separat gesichert, solange Geschwister-Sub-Pipelines noch laufen koennen; der Eltern-Checkpoint erfasst den stabilen Gesamtzustand. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/environment-variables.md b/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/environment-variables.md index d91c89ce..c324969a 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/environment-variables.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/environment-variables.md @@ -52,7 +52,7 @@ Siehe [Alibaba Cloud-Anmeldedaten](./alibaba-cloud-credentials.md) fuer weitere | Variable | Beschreibung | |---|---| | `IAC_CODE_CONFIG_DIR` | Ueberschreibt das Laufzeitkonfigurationsverzeichnis (Standard `~/.iac-code/`); unterstuetzt `~`- und `$VAR`-Erweiterung. Alle persistierten Artefakte (Anmeldedaten, Einstellungen, Verlauf, projects, image-cache, skills, telemetry usw.) folgen diesem Verzeichnis | -| `IAC_CODE_LOG_DIR` | Ueberschreibt das lokale Verzeichnis fuer Start-/Debug-Logs (Standard `/logs/`); unterstuetzt `~`- und `$VAR`-Erweiterung. Berechtigungsauditdatensaetze bleiben unter `/logs/permission-audit.jsonl` | +| `IAC_CODE_LOG_DIR` | Ueberschreibt das lokale Verzeichnis fuer Start-/Debug-Logs (Standard `/logs/`); unterstuetzt `~`- und `$VAR`-Erweiterung. Berechtigungsauditdatensaetze folgen dem Sitzungslayout und werden durch diese Variable nicht verschoben | | `IAC_CODE_PERMISSION_AUDIT_INCLUDE_TOOL_INPUT` | Ueberschreibt `permissions.audit.include_tool_input`; auf `1` / `true` / `yes` / `on` setzen, um die Form der Tool-Eingabe in Berechtigungsauditdatensaetze aufzunehmen, mit Typ/Laenge/Fingerprint statt roher fachlicher Payload-Strings und mit Fingerprints fuer Feldnamen ausserhalb der Whitelist | | `IAC_CODE_ENV` | Bezeichnung der Bereitstellungsumgebung (Standard: `production`) | | `IAC_CODE_TENANT_ID` | Mandantenkennung fuer Telemetrie; wird automatisch mit `iac_tenant_` vorangestellt, wenn nicht bereits vorhanden | @@ -60,3 +60,10 @@ Siehe [Alibaba Cloud-Anmeldedaten](./alibaba-cloud-credentials.md) fuer weitere | `IAC_CODE_A2A_PUSH_KEYRING` | Umgebungsgesteuerter verschluesselter A2A-Push-Secret-Keyring (JSON-Format) | | `OTEL_EXPORTER_OTLP_ENDPOINT` | Standard-OpenTelemetry-Endpunkt; aktiviert den OTLP-Export, wenn gesetzt | | `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` | GenAI-Nachrichten-/Tool-Inhalte auf Spans erfassen: `SPAN_ONLY`, `EVENT_ONLY`, `SPAN_AND_EVENT` | + + +## Sitzungsbackup + +| Variable | Beschreibung | +|---|---| +| `IAC_CODE_CONFIG_BACKUP_DIR` | Optionales Verzeichnis fuer Sitzungsbackups; unterstuetzt `~`- und `$VAR`-Expansion sowie `%VAR%`-Expansion unter Windows. In PowerShell eine konkrete Pfadangabe uebergeben oder `$env:VAR` vor dem Start von `iac-code` durch die Shell expandieren lassen. In Sandbox-Deployments ist dies haeufig ein gemounteter OSS-Pfad, muss aber unabhaengig von `IAC_CODE_CONFIG_DIR` und jeder Sitzungsquelle sein, darf sich nicht ueberschneiden und sollte fuer kritische Checkpoints niedrige Latenz bieten. UNC-Pfade, gemappte Laufwerke und gemountete OSS-Pfade muessen `.backup-lock`-Dateisperren, atomare Replace-Semantik und Dateimetadaten ausreichend fuer inkrementelles Spiegeln erhalten; vermeiden Sie Symlink-, Junction- oder reparse-point-Vorfahren fuer die aktive Sitzungsquelle, den Backup-Root und gespiegelte Sitzungen. Wenn aktiviert, spiegeln Checkpoints jede v2-Sitzung nach `/projects///` und behalten die gleiche Verzeichnisstruktur wie die aktive Sitzung bei; `.backup-state.json` und `.backup-lock` bleiben lokal und werden nicht kopiert. Normale Chat-Turn-End-Backups verwenden `normal_turn_end` und blockieren die Antwort nicht; nur Fehler bei `critical=true`-Checkpoints blockieren die Veroeffentlichung. Gemeinsame A2A task/context-Indizes koennen separat gemountet werden. | diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md b/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md index 0e089fc6..64f36487 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md @@ -19,7 +19,7 @@ Das Laufzeitverzeichnis ist standardmäßig: ~/.iac-code/ ``` -Sie können es verlegen, indem Sie die Umgebungsvariable `IAC_CODE_CONFIG_DIR` setzen (unterstützt `~`- und `$VAR`-Erweiterung). Sobald gesetzt, folgen alle persistierten Artefakte — Anmeldedaten, Einstellungen, Verlauf, `projects/`, `image-cache/`, `tool-results/`, `logs/`, `memory/`, `a2a/`, `telemetry/`, `skills/` — dem neuen Speicherort. Start-/Debug-Logs liegen standardmaessig unter `/logs/` und koennen separat mit `IAC_CODE_LOG_DIR` verschoben werden; Berechtigungsauditdatensaetze bleiben unter `/logs/`. +Sie können es verlegen, indem Sie die Umgebungsvariable `IAC_CODE_CONFIG_DIR` setzen (unterstützt `~`- und `$VAR`-Erweiterung). Sobald gesetzt, folgen alle persistierten Artefakte — Anmeldedaten, Einstellungen, Verlauf, `projects/`, `image-cache/`, `tool-results/`, `logs/`, `memory/`, `a2a/`, `telemetry/`, `skills/` — dem neuen Speicherort. Start-/Debug-Logs liegen standardmaessig unter `/logs/` und koennen separat mit `IAC_CODE_LOG_DIR` verschoben werden; Berechtigungsauditdatensaetze bleiben bei ihrer eigenen Sitzung. Häufige Dateien: @@ -156,13 +156,13 @@ ROA-artige Requests gelten nur dann als nur lesend, wenn die Methode `GET` ist u ### Berechtigungsauditprotokoll -Berechtigungsentscheidungen, die Benutzerabfragen, Tool-Cache-Grenzen, Automatisierungsgenehmigungen oder Resolver-Genehmigungen ueberschreiten, werden angehaengt an: +Berechtigungsentscheidungen, die Benutzerabfragen, Tool-Cache-Grenzen, Automatisierungsgenehmigungen oder Resolver-Genehmigungen ueberschreiten, werden an das Auditlog der aktiven Sitzung angehaengt: ```text -/logs/permission-audit.jsonl +/projects///permission-audit.jsonl ``` -Standardmaessig ist dies `~/.iac-code/logs/permission-audit.jsonl`. Das Berechtigungsauditlog folgt `IAC_CODE_CONFIG_DIR`; `IAC_CODE_LOG_DIR` verschiebt nur Start-/Debug-Logs. Der Audit-Writer haengt JSONL-Datensaetze mit Dateisperren an, rotiert die Datei und schraenkt lokale Dateiberechtigungen ein, soweit das Betriebssystem dies unterstuetzt. Routinemaessige automatische Nur-Lese-Genehmigungen koennen ausgelassen werden, aber Ablehnungen, Prompts, gecachte Entscheidungen, Automatisierungsgenehmigungen, Resolver-Genehmigungen und andere auditierte Berechtigungsgrenzen werden aufgezeichnet. +Pipeline-Step-Transcripts schreiben eigene Auditdatensaetze unter `/projects///pipeline/transcripts//permission-audit.jsonl`. Berechtigungsauditlogs folgen dem Sitzungslayout unter `IAC_CODE_CONFIG_DIR`; `IAC_CODE_LOG_DIR` verschiebt nur Start-/Debug-Logs und verschiebt keine Berechtigungsauditdatensaetze. Der Audit-Writer haengt JSONL-Datensaetze mit Dateisperren an, rotiert die Datei und schraenkt lokale Dateiberechtigungen ein, soweit das Betriebssystem dies unterstuetzt. Routinemaessige automatische Nur-Lese-Genehmigungen koennen ausgelassen werden, aber Ablehnungen, Prompts, gecachte Entscheidungen, Automatisierungsgenehmigungen, Resolver-Genehmigungen und andere auditierte Berechtigungsgrenzen werden aufgezeichnet. Audit-Einstellungen werden unter `permissions.audit` konfiguriert: @@ -173,3 +173,34 @@ Audit-Einstellungen werden unter `permissions.audit` konfiguriert: | `max_files` | `5` | Anzahl der aufzubewahrenden rotierten Auditdateien. Werte ueber dem eingebauten Maximum werden begrenzt. | Wenn eine Allow-Entscheidung, die einen Auditdatensatz erfordert, nicht im Auditprotokoll persistiert werden kann, verhaelt sich IaC Code fail-closed und lehnt die Aktion ab, statt sie ohne Auditspur auszufuehren. + + +## Sitzungslayout und Backup-Status + +Neue Sitzungen verwenden den Metadatenmarker `layout_version`, damit Laufzeit-Artefakte der eigenen Sitzung zugeordnet bleiben. Dazu gehoeren `image-cache/`, `tool-results/`, A2A-Snapshots, Pipeline-Transcript-Laufzeitdaten und `.backup-state.json`, das den letzten Backup-Grund und Status speichert; `.backup-state.json` und `.backup-lock` bleiben lokal in der aktiven Sitzung und werden nicht in Backup-Spiegel kopiert. Alte Sitzungen ohne `layout_version`-Marker bleiben lesbar, erhalten aber keine neuen sitzungsspezifischen Artifact-Verzeichnisse; Sitzungen mit nicht unterstützten Layout-Versionen werden abgelehnt. + +Die wichtigsten Pfade einer v2-Sitzung sind: + +```text +/projects/// +/projects///metadata.json +/projects///session.jsonl +/projects///usage.jsonl +/projects///permission-audit.jsonl +/projects///image-cache/ +/projects///tool-results/ +/projects///a2a/task.json +/projects///a2a/context.json +/projects///a2a/artifacts/ +/projects///a2a/pipeline/ +/projects///a2a/cleanup-deferred-prompts.json +/projects///pipeline/meta.yaml +/projects///pipeline/context.yaml +/projects///pipeline/events.jsonl +/projects///pipeline/display.jsonl +/projects///pipeline/transcripts// +/projects///pipeline/transcripts//session.jsonl +/projects///pipeline/transcripts//usage.jsonl +/projects///pipeline/transcripts//permission-audit.jsonl +/projects///pipeline/transcripts//tool-results/ +``` diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/mcp/capabilities.md b/website/i18n/de/docusaurus-plugin-content-docs/current/mcp/capabilities.md index 192e7989..f1358ac7 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/mcp/capabilities.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/mcp/capabilities.md @@ -41,7 +41,13 @@ IaC Code konvertiert MCP-Content-Blöcke in modell-sichtbaren Text: | `resource_link` | Als Ressourcenlink mit URI und MIME-Type gerendert. | | Bild-, Audio- und Blob-Daten | Als private Artefaktdateien gespeichert und per Artefakt-ID referenziert. | -Binärartefakte werden hier gespeichert: +In v2-Sitzungen werden Binärartefakte im sitzungseigenen MCP-tool-results-Verzeichnis gespeichert: + +```text +/projects///tool-results/mcp/// +``` + +Legacy-Sitzungen ohne unterstützten Layout-Marker verwenden weiterhin: ```text /tool-results//mcp/// diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md b/website/i18n/de/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md index ed19d415..740a3330 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md @@ -145,7 +145,13 @@ Runtime-Logs liegen standardmäßig unter: oder unter `IAC_CODE_LOG_DIR`, wenn gesetzt. -Binärartefakte aus MCP-Tool-Ergebnissen werden gespeichert unter: +In v2-Sitzungen werden Binärartefakte aus MCP-Tool-Ergebnissen im sitzungseigenen Verzeichnis gespeichert: + +```text +/projects///tool-results/mcp/ +``` + +Legacy-Sitzungen ohne unterstützten Layout-Marker verwenden: ```text /tool-results//mcp/ diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/overview.md b/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/overview.md index 4fac1cc8..fd1ca4b8 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/overview.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/overview.md @@ -72,3 +72,8 @@ iac-code soporta el modo servidor A2A sobre HTTP JSON-RPC/REST y varios transpor - La entrega push es al menos una vez para colas respaldadas por Redis; los receptores de callback deben manejar duplicados y aplicar su propia política de autorización del lado del endpoint. Las solicitudes de permisos de herramientas se rechazan automáticamente en modo servidor A2A, salvo que `auto-approve-permissions` o una regla de permisos explícita las permita. Las decisiones de permisos se auditan localmente; toda decisión allow que requiere un registro de auditoría falla en modo cerrado si ese registro no se puede persistir. Las API protegidas de escritura de Alibaba Cloud siguen requiriendo autorización exacta por API fuera de los modos de bypass global. Ejecuta el modo A2A sin autenticación solo en entornos locales de confianza o protégelo con autenticación mediante token Bearer, Basic auth o clave de API. + + +## Persistencia y backup de sesión + +Los índices A2A task/context permanecen en el área runtime compartida, por ejemplo `/a2a/tasks` y `/a2a/contexts`, para que puedan montarse o sincronizarse por separado de los datos por sesión. Cuando `IAC_CODE_CONFIG_BACKUP_DIR` está configurado, los snapshots y artefactos A2A dentro de sesiones v2 se reflejan con el backup de sesión; los índices task/context compartidos deben venir de su propio almacenamiento montado. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md b/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md index e4eacc2b..24547bce 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md @@ -332,7 +332,7 @@ Los IDs de tarea y contexto no deben estar vacíos, pueden tener como máximo 12 | `TASK_STATE_CANCELED` | Se solicitó y aplicó la cancelación | | `TASK_STATE_FAILED` | La tarea falló en validación o ejecución | -iac-code usa `TASK_STATE_INPUT_REQUIRED` como estado completado normal porque el contexto queda disponible para mensajes de seguimiento. +iac-code usa `TASK_STATE_INPUT_REQUIRED` como estado completado normal porque el contexto queda disponible para mensajes de seguimiento. Los turnos A2A ordinarios tratan `TASK_STATE_INPUT_REQUIRED` como ese estado normal de finalización; su backup correcto de fin de turno es el checkpoint no bloqueante `normal_turn_end`. Si una publicación terminal o de cancelación A2A ordinaria queda bloqueada por una barrera crítica de respaldo, los metadatos de tarea exponen `metadata.iac_code.backupBlocked` con `reason`, `error` y `recoverable`; los clientes deben esperar la recuperación antes de enviar el siguiente turno. El modo Pipeline publica los fallos de la barrera de respaldo como eventos de pipeline, por lo que los clientes deben inspeccionar `metadata.iac_code.pipeline.eventType == "backup_blocked"` y los datos del evento. Cuando `IAC_CODE_CONFIG_BACKUP_DIR` está habilitado en modo Pipeline, las publicaciones con razón `pipeline_step_completed`, `input_required`, `waiting_input`, `terminal` y `handoff_ready` quedan detrás de la barrera crítica de respaldo. La razón `terminal` protege las publicaciones terminales, y la razón `handoff_ready` protege el tipo de evento `pipeline_handoff_ready`. Para publicaciones committed terminales y `pipeline_handoff_ready`, iac-code persiste un evento de pipeline `backup_committed` con `committedEventId`, `committedEventType` y `committedSequence`; los clientes pueden emparejarlo con el evento protegido antes de tratar la publicación como recuperable tras reiniciar. ## Actualizaciones en streaming diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/acp/protocol-reference.md b/website/i18n/es/docusaurus-plugin-content-docs/current/acp/protocol-reference.md index ad59c380..15fd89c7 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/acp/protocol-reference.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/acp/protocol-reference.md @@ -27,6 +27,12 @@ Las sesiones tambien pueden cargarse desde el historial (`session/load`) o reanu --- +## Backups de sesión + +Las sesiones ACP usan el mismo comportamiento de backup de sesión v2 que las ejecuciones interactivas y headless. Cuando `IAC_CODE_CONFIG_BACKUP_DIR` está definido, una finalización normal de prompt registra un backup no bloqueante `normal_turn_end`; si ese backup falla, el fallo se registra como `warning` y queda guardado en `.backup-state.json`, mientras que la respuesta final aún se completa sin prometer un campo warning. El modo Pipeline tiene sus propios gates de backup crítico. + +--- + ## Metodos ### initialize diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md b/website/i18n/es/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md index 66bd3840..b5f9ab76 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md @@ -39,6 +39,10 @@ Los formatos de salida soportados son: | `json` | Un único resultado JSON para llamadores que analizan la respuesta final. | | `stream-json` | Eventos JSON en streaming para llamadores que procesan progreso incremental. | +## Backups de sesión + +Cuando `IAC_CODE_CONFIG_BACKUP_DIR` está definido, las ejecuciones no interactivas reflejan la sesión v2 en checkpoints clave. El checkpoint ordinario de fin de turno usa `normal_turn_end`; un fallo de backup en ese punto se registra como `warning` y queda guardado en `.backup-state.json`, sin hacer fallar la respuesta completada ni agregar un campo warning a la salida final. El modo Pipeline tiene sus propios gates de backup crítico. + ## Control de permisos en automatización Cuando ejecute en modo no interactivo, use `--permission-mode` para controlar cómo el agente maneja las aprobaciones de herramientas: diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/automation/pipeline-mode.md b/website/i18n/es/docusaurus-plugin-content-docs/current/automation/pipeline-mode.md index b83d3430..3d78a5aa 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/automation/pipeline-mode.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/automation/pipeline-mode.md @@ -99,3 +99,8 @@ ACP no admite actualmente el modo pipeline. `--prompt` / el [modo no interactivo - El modo pipeline requiere el REPL interactivo. `--prompt` se rechaza cuando `IAC_CODE_MODE=pipeline`. - El modo pipeline admite entrada de texto. Las imágenes pegadas en el REPL se ignoran mientras el pipeline está activo. - Durante el pipeline, los shell escapes, disparadores de skills y la mayoría de los slash commands están restringidos salvo que la definición del pipeline los permita explícitamente. Comandos básicos como `/help`, `/status`, `/resume` y `/exit` siguen disponibles. + + +## Puntos de control de backup + +El modo Pipeline ejecuta backups críticos después de steps agent loop completados y antes de publicar estados de espera, `pipeline_handoff_ready` o terminales visibles externamente. Si un backup crítico no termina, el pipeline emite `backup_blocked` y se pausa en un estado recuperable, en lugar de publicar primero `input_required`, `waiting_input`, `pipeline_handoff_ready` o la terminación. Para observadores A2A, las publicaciones protegidas terminales y `pipeline_handoff_ready` van seguidas de un evento `backup_committed` con `committedEventId`, `committedEventType` y `committedSequence` cuando el mirror ya es durable. El progreso de steps hijos `parallel_sub_pipeline` no se respalda por separado mientras otros sub-pipelines hermanos pueden seguir ejecutándose; el checkpoint padre captura el estado agregado estable. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/environment-variables.md b/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/environment-variables.md index c3777180..76b64498 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/environment-variables.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/environment-variables.md @@ -52,7 +52,7 @@ Consulta [Credenciales de Alibaba Cloud](./alibaba-cloud-credentials.md) para ma | Variable | Descripcion | |---|---| | `IAC_CODE_CONFIG_DIR` | Sobreescribe el directorio de configuracion en tiempo de ejecucion (predeterminado `~/.iac-code/`); admite expansion de `~` y `$VAR`. Todos los artefactos persistidos (credenciales, ajustes, historial, projects, image-cache, skills, telemetry, etc.) siguen este directorio | -| `IAC_CODE_LOG_DIR` | Sobrescribe el directorio local de logs de arranque/depuración (predeterminado `/logs/`); admite expansión de `~` y `$VAR`. Los registros de auditoría de permisos permanecen en `/logs/permission-audit.jsonl` | +| `IAC_CODE_LOG_DIR` | Sobrescribe el directorio local de logs de arranque/depuración (predeterminado `/logs/`); admite expansión de `~` y `$VAR`. Los registros de auditoría de permisos siguen el layout de sesión y esta variable no los mueve | | `IAC_CODE_PERMISSION_AUDIT_INCLUDE_TOOL_INPUT` | Sobrescribe `permissions.audit.include_tool_input`; establécelo en `1` / `true` / `yes` / `on` para incluir entrada de herramienta solo con forma en los registros de auditoría de permisos, usando tipo/longitud/huella en vez de cadenas de payload de negocio sin procesar y aplicando huellas a nombres de campo fuera de la lista permitida | | `IAC_CODE_ENV` | Etiqueta del entorno de despliegue (predeterminado: `production`) | | `IAC_CODE_TENANT_ID` | Identificador de tenant para telemetria; se le agrega automaticamente el prefijo `iac_tenant_` si no lo tiene | @@ -60,3 +60,10 @@ Consulta [Credenciales de Alibaba Cloud](./alibaba-cloud-credentials.md) para ma | `IAC_CODE_A2A_PUSH_KEYRING` | Keyring de secretos push A2A cifrados gestionado por el entorno (formato JSON) | | `OTEL_EXPORTER_OTLP_ENDPOINT` | Endpoint estandar de OpenTelemetry; cuando se establece, habilita la exportacion OTLP | | `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` | Capturar contenido de mensajes/herramientas de GenAI en spans: `SPAN_ONLY`, `EVENT_ONLY`, `SPAN_AND_EVENT` | + + +## Copia de seguridad de sesiones + +| Variable | Descripción | +|---|---| +| `IAC_CODE_CONFIG_BACKUP_DIR` | Directorio opcional para copias de seguridad de sesión; admite expansión de `~` y `$VAR`, y expansión `%VAR%` en Windows. En PowerShell, pase una ruta concreta o deje que el shell expanda `$env:VAR` antes de iniciar `iac-code`. En despliegues sandbox suele ser una ruta OSS montada, pero debe ser independiente de `IAC_CODE_CONFIG_DIR` y de cualquier origen de sesión, sin solaparse, y con latencia suficientemente baja para checkpoints críticos. Las rutas UNC, unidades mapeadas y rutas OSS montadas deben conservar el bloqueo de archivo `.backup-lock`, reemplazo atómico y metadatos de archivo para el mirroring incremental; evite ancestros symlink, junction o reparse point en el origen de sesión activo, la raíz de backup y las sesiones reflejadas. Cuando está habilitado, los puntos de control reflejan cada sesión v2 en `/projects///` con la misma estructura que la sesión activa; `.backup-state.json` y `.backup-lock` quedan locales y no se copian. Los backups de fin de turno normal usan `normal_turn_end` y no bloquean la respuesta; solo los fallos de checkpoints `critical=true` bloquean la publicación. Los índices A2A task/context compartidos pueden montarse por separado. | diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md b/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md index 7b71adbd..71887c2e 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md @@ -19,7 +19,7 @@ El directorio de tiempo de ejecución por defecto es: ~/.iac-code/ ``` -Puede reubicarlo estableciendo la variable de entorno `IAC_CODE_CONFIG_DIR` (admite expansión de `~` y `$VAR`). Cuando se establece, todos los artefactos persistidos — credenciales, ajustes, historial, `projects/`, `image-cache/`, `tool-results/`, `logs/`, `memory/`, `a2a/`, `telemetry/`, `skills/` — siguen la nueva ubicación. Los logs de arranque/depuración van por defecto a `/logs/` y se pueden mover por separado con `IAC_CODE_LOG_DIR`; los registros de auditoría de permisos permanecen en `/logs/`. +Puede reubicarlo estableciendo la variable de entorno `IAC_CODE_CONFIG_DIR` (admite expansión de `~` y `$VAR`). Cuando se establece, todos los artefactos persistidos — credenciales, ajustes, historial, `projects/`, `image-cache/`, `tool-results/`, `logs/`, `memory/`, `a2a/`, `telemetry/`, `skills/` — siguen la nueva ubicación. Los logs de arranque/depuración van por defecto a `/logs/` y se pueden mover por separado con `IAC_CODE_LOG_DIR`; los registros de auditoría de permisos permanecen con su sesión propietaria. Archivos comunes: @@ -156,13 +156,13 @@ Las solicitudes de estilo ROA se tratan como de solo lectura únicamente cuando ### Registro de auditoría de permisos -Las decisiones de permisos que cruzan prompts de usuario, límites de caché de herramientas, aprobación de automatización o aprobación de un resolver se anexan a: +Las decisiones de permisos que cruzan prompts de usuario, límites de caché de herramientas, aprobación de automatización o aprobación de un resolver se anexan al registro de auditoría de la sesión activa: ```text -/logs/permission-audit.jsonl +/projects///permission-audit.jsonl ``` -De forma predeterminada, esta ruta es `~/.iac-code/logs/permission-audit.jsonl`. El registro de auditoría de permisos sigue `IAC_CODE_CONFIG_DIR`; `IAC_CODE_LOG_DIR` solo mueve los logs de arranque/depuración. El escritor de auditoría anexa registros JSONL con bloqueo de archivo, rota el archivo y restringe los permisos del archivo local cuando el sistema operativo lo permite. Las aprobaciones automáticas rutinarias de solo lectura pueden omitirse, pero se registran denegaciones, prompts, decisiones en caché, aprobaciones de automatización, aprobaciones de resolver y otros límites de permisos auditados. +Los transcripts de pasos de Pipeline escriben sus propios registros de auditoría en `/projects///pipeline/transcripts//permission-audit.jsonl`. Los registros de auditoría de permisos siguen el layout de sesión bajo `IAC_CODE_CONFIG_DIR`; `IAC_CODE_LOG_DIR` solo mueve los logs de arranque/depuración y no mueve los registros de auditoría de permisos. El escritor de auditoría anexa registros JSONL con bloqueo de archivo, rota el archivo y restringe los permisos del archivo local cuando el sistema operativo lo permite. Las aprobaciones automáticas rutinarias de solo lectura pueden omitirse, pero se registran denegaciones, prompts, decisiones en caché, aprobaciones de automatización, aprobaciones de resolver y otros límites de permisos auditados. La configuración de auditoría se define en `permissions.audit`: @@ -173,3 +173,34 @@ La configuración de auditoría se define en `permissions.audit`: | `max_files` | `5` | Número de archivos de auditoría rotados que se conservan. Los valores por encima del máximo integrado se limitan. | Si una decisión allow que requiere un registro de auditoría no se puede persistir en el registro de auditoría, IaC Code falla en modo cerrado y deniega la acción en lugar de ejecutarla sin rastro de auditoría. + + +## Layout de sesión y estado de backup + +Las sesiones nuevas usan el marcador de metadatos `layout_version` para mantener los artefactos de ejecución dentro de su sesión propietaria. Esto incluye `image-cache/`, `tool-results/`, snapshots A2A, datos runtime de transcripts de pipeline y `.backup-state.json`, que registra el último motivo y estado de backup; `.backup-state.json` y `.backup-lock` quedan locales en la sesión activa y se excluyen de los espejos de backup. Las sesiones antiguas sin marcador `layout_version` siguen siendo legibles, pero no reciben nuevos directorios de artefactos por sesión; las sesiones con versiones de layout no compatibles se rechazan. + +Las rutas clave de una sesión v2 son: + +```text +/projects/// +/projects///metadata.json +/projects///session.jsonl +/projects///usage.jsonl +/projects///permission-audit.jsonl +/projects///image-cache/ +/projects///tool-results/ +/projects///a2a/task.json +/projects///a2a/context.json +/projects///a2a/artifacts/ +/projects///a2a/pipeline/ +/projects///a2a/cleanup-deferred-prompts.json +/projects///pipeline/meta.yaml +/projects///pipeline/context.yaml +/projects///pipeline/events.jsonl +/projects///pipeline/display.jsonl +/projects///pipeline/transcripts// +/projects///pipeline/transcripts//session.jsonl +/projects///pipeline/transcripts//usage.jsonl +/projects///pipeline/transcripts//permission-audit.jsonl +/projects///pipeline/transcripts//tool-results/ +``` diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/mcp/capabilities.md b/website/i18n/es/docusaurus-plugin-content-docs/current/mcp/capabilities.md index 64b8fb85..3d24d3a9 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/mcp/capabilities.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/mcp/capabilities.md @@ -41,7 +41,13 @@ IaC Code convierte bloques de contenido MCP en texto visible para el modelo: | `resource_link` | Renderizado como enlace de recurso con URI y tipo MIME. | | Datos de imagen, audio y blob | Guardados como archivos de artefacto privados y referenciados por id de artefacto. | -Los artefactos binarios se guardan bajo: +En sesiones v2, los artefactos binarios se guardan en el directorio MCP tool-results propio de la sesión: + +```text +/projects///tool-results/mcp/// +``` + +Las sesiones legacy sin marcador de layout compatible siguen usando: ```text /tool-results//mcp/// diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md b/website/i18n/es/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md index 0453b03a..e8d44690 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md @@ -145,7 +145,13 @@ Los logs runtime van por defecto a: o a `IAC_CODE_LOG_DIR` cuando está definido. -Los artefactos binarios de resultados de herramientas MCP se guardan bajo: +En sesiones v2, los artefactos binarios de resultados de herramientas MCP se guardan en el directorio propio de la sesión: + +```text +/projects///tool-results/mcp/ +``` + +Las sesiones legacy sin marcador de layout compatible usan: ```text /tool-results//mcp/ diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/overview.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/overview.md index f228e731..bbc03873 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/overview.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/overview.md @@ -72,3 +72,8 @@ iac-code prend en charge le mode serveur A2A via HTTP JSON-RPC/REST et plusieurs - La livraison push est au moins une fois pour les files adossées à Redis ; les récepteurs de callback doivent gérer les doublons et appliquer leur propre politique d'autorisation côté endpoint. Les demandes d'autorisation d'outil sont rejetées automatiquement en mode serveur A2A, sauf si `auto-approve-permissions` ou une règle de permission explicite les autorise. Les décisions de permissions sont auditées localement ; toute décision allow nécessitant un enregistrement d'audit échoue en mode fail-closed si cet enregistrement ne peut pas être persisté. Les API d'écriture Alibaba Cloud protégées nécessitent toujours une autorisation exacte par API hors des modes de bypass global. N'exécutez le mode A2A non authentifié que dans des environnements locaux de confiance, ou protégez-le avec une authentification par jeton Bearer, Basic auth ou clé API. + + +## Persistance et sauvegarde de session + +Les index A2A task/context restent dans la zone runtime partagée, par exemple `/a2a/tasks` et `/a2a/contexts`, afin de pouvoir être montés ou synchronisés séparément des données par session. Lorsque `IAC_CODE_CONFIG_BACKUP_DIR` est configuré, les instantanés et artefacts A2A stockés dans les sessions v2 sont répliqués avec la sauvegarde de session; les index task/context partagés doivent être fournis par leur propre stockage monté. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md index 5afe9cd4..7c321cc3 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md @@ -332,7 +332,7 @@ Les ID de tâche et de contexte doivent être non vides, comporter au plus 128 c | `TASK_STATE_CANCELED` | L'annulation a été demandée et appliquée | | `TASK_STATE_FAILED` | La tâche a échoué lors de la validation ou de l'exécution | -iac-code utilise `TASK_STATE_INPUT_REQUIRED` comme état terminé normal, car le contexte reste disponible pour les messages de suivi. +iac-code utilise `TASK_STATE_INPUT_REQUIRED` comme état terminé normal, car le contexte reste disponible pour les messages de suivi. Les tours A2A ordinaires traitent `TASK_STATE_INPUT_REQUIRED` comme cet état de fin normal; leur sauvegarde réussie de fin de tour est le checkpoint non bloquant `normal_turn_end`. Si une publication terminale ou d'annulation A2A ordinaire est bloquée par une barrière de sauvegarde critique, les métadonnées de tâche exposent `metadata.iac_code.backupBlocked` avec `reason`, `error` et `recoverable`; les clients doivent attendre la récupération avant d'envoyer le tour suivant. Le mode Pipeline publie les échecs de la barrière de sauvegarde comme événements pipeline; les clients doivent donc inspecter `metadata.iac_code.pipeline.eventType == "backup_blocked"` et les données de l'événement. Lorsque `IAC_CODE_CONFIG_BACKUP_DIR` est activé en mode Pipeline, les publications avec la raison `pipeline_step_completed`, `input_required`, `waiting_input`, `terminal` et `handoff_ready` restent derrière la barrière de sauvegarde critique. La raison `terminal` protège les publications terminales, et la raison `handoff_ready` protège le type d'événement `pipeline_handoff_ready`. Pour les publications committed terminales et `pipeline_handoff_ready`, iac-code persiste un événement pipeline `backup_committed` dont les données contiennent `committedEventId`, `committedEventType` et `committedSequence`; les clients peuvent l'apparier avec l'événement protégé avant de considérer la publication comme récupérable après redémarrage. ## Mises à jour en streaming diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/acp/protocol-reference.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/acp/protocol-reference.md index c23c07e0..dfc09696 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/acp/protocol-reference.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/acp/protocol-reference.md @@ -27,6 +27,12 @@ Les sessions peuvent également être chargées depuis l'historique (`session/lo --- +## Sauvegardes de session + +Les sessions ACP utilisent le même comportement de sauvegarde de session v2 que les exécutions interactives et headless. Lorsque `IAC_CODE_CONFIG_BACKUP_DIR` est défini, la fin normale d'un prompt enregistre une sauvegarde non bloquante `normal_turn_end`; si cette sauvegarde échoue, l'échec est journalisé comme `warning` et enregistré dans `.backup-state.json`, tandis que la réponse finale se termine encore sans champ warning garanti. Le mode Pipeline possède ses propres gates de sauvegarde critique. + +--- + ## Méthodes ### initialize diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md index 876f10ef..421c1ffc 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md @@ -39,6 +39,10 @@ Les formats de sortie pris en charge sont : | `json` | Un seul résultat JSON pour les appelants qui analysent la réponse finale. | | `stream-json` | Événements JSON en streaming pour les appelants qui traitent la progression incrémentale. | +## Sauvegardes de session + +Lorsque `IAC_CODE_CONFIG_BACKUP_DIR` est défini, les exécutions non interactives répliquent la session v2 aux checkpoints clés. Le checkpoint ordinaire de fin de tour utilise `normal_turn_end`; un échec de sauvegarde à ce moment est journalisé comme `warning` et enregistré dans `.backup-state.json`, sans faire échouer la réponse terminée ni ajouter de champ warning à la sortie finale. Le mode Pipeline possède ses propres gates de sauvegarde critique. + ## Contrôle des permissions en automatisation Lors de l'exécution en mode non interactif, utilisez `--permission-mode` pour contrôler comment l'agent gère les approbations d'outils : diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/automation/pipeline-mode.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/automation/pipeline-mode.md index 5f3bd1b7..2a34acfa 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/automation/pipeline-mode.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/automation/pipeline-mode.md @@ -99,3 +99,8 @@ ACP ne prend pas actuellement en charge le mode pipeline. `--prompt` / le [mode - Le mode pipeline nécessite le REPL interactif. `--prompt` est refusé lorsque `IAC_CODE_MODE=pipeline`. - Le mode pipeline accepte les entrées texte. Les images collées dans le REPL sont ignorées lorsque le pipeline est actif. - Pendant un pipeline, les shell escapes, les déclencheurs de skills et la plupart des slash commands sont limités, sauf autorisation explicite dans la définition du pipeline. Les commandes de base comme `/help`, `/status`, `/resume` et `/exit` restent disponibles. + + +## Points de contrôle de sauvegarde + +Le mode Pipeline exécute des sauvegardes critiques après les steps agent loop terminés et avant de publier des états d'attente, `pipeline_handoff_ready` ou terminaux visibles. Si une sauvegarde critique échoue, le pipeline émet `backup_blocked` et se met en pause dans un état récupérable au lieu de publier d'abord `input_required`, `waiting_input`, `pipeline_handoff_ready` ou la terminaison. Pour les observateurs A2A, les publications protégées terminales et `pipeline_handoff_ready` sont suivies d'un événement `backup_committed` avec `committedEventId`, `committedEventType` et `committedSequence` une fois le miroir durable. La progression des steps enfants `parallel_sub_pipeline` n'est pas sauvegardée séparément pendant que des sous-pipelines frères peuvent encore s'exécuter; le point de contrôle parent capture l'état agrégé stable. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/environment-variables.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/environment-variables.md index 7ca5b5ba..fb7a0db5 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/environment-variables.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/environment-variables.md @@ -52,7 +52,7 @@ Consultez [Identifiants Alibaba Cloud](./alibaba-cloud-credentials.md) pour plus | Variable | Description | |---|---| | `IAC_CODE_CONFIG_DIR` | Remplace le répertoire de configuration à l'exécution (par défaut `~/.iac-code/`) ; prend en charge l'expansion de `~` et `$VAR`. Tous les artefacts persistés (identifiants, paramètres, historique, projects, image-cache, skills, telemetry, etc.) suivent ce répertoire | -| `IAC_CODE_LOG_DIR` | Remplace le répertoire local des journaux de démarrage/débogage (par défaut `/logs/`) ; prend en charge l'expansion de `~` et `$VAR`. Les enregistrements d'audit des permissions restent dans `/logs/permission-audit.jsonl` | +| `IAC_CODE_LOG_DIR` | Remplace le répertoire local des journaux de démarrage/débogage (par défaut `/logs/`) ; prend en charge l'expansion de `~` et `$VAR`. Les enregistrements d'audit des permissions suivent le layout de session et ne sont pas déplacés par cette variable | | `IAC_CODE_PERMISSION_AUDIT_INCLUDE_TOOL_INPUT` | Remplace `permissions.audit.include_tool_input` ; définissez-le sur `1` / `true` / `yes` / `on` pour inclure une entrée d'outil sous forme uniquement dans les enregistrements d'audit des permissions, avec type/longueur/empreinte au lieu des chaînes de payload métier brutes et avec empreinte pour les noms de champs hors liste blanche | | `IAC_CODE_ENV` | Label d'environnement de déploiement (par défaut : `production`) | | `IAC_CODE_TENANT_ID` | Identifiant de locataire pour la télémétrie ; préfixé automatiquement avec `iac_tenant_` si ce n'est pas déjà le cas | @@ -60,3 +60,10 @@ Consultez [Identifiants Alibaba Cloud](./alibaba-cloud-credentials.md) pour plus | `IAC_CODE_A2A_PUSH_KEYRING` | Trousseau de clés secret push A2A chiffré géré par l'environnement (format JSON) | | `OTEL_EXPORTER_OTLP_ENDPOINT` | Point de terminaison OpenTelemetry standard ; lorsqu'il est défini, active l'export OTLP | | `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` | Capturer le contenu des messages/outils GenAI sur les spans : `SPAN_ONLY`, `EVENT_ONLY`, `SPAN_AND_EVENT` | + + +## Sauvegarde de session + +| Variable | Description | +|---|---| +| `IAC_CODE_CONFIG_BACKUP_DIR` | Répertoire optionnel de sauvegarde des sessions; prend en charge l’expansion `~` et `$VAR`, ainsi que l’expansion `%VAR%` sous Windows. Dans PowerShell, fournissez un chemin concret ou laissez le shell développer `$env:VAR` avant de démarrer `iac-code`. Dans les déploiements sandbox, il s’agit souvent d’un chemin OSS monté, mais il doit être indépendant de `IAC_CODE_CONFIG_DIR` et de toute source de session, sans chevauchement, avec une latence assez faible pour les checkpoints critiques. Les chemins UNC, lecteurs mappés et chemins OSS montés doivent conserver le verrouillage de fichier `.backup-lock`, le remplacement atomique et les métadonnées de fichier pour la réplication incrémentale; évitez les ancêtres symlink, junction ou reparse point pour la source de session active, la racine de sauvegarde et les sessions miroir. Lorsqu’il est activé, les points de contrôle répliquent chaque session v2 vers `/projects///` avec la même arborescence que la session active; `.backup-state.json` et `.backup-lock` restent locaux et ne sont pas copiés. Les sauvegardes de fin de tour normal utilisent `normal_turn_end` et ne bloquent pas la réponse; seuls les échecs de checkpoints `critical=true` bloquent la publication. Les index A2A task/context partagés peuvent être montés séparément. | diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md index f71f8b4d..21f534a8 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md @@ -19,7 +19,7 @@ Le répertoire d'exécution par défaut est : ~/.iac-code/ ``` -Vous pouvez le déplacer en définissant la variable d'environnement `IAC_CODE_CONFIG_DIR` (prend en charge l'expansion de `~` et `$VAR`). Une fois définie, tous les artefacts persistés — identifiants, paramètres, historique, `projects/`, `image-cache/`, `tool-results/`, `logs/`, `memory/`, `a2a/`, `telemetry/`, `skills/` — suivent le nouvel emplacement. Les journaux de démarrage/débogage vont par défaut dans `/logs/` et peuvent être déplacés séparément avec `IAC_CODE_LOG_DIR` ; les enregistrements d'audit des permissions restent dans `/logs/`. +Vous pouvez le déplacer en définissant la variable d'environnement `IAC_CODE_CONFIG_DIR` (prend en charge l'expansion de `~` et `$VAR`). Une fois définie, tous les artefacts persistés — identifiants, paramètres, historique, `projects/`, `image-cache/`, `tool-results/`, `logs/`, `memory/`, `a2a/`, `telemetry/`, `skills/` — suivent le nouvel emplacement. Les journaux de démarrage/débogage vont par défaut dans `/logs/` et peuvent être déplacés séparément avec `IAC_CODE_LOG_DIR` ; les enregistrements d'audit des permissions restent avec leur session propriétaire. Fichiers courants : @@ -156,13 +156,13 @@ Les requêtes de style ROA sont traitées comme lecture seule uniquement lorsque ### Journal d'audit des permissions -Les décisions de permissions qui traversent des prompts utilisateur, des frontières de cache d'outils, une approbation d'automatisation ou une approbation de resolver sont ajoutées à : +Les décisions de permissions qui traversent des prompts utilisateur, des frontières de cache d'outils, une approbation d'automatisation ou une approbation de resolver sont ajoutées au journal d'audit de la session active : ```text -/logs/permission-audit.jsonl +/projects///permission-audit.jsonl ``` -Par défaut, ce chemin est `~/.iac-code/logs/permission-audit.jsonl`. Le journal d'audit des permissions suit `IAC_CODE_CONFIG_DIR` ; `IAC_CODE_LOG_DIR` ne déplace que les journaux de démarrage/débogage. Le writer d'audit ajoute des enregistrements JSONL avec verrouillage de fichier, effectue la rotation du fichier et restreint les permissions locales lorsque le système d'exploitation le permet. Les autorisations automatiques routinières en lecture seule peuvent être omises, mais les refus, prompts, décisions mises en cache, approbations d'automatisation, approbations de resolver et autres frontières de permissions auditées sont enregistrés. +Les transcripts de steps Pipeline écrivent leurs propres enregistrements d'audit sous `/projects///pipeline/transcripts//permission-audit.jsonl`. Les journaux d'audit des permissions suivent le layout de session sous `IAC_CODE_CONFIG_DIR` ; `IAC_CODE_LOG_DIR` ne déplace que les journaux de démarrage/débogage et ne déplace pas les enregistrements d'audit des permissions. Le writer d'audit ajoute des enregistrements JSONL avec verrouillage de fichier, effectue la rotation du fichier et restreint les permissions locales lorsque le système d'exploitation le permet. Les autorisations automatiques routinières en lecture seule peuvent être omises, mais les refus, prompts, décisions mises en cache, approbations d'automatisation, approbations de resolver et autres frontières de permissions auditées sont enregistrés. Les paramètres d'audit se configurent sous `permissions.audit` : @@ -173,3 +173,34 @@ Les paramètres d'audit se configurent sous `permissions.audit` : | `max_files` | `5` | Nombre de fichiers d'audit tournés à conserver. Les valeurs au-dessus du maximum intégré sont plafonnées. | Si une décision allow nécessitant un enregistrement d'audit ne peut pas être persistée dans le journal d'audit, IaC Code échoue en mode fail-closed et refuse l'action au lieu de l'exécuter sans trace d'audit. + + +## Layout de session et état de sauvegarde + +Les nouvelles sessions utilisent le marqueur de métadonnées `layout_version` afin de garder les artefacts d'exécution dans leur session propriétaire. Les fichiers concernés incluent `image-cache/`, `tool-results/`, les instantanés A2A, les données runtime des transcripts pipeline et `.backup-state.json`, qui enregistre la dernière raison et le dernier état de sauvegarde; `.backup-state.json` et `.backup-lock` restent locaux à la session active et sont exclus des miroirs de sauvegarde. Les anciennes sessions sans marqueur `layout_version` restent lisibles, mais ne reçoivent pas de nouveaux répertoires d'artefacts propres à la session ; les sessions avec des versions de layout non prises en charge sont refusées. + +Les chemins clés d'une session v2 sont : + +```text +/projects/// +/projects///metadata.json +/projects///session.jsonl +/projects///usage.jsonl +/projects///permission-audit.jsonl +/projects///image-cache/ +/projects///tool-results/ +/projects///a2a/task.json +/projects///a2a/context.json +/projects///a2a/artifacts/ +/projects///a2a/pipeline/ +/projects///a2a/cleanup-deferred-prompts.json +/projects///pipeline/meta.yaml +/projects///pipeline/context.yaml +/projects///pipeline/events.jsonl +/projects///pipeline/display.jsonl +/projects///pipeline/transcripts// +/projects///pipeline/transcripts//session.jsonl +/projects///pipeline/transcripts//usage.jsonl +/projects///pipeline/transcripts//permission-audit.jsonl +/projects///pipeline/transcripts//tool-results/ +``` diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/mcp/capabilities.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/mcp/capabilities.md index e9a9ffa5..9ed2d8ac 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/mcp/capabilities.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/mcp/capabilities.md @@ -41,7 +41,13 @@ IaC Code convertit les blocs de contenu MCP en texte visible par le modèle : | `resource_link` | Rendu comme lien de ressource avec URI et type MIME. | | Images, audio et blobs | Stockés comme fichiers d'artefact privés et référencés par id d'artefact. | -Les artefacts binaires sont stockés sous : +Pour les sessions v2, les artefacts binaires sont stockés dans le répertoire MCP tool-results propre à la session : + +```text +/projects///tool-results/mcp/// +``` + +Les sessions legacy sans marqueur de layout pris en charge continuent d'utiliser : ```text /tool-results//mcp/// diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md index 5b8d95ec..9d9c88d4 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md @@ -145,7 +145,13 @@ Les logs runtime se trouvent par défaut dans : ou dans `IAC_CODE_LOG_DIR` si défini. -Les artefacts binaires produits par les résultats d'outils MCP sont stockés sous : +Pour les sessions v2, les artefacts binaires produits par les résultats d'outils MCP sont stockés dans le répertoire propre à la session : + +```text +/projects///tool-results/mcp/ +``` + +Les sessions legacy sans marqueur de layout pris en charge utilisent : ```text /tool-results//mcp/ diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/overview.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/overview.md index 964f7e03..e45804a5 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/overview.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/overview.md @@ -72,3 +72,8 @@ iac-code は HTTP JSON-RPC/REST と複数の任意トランスポート上の A2 - Redis ベースのキューではプッシュ配信は at-least-once です。コールバック受信側は重複を処理し、エンドポイント側の認可ポリシーを自分で適用する必要があります。 A2A サーバーモードでは、`auto-approve-permissions` または明示的な権限ルールで許可されない限り、ツール権限リクエストは自動的に拒否されます。権限決定はローカルで監査され、監査レコードを必要とする allow 決定は、そのレコードを永続化できない場合に fail-closed になります。保護された Alibaba Cloud 書き込み API は、グローバル bypass モード以外では引き続き API ごとの正確な承認が必要です。認証なしの A2A モードは信頼できるローカル環境でのみ実行するか、Bearer token、Basic auth、または API key 認証で保護してください。 + + +## 永続化とセッションバックアップ + +A2A の task/context インデックスは `/a2a/tasks` と `/a2a/contexts` などの共有実行領域に残り、セッションデータとは別にマウントまたは同期できます。`IAC_CODE_CONFIG_BACKUP_DIR` を設定すると、v2 セッション内の A2A スナップショットと artifact はセッションバックアップでミラーされます。共有 task/context インデックスは別のマウントストレージで提供してください。 diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md index 23afeb94..7adf7298 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md @@ -332,7 +332,7 @@ iac-code は A2A コンテキストを内部エージェントランタイムに | `TASK_STATE_CANCELED` | キャンセルが要求され適用された | | `TASK_STATE_FAILED` | タスクが検証または実行に失敗した | -コンテキストがフォローアップメッセージで利用可能なまま残るため、iac-code は通常の完了状態として `TASK_STATE_INPUT_REQUIRED` を使用します。 +コンテキストがフォローアップメッセージで利用可能なまま残るため、iac-code は通常の完了状態として `TASK_STATE_INPUT_REQUIRED` を使用します。通常の A2A turn は `TASK_STATE_INPUT_REQUIRED` をこの通常完了状態として扱い、成功した turn-end backup は非ブロッキングの `normal_turn_end` checkpoint です。通常の A2A terminal または cancellation publication が重要なバックアップ制御でブロックされた場合、task metadata の `metadata.iac_code.backupBlocked` に `reason`、`error`、`recoverable` が公開されます。クライアントは復旧を待ってから次の turn を送ってください。Pipeline mode ではバックアップ制御の失敗を pipeline event として公開するため、`metadata.iac_code.pipeline.eventType == "backup_blocked"` と event data を確認してください。Pipeline mode で `IAC_CODE_CONFIG_BACKUP_DIR` が有効な場合、理由 `pipeline_step_completed`、`input_required`、`waiting_input`、`terminal`、`handoff_ready` の publication は重要なバックアップ制御の後ろで待機します。理由 `terminal` は terminal publication を保護し、理由 `handoff_ready` は `pipeline_handoff_ready` のイベント種別を保護します。terminal および `pipeline_handoff_ready` の committed publication では、iac-code は `committedEventId`、`committedEventType`、`committedSequence` を含む `backup_committed` pipeline event を永続化します。クライアントは再起動後に publication を復旧可能と扱う前に、保護された event と対応付けられます。 ## ストリーミング更新 diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/acp/protocol-reference.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/acp/protocol-reference.md index f30902bc..78db62d5 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/acp/protocol-reference.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/acp/protocol-reference.md @@ -27,6 +27,12 @@ initialize → new_session → prompt (loop) → close_session --- +## セッションバックアップ + +ACP セッションは、対話実行および headless 実行と同じ v2 セッションバックアップ動作を使います。`IAC_CODE_CONFIG_BACKUP_DIR` が設定されている場合、通常の prompt 完了では非ブロッキングの `normal_turn_end` バックアップを記録します。このバックアップが失敗すると、失敗は `warning` としてログに記録され、`.backup-state.json` に保存されますが、最終レスポンスに warning フィールドが必ず追加されるわけではありません。Pipeline mode には独自の重要なバックアップ制御があります。 + +--- + ## メソッド ### initialize diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md index 6bbbf0fb..dc15e76f 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md @@ -39,6 +39,10 @@ iac-code --prompt "Create a VPC" --max-turns 20 | `json` | 最終レスポンスを解析する呼び出し元向けの単一 JSON 結果。 | | `stream-json` | 増分進捗を処理する呼び出し元向けのストリーミング JSON イベント。 | +## セッションバックアップ + +`IAC_CODE_CONFIG_BACKUP_DIR` を設定すると、非対話実行は重要なチェックポイントで v2 セッションをミラーします。通常のターン終了チェックポイントは `normal_turn_end` を使用します。この時点のバックアップ失敗は `warning` としてログに記録され、`.backup-state.json` に保存されますが、完了レスポンスを失敗させず、最終出力に warning フィールドを追加しません。Pipeline mode には独自の重要なバックアップ制御があります。 + ## 自動化における権限制御 非対話実行時に `--permission-mode` を使用して、エージェントのツール承認の処理方法を制御します: diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/automation/pipeline-mode.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/automation/pipeline-mode.md index 482785bf..0def5a59 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/automation/pipeline-mode.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/automation/pipeline-mode.md @@ -99,3 +99,8 @@ ACP は現在パイプラインモードをサポートしていません。`--p - パイプラインモードには対話型 REPL が必要です。`IAC_CODE_MODE=pipeline` の場合、`--prompt` は拒否されます。 - パイプラインモードはテキスト入力に対応しています。パイプラインが有効な間、REPL に貼り付けられた画像は無視されます。 - パイプライン実行中、shell escape、スキルトリガー、大半の slash command は、パイプライン定義で明示的に許可されていない限り制限されます。`/help`、`/status`、`/resume`、`/exit` などの基本コマンドは引き続き利用できます。 + + +## バックアップチェックポイント + +Pipeline mode は agent loop step の完了後、および待機状態、`pipeline_handoff_ready`、終端状態を外部へ公開する前に重要バックアップを実行します。重要バックアップが完了できない場合、pipeline は `backup_blocked` を出して復旧可能な状態で停止し、`input_required`、`waiting_input`、`pipeline_handoff_ready`、終端完了を先に公開しません。A2A observer には、terminal と `pipeline_handoff_ready` の保護された publication で、mirror が durable になった時点で `committedEventId`、`committedEventType`、`committedSequence` を含む `backup_committed` event が続きます。`parallel_sub_pipeline` の子 step は兄弟 sub-pipeline が実行中の間は個別にバックアップせず、親チェックポイントで安定した集約状態を保存します。 diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/environment-variables.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/environment-variables.md index 28a08550..faea0a02 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/environment-variables.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/environment-variables.md @@ -52,7 +52,7 @@ CLI 引数 > 環境変数 > 設定ファイル | 変数 | 説明 | |---|---| | `IAC_CODE_CONFIG_DIR` | ランタイム設定ディレクトリを上書き(デフォルト `~/.iac-code/`)。`~` と `$VAR` の展開をサポート。永続化されるすべての成果物(認証情報、設定、履歴、projects、image-cache、skills、telemetry など)はこのディレクトリに従います | -| `IAC_CODE_LOG_DIR` | ローカルの起動/デバッグログディレクトリを上書き(デフォルト `/logs/`)。`~` と `$VAR` の展開をサポート。権限監査レコードは引き続き `/logs/permission-audit.jsonl` に保存されます | +| `IAC_CODE_LOG_DIR` | ローカルの起動/デバッグログディレクトリを上書き(デフォルト `/logs/`)。`~` と `$VAR` の展開をサポート。権限監査レコードはセッションレイアウトに従い、この変数では移動されません | | `IAC_CODE_PERMISSION_AUDIT_INCLUDE_TOOL_INPUT` | `permissions.audit.include_tool_input` を上書きします。`1` / `true` / `yes` / `on` に設定すると、権限監査レコードに形状のみのツール入力を含め、業務 payload の生文字列の代わりに型/長さ/フィンガープリントを記録し、ホワイトリスト外のフィールド名もフィンガープリント化します | | `IAC_CODE_ENV` | デプロイ環境ラベル(デフォルト:`production`) | | `IAC_CODE_TENANT_ID` | テレメトリ用テナント識別子。`iac_tenant_` プレフィックスが付いていない場合は自動的に付加されます | @@ -60,3 +60,10 @@ CLI 引数 > 環境変数 > 設定ファイル | `IAC_CODE_A2A_PUSH_KEYRING` | 環境管理された A2A 暗号化プッシュシークレットキーリング(JSON 形式) | | `OTEL_EXPORTER_OTLP_ENDPOINT` | 標準 OpenTelemetry エンドポイント。設定すると OTLP エクスポートが有効になります | | `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` | スパンで GenAI メッセージ/ツールコンテンツをキャプチャ:`SPAN_ONLY`、`EVENT_ONLY`、`SPAN_AND_EVENT` | + + +## セッションバックアップ + +| 変数 | 説明 | +|---|---| +| `IAC_CODE_CONFIG_BACKUP_DIR` | 任意のセッションバックアップ先で、`~` と `$VAR` 展開をサポートし、Windows では `%VAR%` 展開もサポートします。PowerShell では具体的なパスを渡すか、`iac-code` 起動前にシェル側で `$env:VAR` を展開してください。sandbox 環境では OSS のマウント先を指定することが一般的ですが、`IAC_CODE_CONFIG_DIR` やセッションソースとは独立し、重ならない場所にし、重要チェックポイントに十分な低レイテンシである必要があります。UNC パス、マップドライブ、OSS マウントパスは、インクリメンタルミラーリングに必要な `.backup-lock` ファイルロック、atomic replace、ファイルメタデータを保つ必要があります。アクティブなセッションソース、バックアップルート、ミラーされたセッションでは、シンボリックリンク、junction、reparse point の祖先パスを避けてください。有効にすると、チェックポイントで各 v2 セッションを元と同じ構成のまま `/projects///` にミラーします。`.backup-state.json` と `.backup-lock` はローカルに残りコピーされません。通常チャットのターン終了バックアップは `normal_turn_end` を使い、応答をブロックしません。`critical=true` チェックポイントの失敗だけが公開をブロックします。共有 A2A task/context インデックスは別にマウントできます。 | diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md index 3f29510f..51d2fd70 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md @@ -19,7 +19,7 @@ CLI 引数 > 環境変数 > 設定ファイル ~/.iac-code/ ``` -`IAC_CODE_CONFIG_DIR` 環境変数を設定すると、ディレクトリを変更できます(`~` と `$VAR` の展開をサポート)。設定すると、永続化されるすべての成果物 — 認証情報、設定、履歴、`projects/`、`image-cache/`、`tool-results/`、`logs/`、`memory/`、`a2a/`、`telemetry/`、`skills/` — が新しい場所に追従します。起動/デバッグログはデフォルトで `/logs/` に置かれ、`IAC_CODE_LOG_DIR` で別に移動できます。権限監査レコードは `/logs/` に残ります。 +`IAC_CODE_CONFIG_DIR` 環境変数を設定すると、ディレクトリを変更できます(`~` と `$VAR` の展開をサポート)。設定すると、永続化されるすべての成果物 — 認証情報、設定、履歴、`projects/`、`image-cache/`、`tool-results/`、`logs/`、`memory/`、`a2a/`、`telemetry/`、`skills/` — が新しい場所に追従します。起動/デバッグログはデフォルトで `/logs/` に置かれ、`IAC_CODE_LOG_DIR` で別に移動できます。権限監査レコードは所有するセッションに残ります。 主要ファイル: @@ -156,13 +156,13 @@ ROA 形式のリクエストは、メソッドが `GET` で body がない場合 ### 権限監査ログ -ユーザープロンプト、ツールキャッシュ境界、自動化承認、または resolver 承認をまたぐ権限決定は、次のファイルに追記されます: +ユーザープロンプト、ツールキャッシュ境界、自動化承認、または resolver 承認をまたぐ権限決定は、アクティブセッションの監査ログに追記されます: ```text -/logs/permission-audit.jsonl +/projects///permission-audit.jsonl ``` -デフォルトでは `~/.iac-code/logs/permission-audit.jsonl` です。権限監査ログは `IAC_CODE_CONFIG_DIR` に従います。`IAC_CODE_LOG_DIR` で移動されるのは起動/デバッグログだけです。監査 writer はファイルロック付きで JSONL レコードを追記し、ファイルをローテーションし、OS が対応している場合はローカルファイル権限を制限します。通常の読み取り専用自動許可は省略されることがありますが、拒否、プロンプト、キャッシュ済み決定、自動化承認、resolver 承認、その他の監査対象の権限境界は記録されます。 +Pipeline step transcript は `/projects///pipeline/transcripts//permission-audit.jsonl` に独自の監査レコードを書き込みます。権限監査ログは `IAC_CODE_CONFIG_DIR` 配下のセッション layout に従います。`IAC_CODE_LOG_DIR` で移動されるのは起動/デバッグログだけで、権限監査レコードは移動されません。監査 writer はファイルロック付きで JSONL レコードを追記し、ファイルをローテーションし、OS が対応している場合はローカルファイル権限を制限します。通常の読み取り専用自動許可は省略されることがありますが、拒否、プロンプト、キャッシュ済み決定、自動化承認、resolver 承認、その他の監査対象の権限境界は記録されます。 監査設定は `permissions.audit` の下で構成します: @@ -173,3 +173,34 @@ ROA 形式のリクエストは、メソッドが `GET` で body がない場合 | `max_files` | `5` | 保持するローテーション済み監査ファイル数。組み込み上限を超える値は上限に丸められます。 | 監査レコードを必要とする allow 決定を監査ログへ永続化できない場合、IaC Code は fail-closed となり、監査証跡なしで実行する代わりにそのアクションを拒否します。 + + +## セッションレイアウトとバックアップ状態 + +新しいセッションは `layout_version` メタデータでレイアウトを示し、実行時アーティファクトを所有セッション内に閉じ込めます。対象には `image-cache/`、`tool-results/`、A2A スナップショット、pipeline transcript の実行時データ、直近のバックアップ理由と状態を記録する `.backup-state.json` が含まれます。`.backup-state.json` と `.backup-lock` はアクティブセッション側だけに残り、バックアップミラーにはコピーされません。`layout_version` marker のない旧セッションは読み取り可能ですが、新しいセッション単位の artifact ディレクトリは作成されません。非対応の layout version を持つセッションは拒否されます。 + +v2 session の主要パスは次のとおりです。 + +```text +/projects/// +/projects///metadata.json +/projects///session.jsonl +/projects///usage.jsonl +/projects///permission-audit.jsonl +/projects///image-cache/ +/projects///tool-results/ +/projects///a2a/task.json +/projects///a2a/context.json +/projects///a2a/artifacts/ +/projects///a2a/pipeline/ +/projects///a2a/cleanup-deferred-prompts.json +/projects///pipeline/meta.yaml +/projects///pipeline/context.yaml +/projects///pipeline/events.jsonl +/projects///pipeline/display.jsonl +/projects///pipeline/transcripts// +/projects///pipeline/transcripts//session.jsonl +/projects///pipeline/transcripts//usage.jsonl +/projects///pipeline/transcripts//permission-audit.jsonl +/projects///pipeline/transcripts//tool-results/ +``` diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/mcp/capabilities.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/mcp/capabilities.md index a4cfb430..bb7131ae 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/mcp/capabilities.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/mcp/capabilities.md @@ -41,7 +41,13 @@ IaC Code は MCP content blocks をモデルに見えるテキストへ変換し | `resource_link` | URI と MIME type 付きの resource link として表示します。 | | Image、audio、blob data | 非公開 artifact ファイルとして保存し、artifact id で参照します。 | -バイナリ artifacts は次の場所に保存されます。 +v2 セッションでは、バイナリ artifacts はセッション所有の MCP tool-results ディレクトリに保存されます。 + +```text +/projects///tool-results/mcp/// +``` + +対応 layout marker のない旧セッションは引き続き次を使います。 ```text /tool-results//mcp/// diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md index fa758970..5a99ad55 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md @@ -145,7 +145,13 @@ Runtime logs の既定場所: `IAC_CODE_LOG_DIR` が設定されている場合はそのディレクトリを使います。 -MCP tool results の binary artifacts は次に保存されます。 +v2 セッションでは、MCP tool results の binary artifacts はセッション所有のディレクトリに保存されます。 + +```text +/projects///tool-results/mcp/ +``` + +対応 layout marker のない旧セッションは次を使います。 ```text /tool-results//mcp/ diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/overview.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/overview.md index fca3cb80..30056665 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/overview.md +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/overview.md @@ -72,3 +72,8 @@ O iac-code suporta modo servidor A2A sobre HTTP JSON-RPC/REST e vários transpor - A entrega push é at-least-once para filas baseadas em Redis; receptores de callback devem lidar com duplicatas e aplicar sua própria política de autorização no lado do endpoint. Solicitações de permissão de ferramentas são rejeitadas automaticamente no modo servidor A2A, a menos que `auto-approve-permissions` ou uma regra de permissão explícita as permita. Decisões de permissão são auditadas localmente; toda decisão allow que exige um registro de auditoria falha de forma fechada se esse registro não puder ser persistido. APIs protegidas de escrita Alibaba Cloud ainda exigem autorização exata por API fora de modos de bypass global. Execute o modo A2A não autenticado apenas em ambientes locais confiáveis ou proteja-o com autenticação por Bearer token, Basic auth ou API key. + + +## Persistência e backup de sessão + +Os índices A2A task/context permanecem na área runtime compartilhada, por exemplo `/a2a/tasks` e `/a2a/contexts`, para que possam ser montados ou sincronizados separadamente dos dados por sessão. Quando `IAC_CODE_CONFIG_BACKUP_DIR` está configurado, snapshots e artefatos A2A dentro de sessões v2 são espelhados com o backup de sessão; os índices task/context compartilhados devem ser fornecidos por seu próprio armazenamento montado. diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md index fc937504..791a65b0 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md @@ -332,7 +332,7 @@ IDs de tarefas e contextos devem ser não vazios, ter no máximo 128 caracteres | `TASK_STATE_CANCELED` | O cancelamento foi solicitado e aplicado | | `TASK_STATE_FAILED` | A tarefa falhou na validação ou execução | -O iac-code usa `TASK_STATE_INPUT_REQUIRED` como o estado normal de conclusão porque o contexto permanece disponível para mensagens de acompanhamento. +O iac-code usa `TASK_STATE_INPUT_REQUIRED` como o estado normal de conclusão porque o contexto permanece disponível para mensagens de acompanhamento. Turnos A2A comuns tratam `TASK_STATE_INPUT_REQUIRED` como esse estado normal de conclusão; o backup bem-sucedido de fim de turno é o checkpoint não bloqueante `normal_turn_end`. Se uma publicação terminal ou de cancelamento A2A comum for bloqueada por uma barreira crítica de backup, os metadados da task expõem `metadata.iac_code.backupBlocked` com `reason`, `error` e `recoverable`; clientes devem aguardar a recuperação antes de enviar o próximo turno. O modo Pipeline publica falhas da barreira de backup como eventos de pipeline, portanto os clientes devem inspecionar `metadata.iac_code.pipeline.eventType == "backup_blocked"` e os dados do evento. Quando `IAC_CODE_CONFIG_BACKUP_DIR` está habilitado no modo Pipeline, publicações com motivo `pipeline_step_completed`, `input_required`, `waiting_input`, `terminal` e `handoff_ready` ficam atrás da barreira crítica de backup. O motivo `terminal` protege publicações terminais, e o motivo `handoff_ready` protege o tipo de evento `pipeline_handoff_ready`. Para publicações committed terminais e `pipeline_handoff_ready`, o iac-code persiste um evento de pipeline `backup_committed` com `committedEventId`, `committedEventType` e `committedSequence`; clientes podem pareá-lo com o evento protegido antes de tratar a publicação como recuperável após reiniciar. ## Atualizações em streaming diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/acp/protocol-reference.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/acp/protocol-reference.md index 61a0dc87..c128a0f8 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current/acp/protocol-reference.md +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/acp/protocol-reference.md @@ -27,6 +27,12 @@ As sessoes tambem podem ser carregadas do historico (`session/load`) ou retomada --- +## Backups de sessão + +Sessões ACP usam o mesmo comportamento de backup de sessão v2 das execuções interativas e headless. Quando `IAC_CODE_CONFIG_BACKUP_DIR` está definido, uma conclusão normal de prompt registra um backup não bloqueante `normal_turn_end`; se esse backup falhar, a falha é registrada como `warning` e gravada em `.backup-state.json`, enquanto a resposta final ainda conclui sem prometer um campo warning. O modo Pipeline tem seus próprios gates de backup crítico. + +--- + ## Metodos ### initialize diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md index de6cdc61..a362231f 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md @@ -39,6 +39,10 @@ Os formatos de saída suportados são: | `json` | Um único resultado JSON para chamadores que analisam a resposta final. | | `stream-json` | Eventos JSON em streaming para chamadores que processam progresso incremental. | +## Backups de sessão + +Quando `IAC_CODE_CONFIG_BACKUP_DIR` está definido, execuções não interativas espelham a sessão v2 em checkpoints importantes. O checkpoint comum de fim de turno usa `normal_turn_end`; uma falha de backup nesse ponto é registrada como `warning` e gravada em `.backup-state.json`, sem falhar a resposta concluída nem adicionar um campo warning à saída final. O modo Pipeline tem seus próprios gates de backup crítico. + ## Controle de permissões na automação Ao executar em modo não interativo, use `--permission-mode` para controlar como o agente lida com aprovações de ferramentas: diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/automation/pipeline-mode.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/automation/pipeline-mode.md index 3ac55e5e..2dd39753 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current/automation/pipeline-mode.md +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/automation/pipeline-mode.md @@ -99,3 +99,8 @@ ACP atualmente não oferece suporte ao modo pipeline. `--prompt` / o [modo não - O modo pipeline requer o REPL interativo. `--prompt` é rejeitado quando `IAC_CODE_MODE=pipeline`. - O modo pipeline aceita entrada de texto. Imagens coladas no REPL são ignoradas enquanto o pipeline está ativo. - Durante o pipeline, shell escapes, gatilhos de skills e a maioria dos slash commands são restritos, a menos que a definição do pipeline os permita explicitamente. Comandos básicos como `/help`, `/status`, `/resume` e `/exit` continuam disponíveis. + + +## Checkpoints de backup + +O modo Pipeline executa backups críticos após steps agent loop concluídos e antes de publicar estados de espera, `pipeline_handoff_ready` ou terminais visíveis externamente. Se um backup crítico não puder terminar, o pipeline emite `backup_blocked` e pausa em um estado recuperável, em vez de publicar primeiro `input_required`, `waiting_input`, `pipeline_handoff_ready` ou a conclusão terminal. Para observadores A2A, publicações protegidas terminais e `pipeline_handoff_ready` são seguidas por um evento `backup_committed` com `committedEventId`, `committedEventType` e `committedSequence` quando o mirror estiver durável. O progresso de steps filhos `parallel_sub_pipeline` não é salvo separadamente enquanto sub-pipelines irmãos ainda podem estar em execução; o checkpoint pai captura o estado agregado estável. diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/environment-variables.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/environment-variables.md index 1ccb6640..04e1431d 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/environment-variables.md +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/environment-variables.md @@ -52,7 +52,7 @@ Consulte [Credenciais da Alibaba Cloud](./alibaba-cloud-credentials.md) para mai | Variavel | Descricao | |---|---| | `IAC_CODE_CONFIG_DIR` | Substitui o diretorio de configuracao em tempo de execucao (padrao `~/.iac-code/`); suporta expansao de `~` e `$VAR`. Todos os artefatos persistidos (credenciais, configuracoes, historico, projects, image-cache, skills, telemetry, etc.) seguem este diretorio | -| `IAC_CODE_LOG_DIR` | Substitui o diretório local de logs de inicialização/depuração (padrão `/logs/`); suporta expansão de `~` e `$VAR`. Registros de auditoria de permissões continuam em `/logs/permission-audit.jsonl` | +| `IAC_CODE_LOG_DIR` | Substitui o diretório local de logs de inicialização/depuração (padrão `/logs/`); suporta expansão de `~` e `$VAR`. Registros de auditoria de permissões seguem o layout da sessão e não são movidos por esta variável | | `IAC_CODE_PERMISSION_AUDIT_INCLUDE_TOOL_INPUT` | Substitui `permissions.audit.include_tool_input`; defina como `1` / `true` / `yes` / `on` para incluir entrada de ferramenta apenas em forma estrutural nos registros de auditoria de permissões, usando tipo/tamanho/fingerprint em vez de strings brutas de payload de negócio e aplicando fingerprint a nomes de campo fora da lista permitida | | `IAC_CODE_ENV` | Rotulo do ambiente de implantacao (padrao: `production`) | | `IAC_CODE_TENANT_ID` | Identificador de tenant para telemetria; prefixado automaticamente com `iac_tenant_` se ainda nao estiver | @@ -60,3 +60,10 @@ Consulte [Credenciais da Alibaba Cloud](./alibaba-cloud-credentials.md) para mai | `IAC_CODE_A2A_PUSH_KEYRING` | Keyring criptografado de push secrets A2A gerenciado pelo ambiente (formato JSON) | | `OTEL_EXPORTER_OTLP_ENDPOINT` | Endpoint padrao do OpenTelemetry; quando definido, habilita a exportacao OTLP | | `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` | Capturar conteudo de mensagens/ferramentas GenAI em spans: `SPAN_ONLY`, `EVENT_ONLY`, `SPAN_AND_EVENT` | + + +## Backup de sessão + +| Variável | Descrição | +|---|---| +| `IAC_CODE_CONFIG_BACKUP_DIR` | Diretório opcional de backup de sessões; suporta expansão de `~` e `$VAR`, e expansão `%VAR%` no Windows. No PowerShell, passe um caminho concreto ou deixe o shell expandir `$env:VAR` antes de iniciar `iac-code`. Em ambientes sandbox geralmente é um caminho OSS montado, mas deve ser independente de `IAC_CODE_CONFIG_DIR` e de qualquer origem de sessão, sem sobreposição, e com latência baixa o suficiente para checkpoints críticos. Caminhos UNC, unidades mapeadas e caminhos OSS montados devem preservar bloqueio de arquivo `.backup-lock`, substituição atômica e metadados de arquivo para mirroring incremental; evite ancestrais symlink, junction ou reparse point na origem da sessão ativa, na raiz de backup e nas sessões espelhadas. Quando habilitado, checkpoints espelham cada sessão v2 para `/projects///` com a mesma estrutura da sessão ativa; `.backup-state.json` e `.backup-lock` ficam locais e não são copiados. Backups de fim de turno normal usam `normal_turn_end` e não bloqueiam a resposta; somente falhas em checkpoints `critical=true` bloqueiam a publicação. Índices A2A task/context compartilhados podem ser montados separadamente. | diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md index aa05d1b3..227b7e13 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md @@ -19,7 +19,7 @@ O diretório de tempo de execução padrão é: ~/.iac-code/ ``` -Você pode realocá-lo definindo a variável de ambiente `IAC_CODE_CONFIG_DIR` (suporta expansão de `~` e `$VAR`). Quando definida, todos os artefatos persistidos — credenciais, configurações, histórico, `projects/`, `image-cache/`, `tool-results/`, `logs/`, `memory/`, `a2a/`, `telemetry/`, `skills/` — seguem o novo local. Logs de inicialização/depuração ficam por padrão em `/logs/` e podem ser movidos separadamente com `IAC_CODE_LOG_DIR`; registros de auditoria de permissões permanecem em `/logs/`. +Você pode realocá-lo definindo a variável de ambiente `IAC_CODE_CONFIG_DIR` (suporta expansão de `~` e `$VAR`). Quando definida, todos os artefatos persistidos — credenciais, configurações, histórico, `projects/`, `image-cache/`, `tool-results/`, `logs/`, `memory/`, `a2a/`, `telemetry/`, `skills/` — seguem o novo local. Logs de inicialização/depuração ficam por padrão em `/logs/` e podem ser movidos separadamente com `IAC_CODE_LOG_DIR`; registros de auditoria de permissões permanecem com sua sessão proprietária. Arquivos comuns: @@ -156,13 +156,13 @@ Requisições no estilo ROA são tratadas como somente leitura apenas quando o m ### Log de auditoria de permissões -Decisões de permissão que cruzam prompts do usuário, limites de cache de ferramentas, aprovação de automação ou aprovação de resolver são anexadas a: +Decisões de permissão que cruzam prompts do usuário, limites de cache de ferramentas, aprovação de automação ou aprovação de resolver são anexadas ao log de auditoria da sessão ativa: ```text -/logs/permission-audit.jsonl +/projects///permission-audit.jsonl ``` -Por padrão, este caminho é `~/.iac-code/logs/permission-audit.jsonl`. O log de auditoria de permissões segue `IAC_CODE_CONFIG_DIR`; `IAC_CODE_LOG_DIR` move apenas logs de inicialização/depuração. O gravador de auditoria anexa registros JSONL com bloqueio de arquivo, rotaciona o arquivo e restringe permissões locais do arquivo quando o sistema operacional oferece suporte. Aprovações automáticas rotineiras de somente leitura podem ser omitidas, mas negações, prompts, decisões em cache, aprovações de automação, aprovações de resolver e outros limites de permissão auditados são registrados. +Transcripts de steps do Pipeline escrevem seus próprios registros de auditoria em `/projects///pipeline/transcripts//permission-audit.jsonl`. Logs de auditoria de permissões seguem o layout de sessão em `IAC_CODE_CONFIG_DIR`; `IAC_CODE_LOG_DIR` move apenas logs de inicialização/depuração e não move registros de auditoria de permissões. O gravador de auditoria anexa registros JSONL com bloqueio de arquivo, rotaciona o arquivo e restringe permissões locais do arquivo quando o sistema operacional oferece suporte. Aprovações automáticas rotineiras de somente leitura podem ser omitidas, mas negações, prompts, decisões em cache, aprovações de automação, aprovações de resolver e outros limites de permissão auditados são registrados. As configurações de auditoria são definidas em `permissions.audit`: @@ -173,3 +173,34 @@ As configurações de auditoria são definidas em `permissions.audit`: | `max_files` | `5` | Número de arquivos de auditoria rotacionados a manter. Valores acima do máximo integrado são limitados. | Se uma decisão allow que exige um registro de auditoria não puder ser persistida no log de auditoria, o IaC Code falha de forma fechada e nega a ação em vez de executá-la sem trilha de auditoria. + + +## Layout de sessão e estado do backup + +Novas sessões usam o marcador de metadados `layout_version` para manter artefatos de runtime dentro da sessão proprietária. Isso inclui `image-cache/`, `tool-results/`, snapshots A2A, dados runtime de transcripts de pipeline e `.backup-state.json`, que registra o último motivo e status do backup; `.backup-state.json` e `.backup-lock` permanecem locais na sessão ativa e são excluídos dos espelhos de backup. Sessões antigas sem marcador `layout_version` continuam legíveis, mas não recebem novos diretórios de artefatos por sessão; sessões com versões de layout não suportadas são rejeitadas. + +Os caminhos principais de uma sessão v2 são: + +```text +/projects/// +/projects///metadata.json +/projects///session.jsonl +/projects///usage.jsonl +/projects///permission-audit.jsonl +/projects///image-cache/ +/projects///tool-results/ +/projects///a2a/task.json +/projects///a2a/context.json +/projects///a2a/artifacts/ +/projects///a2a/pipeline/ +/projects///a2a/cleanup-deferred-prompts.json +/projects///pipeline/meta.yaml +/projects///pipeline/context.yaml +/projects///pipeline/events.jsonl +/projects///pipeline/display.jsonl +/projects///pipeline/transcripts// +/projects///pipeline/transcripts//session.jsonl +/projects///pipeline/transcripts//usage.jsonl +/projects///pipeline/transcripts//permission-audit.jsonl +/projects///pipeline/transcripts//tool-results/ +``` diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/mcp/capabilities.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/mcp/capabilities.md index 63131350..e1128819 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current/mcp/capabilities.md +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/mcp/capabilities.md @@ -41,7 +41,13 @@ O IaC Code converte blocos de conteúdo MCP em texto visível ao modelo: | `resource_link` | Renderizado como link de recurso com URI e tipo MIME. | | Dados de imagem, áudio e blob | Armazenados como arquivos privados de artefato e referenciados por id de artefato. | -Artefatos binários são armazenados em: +Em sessões v2, artefatos binários são armazenados no diretório MCP tool-results da própria sessão: + +```text +/projects///tool-results/mcp/// +``` + +Sessões legacy sem marcador de layout suportado continuam usando: ```text /tool-results//mcp/// diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md index fcc80fbf..49b30f4f 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md @@ -145,7 +145,13 @@ Logs runtime usam por padrão: ou `IAC_CODE_LOG_DIR` quando definido. -Artefatos binários de resultados de ferramentas MCP são armazenados em: +Em sessões v2, artefatos binários de resultados de ferramentas MCP são armazenados no diretório da própria sessão: + +```text +/projects///tool-results/mcp/ +``` + +Sessões legacy sem marcador de layout suportado usam: ```text /tool-results//mcp/ diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/overview.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/overview.md index 64836fe0..40887fe0 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/overview.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/overview.md @@ -72,3 +72,8 @@ iac-code 支持通过 HTTP JSON-RPC/REST 以及若干可选传输运行 A2A serv - Redis 后端队列的推送投递是至少一次;callback 接收方必须处理重复投递,并执行自己的端点侧授权策略。 在 A2A server 模式下,除非 `auto-approve-permissions` 或显式权限规则允许,否则工具权限请求会被自动拒绝。权限决策会在本地审计;任何需要审计记录的允许决策在审计记录无法持久化时都会 fail closed。在非 blanket bypass 模式下,受保护的阿里云写 API 仍然需要按 API 精确授权。只应在受信任的本地环境中运行未认证的 A2A 模式,或者使用 Bearer token、Basic auth 或 API key authentication 进行保护。 + + +## 持久化和会话备份 + +A2A task/context 索引仍保存在共享运行目录,例如 `/a2a/tasks` 和 `/a2a/contexts`,这样可以与 session 数据分开挂载或同步。配置 `IAC_CODE_CONFIG_BACKUP_DIR` 后,v2 session 内的 A2A 快照和 artifact 会随 session 备份一起镜像;共享 task/context 索引应由单独挂载的存储提供。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md index dbb1bb6a..ba13ecbe 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md @@ -352,7 +352,7 @@ Task 和 context IDs 必须非空,最多 128 个字符,并且只能包含字 | `TASK_STATE_CANCELED` | 已请求并应用取消 | | `TASK_STATE_FAILED` | task 验证或执行失败 | -iac-code 使用 `TASK_STATE_INPUT_REQUIRED` 作为正常完成状态,因为 context 仍可用于后续消息。 +iac-code 使用 `TASK_STATE_INPUT_REQUIRED` 作为正常完成状态,因为 context 仍可用于后续消息。普通 A2A 轮次会把 `TASK_STATE_INPUT_REQUIRED` 视为这种正常完成状态;成功的轮次结束备份是非阻塞的 `normal_turn_end` 检查点。如果普通 A2A 的 terminal 或 cancellation 发布被关键备份门控阻塞,task metadata 会暴露 `metadata.iac_code.backupBlocked`,其中包含 `reason`、`error` 和 `recoverable`;客户端应等待恢复后再发送下一轮。Pipeline 模式会把备份门控失败作为 pipeline 事件发布,客户端应检查 `metadata.iac_code.pipeline.eventType == "backup_blocked"` 以及事件 data。Pipeline 模式启用 `IAC_CODE_CONFIG_BACKUP_DIR` 后,原因是 `pipeline_step_completed`、`input_required`、`waiting_input`、`terminal` 和 `handoff_ready` 的发布都会被关键备份门控阻塞。`terminal` reason 保护的是 terminal 发布,`handoff_ready` reason 保护的是 `pipeline_handoff_ready` 事件类型。对于 terminal 和 `pipeline_handoff_ready` 的 committed 发布,iac-code 会持久化一个 `backup_committed` pipeline 事件,其 data 包含 `committedEventId`、`committedEventType` 和 `committedSequence`;客户端可先把它与受保护事件配对,再把该发布视为重启后可恢复。 ## 流式更新 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/acp/protocol-reference.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/acp/protocol-reference.md index 66cfd976..5254cc91 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/acp/protocol-reference.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/acp/protocol-reference.md @@ -27,6 +27,12 @@ initialize → new_session → prompt (loop) → close_session --- +## 会话备份 + +ACP session 使用与交互式和 headless 运行相同的 v2 session 备份行为。设置 `IAC_CODE_CONFIG_BACKUP_DIR` 后,普通 prompt 完成会记录非阻塞的 `normal_turn_end` 备份;如果该备份失败,失败会记录为 `warning` 并写入 `.backup-state.json`,最终响应仍会完成,但不承诺包含 warning 字段。Pipeline 模式有自己的关键备份 gate。 + +--- + ## 方法 ### initialize diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md index af26ed96..d929430c 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/automation/non-interactive-mode.md @@ -39,6 +39,10 @@ iac-code --prompt "创建一个 VPC" --max-turns 20 | `json` | 返回单个 JSON 结果,适合调用方解析最终响应。 | | `stream-json` | 输出流式 JSON 事件,适合调用方处理增量进度。 | +## 会话备份 + +设置 `IAC_CODE_CONFIG_BACKUP_DIR` 后,非交互运行会在关键检查点镜像 v2 session。普通轮次结束检查点使用 `normal_turn_end`;此时备份失败只会记录为 `warning` 并写入 `.backup-state.json`,不会让已完成响应失败,也不会在最终输出中新增 warning 字段。Pipeline 模式有自己的关键备份 gate。 + ## 自动化中的权限控制 在非交互模式下运行时,使用 `--permission-mode` 控制代理处理工具审批的方式: diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/automation/pipeline-mode.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/automation/pipeline-mode.md index 83bd1edf..57c2b851 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/automation/pipeline-mode.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/automation/pipeline-mode.md @@ -99,3 +99,8 @@ ACP 目前不支持 Pipeline 模式。`--prompt` / [非交互模式](./non-inter - Pipeline 模式需要交互式 REPL;当 `IAC_CODE_MODE=pipeline` 时,`--prompt` 会被拒绝。 - Pipeline 模式支持文本输入。Pipeline 激活时,粘贴到 REPL 的图片会被忽略。 - Pipeline 运行期间,shell escape、技能触发器和大多数 slash command 会被限制,除非 pipeline 定义显式允许。`/help`、`/status`、`/resume`、`/exit` 等基础命令仍然可用。 + + +## 备份检查点 + +Pipeline 模式会在每个 agent loop step 完成后,以及对外发布等待输入、`pipeline_handoff_ready` 或终态前执行关键备份。如果关键备份失败,pipeline 会发送 `backup_blocked` 并停在可恢复状态,而不会先发布 `input_required`、`waiting_input`、`pipeline_handoff_ready` 或终态完成事件。对 A2A 观察者来说,terminal 和 `pipeline_handoff_ready` 受保护发布在镜像持久化后,会跟随一个包含 `committedEventId`、`committedEventType` 和 `committedSequence` 的 `backup_committed` 事件。`parallel_sub_pipeline` 的子 step 进度不会在兄弟 sub-pipeline 仍运行时单独备份,父级检查点会捕获稳定的聚合状态。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/environment-variables.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/environment-variables.md index 329cba1d..c0c8ec4f 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/environment-variables.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/environment-variables.md @@ -52,7 +52,7 @@ CLI 参数 > 环境变量 > 配置文件 | 变量 | 说明 | |---|---| | `IAC_CODE_CONFIG_DIR` | 覆盖运行时配置目录(默认 `~/.iac-code/`);支持 `~` 和 `$VAR` 展开。所有持久化产物(凭证、设置、历史、projects、image-cache、skills、telemetry 等)均会跟随该目录 | -| `IAC_CODE_LOG_DIR` | 覆盖本地启动/调试日志目录(默认 `/logs/`);支持 `~` 和 `$VAR` 展开。权限审计记录仍保留在 `/logs/permission-audit.jsonl` | +| `IAC_CODE_LOG_DIR` | 覆盖本地启动/调试日志目录(默认 `/logs/`);支持 `~` 和 `$VAR` 展开。权限审计记录遵循 session layout,不会被此变量移动 | | `IAC_CODE_PERMISSION_AUDIT_INCLUDE_TOOL_INPUT` | 覆盖 `permissions.audit.include_tool_input`;设为 `1` / `true` / `yes` / `on` 时,在权限审计记录中包含仅保留形态的工具输入,使用类型/长度/指纹而不是业务原文,并对非白名单字段名使用指纹 | | `IAC_CODE_ENV` | 部署环境标签(默认:`production`) | | `IAC_CODE_TENANT_ID` | 遥测租户标识;如未以 `iac_tenant_` 开头则自动添加前缀 | @@ -60,3 +60,10 @@ CLI 参数 > 环境变量 > 配置文件 | `IAC_CODE_A2A_PUSH_KEYRING` | 由环境管理的 A2A 加密推送密钥环(JSON 格式) | | `OTEL_EXPORTER_OTLP_ENDPOINT` | 标准 OpenTelemetry 端点;设置后启用 OTLP 导出 | | `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` | 在 span 上捕获 GenAI 消息/工具内容:`SPAN_ONLY`、`EVENT_ONLY`、`SPAN_AND_EVENT` | + + +## 会话备份 + +| 变量 | 说明 | +|---|---| +| `IAC_CODE_CONFIG_BACKUP_DIR` | 可选的会话备份目录,支持 `~` 和 `$VAR` 展开,在 Windows 上也支持 `%VAR%` 展开。PowerShell 中请传入具体路径,或在启动 `iac-code` 前让 shell 展开 `$env:VAR`。在 sandbox 部署中通常指向 OSS 挂载路径,但必须独立于 `IAC_CODE_CONFIG_DIR` 和任何 session 源目录且不能互相重叠,并且关键检查点需要足够低延迟。UNC 路径、映射盘和 OSS 挂载路径必须保留 `.backup-lock` 文件锁、原子替换和文件元数据语义,才能支持增量镜像;活动 session 源目录、备份根目录和镜像 session 路径都应避免符号链接、junction 或 reparse point 祖先路径。启用后,检查点会把每个 v2 会话按原目录结构镜像到 `/projects///`;`.backup-state.json` 和 `.backup-lock` 只保留在本地,不会复制。普通对话轮次结束备份使用 `normal_turn_end` 且不阻塞响应;只有 `critical=true` 检查点失败才会阻塞发布。共享 A2A task/context 索引可以单独挂载。 | diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md index 802e49da..3fc9b499 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md @@ -19,7 +19,7 @@ CLI 参数 > 环境变量 > 配置文件 ~/.iac-code/ ``` -可通过设置 `IAC_CODE_CONFIG_DIR` 环境变量更改该目录(支持 `~` 和 `$VAR` 展开)。设置后,所有持久化产物——凭证、设置、历史、`projects/`、`image-cache/`、`tool-results/`、`logs/`、`memory/`、`a2a/`、`telemetry/`、`skills/`——都会跟随到新位置。启动/调试日志默认位于 `/logs/`,可用 `IAC_CODE_LOG_DIR` 单独移动;权限审计记录仍位于 `/logs/`。 +可通过设置 `IAC_CODE_CONFIG_DIR` 环境变量更改该目录(支持 `~` 和 `$VAR` 展开)。设置后,所有持久化产物——凭证、设置、历史、`projects/`、`image-cache/`、`tool-results/`、`logs/`、`memory/`、`a2a/`、`telemetry/`、`skills/`——都会跟随到新位置。启动/调试日志默认位于 `/logs/`,可用 `IAC_CODE_LOG_DIR` 单独移动;权限审计记录会保留在所属 session 中。 常见文件: @@ -156,13 +156,13 @@ ROA 风格请求只有在方法为 `GET` 且请求没有 body 时才视为只读 ### 权限审计日志 -跨越用户提示、工具缓存边界、自动化批准或 resolver 批准的权限决策会追加写入: +跨越用户提示、工具缓存边界、自动化批准或 resolver 批准的权限决策会追加写入当前 session 的审计日志: ```text -/logs/permission-audit.jsonl +/projects///permission-audit.jsonl ``` -默认情况下,该路径是 `~/.iac-code/logs/permission-audit.jsonl`。权限审计日志跟随 `IAC_CODE_CONFIG_DIR`;`IAC_CODE_LOG_DIR` 只移动启动/调试日志。审计写入器会用文件锁追加 JSONL 记录,执行日志轮转,并在操作系统支持时限制本地文件权限。常规只读自动允许决策可能会省略,但拒绝、提示、缓存决策、自动化批准、resolver 批准以及其他被审计的权限边界都会记录。 +Pipeline step transcript 会把自己的审计记录写到 `/projects///pipeline/transcripts//permission-audit.jsonl`。权限审计日志跟随 `IAC_CODE_CONFIG_DIR` 下的 session layout;`IAC_CODE_LOG_DIR` 只移动启动/调试日志,不会移动权限审计记录。审计写入器会用文件锁追加 JSONL 记录,执行日志轮转,并在操作系统支持时限制本地文件权限。常规只读自动允许决策可能会省略,但拒绝、提示、缓存决策、自动化批准、resolver 批准以及其他被审计的权限边界都会记录。 审计设置位于 `permissions.audit` 下: @@ -173,3 +173,34 @@ ROA 风格请求只有在方法为 `GET` 且请求没有 body 时才视为只读 | `max_files` | `5` | 保留的轮转审计文件数量。超过内置上限的值会被截断到上限。 | 如果任何需要审计记录的允许决策无法持久化到审计日志,IaC Code 会 fail closed,拒绝该操作,而不是在没有审计轨迹的情况下执行。 + + +## 会话布局和备份状态 + +新会话使用 `layout_version` 元数据标记,确保运行期产物只归属到自己的 session。会话内文件包括 `image-cache/`、`tool-results/`、A2A 快照、pipeline transcript 运行期数据以及 `.backup-state.json`,其中记录最近一次备份原因和状态;`.backup-state.json` 和 `.backup-lock` 只保留在活跃 session 本地,不会复制到备份镜像。没有 `layout_version` marker 的旧会话仍可读取,但不会再写入新的 session 级 artifact 目录;带有不受支持 layout version 的会话会被拒绝。 + +v2 session 的关键路径如下: + +```text +/projects/// +/projects///metadata.json +/projects///session.jsonl +/projects///usage.jsonl +/projects///permission-audit.jsonl +/projects///image-cache/ +/projects///tool-results/ +/projects///a2a/task.json +/projects///a2a/context.json +/projects///a2a/artifacts/ +/projects///a2a/pipeline/ +/projects///a2a/cleanup-deferred-prompts.json +/projects///pipeline/meta.yaml +/projects///pipeline/context.yaml +/projects///pipeline/events.jsonl +/projects///pipeline/display.jsonl +/projects///pipeline/transcripts// +/projects///pipeline/transcripts//session.jsonl +/projects///pipeline/transcripts//usage.jsonl +/projects///pipeline/transcripts//permission-audit.jsonl +/projects///pipeline/transcripts//tool-results/ +``` diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/mcp/capabilities.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/mcp/capabilities.md index 43f68a7a..94d670f6 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/mcp/capabilities.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/mcp/capabilities.md @@ -41,7 +41,13 @@ IaC Code 会把 MCP content blocks 转换为模型可见文本: | `resource_link` | 渲染为带 URI 和 MIME type 的 resource link。 | | Image、audio 和 blob data | 存为私有 artifact 文件,并以 artifact id 引用。 | -二进制 artifacts 存储在: +v2 session 的二进制 artifacts 存储在 session 自己的 MCP tool-results 目录: + +```text +/projects///tool-results/mcp/// +``` + +没有受支持 layout marker 的旧 session 继续使用: ```text /tool-results//mcp/// diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md index e0d98ed0..951b7fa0 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md @@ -145,7 +145,13 @@ Runtime logs 默认位于: 设置 `IAC_CODE_LOG_DIR` 时使用该目录。 -MCP tool results 产生的 binary artifacts 存储在: +v2 session 中,MCP tool results 产生的 binary artifacts 存储在 session 自己的目录: + +```text +/projects///tool-results/mcp/ +``` + +没有受支持 layout marker 的旧 session 使用: ```text /tool-results//mcp/