Track Runs and Actions Through Their Lifecycle
Understand the run/action hierarchy
When you launch a remote task, the returned flyte.remote.Run is not a flat record of every execution node. A Run requires an action in its protobuf and creates run.action, the root flyte.remote.Action, during initialization. The Action docstring models the relationship as a hierarchy: a root action such as a0 can have child actions, and those children can have their own children.
run
└── a0
├── action1 under a0
└── action2 under a0
├── action1 under action2 under a0
└── action2 under action1 under action2 under a0
Use the run façade for run-wide lifecycle operations, and use run.action or separately retrieved actions when you need action-level state:
import flyte
from flyte.remote import Run
flyte.init_from_config()
r: Run = flyte.run(main, 10, 30.0)
print(r.name)
print(r.url)
r.wait()
value = r.outputs()
This is the lifecycle pattern used by examples/advanced/external_abort.py. Run.wait(), Run.outputs(), and Run.done() delegate to the root action. Run.get_logs() and Run.show_logs() do the same for logs. For child actions, call Action.listall(for_run_name=...); each action identifier contains both the run identifier and the action name. The source describes action names as unique within a parent, so address a child with its run name and action name rather than treating the action name as a standalone global identifier.
Summary objects and detail objects
Run and Action wrap summary protobufs. RunDetails and ActionDetails wrap the server-backed detail protobufs and expose richer status, attempts, phase transitions, logs metadata, errors, inputs, and outputs.
A RunDetails constructs its action_details from pb2.action, making the root action's detailed state the detailed representation of the run. Likewise, Action.details() lazily calls ActionDetails.get_details() and caches the result. Run.get() first retrieves RunDetails, then reconstructs a summary Run from the detailed action identifier, metadata, and status.
Initialize the remote client before retrieval
Call flyte.init(...), flyte.init_from_config(), or an equivalent run-context initializer before using these objects. The retrieval, listing, watching, log, data, and abort methods call ensure_client() and use the initialized organization, project, and domain. The normal configuration supplies the endpoint or API key as part of client initialization.
import flyte
from flyte.remote import Run, RunDetails
flyte.init_from_config()
run = Run.get(name="my-run")
details = RunDetails.get(name="my-run")
RunDetails.get() builds a RunIdentifier from the initialized organization, project, and domain. ActionDetails.get() similarly requires an action name and run name when no URI is supplied:
from flyte.remote import ActionDetails
run_details = RunDetails.get(name="my-run")
action_details = ActionDetails.get(
run_name="my-run",
name="child-action",
)
Although Action.get() and ActionDetails.get() expose a uri parameter, the current implementation does not parse it. When no URI is supplied, the implementation asserts that both run_name and name are present.
Retrieve runs and nested actions
Use Run.get() for one known run, or Run.listall() when you need server-side filtering. Listing defaults to the configured project and domain; pass project and domain to override them. limit defaults to 100, and the implementation follows continuation tokens while fetching pages.
runs = Run.listall(
in_phase=("succeeded",),
task_name="produce_value",
task_version="v1",
limit=1,
sort_by=("created_at", "desc"),
project="proj-x",
domain="dev",
)
for run in runs:
print(run.name, run.phase, run.url)
in_phase accepts strings or ActionPhase values. A single phase becomes an equality filter; multiple phases become a VALUE_IN filter. created_by_subject, created_at, and updated_at are also supported by Run.listall().
To inspect the execution nodes inside one run, list actions by run name and filter their phases:
from flyte.models import ActionPhase
from flyte.remote import Action
running_actions = list(
Action.listall(
for_run_name=run.name,
in_phase=[ActionPhase.RUNNING],
)
)
for action in running_actions:
print(action.name, action.phase, action.task_name)
Action.listall() supports phase, creation-time, update-time, and ordering filters. Action.get(run_name=..., name=...) fetches one action and rebuilds its summary object from ActionDetails; ActionDetails.get(...) returns the detail object directly.
A production use of the same listing API appears in flyte.app._parameter: it selects the newest successful run, fetches details, and reads decoded outputs.
runs = Run.listall.aio(
in_phase=("succeeded",),
task_name=self.task_name,
task_version=task_version,
limit=1,
sort_by=("created_at", "desc"),
)
try:
run = await anext(runs)
run_details: RunDetails = await run.details.aio()
output = await run_details.outputs()
for getter in self.getter:
output = output[getter]
except StopAsyncIteration:
raise flyte.errors.ParameterMaterializationError(f"No runs found for task {self.task_name}")
Inspect phase and attempt state
For a summary object, Run.phase is the root action's phase as a string, while Run.raw_phase exposes the protobuf enum. Action.phase and ActionDetails.phase return ActionPhase; their raw_phase properties return the protobuf enum. ActionDetails.is_running is true only when the phase is ACTION_PHASE_RUNNING.
done() means terminal, not successful. Action and ActionDetails treat FAILED, SUCCEEDED, ABORTED, and TIMED_OUT as terminal; Run.done() delegates to its root action. Check the phase and error_info when distinguishing success from other terminal outcomes:
details = await run.action.details()
if details.error_info:
raise RuntimeError(f"Run failed with error: {details.error_info.message}")
if run.done():
print(f"Terminal phase: {run.phase}")
ActionDetails.attempts reports the status attempt count. For per-attempt timing, call get_phase_transitions(attempt=...). Attempts are 1-indexed, and omitting attempt selects the latest attempt. A missing or invalid attempt returns an empty list. Each PhaseTransitionInfo contains the phase and timestamps; an unfinished transition computes its duration through the current UTC time.
transitions = details.get_phase_transitions()
for transition in transitions:
print(transition.phase, transition.duration.total_seconds())
print(details.phase_durations)
print(details.queued_time)
print(details.initializing_time)
print(details.running_time)
phase_durations describes the latest attempt by default and is keyed by phase. It is a dictionary, so a phase occurring more than once in one selected attempt is represented by the later dictionary entry. The focused phase-transition test constructs a completed action whose transitions report QUEUED for 30 seconds, INITIALIZING for 30 seconds, and RUNNING for 140 seconds.
Wait for a lifecycle point or stream updates
Use the default wait_for="terminal" when you need completion. Use "running" to return as soon as the action enters RUNNING, or "logs-ready" to return when the selected attempt reports available logs. The latter two are early observation points; they do not imply that outputs are ready or that the run is complete.
run.wait() # terminal
run.wait(wait_for="running") # returns when running
run.wait(wait_for="logs-ready") # returns when logs are available
if not run.done():
run.wait()
Run.wait() delegates to Action.wait() and then copies the updated action protobuf back to Run.pb2.action. Action.wait() consumes the action detail stream and renders either Rich progress output or plain status lines, depending on the SDK output mode. Pass quiet=True to suppress those progress and completion messages without changing the wait condition.
The public methods wrapped by syncify have synchronous and asynchronous forms. For example, Run.wait() has an asynchronous .aio() variant, while Run.listall.aio() is used as an asynchronous iterator:
await run.wait.aio(wait_for="terminal", quiet=True)
async for candidate in Run.listall.aio(in_phase=("succeeded",), limit=10):
print(candidate.name)
For update-by-update processing, Action.watch() yields ActionDetails updates and accepts the same wait_for values plus cache_data_on_done. It updates both its cached detail object and its summary protobuf, then stops at the requested condition or at a terminal update. Run.watch() delegates to the root action and additionally copies each updated action protobuf into Run.pb2.action:
async for update in run.watch(cache_data_on_done=True):
print(update.phase, update.runtime, update.attempts)
if update.done():
break
ActionDetails.watch(action_id) is the lower-level stream: it calls watch_action_details, yields each server response as an ActionDetails, and stops after a terminal update. A Connect CANCELED error ends that stream normally; other Connect errors are re-raised.
Read logs at the right point in the lifecycle
Use get_logs() when your code needs an iterator of plain strings, and show_logs() when you want the SDK's log viewer. Both default to the latest attempt when attempt is omitted. get_logs() supports filter_system and show_ts; show_logs() additionally accepts max_lines and raw (Run.show_logs() defaults to 100 lines, while Action.show_logs() defaults to 30).
async for line in run.get_logs(
attempt=None,
filter_system=True,
show_ts=True,
):
print(line)
For an action that is neither running nor terminal, Action.get_logs() and Action.show_logs() first call wait(wait_for="logs-ready"), refresh details, choose details.attempts, and then tail that attempt through Logs. A queued action therefore does not tail immediately. The remote log test verifies that a queued action causes exactly this wait before Logs.tail is consumed. Running or terminal actions do not require that preliminary wait.
ActionDetails.logs_available(attempt=None) checks the selected attempt's logs_available flag. As with phase inspection, attempts are 1-indexed and the default is the latest status attempt.
Abort a run or one nested action
Run-level and action-level aborts are separate remote operations. Call Run.abort() to issue abort_run, or call Action.abort() to issue abort_action for a selected execution node:
run.abort(reason="No longer needed")
action.abort(reason="Stop this child action")
The examples/advanced/external_abort.py example lists running actions, skips the root a0, aborts one child, and then waits for the parent run before reading outputs:
from flyte.models import ActionPhase
from flyte.remote import Action
actions = list(
Action.listall(
for_run_name=r.name,
in_phase=[ActionPhase.RUNNING],
)
)
for a in actions:
if a.name != "a0":
a.abort("External abort!!")
break
r.wait()
v = r.outputs()
Both abort methods swallow a Connect NOT_FOUND error and return None, making an already-missing run or action an idempotent case. Other Connect errors propagate to the caller.
Fetch inputs and outputs after observation
Run.details() returns cached RunDetails when the cached details are terminal; otherwise it fetches run details again. Run.inputs() and Run.outputs() delegate to that detail object. At the action level, ActionDetails._cache_data() calls the data proxy and decodes values using the task or trace interface. inputs() can fetch data before completion when the service provides it, but outputs() raises RuntimeError until the action is terminal and output data is available.
Set cache_data_on_done=True on a watch or wait path when you want terminal watching to prefetch outputs:
await run.wait.aio(quiet=True)
run_details = await run.details.aio()
outputs = await run_details.outputs()
Finally, do not use Run.sync() or Action.sync() as server refresh operations: both are currently placeholders that return self. Use a fresh details() call or watch() when you need current remote state. After a wait or watch, read the run's phase through run.phase or its action details; the lifecycle methods explicitly synchronize the summary protobuf with the latest action update.