Skip to content

Commit 1a0d78f

Browse files
cosmicBboyclaude
andauthored
Add flyte.is_control_plane_available() and Run.first_failure() for agent edit-and-relaunch loops (#1522)
## Why The agent-mediated forking example (unionai/unionai-examples#308) — an agent task that launches a workflow, observes the failure, patches the workflow's source on disk, reloads it, and `fork()`s the failed run with the fixed code — had to reach into SDK internals: 1. `isinstance(get_controller(), RemoteController)` to decide whether launching/forking real runs is possible, or whether to fall back to running the workflow inline. 2. Hand-rolled `Action.listall(...FAILED...)` iteration to find which step failed and its error message. 3. `flyte._code_bundle.bundle.build_code_bundle.cache_clear()` to make the next fork ship the edited working tree — **no longer needed**: #1508 removed the code-bundle memoization entirely, so every launch re-bundles from disk. This PR originally shipped a `flyte.refresh_code_bundle_cache()` for that; it was dropped when merging main, since the cache it managed no longer exists. ## What **`flyte.is_control_plane_available()`** — True when the process can submit work to a control plane (launch real runs whose actions can be awaited and replayed/forked). Inside a task, the orchestration mode decides (`remote`/`hybrid` → True, `local` → False, even when a client is configured — `flyte run --local` configures one too); outside a task, a configured client decides. This replaces the isinstance-on-internal-controller probe with the `TaskContext.mode` the runtime already maintains. **`Run.first_failure()` and `ActionDetails.error_message`** — the observation half of a repair loop: which step of a run failed, and why. `first_failure()` returns the `ActionDetails` of the first failed action in creation order, preferring a failed sub-action over the failed root (whose error usually just repeats the sub-action's); `error_message` is the failed action's message or `""`. With these, the example's loop reduces to: ```python run = await flyte.run.aio(wf.main, n_records=n_records) await run.wait.aio(quiet=True) if failure := await run.first_failure.aio(): patch_workflow_source(failure.task_name, failure.error_message) importlib.reload(sys.modules["workflow"]) run = await fork.aio(run.name, task_template=wf.main) ``` ## Testing - New unit tests: `is_control_plane_available()` across uninitialized/client/local/remote/hybrid contexts; `Run.first_failure()` sub-action preference, root fallback, and no-failure; `error_message`. - Existing `code_bundle`, `remote`, `deploy`, and `cli/test_run.py` suites pass (the only failures are pre-existing on `main`: the two `loaded_modules` discovery tests). - `ruff`, `mypy`, and `ty` clean via pre-commit hooks. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01NwQixBcyR5va6BC75jaQx3 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent da1e6cb commit 1a0d78f

6 files changed

Lines changed: 274 additions & 0 deletions

File tree

src/flyte/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
init_from_config,
2626
init_in_cluster,
2727
init_passthrough,
28+
is_control_plane_available,
2829
)
2930
from ._interactive_run_context import load_interactive_ctx
3031
from ._link import Link
@@ -108,6 +109,7 @@ def version() -> str:
108109
"init_from_config",
109110
"init_in_cluster",
110111
"init_passthrough",
112+
"is_control_plane_available",
111113
"latest_checkpoint",
112114
"load_interactive_ctx",
113115
"load_plugin_config",

src/flyte/_initialize.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -801,6 +801,48 @@ def is_initialized() -> bool:
801801
return _get_init_config() is not None
802802

803803

804+
def is_control_plane_available() -> bool:
805+
"""
806+
True when this process can submit work to a Flyte control plane — `flyte.run` launches real
807+
remote runs whose actions can be inspected, awaited, and replayed (recovered/forked).
808+
809+
The answer depends on where the code is executing:
810+
811+
* Inside a task launched on a Flyte cluster (`flyte.ctx().is_in_cluster()`): True. The
812+
in-cluster runtime configures the connection before user code runs.
813+
* Inside a task executing locally (`flyte run --local` / `flyte.run(mode="local")`): False,
814+
even when a client happens to be configured — the local dev loop is expected to stay
815+
local, and a locally-orchestrated run has no control-plane actions to replay.
816+
* Outside any task (a driver script, a notebook): True iff a client has been configured via
817+
`flyte.init` / `flyte.init_from_config` / `flyte.init_from_api_key`.
818+
819+
Typical use is a task that adapts to where it runs — e.g. an agent that launches and forks
820+
real runs on a cluster, but falls back to invoking the task functions in-process when
821+
developed locally:
822+
823+
```python
824+
@env.task
825+
async def agent() -> None:
826+
if flyte.is_control_plane_available():
827+
run = await flyte.run.aio(my_pipeline, x=1)
828+
await run.wait.aio()
829+
else:
830+
await my_pipeline.func(x=1)
831+
```
832+
833+
Returns:
834+
True when remote submission is available, False otherwise.
835+
"""
836+
from flyte._context import ctx
837+
838+
tctx = ctx()
839+
if tctx:
840+
# "remote" and "hybrid" tasks are orchestrated by a control plane; "local" ones are not.
841+
return tctx.mode != "local"
842+
cfg = _get_init_config()
843+
return cfg is not None and cfg.client is not None
844+
845+
804846
def is_persistence_enabled() -> bool:
805847
"""Check if local run persistence is enabled."""
806848
cfg = _get_init_config()

src/flyte/remote/_action.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,6 +1020,14 @@ def error_info(self) -> run_definition_pb2.ErrorInfo | None:
10201020
return self.pb2.error_info
10211021
return None
10221022

1023+
@property
1024+
def error_message(self) -> str:
1025+
"""
1026+
The error message of a failed action, or an empty string when the action did not fail
1027+
(or carries no error details).
1028+
"""
1029+
return self.pb2.error_info.message if self.pb2.HasField("error_info") else ""
1030+
10231031
@property
10241032
def abort_info(self) -> run_definition_pb2.AbortInfo | None:
10251033
"""

src/flyte/remote/_run.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,35 @@ async def details(self) -> RunDetails:
315315
self._details.action_details._preserve_original_types = self._preserve_original_types
316316
return self._details
317317

318+
@syncify
319+
async def first_failure(self) -> ActionDetails | None:
320+
"""
321+
Details of the first action that failed in this run, or None when no action failed.
322+
323+
Failed actions are considered in creation order, and a failed sub-action (a step inside
324+
the run) is preferred over the failed root action — the root's error usually just
325+
repeats the sub-action's. The root's details are returned only when it is the only
326+
failure recorded. Together with `ActionDetails.task_name` and
327+
`ActionDetails.error_message`, this answers "which step broke, and why" — the
328+
observation an automated repair loop (or a human) needs before patching code and
329+
rerunning or forking the run:
330+
331+
```python
332+
run = flyte.run(my_pipeline, x=1)
333+
run.wait()
334+
if failure := run.first_failure():
335+
print(f"{failure.task_name} failed: {failure.error_message}")
336+
```
337+
"""
338+
fallback: ActionDetails | None = None
339+
async for action in Action.listall.aio(for_run_name=self.name, in_phase=(ActionPhase.FAILED,)):
340+
details = await action.details()
341+
if action.parent_name:
342+
return details
343+
if fallback is None:
344+
fallback = details
345+
return fallback
346+
318347
@syncify
319348
async def inputs(self) -> ActionInputs:
320349
"""
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""
2+
Tests for `Run.first_failure()` and `ActionDetails.error_message`.
3+
4+
These are the observation half of an automated repair loop: after a run fails, an agent (or a
5+
human script) asks which step broke and why, patches the code, and reruns/forks the run.
6+
"""
7+
8+
from unittest.mock import AsyncMock, MagicMock, patch
9+
10+
from flyteidl2.common import identifier_pb2, phase_pb2
11+
from flyteidl2.workflow import run_definition_pb2
12+
13+
from flyte.remote._action import ActionDetails
14+
from flyte.remote._run import Run
15+
16+
RUN_NAME = "run-abc"
17+
18+
19+
def _action_pb2(name: str, parent: str | None = None, task_name: str | None = None) -> run_definition_pb2.Action:
20+
action = run_definition_pb2.Action(
21+
id=identifier_pb2.ActionIdentifier(
22+
run=identifier_pb2.RunIdentifier(org="o", project="p", domain="d", name=RUN_NAME),
23+
name=name,
24+
),
25+
status=run_definition_pb2.ActionStatus(phase=phase_pb2.ACTION_PHASE_FAILED),
26+
)
27+
if parent:
28+
action.metadata.parent = parent
29+
if task_name:
30+
action.metadata.task.id.name = task_name
31+
return action
32+
33+
34+
def _details_pb2(name: str, message: str) -> run_definition_pb2.ActionDetails:
35+
return run_definition_pb2.ActionDetails(
36+
id=identifier_pb2.ActionIdentifier(
37+
run=identifier_pb2.RunIdentifier(org="o", project="p", domain="d", name=RUN_NAME),
38+
name=name,
39+
),
40+
error_info=run_definition_pb2.ErrorInfo(message=message),
41+
)
42+
43+
44+
def _run(phase=phase_pb2.ACTION_PHASE_FAILED) -> Run:
45+
return Run(
46+
run_definition_pb2.Run(
47+
action=run_definition_pb2.Action(
48+
id=identifier_pb2.ActionIdentifier(
49+
run=identifier_pb2.RunIdentifier(org="o", project="p", domain="d", name=RUN_NAME),
50+
name="a0",
51+
),
52+
status=run_definition_pb2.ActionStatus(phase=phase),
53+
)
54+
)
55+
)
56+
57+
58+
def _mock_client(failed_actions, details_by_name):
59+
client = MagicMock()
60+
61+
resp = MagicMock()
62+
resp.actions = failed_actions
63+
resp.token = ""
64+
client.run_service.list_actions = AsyncMock(return_value=resp)
65+
66+
async def get_action_details(request):
67+
details_resp = MagicMock()
68+
details_resp.details = details_by_name[request.action_id.name]
69+
return details_resp
70+
71+
client.run_service.get_action_details = AsyncMock(side_effect=get_action_details)
72+
return client
73+
74+
75+
def _first_failure(run, client):
76+
cfg = MagicMock()
77+
cfg.org, cfg.project, cfg.domain = "o", "p", "d"
78+
with (
79+
patch("flyte.remote._action.ensure_client"),
80+
patch("flyte.remote._action.get_client", return_value=client),
81+
patch("flyte.remote._action.get_init_config", return_value=cfg),
82+
):
83+
return run.first_failure()
84+
85+
86+
class TestRunFirstFailure:
87+
def test_prefers_failed_sub_action_over_root(self):
88+
# The root action's error just repeats the step's — the step is the useful answer.
89+
client = _mock_client(
90+
failed_actions=[
91+
_action_pb2("a0"),
92+
_action_pb2("clean-1", parent="a0", task_name="clean_records"),
93+
],
94+
details_by_name={
95+
"a0": _details_pb2("a0", "child failed"),
96+
"clean-1": _details_pb2("clean-1", "KeyError: 'price'"),
97+
},
98+
)
99+
failure = _first_failure(_run(), client)
100+
assert failure is not None
101+
assert failure.error_message == "KeyError: 'price'"
102+
103+
def test_falls_back_to_root_when_only_failure(self):
104+
client = _mock_client(
105+
failed_actions=[_action_pb2("a0")],
106+
details_by_name={"a0": _details_pb2("a0", "OOMKilled")},
107+
)
108+
failure = _first_failure(_run(), client)
109+
assert failure is not None
110+
assert failure.error_message == "OOMKilled"
111+
112+
def test_none_when_no_action_failed(self):
113+
client = _mock_client(failed_actions=[], details_by_name={})
114+
assert _first_failure(_run(phase=phase_pb2.ACTION_PHASE_SUCCEEDED), client) is None
115+
116+
117+
class TestActionDetailsErrorMessage:
118+
def test_message_of_failed_action(self):
119+
details = ActionDetails(_details_pb2("a0", "boom"))
120+
assert details.error_message == "boom"
121+
122+
def test_empty_when_no_error_info(self):
123+
details = ActionDetails(
124+
run_definition_pb2.ActionDetails(
125+
id=identifier_pb2.ActionIdentifier(
126+
run=identifier_pb2.RunIdentifier(org="o", project="p", domain="d", name=RUN_NAME),
127+
name="a0",
128+
),
129+
)
130+
)
131+
assert details.error_message == ""
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""
2+
Tests for `flyte.is_control_plane_available()`.
3+
4+
The predicate answers "can this process submit work to a control plane" — inside a task it is
5+
decided by how the task is orchestrated (mode), outside a task by whether a client is
6+
configured. See the function's docstring for the full decision table.
7+
"""
8+
9+
import pathlib
10+
11+
import pytest
12+
13+
import flyte
14+
from flyte._context import internal_ctx
15+
from flyte._initialize import _InitConfig
16+
from flyte.models import ActionID, RawDataPath, TaskContext
17+
from flyte.report import Report
18+
19+
20+
def _tctx(mode: str) -> TaskContext:
21+
return TaskContext(
22+
action=ActionID(name="a0"),
23+
version="v1",
24+
raw_data_path=RawDataPath(path="/tmp/rd"),
25+
output_path="/tmp/o",
26+
run_base_dir="/tmp",
27+
report=Report(name="t"),
28+
mode=mode,
29+
)
30+
31+
32+
def test_false_when_uninitialized(monkeypatch):
33+
monkeypatch.setattr("flyte._initialize._init_config", None)
34+
assert flyte.is_control_plane_available() is False
35+
36+
37+
def test_false_when_initialized_without_client(monkeypatch):
38+
monkeypatch.setattr("flyte._initialize._init_config", _InitConfig(root_dir=pathlib.Path.cwd()))
39+
assert flyte.is_control_plane_available() is False
40+
41+
42+
def test_true_when_client_configured(monkeypatch):
43+
monkeypatch.setattr(
44+
"flyte._initialize._init_config",
45+
_InitConfig(root_dir=pathlib.Path.cwd(), client=object()),
46+
)
47+
assert flyte.is_control_plane_available() is True
48+
49+
50+
@pytest.mark.parametrize(
51+
("mode", "expected"),
52+
[("remote", True), ("hybrid", True), ("local", False)],
53+
)
54+
def test_task_context_mode_decides(monkeypatch, mode, expected):
55+
# Inside a task, the orchestration mode wins over client presence: `flyte run --local`
56+
# configures a client too, but a locally-orchestrated run has no control plane behind it.
57+
monkeypatch.setattr(
58+
"flyte._initialize._init_config",
59+
_InitConfig(root_dir=pathlib.Path.cwd(), client=object()),
60+
)
61+
with internal_ctx().replace_task_context(_tctx(mode)):
62+
assert flyte.is_control_plane_available() is expected

0 commit comments

Comments
 (0)