Skip to content

Commit 8ecc4c2

Browse files
authored
Merge pull request #158 from aliyun/codex/fix-step1-handle-off
fix: harden A2A recovery e2e flows
2 parents 9875e3c + 594e15d commit 8ecc4c2

24 files changed

Lines changed: 2027 additions & 153 deletions

File tree

scripts/a2a/e2e/fixtures/text-images/manifest.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@
3636
},
3737
"rollback-interrupt": {
3838
"filename": "rollback-interrupt.png",
39-
"text": "回退到 intent_parsing,选择一个已有vpc,创建一个安全组",
39+
"text": "我改需求了:使用已有 VPC 创建一个安全组,不创建 VSwitch。请基于这个新需求重新规划。",
4040
"mediaType": "image/png",
41-
"byteSize": 20967,
42-
"sha256": "1dfa25bba58757704b27a7ee8f44a42f4e69045730bba5320986194c873a5937"
41+
"byteSize": 33436,
42+
"sha256": "99399b761853dd9a9188349549933de6cdd074051697ecbb23cd687769222ca3"
4343
}
4444
}
12.2 KB
Loading

scripts/a2a/e2e/run_recovery_scenarios.py

Lines changed: 484 additions & 60 deletions
Large diffs are not rendered by default.

src/iac_code/a2a/executor.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,11 @@
3939
from iac_code.a2a.pipeline_executor import IacCodeA2APipelineExecutor, recoverable_task_id_from_sidecar
4040
from iac_code.a2a.pipeline_journal import A2APipelineJournal
4141
from iac_code.a2a.pipeline_paths import existing_a2a_pipeline_dir_for_session
42-
from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore, reduce_pipeline_events
42+
from iac_code.a2a.pipeline_snapshot import (
43+
A2APipelineSnapshotStore,
44+
reduce_pipeline_events,
45+
snapshot_needs_backup_commit_repair,
46+
)
4347
from iac_code.a2a.pipeline_stream import BACKUP_COMMITTED_EVENT_TYPE, PipelineA2AEventPublisher
4448
from iac_code.a2a.runtime_overrides import (
4549
a2a_request_context,
@@ -89,6 +93,7 @@
8993

9094
logger = logging.getLogger(__name__)
9195
_CONTEXT_LOCK_ACQUIRE_TIMEOUT_SECONDS = 1
96+
_CANCEL_ACTIVE_TASK_DRAIN_TIMEOUT_SECONDS = 30
9297
_ERROR_TEXT_MAX_CHARS = 1000
9398
_DEFERRED_CLEANUP_PROMPTS_FILENAME = "cleanup-deferred-prompts.json"
9499
_A2A_SAFE_MODE_ENV = "IAC_CODE_A2A_SAFE_MODE"
@@ -434,7 +439,12 @@ def _a2a_pipeline_state_for_session(
434439
(_a2a_pipeline_sequence_number(event.get("sequence")) for event in journal_events if isinstance(event, dict)),
435440
default=0,
436441
)
437-
if journal_events and (not isinstance(snapshot, dict) or journal_sequence != snapshot_sequence):
442+
needs_backup_commit_repair = (
443+
isinstance(snapshot, dict) and snapshot_needs_backup_commit_repair(snapshot, journal_events)
444+
)
445+
if journal_events and (
446+
not isinstance(snapshot, dict) or journal_sequence != snapshot_sequence or needs_backup_commit_repair
447+
):
438448
snapshot = reduce_pipeline_events(journal_events)
439449
if not isinstance(snapshot, dict):
440450
return None
@@ -1336,7 +1346,10 @@ def runtime_factory(session_id: str) -> Any:
13361346
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
13371347
task_id = context.task_id
13381348
context_id = context.context_id or "unknown"
1339-
if task_id and await self._task_store.cancel_task(task_id):
1349+
if task_id and await self._task_store.cancel_task_and_wait(
1350+
task_id,
1351+
timeout=_CANCEL_ACTIVE_TASK_DRAIN_TIMEOUT_SECONDS,
1352+
):
13401353
await self._publish_status(
13411354
event_queue,
13421355
task_id=task_id,

src/iac_code/a2a/pipeline_executor.py

Lines changed: 96 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,13 @@
9090
_PENDING_QUESTION_NOT_ROUTED = "not_routed"
9191
_PENDING_QUESTION_ANSWERED = "answered"
9292
_PENDING_QUESTION_STALE_FINISHED = "stale_finished"
93+
_ACTIVE_INTERRUPT_TERMINAL_WAIT_TIMEOUT_SECONDS = 30.0
94+
95+
96+
def _new_set_asyncio_event() -> asyncio.Event:
97+
event = asyncio.Event()
98+
event.set()
99+
return event
93100

94101

95102
class WaitingInputCancelResult(str, Enum):
@@ -143,6 +150,7 @@ class A2APipelineRuntime:
143150
restart_after_interrupt: bool = False
144151
pause_after_interrupt: bool = False
145152
restart_requested: asyncio.Event = field(default_factory=asyncio.Event)
153+
interrupt_settled: asyncio.Event = field(default_factory=_new_set_asyncio_event)
146154

147155

148156
@dataclass(frozen=True)
@@ -574,7 +582,7 @@ def fresh_pipeline_factory() -> Any:
574582
ctx.active_task_id = task.task_id
575583
elif task.active_task is owner_task:
576584
task.active_task = None
577-
if ctx.active_task_id == task.task_id and getattr(pipeline, "sidecar_status", None) != "running":
585+
if ctx.active_task_id == task.task_id:
578586
ctx.active_task_id = None
579587
if replacement_owner is owner_task and hasattr(ctx.runtime, "active_owner_task"):
580588
ctx.runtime.active_owner_task = None
@@ -725,6 +733,8 @@ async def _route_active_pipeline_interrupt(
725733
paused = False
726734
verdict: Any | None = None
727735
interrupt_received_published = False
736+
interrupt_settled = _interrupt_settled_event(runtime)
737+
interrupt_settled.clear()
728738
try:
729739
publish_interrupt_received = getattr(publisher, "publish_interrupt_received", None)
730740
if callable(publish_interrupt_received):
@@ -743,9 +753,8 @@ async def _route_active_pipeline_interrupt(
743753
apply_hard_interrupt = getattr(pipeline, "apply_hard_interrupt", None)
744754
if callable(apply_hard_interrupt):
745755
parameters = inspect.signature(apply_hard_interrupt).parameters
746-
if pipeline_input.has_images and (
747-
"source_input" in parameters
748-
or any(parameter.kind == inspect.Parameter.VAR_KEYWORD for parameter in parameters.values())
756+
if "source_input" in parameters or any(
757+
parameter.kind == inspect.Parameter.VAR_KEYWORD for parameter in parameters.values()
749758
):
750759
applied = apply_hard_interrupt(verdict, source_input=runner_input)
751760
else:
@@ -818,6 +827,7 @@ async def _route_active_pipeline_interrupt(
818827
await self._complete_backup_blocked_transition(task=task, ctx=ctx)
819828
return True
820829
finally:
830+
interrupt_settled.set()
821831
if paused and not bool(getattr(verdict, "paused", False)):
822832
resume_agent_loops = getattr(pipeline, "resume_agent_loops", None)
823833
if callable(resume_agent_loops):
@@ -1148,6 +1158,22 @@ async def _consume_stream_until_restart(
11481158
finally:
11491159
next_task = None
11501160

1161+
interrupt_action = await _consume_active_interrupt_action_before_terminal(runtime, event)
1162+
if interrupt_action == "restart":
1163+
close_stream_on_exit = True
1164+
return _StreamConsumeResult(
1165+
had_events=had_events,
1166+
restart_requested=True,
1167+
terminal_handoff_unavailable=terminal_handoff_unavailable,
1168+
)
1169+
if interrupt_action == "pause":
1170+
close_stream_on_exit = True
1171+
return _StreamConsumeResult(
1172+
had_events=had_events,
1173+
restart_requested=False,
1174+
terminal_handoff_unavailable=terminal_handoff_unavailable,
1175+
)
1176+
11511177
had_events = True
11521178
await publish_mcp_warnings(
11531179
publisher.event_queue,
@@ -3410,6 +3436,72 @@ def _restart_requested_event(runtime: Any) -> asyncio.Event:
34103436
return restart_requested
34113437

34123438

3439+
def _interrupt_settled_event(runtime: Any) -> asyncio.Event:
3440+
interrupt_settled = getattr(runtime, "interrupt_settled", None)
3441+
if isinstance(interrupt_settled, asyncio.Event):
3442+
return interrupt_settled
3443+
interrupt_settled = _new_set_asyncio_event()
3444+
runtime.interrupt_settled = interrupt_settled
3445+
return interrupt_settled
3446+
3447+
3448+
async def _consume_active_interrupt_action_before_terminal(runtime: Any, event: Any) -> str | None:
3449+
if not _is_pipeline_terminal_stream_event(event):
3450+
return None
3451+
3452+
action = _consume_requested_interrupt_action(runtime)
3453+
if action is not None:
3454+
return action
3455+
3456+
interrupt_settled = _interrupt_settled_event(runtime)
3457+
if interrupt_settled.is_set():
3458+
return None
3459+
3460+
restart_event = _restart_requested_event(runtime)
3461+
settled_task = asyncio.create_task(interrupt_settled.wait())
3462+
restart_task = asyncio.create_task(restart_event.wait())
3463+
try:
3464+
done, _pending = await asyncio.wait(
3465+
{settled_task, restart_task},
3466+
timeout=_ACTIVE_INTERRUPT_TERMINAL_WAIT_TIMEOUT_SECONDS,
3467+
return_when=asyncio.FIRST_COMPLETED,
3468+
)
3469+
finally:
3470+
await _cancel_task_safely(settled_task)
3471+
await _cancel_task_safely(restart_task)
3472+
3473+
action = _consume_requested_interrupt_action(runtime)
3474+
if action is not None:
3475+
return action
3476+
3477+
if not done and not interrupt_settled.is_set():
3478+
logger.warning(
3479+
"Timed out waiting for active A2A pipeline interrupt before publishing terminal pipeline event"
3480+
)
3481+
return None
3482+
3483+
3484+
def _consume_requested_interrupt_action(runtime: Any) -> str | None:
3485+
restart_event = _restart_requested_event(runtime)
3486+
if bool(getattr(runtime, "restart_after_interrupt", False)) and restart_event.is_set():
3487+
restart_event.clear()
3488+
runtime.restart_after_interrupt = False
3489+
return "restart"
3490+
if bool(getattr(runtime, "pause_after_interrupt", False)) and restart_event.is_set():
3491+
restart_event.clear()
3492+
runtime.pause_after_interrupt = False
3493+
return "pause"
3494+
return None
3495+
3496+
3497+
def _is_pipeline_terminal_stream_event(event: Any) -> bool:
3498+
return isinstance(event, PipelineEvent) and event.type in {
3499+
PipelineEventType.PIPELINE_COMPLETED,
3500+
PipelineEventType.PIPELINE_ERROR,
3501+
PipelineEventType.BACKUP_BLOCKED,
3502+
}
3503+
3504+
34133505
def _is_active_task_record(task: Any, active_task_id: str | None) -> bool:
34143506
return active_task_id is not None and getattr(task, "task_id", None) == active_task_id
34153507

src/iac_code/a2a/pipeline_recovery.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
reduce_pipeline_events,
1414
sanitize_pipeline_artifact_uris,
1515
sanitize_pipeline_cleanup_private_fields,
16+
snapshot_needs_backup_commit_repair,
1617
)
1718
from iac_code.i18n import _
1819

@@ -71,6 +72,11 @@ async def get_state(
7172
else:
7273
replay_events = _events_for_task(events, task_id=recovery_task_id, context_id=context_id)
7374

75+
if snapshot_needs_backup_commit_repair(snapshot, replay_events):
76+
snapshot = reduce_pipeline_events(replay_events)
77+
snapshot_store.save(snapshot)
78+
snapshot = snapshot_store.load() or snapshot
79+
7480
if (
7581
snapshot is not None
7682
and _snapshot_schema_is_stale(snapshot)

src/iac_code/a2a/pipeline_snapshot.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1122,6 +1122,78 @@ def _backup_ack_authoritative_events(events: list[dict[str, Any]]) -> list[dict[
11221122
return authoritative
11231123

11241124

1125+
def snapshot_needs_backup_commit_repair(snapshot: dict[str, Any] | None, events: list[dict[str, Any]]) -> bool:
1126+
if not isinstance(snapshot, dict):
1127+
return False
1128+
pending_publications = _pending_backup_publications_from_snapshot(snapshot)
1129+
if not pending_publications:
1130+
return False
1131+
1132+
matching_events = [
1133+
event
1134+
for event in events
1135+
if isinstance(event, dict) and _event_matches_snapshot_task_context(event, snapshot)
1136+
]
1137+
committed_by_id: dict[str, dict[str, Any]] = {}
1138+
committed_by_sequence_type: dict[tuple[int, str], dict[str, Any]] = {}
1139+
for event in matching_events:
1140+
if not _is_committed_backup_publication(event):
1141+
continue
1142+
event_id = _string_or_none(event.get("eventId"))
1143+
event_type = _string_or_none(event.get("eventType")) or ""
1144+
sequence = _sequence_value(event)
1145+
if event_id:
1146+
committed_by_id[event_id] = event
1147+
committed_by_sequence_type[(sequence, event_type)] = event
1148+
1149+
if not committed_by_id and not committed_by_sequence_type:
1150+
return False
1151+
1152+
for event in matching_events:
1153+
if event.get("eventType") != _BACKUP_COMMITTED_EVENT_TYPE:
1154+
continue
1155+
data = _dict_or_empty(event.get("data"))
1156+
committed_event = None
1157+
committed_event_id = _string_or_none(data.get("committedEventId"))
1158+
if committed_event_id:
1159+
committed_event = committed_by_id.get(committed_event_id)
1160+
if committed_event is None:
1161+
committed_event_type = _string_or_none(data.get("committedEventType")) or ""
1162+
committed_event = committed_by_sequence_type.get(
1163+
(_sequence_number(data.get("committedSequence")), committed_event_type),
1164+
)
1165+
if committed_event is None:
1166+
continue
1167+
committed_event_type = _string_or_none(committed_event.get("eventType"))
1168+
committed_sequence = _sequence_value(committed_event)
1169+
for pending in pending_publications:
1170+
if (
1171+
committed_event_type == _string_or_none(pending.get("eventType"))
1172+
and committed_sequence >= _sequence_value(pending)
1173+
):
1174+
return True
1175+
return False
1176+
1177+
1178+
def _pending_backup_publications_from_snapshot(snapshot: dict[str, Any]) -> list[dict[str, Any]]:
1179+
publications: list[dict[str, Any]] = []
1180+
for key in ("pendingTerminal", "pendingNormalHandoff"):
1181+
publication = snapshot.get(key)
1182+
if isinstance(publication, dict) and _publication_visibility(publication) == _PENDING_BACKUP_VISIBILITY:
1183+
publications.append(publication)
1184+
return publications
1185+
1186+
1187+
def _event_matches_snapshot_task_context(event: dict[str, Any], snapshot: dict[str, Any]) -> bool:
1188+
context_id = _string_or_none(snapshot.get("contextId"))
1189+
if context_id is not None and event.get("contextId") != context_id:
1190+
return False
1191+
task_id = _string_or_none(snapshot.get("taskId"))
1192+
if task_id is None:
1193+
return True
1194+
return event.get("taskId") == task_id or event.get("deliveryTaskId") == task_id
1195+
1196+
11251197
def _publication_visibility(event: dict[str, Any]) -> str | None:
11261198
value = _string_or_none(event.get("visibility"))
11271199
if value is not None:
@@ -1685,4 +1757,5 @@ def _utc_now() -> str:
16851757
"SNAPSHOT_SCHEMA_VERSION",
16861758
"reduce_pipeline_events",
16871759
"sanitize_pipeline_cleanup_private_fields",
1760+
"snapshot_needs_backup_commit_repair",
16881761
]

src/iac_code/a2a/task_store.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,28 @@ async def cancel_task(self, task_id: str) -> bool:
431431
record.active_task.cancel()
432432
return True
433433

434+
async def cancel_task_and_wait(self, task_id: str, *, timeout: float | None = None) -> bool:
435+
async with self._mutation_lock:
436+
record = self._tasks.get(validate_protocol_id(task_id))
437+
if record is None or record.active_task is None or record.active_task.done():
438+
return False
439+
active_task = record.active_task
440+
active_task.cancel()
441+
442+
if active_task is asyncio.current_task():
443+
return True
444+
try:
445+
if timeout is None:
446+
await asyncio.shield(active_task)
447+
else:
448+
await asyncio.wait_for(asyncio.shield(active_task), timeout=timeout)
449+
except asyncio.CancelledError:
450+
if not active_task.done():
451+
raise
452+
except TimeoutError:
453+
logger.warning("Timed out waiting for canceled A2A task %s to finish", task_id)
454+
return True
455+
434456
async def is_task_active(self, task_id: str) -> bool:
435457
async with self._mutation_lock:
436458
record = self._tasks.get(validate_protocol_id(task_id))

src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3087,6 +3087,7 @@ msgid "The user-facing question to ask."
30873087
msgstr "Die dem Benutzer zu stellende Frage."
30883088

30893089
#: src/iac_code/pipeline/engine/ask_user_question_tool.py
3090+
#: src/iac_code/ui/renderer.py
30903091
msgid "ask_user_question validation failed."
30913092
msgstr "Die Validierung von ask_user_question ist fehlgeschlagen."
30923093

@@ -3626,6 +3627,11 @@ msgstr ""
36263627
msgid "Step {step_id} completed. Conclusion submitted."
36273628
msgstr "Schritt {step_id} abgeschlossen. Schlussfolgerung übermittelt."
36283629

3630+
#: src/iac_code/pipeline/engine/pipeline_runner.py
3631+
#, python-brace-format
3632+
msgid "用户反馈:{}"
3633+
msgstr "Benutzerfeedback: {}"
3634+
36293635
#: src/iac_code/pipeline/engine/pipeline_runner.py
36303636
msgid "Pipeline state persistence failed."
36313637
msgstr "Persistenz des Pipeline-Zustands fehlgeschlagen."

src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3088,6 +3088,7 @@ msgid "The user-facing question to ask."
30883088
msgstr "La pregunta que se mostrará al usuario."
30893089

30903090
#: src/iac_code/pipeline/engine/ask_user_question_tool.py
3091+
#: src/iac_code/ui/renderer.py
30913092
msgid "ask_user_question validation failed."
30923093
msgstr "La validación de ask_user_question falló."
30933094

@@ -3609,6 +3610,11 @@ msgstr ""
36093610
msgid "Step {step_id} completed. Conclusion submitted."
36103611
msgstr "Paso {step_id} completado. Conclusión enviada."
36113612

3613+
#: src/iac_code/pipeline/engine/pipeline_runner.py
3614+
#, python-brace-format
3615+
msgid "用户反馈:{}"
3616+
msgstr "Comentarios del usuario: {}"
3617+
36123618
#: src/iac_code/pipeline/engine/pipeline_runner.py
36133619
msgid "Pipeline state persistence failed."
36143620
msgstr "Error al persistir el estado de la pipeline."

0 commit comments

Comments
 (0)