fcloud-sdk 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. fcloud/SKILL.md +1185 -0
  2. fcloud/__init__.py +85 -0
  3. fcloud/__main__.py +150 -0
  4. fcloud/_direct_bridge.py +1645 -0
  5. fcloud/_legacy_env.py +14 -0
  6. fcloud/cli/__init__.py +23 -0
  7. fcloud/cli/attach.py +180 -0
  8. fcloud/cli/common.py +382 -0
  9. fcloud/cli/console.py +227 -0
  10. fcloud/cli/context.py +161 -0
  11. fcloud/cli/exec_cmd.py +201 -0
  12. fcloud/cli/files.py +280 -0
  13. fcloud/cli/hardware.py +83 -0
  14. fcloud/cli/help.py +150 -0
  15. fcloud/cli/interactive.py +104 -0
  16. fcloud/cli/job.py +300 -0
  17. fcloud/cli/main.py +140 -0
  18. fcloud/cli/migration.py +276 -0
  19. fcloud/cli/mount.py +150 -0
  20. fcloud/cli/output.py +17 -0
  21. fcloud/cli/processes.py +360 -0
  22. fcloud/cli/registry.py +28 -0
  23. fcloud/cli/run.py +318 -0
  24. fcloud/cli/sessions.py +592 -0
  25. fcloud/cli/setup.py +44 -0
  26. fcloud/cli/ssh.py +255 -0
  27. fcloud/cli/sweep.py +578 -0
  28. fcloud/cli/sweep_harvest.py +187 -0
  29. fcloud/cli/sweep_watch.py +104 -0
  30. fcloud/cli/volume.py +317 -0
  31. fcloud/cli/wait.py +314 -0
  32. fcloud/cli_args.py +348 -0
  33. fcloud/client.py +632 -0
  34. fcloud/client_projects.py +52 -0
  35. fcloud/client_sessions.py +111 -0
  36. fcloud/client_volumes.py +80 -0
  37. fcloud/config.py +404 -0
  38. fcloud/direct.py +535 -0
  39. fcloud/errors.py +102 -0
  40. fcloud/fileset.py +155 -0
  41. fcloud/image.py +483 -0
  42. fcloud/job.py +249 -0
  43. fcloud/providers/__init__.py +5 -0
  44. fcloud/providers/requests_http.py +59 -0
  45. fcloud/py.typed +0 -0
  46. fcloud/session.py +442 -0
  47. fcloud/setup_cmd.py +213 -0
  48. fcloud/shell.py +558 -0
  49. fcloud/sweeps.py +557 -0
  50. fcloud/tunnel.py +251 -0
  51. fcloud/types.py +302 -0
  52. fcloud/v2_connect.py +928 -0
  53. fcloud/version.py +33 -0
  54. fcloud/volume_wait.py +131 -0
  55. fcloud/volumes.py +204 -0
  56. fcloud_sdk-0.1.0.dist-info/METADATA +234 -0
  57. fcloud_sdk-0.1.0.dist-info/RECORD +63 -0
  58. fcloud_sdk-0.1.0.dist-info/WHEEL +5 -0
  59. fcloud_sdk-0.1.0.dist-info/entry_points.txt +3 -0
  60. fcloud_sdk-0.1.0.dist-info/licenses/LICENSE +202 -0
  61. fcloud_sdk-0.1.0.dist-info/licenses/NOTICE +4 -0
  62. fcloud_sdk-0.1.0.dist-info/top_level.txt +2 -0
  63. foom/__init__.py +36 -0
fcloud/cli/console.py ADDED
@@ -0,0 +1,227 @@
1
+ """Foreground-command plumbing shared by exec/run/wait.
2
+
3
+ Live console streaming, the long-wait stderr heartbeat, the ExecResult
4
+ exit-code contract and the --emit-pid writer.
5
+ """
6
+
7
+ import json as _json
8
+ import os
9
+ import sys
10
+
11
+ from fcloud.cli import output
12
+
13
+
14
+ def _live_console_sink():
15
+ """Return (on_output, pty) for live console streaming, or (None, None).
16
+
17
+ In human (non-JSON) output mode we stream the command's output to the
18
+ console as it arrives and run it under a pseudoterminal so the remote
19
+ process flushes in real time — just like running it locally in a
20
+ terminal. Under --json the output is captured and returned as one
21
+ structured result, so we neither stream nor allocate a PTY (a PTY
22
+ would merge stdout/stderr and inject \\r\\n into machine-read output).
23
+ """
24
+ if output._JSON_OUTPUT:
25
+ return None, None
26
+
27
+ def on_output(text: str) -> None:
28
+ sys.stdout.write(text)
29
+ sys.stdout.flush()
30
+
31
+ return on_output, _console_pty()
32
+
33
+
34
+ def _console_pty() -> dict[str, int]:
35
+ """PTY dimensions matching the local terminal (fallback 80x24)."""
36
+ import shutil
37
+
38
+ try:
39
+ sz = shutil.get_terminal_size()
40
+ return {"cols": max(1, int(sz.columns)), "rows": max(1, int(sz.lines))}
41
+ except Exception:
42
+ return {"cols": 80, "rows": 24}
43
+
44
+
45
+ class _ExecKeepalive:
46
+ """Periodic stderr heartbeat during a long foreground fcloud exec wait.
47
+
48
+ Tooling like the Claude Code Bash harness and many CI runners escalate
49
+ on no-output timers shorter than `fcloud exec --wait 8m`. Emitting a
50
+ short line every `interval_s` keeps those watchdogs happy without
51
+ polluting stdout (which carries the command's actual output / JSON).
52
+ """
53
+
54
+ def __init__(self, interval_s: float = 30.0) -> None:
55
+ import threading
56
+ self._interval = max(1.0, float(interval_s))
57
+ self._stop = threading.Event()
58
+ self._thread: threading.Thread | None = None
59
+ self._start_t = 0.0
60
+
61
+ def __enter__(self) -> "_ExecKeepalive":
62
+ import threading
63
+ import time
64
+ self._start_t = time.time()
65
+ self._thread = threading.Thread(
66
+ target=self._loop, name="fcloud-exec-keepalive", daemon=True,
67
+ )
68
+ self._thread.start()
69
+ return self
70
+
71
+ def __exit__(self, *exc) -> None:
72
+ self._stop.set()
73
+ if self._thread is not None:
74
+ self._thread.join(timeout=1.0)
75
+
76
+ def _loop(self) -> None:
77
+ import time
78
+ while not self._stop.wait(self._interval):
79
+ elapsed = int(time.time() - self._start_t)
80
+ sys.stderr.write(
81
+ f" ⟳ still waiting on remote command ({elapsed}s)...\n",
82
+ )
83
+ sys.stderr.flush()
84
+
85
+
86
+ def _maybe_keepalive(enabled: bool):
87
+ """Context manager that activates the stderr heartbeat only when
88
+ long-wait runs would otherwise trip no-output watchdogs."""
89
+ import contextlib
90
+ if not enabled:
91
+ return contextlib.nullcontext()
92
+ return _ExecKeepalive()
93
+
94
+
95
+ def _exec_result(result, *, streamed: bool = False,
96
+ still_running_exit: int = 75) -> None:
97
+ """Print an ExecResult and exit with its return code.
98
+
99
+ When ``streamed`` is True the output was already written to the
100
+ console live (see ``_live_console_sink``), so we skip re-printing it
101
+ here and only surface status/return code.
102
+ """
103
+ if output._JSON_OUTPUT:
104
+ output._out({
105
+ "stdout": result.stdout,
106
+ "stderr": result.stderr,
107
+ "returncode": result.returncode,
108
+ "duration_ms": result.duration_ms,
109
+ "status": result.status,
110
+ "process_id": result.process_id,
111
+ "session_id": result.session_id,
112
+ "message": result.message,
113
+ "stdout_truncated": result.stdout_truncated,
114
+ "stdout_total_bytes": result.stdout_total_bytes,
115
+ "stdout_retained_bytes": result.stdout_retained_bytes,
116
+ "stdout_range": result.stdout_range,
117
+ "output_dropped_bytes": result.output_dropped_bytes,
118
+ })
119
+ else:
120
+ if not streamed:
121
+ if result.stdout:
122
+ sys.stdout.write(result.stdout)
123
+ if result.stderr:
124
+ sys.stderr.write(result.stderr)
125
+ if result.stdout_truncated:
126
+ print(_stdout_truncation_warning(result), file=sys.stderr)
127
+ if result.output_dropped_bytes:
128
+ print(
129
+ "Warning: command output is incomplete: "
130
+ f"{result.output_dropped_bytes} byte(s) were dropped in transport.",
131
+ file=sys.stderr,
132
+ )
133
+ if result.status in ("running", "migrating"):
134
+ msg = result.message or (
135
+ "Command is still running. "
136
+ f"Follow logs: fcloud logs {result.session_id} "
137
+ f"{result.process_id} --follow"
138
+ )
139
+ print(msg, file=sys.stderr)
140
+ elif result.status in ("host_terminated", "host_lost"):
141
+ msg = result.message or (
142
+ "Host was interrupted; the process did not survive. "
143
+ f"Fetch any captured output: fcloud logs {result.session_id} "
144
+ f"{result.process_id} --output all"
145
+ )
146
+ print(msg, file=sys.stderr)
147
+ if result.returncode is None:
148
+ # Distinguish "still running, no return code yet" from "host died,
149
+ # the process was killed" (exit 143 = SIGTERM-style, so shell
150
+ # scripts and `set -e` callers don't silently treat eviction as
151
+ # success). host_lost reaching here means a migration ride wasn't
152
+ # wired for this path — treat it as a lost host (143), never a
153
+ # false success.
154
+ if result.status in ("host_terminated", "host_lost"):
155
+ sys.exit(143)
156
+ # "migrating": the ride ran out of --wait budget mid-restore. The
157
+ # process is neither confirmed alive nor dead, so exit retryable
158
+ # (75, EX_TEMPFAIL — the same convention as `fcloud wait` timeouts).
159
+ if result.status == "migrating":
160
+ sys.exit(75)
161
+ if result.status == "running":
162
+ # An expired wait exits 75 like `fcloud wait` does, so automation
163
+ # can't mistake an unfinished command for success (a fan-out
164
+ # once failed hundreds of healthy tasks on exactly that). Callers
165
+ # that explicitly did NOT wait (exec --wait 0 fire-and-forget)
166
+ # pass still_running_exit=0: "started" is their success.
167
+ sys.exit(still_running_exit)
168
+ sys.exit(0)
169
+ if result.returncode < 0:
170
+ # A negative code means the runtime lost the process to a signal —
171
+ # in practice the OOM killer. Passing it to sys.exit() wrapped it to
172
+ # 255, an exit code that means nothing to anyone: a training script
173
+ # dying at step 8 with "255" and no other clue is what that looks
174
+ # like. 137 (128+SIGKILL) is what a shell reports for a killed
175
+ # process, so `set -e` scripts and agents can branch on it. The
176
+ # reason, when the host knows it, arrives separately as the
177
+ # workload_oom session notice.
178
+ print(
179
+ "The command was killed before it could report an exit code "
180
+ "(exit 137). If this was memory, `fcloud logs` names the cause.",
181
+ file=sys.stderr,
182
+ )
183
+ sys.exit(137)
184
+ sys.exit(result.returncode)
185
+
186
+
187
+ def _pid_emitter(path: str, session_id: str):
188
+ """Build an on_start callback that records the process id to ``path`` the
189
+ instant the command starts — before the blocking wait.
190
+
191
+ This is the pipe- and background-proof channel the detach message can't
192
+ be: `fcloud exec --wait 60m ... 2>&1 | tail` buffers all stdio until EOF and
193
+ a backgrounded shell swallows it, so the proc-id needed for `fcloud wait` /
194
+ `fcloud logs --follow` never surfaces. The file is written independently of
195
+ stdout/stderr, so it's readable immediately even mid-wait. Written
196
+ atomically (temp + rename) so a reader never sees a half-written id.
197
+ """
198
+ def _emit(pid: str) -> None:
199
+ payload = _json.dumps({"session_id": session_id, "process_id": pid})
200
+ tmp = f"{path}.tmp"
201
+ try:
202
+ # Create missing parent dirs so a mkdir failure lands in the same
203
+ # warn-and-continue branch as a write failure.
204
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
205
+ with open(tmp, "w") as fh:
206
+ fh.write(payload + "\n")
207
+ os.replace(tmp, path)
208
+ except OSError as exc:
209
+ # A bad --emit-pid path shouldn't sink the actual command; warn
210
+ # and carry on (the proc still runs and is waitable by other means).
211
+ sys.stderr.write(f"fcloud: could not write --emit-pid {path}: {exc}\n")
212
+ return _emit
213
+
214
+
215
+ def _stdout_truncation_warning(result) -> str:
216
+ logs_cmd = (
217
+ f" If the session is still active, fetch full log: fcloud logs {result.session_id} "
218
+ f"{result.process_id} --output all."
219
+ if result.session_id and result.process_id else ""
220
+ )
221
+ return (
222
+ "Warning: stdout truncated to "
223
+ f"{result.stdout_range} {result.stdout_retained_bytes} of "
224
+ f"{result.stdout_total_bytes} bytes. Rerun with --output all, "
225
+ "or choose --output head|middle|tail --output-bytes N."
226
+ f"{logs_cmd}"
227
+ )
fcloud/cli/context.py ADDED
@@ -0,0 +1,161 @@
1
+ """Local context: fcloud.json project defaults and API credentials.
2
+
3
+ Resolves what a command needs from the working directory (project
4
+ config, volumes, disk floor) and from the saved token/URL.
5
+ """
6
+
7
+ import sys
8
+
9
+ from fcloud.cli.common import _parse_volume_mount
10
+ from fcloud.client import Client
11
+ from fcloud.config import image_from_config, load_project_config
12
+
13
+
14
+ def _resolve_project(client: Client, sku: str = ""):
15
+ """Build a project from fcloud.json if present, else use defaults.
16
+
17
+ `sku` is the CLI-provided override (e.g. `--sku cpu_8x_graviton`).
18
+ When present, it takes precedence over `cfg["sku"]` for image
19
+ construction — without this, an fcloud.json declaring a GPU sku
20
+ would force the pytorch wheel index even when the user explicitly
21
+ overrides to a CPU sku at the CLI.
22
+ """
23
+ cfg = load_project_config()
24
+ default_sku = cfg.get("sku", "")
25
+ image = image_from_config(cfg, sku=sku or default_sku)
26
+ if image:
27
+ project = client.project("_cli", image=image)
28
+ else:
29
+ project = client.project("_cli")
30
+ return project, default_sku
31
+
32
+
33
+ def _resolve_min_disk_gb(cli_value: int) -> int:
34
+ """CLI --min-disk-gb wins; else fall back to fcloud.json `min_disk_gb`.
35
+
36
+ Mirrors how --sku overrides cfg["sku"], so a project can declare a disk
37
+ floor once in fcloud.json and skip the flag on every command.
38
+ """
39
+ if cli_value > 0:
40
+ return cli_value
41
+ try:
42
+ return int(load_project_config().get("min_disk_gb", 0) or 0)
43
+ except (TypeError, ValueError):
44
+ return 0
45
+
46
+
47
+ def _normalize_config_volume(entry: object) -> dict[str, str] | None:
48
+ """Normalize one fcloud.json `volumes` entry to {name[, mount_path]}.
49
+
50
+ Accepts a string ("name" or "name:/mount") or an object with `name`
51
+ and optional `mount`/`mount_path`.
52
+ """
53
+ if isinstance(entry, str):
54
+ try:
55
+ return _parse_volume_mount(entry)
56
+ except ValueError:
57
+ return None
58
+ if isinstance(entry, dict):
59
+ name = str(entry.get("name", "")).strip()
60
+ if not name:
61
+ return None
62
+ out = {"name": name}
63
+ mount = entry.get("mount_path") or entry.get("mount") or ""
64
+ if mount:
65
+ out["mount_path"] = str(mount).strip()
66
+ return out
67
+ return None
68
+
69
+
70
+ def _resolve_volume_mounts(
71
+ cli_mounts: list[dict[str, str]],
72
+ ) -> list[dict[str, str]]:
73
+ """Merge fcloud.json `volumes` defaults with CLI --volume flags.
74
+
75
+ Effective set = config volumes ∪ CLI flags, keyed by name; a CLI
76
+ --volume for a name already in fcloud.json re-maps its mount path for
77
+ that invocation. Mirrors how --sku overrides cfg["sku"], so a project
78
+ can declare its volumes once and skip --volume on every command.
79
+ """
80
+ cfg = load_project_config()
81
+ order: list[str] = []
82
+ by_name: dict[str, dict[str, str]] = {}
83
+ for entry in cfg.get("volumes", []) or []:
84
+ mount = _normalize_config_volume(entry)
85
+ if mount and mount["name"] not in by_name:
86
+ order.append(mount["name"])
87
+ by_name[mount["name"]] = mount
88
+ cli_seen: dict[str, dict[str, str]] = {}
89
+ for mount in cli_mounts:
90
+ name = mount["name"]
91
+ # A CLI flag overriding a fcloud.json default is the re-map feature;
92
+ # two CLI flags naming the same volume differently is a conflict —
93
+ # a volume mounts once per session, and silently keeping only the
94
+ # last flag hid that (BUG-14, 2026-08-20 report).
95
+ prior = cli_seen.get(name)
96
+ if prior is not None and prior != mount:
97
+ print(
98
+ f"Error: duplicate_volume — volume {name!r} can only be "
99
+ f"mounted once per session (asked for "
100
+ f"{prior.get('mount_path') or '/workspace/' + name} and "
101
+ f"{mount.get('mount_path') or '/workspace/' + name}).",
102
+ file=sys.stderr)
103
+ sys.exit(2)
104
+ cli_seen[name] = mount
105
+ if name not in by_name:
106
+ order.append(name)
107
+ by_name[name] = mount # CLI re-maps mount path
108
+ return [by_name[name] for name in order]
109
+
110
+
111
+ def _current_image_spec_json(sku: str) -> str:
112
+ """Serialize the local fcloud.json image spec for resume re-baseline.
113
+
114
+ Returns "" when there's no project config, so callers fall through
115
+ to the dispatcher's stored spec (existing behavior). When set, the
116
+ dispatcher uses this on resume of a cold session — the edited
117
+ fcloud.json actually takes effect on next use.
118
+ """
119
+ cfg = load_project_config()
120
+ if not cfg:
121
+ return ""
122
+ image = image_from_config(cfg, sku=sku or cfg.get("sku", ""))
123
+ if image is None:
124
+ return ""
125
+ import json as _json
126
+ return _json.dumps(image.to_dict())
127
+
128
+
129
+ def _api_base() -> tuple[str, dict]:
130
+ from fcloud.config import resolve_api_key, resolve_url
131
+
132
+ url = resolve_url(None)
133
+ key = resolve_api_key(None)
134
+ if not key:
135
+ print("Error: no API key. Run: fcloud set_token <key>", file=sys.stderr)
136
+ sys.exit(2)
137
+ return url, {"Authorization": f"Bearer {key}"}
138
+
139
+
140
+ def _api_key() -> str:
141
+ """The resolved API key, for SDK helpers that take it directly."""
142
+ from fcloud.config import resolve_api_key
143
+
144
+ key = resolve_api_key(None)
145
+ if not key:
146
+ print("Error: no API key. Run: fcloud set_token <key>", file=sys.stderr)
147
+ sys.exit(2)
148
+ return key
149
+
150
+
151
+ def _fs_base(session_id: str) -> tuple[str, dict]:
152
+ """Resolve (browse-endpoint-base, auth-headers) for a session's
153
+ read-only file tree on the dispatcher."""
154
+ from fcloud.config import resolve_api_key, resolve_url
155
+
156
+ url = resolve_url(None)
157
+ key = resolve_api_key(None)
158
+ if not key:
159
+ print("Error: no API key. Run: fcloud set_token <key>", file=sys.stderr)
160
+ sys.exit(2)
161
+ return f"{url}/v1/fs/{session_id}/", {"Authorization": f"Bearer {key}"}
fcloud/cli/exec_cmd.py ADDED
@@ -0,0 +1,201 @@
1
+ """`fcloud exec`: run one command on a GPU host in the foreground.
2
+
3
+ (Named exec_cmd because `exec` shadows the builtin.)
4
+ """
5
+
6
+ import sys
7
+ import time
8
+
9
+ from fcloud.cli.attach import _attach_with_retry, _exit_session_unreachable
10
+ from fcloud.cli.common import (
11
+ _consume_positional_session,
12
+ _extract_flag,
13
+ _extract_min_disk,
14
+ _extract_on,
15
+ _extract_output_policy,
16
+ _extract_sku,
17
+ _extract_volumes,
18
+ _extract_wait,
19
+ _shape_exec_command,
20
+ _split_trailing_args,
21
+ _warn_sku_ignored_on_attach,
22
+ )
23
+ from fcloud.cli.console import (
24
+ _exec_result,
25
+ _live_console_sink,
26
+ _maybe_keepalive,
27
+ _pid_emitter,
28
+ )
29
+ from fcloud.cli.context import (
30
+ _current_image_spec_json,
31
+ _resolve_min_disk_gb,
32
+ _resolve_project,
33
+ _resolve_volume_mounts,
34
+ )
35
+ from fcloud.cli.migration import _ride_migration
36
+ from fcloud.cli.registry import cmd
37
+ from fcloud.client import Client
38
+
39
+ _EXEC_LONG_HELP = """\
40
+ Where it runs:
41
+ --sku SKU make a throwaway session on that hardware, run, release it.
42
+ Its filesystem is still saved and downloadable afterwards.
43
+ --on SID attach to an existing session (see `fcloud create`), run, detach.
44
+ The session stays alive; a cold one comes online first.
45
+ (neither) a throwaway session on the fcloud.json sku, or the default.
46
+
47
+ Commands run from /workspace — the same place `fcloud upload` puts your files —
48
+ so an uploaded train.py runs as `python train.py`. Don't hardcode /root paths.
49
+ For shell syntax (pipes, &&, redirects) pass one quoted expression or use
50
+ `sh -c`; unquoted metacharacters are eaten by your LOCAL shell first.
51
+
52
+ --wait bounds the foreground wait (default 30s). Past the deadline the process
53
+ keeps running and exec exits 75 — pick it up with `fcloud logs <sid> <pid>` and
54
+ `fcloud wait`, or start it detached with `fcloud spawn` in the first place.
55
+
56
+ --volume NAME[:MOUNT] mounts a volume for this command (repeatable) — see
57
+ `fcloud help volume`.
58
+
59
+ Examples:
60
+ fcloud exec --sku gpu_1x_l4 nvidia-smi -L
61
+ fcloud exec --on s-abc123 --wait 10m python train.py
62
+ fcloud exec --sku gpu_1x_l4 --volume checkpoints python train.py
63
+ fcloud exec --on s-abc123 -- sh -c 'cd data && ls | head'"""
64
+
65
+
66
+ @cmd("exec",
67
+ "fcloud exec [--sku SKU] [--on SID|SID] [--volume NAME[:MOUNT]] "
68
+ "[--wait 30s] [--output MODE] <cmd...>",
69
+ "Run a command on a GPU host",
70
+ long_help=_EXEC_LONG_HELP)
71
+ def cmd_exec(args: list[str]) -> None:
72
+ (sku, on_sid, wait_s, output_range, output_limit,
73
+ volume_mounts, min_disk_gb, emit_pid, cmd_args) = _parse_exec_args(args)
74
+ if not cmd_args:
75
+ print("Usage: fcloud exec [--sku SKU] [--on SESSION|SESSION] "
76
+ "[--volume NAME[:MOUNT]] [--wait 30s] "
77
+ "[--emit-pid FILE] "
78
+ "[--output tail|head|middle|all] "
79
+ "[--output-bytes N] <command...>")
80
+ print("\nExamples:")
81
+ print(" fcloud exec nvidia-smi -L")
82
+ print(" fcloud exec --sku gpu_1x_l4 python train.py")
83
+ print(" fcloud exec --on s-abc123 --wait 10m python train.py")
84
+ print(" fcloud exec --volume checkpoints:/workspace/ckpt python train.py")
85
+ print(" fcloud exec --json --output all python cuda_probe.py")
86
+ print(" fcloud exec --output head --output-bytes 128k python noisy.py")
87
+ print(" fcloud exec s-abc123 --wait 10m -- python train.py")
88
+ print(" fcloud exec --on s-abc123 --emit-pid pid.txt --wait 10m python train.py")
89
+ print(" fcloud exec --on s-abc123 python train.py")
90
+ sys.exit(1)
91
+ cmd_args = _shape_exec_command(cmd_args)
92
+ volume_mounts = _resolve_volume_mounts(volume_mounts)
93
+ on_output, pty = _live_console_sink()
94
+ # Long --wait blocks the foreground; no-output watchdogs (CI runners,
95
+ # Claude Code Bash) escalate without periodic stderr activity. Only
96
+ # arm the heartbeat for waits long enough to risk a watchdog kill, and
97
+ # only when stdout isn't already streaming (the stream itself is the
98
+ # heartbeat in human mode).
99
+ use_keepalive = wait_s >= 60 and on_output is None
100
+ client = Client()
101
+ if on_sid:
102
+ _warn_sku_ignored_on_attach(client, on_sid, sku)
103
+ # Forward the local fcloud.json spec on attach so a cold session's
104
+ # next resume re-baselines against the current config. Without
105
+ # this, sessions stay pinned to the spec they were first created
106
+ # with and edits to fcloud.json silently never apply.
107
+ attach_spec = _current_image_spec_json(sku)
108
+ on_start = _pid_emitter(emit_pid, on_sid) if emit_pid else None
109
+ try:
110
+ # Attach can blip in the setup/binding window right after a host
111
+ # first reports ready — the session isn't bound to its host yet, so
112
+ # the very first exec fails transiently and self-heals in seconds.
113
+ # Retry the (idempotent) attach before giving up; run() is issued
114
+ # once, outside the retry, so a command never double-executes.
115
+ session = _attach_with_retry(
116
+ client, on_sid, volume_mounts, attach_spec, sku)
117
+ try:
118
+ # The --wait budget covers the whole foreground command,
119
+ # including any migration ride after the run itself returns.
120
+ deadline = time.monotonic() + wait_s if wait_s > 0 else None
121
+ with _maybe_keepalive(use_keepalive):
122
+ result = session.run(
123
+ cmd_args,
124
+ timeout_ms=wait_s * 1000,
125
+ output_limit_bytes=output_limit,
126
+ output_range=output_range,
127
+ on_output=on_output,
128
+ on_start=on_start,
129
+ pty=pty,
130
+ )
131
+ result = _ride_migration(client, result, on_output, deadline)
132
+ # --wait 0 is explicit fire-and-forget: "launched OK" is
133
+ # success (0). Any positive wait that expires exits 75.
134
+ _exec_result(result, streamed=on_output is not None,
135
+ still_running_exit=0 if wait_s == 0 else 75)
136
+ finally:
137
+ client.detach_session(on_sid)
138
+ except Exception as exc:
139
+ # Attach/run raised and couldn't recover. This fires for an initial
140
+ # attach that never landed, or a run whose host vanished before
141
+ # _ride_migration could re-attach. Ask the dispatcher what actually
142
+ # happened and report the true cause — we only claim a migration
143
+ # when one is real, never as a blanket label for every blip.
144
+ _exit_session_unreachable(client, on_sid, exc)
145
+ else:
146
+ project, default_sku = _resolve_project(client, sku=sku)
147
+ sku = sku or default_sku
148
+ session = project.session(
149
+ sku=sku, volume_mounts=volume_mounts,
150
+ min_disk_gb=_resolve_min_disk_gb(min_disk_gb),
151
+ )
152
+ print(f"Session: {session.session_id}", file=sys.stderr)
153
+ on_start = _pid_emitter(emit_pid, session.session_id) if emit_pid else None
154
+ result = None
155
+ try:
156
+ # The --wait budget covers the whole foreground command,
157
+ # including any migration ride after the run itself returns.
158
+ deadline = time.monotonic() + wait_s if wait_s > 0 else None
159
+ with _maybe_keepalive(use_keepalive):
160
+ result = session.run(
161
+ cmd_args,
162
+ timeout_ms=wait_s * 1000,
163
+ output_limit_bytes=output_limit,
164
+ output_range=output_range,
165
+ on_output=on_output,
166
+ on_start=on_start,
167
+ pty=pty,
168
+ )
169
+ result = _ride_migration(client, result, on_output, deadline)
170
+ finally:
171
+ # Soft-release once the command is done: the host tears the
172
+ # ephemeral session down only if nothing else is using it,
173
+ # freeing the host promptly for the common one-shot case. If
174
+ # the command is still running — or still riding a restore
175
+ # ("migrating": releasing would tear down the session the
176
+ # restore is bringing back) — detach instead.
177
+ if result is None or result.status not in ("running", "migrating"):
178
+ client.release_session(session.session_id)
179
+ else:
180
+ client.detach_session(session.session_id)
181
+ _exec_result(result, streamed=on_output is not None,
182
+ still_running_exit=0 if wait_s == 0 else 75)
183
+
184
+
185
+ def _parse_exec_args(
186
+ args: list[str],
187
+ ) -> tuple[str, str, int, str, int | None, list[dict[str, str]], int, str, list[str]]:
188
+ args, trailing = _split_trailing_args(args)
189
+ volume_mounts, args = _extract_volumes(args)
190
+ sku, remaining = _extract_sku(args)
191
+ min_disk_gb, remaining = _extract_min_disk(remaining)
192
+ on_sid, remaining = _extract_on(remaining)
193
+ wait_s, remaining = _extract_wait(remaining)
194
+ emit_pid, remaining = _extract_flag(remaining, "--emit-pid")
195
+ output_range, output_limit, remaining = _extract_output_policy(remaining)
196
+ if not on_sid:
197
+ on_sid, remaining = _consume_positional_session(remaining)
198
+ return (
199
+ sku, on_sid, wait_s, output_range, output_limit,
200
+ volume_mounts, min_disk_gb, emit_pid, remaining + trailing,
201
+ )