Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,18 +54,18 @@ dependencies = [

[project.optional-dependencies]
http = [
"a2a-sdk[http-server,signing]>=1.0.2,<2",
"a2a-sdk[http-server,signing]==1.1.0",
"starlette>=0.39.0",
"uvicorn[standard]>=0.30.0",
]
a2a = [
"a2a-sdk[http-server,signing]>=1.0.2,<2",
"a2a-sdk[http-server,signing]==1.1.0",
"cryptography>=42.0",
"starlette>=0.39.0",
"uvicorn[standard]>=0.30.0",
]
a2a-signing = [
"a2a-sdk[signing]>=1.0.2,<2",
"a2a-sdk[signing]==1.1.0",
]
a2a-grpc = [
"grpcio>=1.60.0",
Expand All @@ -76,7 +76,7 @@ a2a-redis = [
]
agui = [
"ag-ui-protocol==0.1.20",
"a2a-sdk[http-server,signing]>=1.0.2,<2",
"a2a-sdk[http-server,signing]==1.1.0",
"starlette>=0.39.0",
"uvicorn[standard]>=0.30.0",
]
Expand All @@ -99,7 +99,7 @@ dev = [
"pexpect>=4.9.0",
]
desktop = [
"a2a-sdk[http-server,signing]>=1.0.2,<2",
"a2a-sdk[http-server,signing]==1.1.0",
"pyinstaller==6.21.0",
"termaid>=0.1; python_version >= '3.11'",
"uvicorn[standard]>=0.30.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,16 @@ def snapshot(self) -> list[dict[str, Any]]:
with self._lock:
return list(self.events)

def wait_for_text(self, text: str, *, timeout: float) -> None:
_wait_until(
lambda: self.error or self.done.is_set() or text in json.dumps(self.snapshot()),
timeout=timeout,
description="{} stream output {}".format(self.name, text),
)
if self.error is not None:
raise RuntimeError("A2A stream failed: {}".format(self.error)) from self.error
assert text in json.dumps(self.snapshot()), "A2A stream ended before expected output"

def close(self) -> None:
self._closed = True
response = self._response
Expand Down Expand Up @@ -543,12 +553,25 @@ def _assert_other_context_progress(self) -> None:
other_context = _first(other.snapshot(), "contextId")
assert other_context and other_context != self.context_id
assert "ISOLATION_FIXTURE_FINAL" in json.dumps(other.snapshot())
status, other_state = _http_json(
"GET",
self.server.url + "/iac-code/execution/state?" + urlencode({"contextId": other_context}),
timeout=1.0,

def other_context_released() -> dict[str, Any] | None:
status, state = _http_json(
"GET",
self.server.url + "/iac-code/execution/state?" + urlencode({"contextId": other_context}),
timeout=1.0,
)
assert status == 200
if state["phase"] == "running":
return state
if state.get("terminationReason") == "natural_completion" and state.get("releaseReady") is True:
return state
return None

_wait_until(
other_context_released,
timeout=self.timeout,
description="other context natural release",
)
assert status == 200 and other_state["phase"] == "running"
assert not (self.control_dir / "release-storage").exists()
lifecycle = _read_jsonl(self.run_dir / "fixture-lifecycle.jsonl")
assert not any(value["event"].endswith("commit_finished") for value in lifecycle)
Expand All @@ -562,6 +585,9 @@ def _assert_other_context_progress(self) -> None:
"otherElapsedSeconds": time.monotonic() - started,
},
)
# Business progress above proves isolation while the primary write is held.
# Allow the normal scenario budget for backup and stream shutdown as well.
other.join(self.timeout)

def _slow_termination_storage(self) -> None:
_atomic_json(self.control_dir / "arm-termination-storage", {"contextId": self.context_id})
Expand Down Expand Up @@ -751,7 +777,11 @@ def _verify_agent_card(self) -> None:
def _state(self) -> dict[str, Any]:
self.server.assert_running()
query = urlencode({"contextId": self.context_id})
status, state = _http_json("GET", self.server.url + "/iac-code/execution/state?" + query)
status, state = _http_json(
"GET",
self.server.url + "/iac-code/execution/state?" + query,
timeout=self.timeout,
)
assert status == 200, state
self.timeline.append({"observedAt": time.time(), **state})
_atomic_json(self.run_dir / "execution-state-timeline.json", self.timeline)
Expand Down Expand Up @@ -939,7 +969,6 @@ def _warm_resume_paused(self) -> None:
assert "DURABLE_FIXTURE_RESULT" in json.dumps(paused_recovery, ensure_ascii=False)
subscription = self._subscribe()
self._resume(epoch=2, request_id="resume-paused")
self._wait_state(lambda value: value["phase"] == "running", "running after paused resume")
subscription.join(self.timeout)
self._assert_normal_completion(subscription.snapshot())

Expand Down Expand Up @@ -1088,9 +1117,17 @@ def _natural_completion(self) -> None:
assert transcript.count("NATURAL_FIXTURE_FINAL") == 1
assert recovery["outputText"] == ["NATURAL_FIXTURE_FINAL"]
self._resume(epoch=2, request_id="resume-natural")
final = self._wait_state(lambda value: value["phase"] == "running", "natural completion hold release")
final = self._wait_state(
lambda value: (
value["phase"] == "terminated"
and value.get("terminationReason") == "natural_completion"
and value.get("releaseReady") is True
),
"natural completion hold release",
)
assert final.get("executionStatus") == completed.get("executionStatus")
assert final.get("streamAvailable") is False
self._assert_shared_backup(final, None, require_reason=False)
assert len(self._provider_calls()) == 1

def _capture_recovery(self, suffix: str) -> dict[str, Any]:
Expand All @@ -1117,10 +1154,15 @@ def _assert_normal_completion(self, events: list[dict[str, Any]]) -> None:
"completed normal turn",
)
task = self._task()
state = self._state()
assert state["phase"] == "running"
assert state.get("terminationReason") is None
assert state.get("backup", {}).get("status") == "not_requested"
state = self._wait_state(
lambda value: (
value["phase"] == "terminated"
and value.get("terminationReason") == "natural_completion"
and value.get("releaseReady") is True
),
"natural completion release",
)
self._assert_shared_backup(state, None, require_reason=False)
assert state["executionId"] == self.execution_id
assert state["serverInstanceId"] == self.server_instance_id
assert len([value for value in self._tool_events() if value.get("event") == "tool.started"]) == 1
Expand Down
19 changes: 14 additions & 5 deletions src/iac_code/a2a/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,7 @@ async def pause_execution(request: Request) -> JSONResponse:
reason=required_string(payload, "reason"),
reconnect_timeout_seconds=float(timeout),
)
state = control.protocol_snapshot(state)
return JSONResponse(state, status_code=200 if state["phase"] == "paused" else 202)
except Exception as exc:
return await execution_error_response(exc)
Expand All @@ -662,14 +663,20 @@ async def get_execution_state(request: Request) -> JSONResponse:
context_id = request.query_params.get("contextId")
if not context_id:
raise ValueError("contextId is required")
control = await execution_control_from_request(request, context_id)
service = components.execution_control_service
if service is None:
raise ExecutionControlNotFoundError("Execution control is unavailable")
state = await service.observe(
context_id=validate_protocol_id(context_id),
owner=execution_owner(request),
)
expected_execution_id = request.query_params.get("executionId")
pause_id = request.query_params.get("pauseId")
if expected_execution_id is not None and expected_execution_id != control.execution_id:
if expected_execution_id is not None and expected_execution_id != state.get("executionId"):
raise ExecutionControlConflictError("executionId does not identify the current execution")
if pause_id is not None and pause_id != control.pause_id:
if pause_id is not None and pause_id != state.get("pauseId"):
raise ExecutionControlConflictError("pauseId does not identify the current pause")
return JSONResponse(control.snapshot())
return JSONResponse(state)
except Exception as exc:
return await execution_error_response(exc)

Expand All @@ -684,6 +691,7 @@ async def resume_execution(request: Request) -> JSONResponse:
request_id=validate_protocol_id(required_string(payload, "requestId")),
connection_epoch=required_epoch(payload),
)
state = control.protocol_snapshot(state)
return JSONResponse(state, status_code=200 if state["phase"] == "running" else 202)
except Exception as exc:
return await execution_error_response(exc)
Expand All @@ -708,6 +716,7 @@ async def terminate_execution(request: Request) -> JSONResponse:
reason=reason,
pause_id=pause_id,
)
state = control.protocol_snapshot(state)
return JSONResponse(state, status_code=200 if state["phase"] == "terminated" else 202)
except Exception as exc:
return await execution_error_response(exc)
Expand Down Expand Up @@ -752,7 +761,7 @@ async def get_session_recovery(request: Request) -> JSONResponse:
"outputText": list(task_record.output_text),
"task": MessageToDict(task, preserving_proto_field_name=False),
"messages": [message.to_dict() for message in messages],
"executionControl": control.snapshot(),
"executionControl": control.protocol_snapshot(control.snapshot()),
}
return JSONResponse(project_a2a_data(recovery, public_path_roots=roots))
except Exception as exc:
Expand Down
Loading
Loading