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/SKILL.md ADDED
@@ -0,0 +1,1185 @@
1
+ ---
2
+ name: fcloud
3
+ description: >-
4
+ Use fcloud to provision GPU or CPU hosts, run remote commands and scripts,
5
+ manage persistent sessions, transfer files, and monitor long-running jobs from
6
+ agent workflows.
7
+ ---
8
+
9
+ # fcloud — GPU compute from the command line
10
+
11
+ fcloud provisions GPU and CPU hosts, runs code on them, and transfers files — all
12
+ from the terminal. It works with any AI coding agent (Cursor, Claude Code, Codex,
13
+ etc.) so your agent can provision hardware, run experiments, and pull results
14
+ autonomously.
15
+
16
+ Package: `fcloud` | CLI command: `fcloud` | Python 3.10+
17
+
18
+ ---
19
+
20
+ ## Setup
21
+
22
+ ```bash
23
+ pip install fcloud-sdk
24
+ fcloud set_token <api_key> # one-time; saves to ~/.fcloud/token
25
+ fcloud health # verify connectivity
26
+ ```
27
+
28
+ Token resolution order:
29
+
30
+ 1. `api_key=` parameter to `Client()`
31
+ 2. `FCLOUD_API_KEY` environment variable (legacy `FOOM_API_KEY` still accepted)
32
+ 3. Saved token in `~/.fcloud/token`
33
+ 4. `FCLOUD_API_KEY` from the nearest `.env` file
34
+
35
+ (For a human at a terminal, plain `fcloud set_token` with no argument prompts
36
+ with hidden input, keeping the key out of shell history.)
37
+
38
+ ---
39
+
40
+ ## Quick Start
41
+
42
+ ### Run a script on a GPU (throwaway session)
43
+
44
+ ```bash
45
+ # Upload a script, run it on an L4, print output, tear down
46
+ fcloud run train.py --sku gpu_1x_l4
47
+
48
+ # Upload a directory, run a specific script, pass arguments
49
+ fcloud run . --script train.py --sku gpu_1x_l4 -- --epochs 50 --lr 1e-4
50
+
51
+ # Run a one-off command (no file upload)
52
+ fcloud exec --sku gpu_1x_l4 nvidia-smi -L
53
+ ```
54
+
55
+ ### Choosing a SKU
56
+
57
+ Request hardware by passing `--sku` to `exec`/`run`/`create`. SKUs follow the
58
+ pattern `gpu_<count>x_<family>` or `cpu_<count>x_<family>`: `gpu_1x_l4`,
59
+ `gpu_1x_a10`, `gpu_8x_h100`, `cpu_generic`, `cpu_2x_epyc`, etc. If a SKU has
60
+ no available capacity, the session queues until a host is provisioned.
61
+
62
+ ---
63
+
64
+ ## CLI Reference
65
+
66
+ ### Global Flags
67
+
68
+
69
+ | Flag | Description |
70
+ | -------- | --------------------------------------------------------------------- |
71
+ | `--json` | Machine-readable JSON output to stdout. Status messages go to stderr. |
72
+
73
+
74
+ Use `--json` in agent workflows so output can be parsed programmatically. Place
75
+ `--json` before your remote command (e.g. `fcloud exec --json -- python app.py`):
76
+ flags after the command — or after a `--` — belong to *your* program, so
77
+ `fcloud exec -- python app.py --json` passes `--json` to `app.py`, not to fcloud.
78
+ Unknown flags are rejected (e.g. a typo'd `--limitt`) rather than silently
79
+ ignored.
80
+
81
+ ### Infrastructure
82
+
83
+
84
+ | Command | Description |
85
+ | ---------------------- | ----------------------------------------- |
86
+ | `fcloud health` | Check API connectivity |
87
+ | `fcloud set_token <key>` | Save API key |
88
+
89
+
90
+ ### Execution
91
+
92
+
93
+ | Command | Description |
94
+ | ----------------------------------------------------------------------------- | ----------------------- |
95
+ | `fcloud exec [--volume NAME[:MOUNT]] [--sku SKU] [--on SID|SID] [--wait 30s] [--emit-pid FILE] <cmd...>` | Run a command on a host |
96
+ | `fcloud run [--volume NAME[:MOUNT]] [--on SID|SID] <file|dir> [--sku SKU] [--script NAME] [-- args...]` | Upload + run on a host |
97
+ | `fcloud upload [--on SID|SID] <local> [remote]` / `fcloud download [--on SID|SID] <remote> [local]` | Transfer files (session via `--on` or positional) |
98
+
99
+
100
+ Without `--on`, `exec` and `run` create a throwaway session, do the work, and
101
+ release it automatically — its filesystem is still saved and downloadable
102
+ afterwards. For a true no-persistence run (cheaper; nothing syncs to
103
+ storage), use a **job** — see the Jobs section. With `--on <session-id>` or a
104
+ positional session ID alias, they attach to an existing session, run, and
105
+ detach (session stays alive).
106
+
107
+ Cached images complete in ~2s. Cold builds (first run on a host) take 30–120s.
108
+
109
+ ### Exec Semantics
110
+
111
+ `fcloud exec` is optimized for agents and ML workloads. It starts commands as
112
+ managed processes and waits for a foreground result for a configurable budget.
113
+
114
+ **Working directory.** Commands run from `/workspace` by default — the same
115
+ place `fcloud upload` puts your files. So an uploaded `train.py` runs as
116
+ `python train.py` (relative path, no `cd` and no need to spell out
117
+ `/workspace/train.py`), and commands that write to relative paths also land in
118
+ `/workspace` and persist. Do **not** assume a different default (e.g. `/root`)
119
+ or hardcode `/root/...` paths — that's the single most common cwd mistake. To
120
+ run elsewhere for one command, `cd` inside it: `sh -c 'cd /some/dir && ...'`.
121
+ This applies uniformly to `exec`, `run`, `spawn`, and `shell`.
122
+
123
+ ```bash
124
+ # Default wait budget is 30s
125
+ fcloud exec --on s-abc123 python3 /workspace/train.py
126
+
127
+ # Wait longer before detaching
128
+ fcloud exec --on s-abc123 --wait 10m python3 /workspace/train.py
129
+
130
+ # Positional session alias for --on
131
+ fcloud exec s-abc123 --wait 10m -- python3 /workspace/train.py
132
+
133
+ # Alias for --wait, useful when an agent thinks in timeouts
134
+ fcloud exec --on s-abc123 --timeout 10m python3 /workspace/train.py
135
+ ```
136
+
137
+ If the command exits within `--wait`, `exec` behaves like a normal foreground
138
+ command: stdout/stderr are printed and the CLI exits with the remote return code.
139
+
140
+ If the command is still running when the wait budget expires, `exec` does **not**
141
+ kill it. It detaches and prints a process ID:
142
+
143
+ ```bash
144
+ Command is still running; use fcloud logs s-abc123 proc-456 --follow
145
+ ```
146
+
147
+ In `--json` mode, a detached command returns `status: "running"`,
148
+ `returncode: null`, plus `session_id` and `process_id`. Agents should treat that
149
+ as success-with-handle, not as command failure, then block on it with
150
+ `fcloud wait <session_id> <process_id>` (returns the real exit code + tail) or
151
+ tail it with `fcloud logs --follow`.
152
+
153
+ ```json
154
+ {
155
+ "stdout": "",
156
+ "stderr": "",
157
+ "returncode": null,
158
+ "status": "running",
159
+ "session_id": "s-abc123",
160
+ "process_id": "proc-456",
161
+ "message": "Command is still running; use fcloud logs s-abc123 proc-456 --follow"
162
+ }
163
+ ```
164
+
165
+ `exec` returns a bounded stdout payload by default so a noisy command cannot flood
166
+ the caller. If the JSON response has `stdout_truncated: true`, do **not** rerun the
167
+ job just to recover logs. Use the returned `session_id` and `process_id`:
168
+
169
+ ```bash
170
+ # Fetch the durable combined process log captured by the host
171
+ fcloud logs s-abc123 proc-456 --output all
172
+
173
+ # Fetch a specific range or stream
174
+ fcloud logs s-abc123 proc-456 --output head --output-bytes 128k
175
+ fcloud logs s-abc123 proc-456 --stream stderr --output all
176
+ ```
177
+
178
+ > The default stream is **combined** (stdout + stderr) — prefer it. Most ML
179
+ > tooling (tqdm progress bars, HuggingFace/TRL logs) writes to **stderr**, so
180
+ > `--stream stdout` on a training job often looks empty even though the job is
181
+ > producing output. Reach for `--stream stdout`/`stderr` only when you
182
+ > specifically need one split.
183
+
184
+ **Liveness while polling a long job:** `fcloud logs <sid> <pid> --json` includes
185
+ `last_write_ts` (when the durable log was last written) and `session_status`.
186
+ A `status: "running"` process whose `last_write_ts` stops advancing across
187
+ polls is stalled or on a lost host — no need to diff `output_total_bytes`
188
+ between polls. A `status: "lost_host"` (with `process_alive: false`) means the
189
+ session ended without the process's exit ever being recorded: the host was
190
+ lost, the tail is final, don't keep polling.
191
+
192
+ SDK equivalent:
193
+
194
+ ```python
195
+ log = session.logs("proc-456", output_range="all")
196
+ print(log.output)
197
+
198
+ stderr = session.logs("proc-456", stream="stderr", output_range="all")
199
+ ```
200
+
201
+ Persistent-session commands (`--on <sid>`) print a one-line `✓ ready` to
202
+ stderr on every attach; fuller provision/setup narration appears only when
203
+ the session actually has setup work to do. Terminal/problem statuses also
204
+ print to stderr. Parse stdout (or use `--json`) — never stderr.
205
+
206
+ #### Shell Parsing Rules
207
+
208
+ Prefer passing commands as normal argv:
209
+
210
+ ```bash
211
+ fcloud exec --on s-abc123 ls /workspace
212
+ fcloud exec s-abc123 -- ls /workspace
213
+ fcloud exec --on s-abc123 python3 /workspace/train.py --epochs 10
214
+ ```
215
+
216
+ A single quoted string is treated as shell text (it runs via `bash -lc`),
217
+ so both of these work and are equivalent:
218
+
219
+ ```bash
220
+ fcloud exec --on s-abc123 'ls /workspace'
221
+ fcloud exec --on s-abc123 'echo one two && echo piped | tr a-z A-Z'
222
+ ```
223
+
224
+ When you need shell syntax (`;`, `&&`, pipes, redirects, variables), either pass a
225
+ single quoted shell expression or be explicit with `sh -c`:
226
+
227
+ ```bash
228
+ fcloud exec --on s-abc123 'cd /workspace; ls'
229
+ fcloud exec --on s-abc123 -- sh -c 'sleep 45; echo done'
230
+ ```
231
+
232
+ Remember that unquoted shell metacharacters are handled by the local shell before
233
+ `fcloud` sees them:
234
+
235
+ ```bash
236
+ # Runs "sleep 45" remotely, then "echo done" locally
237
+ fcloud exec --on s-abc123 sleep 45; echo done
238
+ ```
239
+
240
+ ### Jobs (`fcloud job`) — ephemeral run-to-completion
241
+
242
+ A job is a session **without a saved filesystem**: image + `--include` files
243
+ + volumes in; exit code, logs, and volume writes out. The workspace is
244
+ discarded at close — a job can never be resumed, `ls`'d, mounted, or
245
+ downloaded after it ends (those return a clear error pointing at volumes and
246
+ logs). In exchange, nothing syncs to storage while it runs, and close only
247
+ has the volumes to flush.
248
+
249
+ `job run` returns once the command has exited **and** its volumes are
250
+ committed (it prints `committing volumes…` while that runs), so
251
+ `fcloud volume files <name>` right after it shows the job's output. If the
252
+ host is unreachable at close the commit is deferred to its teardown replay
253
+ and the command says so — `--json` reports it as `volume_sync`.
254
+
255
+ | Command | Description |
256
+ |---|---|
257
+ | `fcloud job run [flags] -- <cmd...>` | Run to completion on a fresh ephemeral session; exits with the command's exit code |
258
+ | `fcloud job ls [--all] [--limit N]` | List jobs (`--all` includes finished ones) |
259
+ | `fcloud job logs <job-id> [--follow]` | Durable output — live or after close |
260
+ | `fcloud job wait <job-id>` | Block until the job ends |
261
+ | `fcloud job kill <job-id>` | Stop now; workspace discarded, volume commits + logs survive |
262
+
263
+ `job run` flags: `--sku SKU` · `--volume NAME[:MOUNT]` (repeatable — the
264
+ durable output surface) · `--include PATH` (repeatable; uploaded before the
265
+ command starts) · `--env K=V` · `--secret KEY[=VALUE]` · `--retries N` ·
266
+ `--min-disk-gb N` · `--detach` (print the job id and return).
267
+
268
+ Jobs are never checkpoint/migrated: a lost host (spot preemption) kills the
269
+ run, and `--retries N` re-runs the command on a fresh session — so make job
270
+ commands re-runnable. If a job is preempted, the system automatically
271
+ attempts to requeue it — don't immediately retry/resubmit yourself; follow
272
+ the same job id with `fcloud job wait`/`logs`. Write outputs to a volume:
273
+
274
+ ```bash
275
+ fcloud volume create results
276
+ fcloud job run --sku gpu_1x_l4 --volume results:/results -- \
277
+ python train.py --out /results/model.pt
278
+ fcloud volume files results # outputs survive the job
279
+ fcloud volume download results model.pt # pull them back locally
280
+ ```
281
+
282
+ SDK mirror:
283
+
284
+ ```python
285
+ job = project.job("python train.py --out /results/model.pt",
286
+ sku="gpu_1x_l4",
287
+ volume_mounts=[{"name": "results", "mount_path": "/results"}],
288
+ include=["./src"], retries=1)
289
+ log = job.wait() # ProcessLog: exit_code, output
290
+
291
+ client.list_volume_files("results") # [{path, size, ...}]
292
+ data = client.read_volume_file("results", "model.pt") # bytes, no session
293
+ client.download_volume_file("results", "model.pt", "./model.pt")
294
+ client.delete_volume("results") # when you're done with it
295
+ ```
296
+
297
+ Rule of thumb: a **session** when you'll iterate interactively or want the
298
+ filesystem later; a **job** for batch/CI-style runs with declared outputs.
299
+
300
+ ### Persistent Sessions
301
+
302
+
303
+ | Command | Description |
304
+ | ----------------------------- | ---------------------------------------------------- |
305
+ | `fcloud create [--sku SKU]` | Create a session (cold — $0, no host, until first used) |
306
+ | `fcloud sessions [--all]` | List sessions |
307
+ | `fcloud stop <session-id> [--wait]` | Stop a session now: halt GPU spend (files kept). `--wait` blocks until the workspace manifest is durable |
308
+
309
+
310
+ `create` registers a session and returns a session ID without allocating a host —
311
+ it is cold and costs nothing until you use it. The first `exec`/`run`/`upload`/
312
+ `shell`/`ssh` brings it online automatically with its files intact. Use
313
+ `--on <session-id>` with `exec`, `run`, `upload`, or `download` to interact with it
314
+ repeatedly.
315
+
316
+ Sessions resume transparently: there is **no `resume` command**. A session that was
317
+ stopped or reaped while idle comes back online (on a fresh host, with its workspace
318
+ restored from cloud storage) the moment you use it again. `fcloud stop` exists only to halt spend
319
+ immediately — your files are preserved either way. (Ephemeral **job**
320
+ sessions are the one exception: their workspace is never saved and they can
321
+ never resume — see Jobs.)
322
+
323
+ `fcloud stop` can return `stop deferred; host will retry teardown` — the stop
324
+ stands, but the workspace manifest is not durable yet. Anything that reads
325
+ that manifest (`fcloud ls`, `fcloud mount`, `fcloud map --from <sid>`) must wait
326
+ for it: pass **`fcloud stop <sid> --wait`** (`--timeout SECONDS`, default 300,
327
+ `0` waits forever) instead of polling `fcloud sessions` for `state=cold`
328
+ yourself. It exits 1 if the wait expires; the stop is unaffected.
329
+
330
+ **Keep a client attached until the host is up.** First use claims a host for the
331
+ session; if no client stays attached while it comes online, the host is reclaimed
332
+ ~60s after it's claimed (the session goes back to cold and resumes on next use —
333
+ no data loss, but you paid for a boot that did nothing). To run **N boxes at
334
+ once**, keep one client attached per box for its whole run — e.g. N parallel
335
+ `fcloud exec --on <sid> --wait <long>` (or `spawn` + `fcloud wait`) — rather than
336
+ creating N sessions and attending to them one at a time, which lets the idle
337
+ ones lose their hosts.
338
+
339
+ ### File Transfer
340
+
341
+ | Command | Description | Needs active session? |
342
+ | -------------------------------------- | ------------------------------------------- | ------------------------------- |
343
+ | `fcloud upload <sid> <local> [remote]` | Upload files to a session | Yes (live host receives them) |
344
+ | `fcloud download <sid> <remote> [local]` | Download a file from a session | No — works on active **or** closed |
345
+ | `fcloud ls <sid> [path]` | List a stopped session's workspace manifest | No |
346
+ | `fcloud mount <sid> <mountpoint>` | Mount a stopped session's files (read-only) | No |
347
+
348
+ Only `upload` needs a live session. `download` uses the live host when the
349
+ session is active, for the freshest bytes, and falls back to the saved cloud-storage
350
+ manifest otherwise. `ls` and `mount` are post-hoc manifest views: use them after
351
+ `fcloud stop`, not while the session is active. None of the post-close views
352
+ work for ephemeral **jobs** (no manifest is ever saved) — a closed job's
353
+ outputs live in its volumes (`fcloud volume files <name>`) and logs
354
+ (`fcloud job logs <id>`).
355
+
356
+
357
+ Uploaded files land in `/workspace/` on the host. Files **at or under 5 MiB**
358
+ stream inline over the WebSocket; anything larger goes client → cloud storage
359
+ → host via a pre-signed URL minted by the fcloud API. `fcloud upload` prints which
360
+ files took that second route (and names them again if it fails), so a broken
361
+ large-file upload is attributable to the file, not to the command — one
362
+ oversized results JSON in an otherwise tiny code tree is enough to put the
363
+ batch on the pre-signed path. That path is a single PUT, so each file is
364
+ capped at **5 GiB**; split larger files (e.g. `split -b 4G`) or stage them
365
+ through a volume/import path instead. Downloads work the same way in
366
+ reverse.
367
+
368
+ **Client version.** Every request states the client's version, and the API
369
+ rejects clients below the deployment's floor with HTTP 426 / `client_too_old`
370
+ (`fcloud.ClientTooOldError`) naming the upgrade command. If you see that, run
371
+ `pip install -U fcloud-sdk` — it is a client-age problem, never a credential one.
372
+
373
+ ### Browsing & mounting a session
374
+
375
+ `fcloud ls` and `fcloud mount` read the session's file manifest, which the
376
+ host writes **when the session is stopped**. So they show a session's files
377
+ after a `fcloud stop` (the common "grab my outputs later" case); a session
378
+ that has never been stopped lists empty.
379
+
380
+ ```bash
381
+ fcloud ls s-abc123 # list files at the root
382
+ fcloud ls s-abc123 data # list files under data/
383
+ fcloud mount s-abc123 ~/mnt # browse in Finder/Explorer (read-only)
384
+ ```
385
+
386
+ A stopped session's **durable logs** appear under a virtual `_logs/` directory
387
+ next to its workspace files — the image build log, the session lifecycle log,
388
+ and the per-process exec logs the host streamed to cloud storage:
389
+
390
+ ```bash
391
+ fcloud ls s-abc123 _logs # build.log, session.log, exec-<pid>.log, ...
392
+ fcloud download s-abc123 _logs/build.log # read a log long after the session is gone
393
+ ```
394
+
395
+ These are the same streams `fcloud logs <sid> <pid>` shows live; `_logs/` just
396
+ makes them browsable and downloadable post-hoc, and they appear under `_logs/`
397
+ in `fcloud mount` too.
398
+
399
+ `mount` streams file bytes **directly from cloud storage** to your machine (the
400
+ API only serves the listing + a redirect), so it stays fast for large
401
+ files. It uses rclone's **NFS mount** (the OS NFS client), so it needs only
402
+ rclone — **no macFUSE, no kernel extension, no recovery-mode approval**:
403
+
404
+ ```bash
405
+ brew install rclone # macOS
406
+ # Linux: sudo apt-get install -y rclone (or curl https://rclone.org/install.sh | sudo bash)
407
+ ```
408
+
409
+ Then `fcloud mount <sid> <dir>` and open `<dir>` in Finder/Explorer. `<dir>`
410
+ must be a path that does **not** exist yet — fcloud creates the mountpoint and
411
+ deletes it on unmount, and refuses an existing folder so it can never touch
412
+ your data. Note: on
413
+ macOS the **Terminal** may be blocked from reading the mount (a privacy/TCC
414
+ quirk) — browse it in **Finder**, or use `fcloud ls` from the CLI. `fcloud ls`
415
+ needs no rclone at all (plain API call). Press Ctrl-C to unmount.
416
+
417
+ ### Volumes
418
+
419
+ Volumes are named, manifest-backed folders for moving data between sessions.
420
+ They are not EBS volumes and do not reserve separate block storage: file bytes
421
+ stay in the shared content-addressed blob store, while the volume records the
422
+ path → blob manifest.
423
+
424
+ | Command | Description |
425
+ | ----------------------------------------------------------- | ------------------------------------------------ |
426
+ | `fcloud volume create <name>` | Create an empty volume |
427
+ | `fcloud volume list` | List your volumes |
428
+ | `fcloud volume files <name> [path]` | List files in a volume |
429
+ | `fcloud volume download <name> <vol-path> [local] [-r]` | Fetch a file (or, with `-r`, a directory) locally |
430
+ | `fcloud volume cat <name> <vol-path>` | Print a volume file to stdout |
431
+ | `fcloud volume delete <name>` | Delete a volume and its manifest |
432
+ | `fcloud volume import <name> <sid> [session-path] [vol-path]` | Copy a folder from a stopped session into volume |
433
+
434
+ Reads need no session and no host — `files` / `download` / `cat` serve the
435
+ volume's committed manifest straight from the API, so this is how you
436
+ get a job's outputs back:
437
+
438
+ ```bash
439
+ fcloud volume files results # what's in there
440
+ fcloud volume cat results slice-0.json # small file to stdout
441
+ fcloud volume download results slice-0.json
442
+ fcloud volume download results / ./out -r # whole volume into ./out
443
+ ```
444
+
445
+ `delete` returns a conflict while any session still has the volume mounted;
446
+ stop or detach that session first.
447
+
448
+ Mount a volume into a session with repeatable `--volume` flags — on `exec`,
449
+ `run`, `shell`, `spawn`, `job run` and `map`:
450
+
451
+ ```bash
452
+ fcloud exec --volume checkpoints:/workspace/checkpoints --sku gpu_1x_l4 \
453
+ python train.py
454
+ fcloud run --volume checkpoints . --script train.py --sku gpu_1x_l4
455
+ ```
456
+
457
+ `--volume NAME` mounts at `/workspace/NAME`; `--volume NAME:/absolute/path`
458
+ mounts at that absolute path inside the session rootfs.
459
+
460
+ **Volumes are not block devices and are not mounted with the unix `mount`
461
+ command.** There is no `/dev/*` node — don't probe for one with `mount`,
462
+ `lsblk`, or `df` inside the session, and don't try to mount/umount/format
463
+ anything. The host composes each volume into the session's root filesystem as a
464
+ read-only content-addressed layer (file bytes fetched lazily from the blob
465
+ store on first read) plus a writable overlay at the mount path. Inside the
466
+ session a volume is just an ordinary directory: use normal file I/O. Writes
467
+ land in the overlay and are committed back to the volume as a new version on
468
+ detach or session close.
469
+
470
+ Volumes are attached
471
+ **after** the session is online, so a volume-mounted command queues and
472
+ provisions a host like any other session, and `--volume` also works against an
473
+ already-running session (`--on <sid>`). Mounted volumes are active in at most
474
+ one session, and browsing/import/delete returns a conflict while a volume is
475
+ attached.
476
+
477
+ Declare volumes once in `fcloud.json` to skip `--volume` on every command (see
478
+ [Project Config](#project-config-fcloudjson)):
479
+
480
+ ```json
481
+ { "sku": "gpu_1x_l4", "volumes": [{ "name": "checkpoints", "mount": "/workspace/checkpoints" }] }
482
+ ```
483
+
484
+ The effective set is the `fcloud.json` volumes plus any `--volume` flags, keyed by
485
+ name; a `--volume` for a name already in `fcloud.json` re-maps its mount path for
486
+ that invocation (just like `--sku` overrides the config `sku`). The SDK
487
+ re-attaches this set every time it brings the session online — including after a
488
+ stop/resume on a fresh host — and attach is idempotent, so re-passing a volume
489
+ already mounted is a safe no-op.
490
+
491
+ `volume import` is a single batch metadata operation at folder scope; it does
492
+ not issue per-file API calls and does not copy bytes when blobs already exist.
493
+ The source session must be stopped/closed so the import reads a finalized
494
+ manifest. Volume browsing/modification is blocked while that volume is attached
495
+ to an active session.
496
+
497
+ ```bash
498
+ fcloud volume create checkpoints
499
+ fcloud stop s-abc123
500
+ fcloud volume import checkpoints s-abc123 runs/run-42 run-42
501
+ fcloud volume files checkpoints run-42
502
+ ```
503
+
504
+ #### End-to-end: persist results into a volume across sessions
505
+
506
+ A volume is the way to carry data from one session to another (or to a later
507
+ run on a fresh host). Writes land in the volume on session close; reads see them
508
+ on the next attach — no manual sync step.
509
+
510
+ ```bash
511
+ # 1. Create the volume once.
512
+ fcloud volume create runs
513
+
514
+ # 2. Run a job that writes into the mounted volume. This queues and
515
+ # provisions a host even when none is idle (no --volume special-casing).
516
+ fcloud exec --volume runs:/workspace/runs --sku gpu_1x_l4 \
517
+ -- sh -lc 'python train.py && cp model.pt /workspace/runs/model.pt'
518
+
519
+ # 3. After the ephemeral session closes, the file is committed to the volume.
520
+ fcloud volume files runs # -> model.pt
521
+
522
+ # 4. A later session mounts the same volume and sees the file.
523
+ fcloud exec --volume runs:/workspace/runs --sku gpu_1x_l4 \
524
+ -- ls -l /workspace/runs/model.pt
525
+ ```
526
+
527
+ Two things worth knowing:
528
+
529
+ - **Mount onto an empty path.** Attaching a volume onto a directory that
530
+ already contains session files is rejected (`volume_mount_target_not_empty`)
531
+ rather than silently shadowing them. Mount at a fresh path like
532
+ `/workspace/runs`. The same applies to `fcloud run <dir> --volume`: a mount
533
+ at the upload destination (`/workspace/<dir>`), or below it where the
534
+ uploaded tree has content, is rejected before the session starts.
535
+ - **Save without stopping.** Closing the session commits attached volumes
536
+ automatically — that is the common case. To checkpoint a volume mid-run while
537
+ the session keeps running, use `fcloud stop` and start again — stopping commits
538
+ the volume, and the session comes back on next use with the mount intact.
539
+ The commit lands asynchronously, up to ~30s after the session reports
540
+ closing — a `volume files`/`cat` in that window reads the previous version.
541
+
542
+ ### Background Processes (requires an active session)
543
+
544
+
545
+ | Command | Description |
546
+ | ------------------------------------------------ | ----------------------------------------------- |
547
+ | `fcloud spawn [--on SID\|SID] [--volume NAME[:MOUNT]] [--emit-pid FILE] <cmd...>` | Start a background process |
548
+ | `fcloud wait <sid> <pid> [--timeout DUR]` | Block until a process exits; return its exit code + tail |
549
+ | `fcloud logs <sid> <pid> [--follow] [--output MODE]` | Show output from a process |
550
+ | `fcloud kill <sid> <pid>` | Kill a background or auto-detached exec process |
551
+
552
+ `spawn --emit-pid FILE` writes `{"session_id": ..., "process_id": ...}` JSON —
553
+ the same format `exec`/`run --emit-pid` produce — not a bare process id.
554
+
555
+ Use `--follow` for live monitoring. Use `--output all` after a command has already
556
+ run to fetch its durable log. `--stream stdout|stderr|combined` selects a stream for
557
+ newer process logs; `combined` is the default and works with older logs.
558
+
559
+ **Waiting on a job.** Prefer `fcloud wait <sid> <pid>` over hand-rolled
560
+ `until fcloud logs ... | grep` loops. It blocks until the process actually
561
+ terminates, prints its exit code and log tail, and **exits with the process's
562
+ own exit code** (`75` if a `--timeout` elapses while it's still running). It
563
+ reads durable logs, so it also works after the host is gone. This is the
564
+ reliable way to detect a failed background job — a grep loop can miss failures
565
+ whose text doesn't match the pattern, and can't tell "still starting" from
566
+ "already died". In `--json` mode `fcloud logs <sid> <pid>` and `fcloud wait` both
567
+ surface terminal `status` and `exit_code`, so a poller can check
568
+ `status != "running"` instead of grepping log text.
569
+
570
+ ### SSH
571
+
572
+
573
+ | Command | Description |
574
+ | ------------------------------------ | --------------------------------------------------------- |
575
+ | `fcloud shell [--sku SKU] [--volume NAME[:MOUNT]]` | Start an interactive fcloud-native shell in a new session |
576
+ | `fcloud shell [--on SID|SID] [--volume NAME[:MOUNT]] [cmd...]` | Attach an interactive PTY to an existing session |
577
+ | `fcloud ssh <session-id>` | SSH into a session |
578
+ | `fcloud tunnel <sid> [--port PORT]` | SSH ProxyCommand tunnel |
579
+
580
+
581
+ `shell` is the fcloud-native PTY path. `fcloud shell` creates a temporary
582
+ interactive session, while `fcloud shell <sid>` or `fcloud shell --on <sid>`
583
+ attaches to a persistent session and leaves it alive on exit. `--volume` works
584
+ the same as on `exec`: mounts ride the create for a new session and are
585
+ attached before the prompt on an existing one. `ssh` uses the
586
+ local OpenSSH client over `fcloud tunnel`; use it when you specifically want SSH
587
+ tooling/config behavior.
588
+
589
+ `fcloud tunnel <sid>` defaults to the session SSH server port (`2222`) so it can
590
+ be used directly as an OpenSSH `ProxyCommand`. Pass `--port` only when tunneling
591
+ to a different TCP service inside the session.
592
+
593
+ `fcloud ssh` requires an SSH server inside the session image. If `sshd` is not
594
+ already present, the CLI bootstraps it with `apt-get install openssh-server` on
595
+ the first SSH connection, which can add tens of seconds. To avoid that cost,
596
+ include `openssh-server` in the project image when SSH access matters:
597
+
598
+ ```json
599
+ {
600
+ "image": {
601
+ "apt": ["openssh-server"]
602
+ }
603
+ }
604
+ ```
605
+
606
+ Repeat SSH connections to the same session reuse the already-running `sshd`.
607
+
608
+ ---
609
+
610
+ ## Sweeps (`fcloud map`) — fan one command out over N bindings
611
+
612
+ A sweep runs your command N times with different arguments as a **durable
613
+ server-side fan-out**: submit, disconnect, and the service drives every
614
+ task through its own session — queueing for capacity, retrying failures, and
615
+ recording exit codes + logs. No changes to your code; your script's CLI *is*
616
+ the interface.
617
+
618
+ ```bash
619
+ # Hyperparameter sweep (cartesian product: 3 × 2 = 6 tasks)
620
+ fcloud map --name lr-sweep --sku gpu_1x_l4 \
621
+ -- python3 train.py --lr {lr} --bs {bs} ::: lr=1e-4,3e-4,1e-3 ::: bs=32,64
622
+
623
+ # Datagen over indices (shell brace expansion feeds the group)
624
+ fcloud map --name gen -- python3 gen.py --shard {} --of 500 ::: {0..499}
625
+
626
+ # One task per line from a pipe (no ::: → stdin binds {})
627
+ jq -r '.items[]' work.json | fcloud map --name batch -- python3 proc.py {}
628
+ ```
629
+
630
+ **Supplying args (GNU-parallel style).** Everything after the command is
631
+ parsed for `:::` markers: each one starts a **group** of values (space- or
632
+ comma-separated), and each task runs the command with one combination of
633
+ values substituted in. Four ways to feed values:
634
+
635
+ | Spec | Tasks | Meaning |
636
+ |---|---|---|
637
+ | `cmd {} ::: a b c` | 3 | One task per value; `{}` (or `{1}`) is the value |
638
+ | `cmd {1} {2} ::: a b ::: x y` | 2×2 = 4 | Multiple groups **cross** (cartesian product) |
639
+ | `cmd {1} {2} ::: m1 m2 :::+ t1 t2` | 2 | `:::+` **zips** with the previous group (paired values like model+tokenizer; lengths must match) |
640
+ | `producer \| fcloud map -- cmd {}` | one per line | No `:::` groups → each stdin line is one task |
641
+
642
+ Placeholders: `{}` = the single group's value · `{1}` `{2}` = group by
643
+ position · `{lr}` = named group (write it `::: lr=1e-4,3e-4`) · `{i}` = task
644
+ index (0-based) · `{n}` = task count. A command with **no** placeholders gets
645
+ each task's values appended at the end (`cmd ::: a b` runs `cmd a`, `cmd b`).
646
+ Every task also gets env vars `FCLOUD_TASK_ID`, `FCLOUD_WORLD_SIZE`, `FCLOUD_JOB` —
647
+ a script can read those to self-shard and skip templating entirely.
648
+ `fcloud help map` prints this reference.
649
+
650
+ **Code binding modes.** The sweep pins an immutable snapshot at submit, so
651
+ task 400 runs the same bytes as task 0:
652
+ - **Default (Mode 2):** the current dir (or `--code DIR`) is hashed and
653
+ uploaded once; each task session starts from it. Image comes from
654
+ `fcloud.json` or the SKU default.
655
+ - **`--from <session-id>` (Mode 1):** fork the session you just verified —
656
+ each task starts from that session's synced workspace (deps installed,
657
+ weights downloaded) and inherits its image. The session must have synced a
658
+ manifest (a stopped session always has; a live one syncs periodically).
659
+
660
+ **Outputs.** Write task-keyed files (`shard-{i}.jsonl`) into the task's own
661
+ workspace and pull them all with **`fcloud sweep harvest <name> <remote-glob>
662
+ <local-dir>`** — it resolves each task's session from the sweep and writes
663
+ `<local-dir>/task-<index>/<path>`, so there is no session-id bookkeeping and
664
+ no cross-task filename collisions. (`fcloud download <task-session> <path>` is
665
+ still there for one file from one task.) Or write into a shared volume. Volume commits are per-path
666
+ merges (last writer wins per file): parallel tasks writing DISTINCT files
667
+ into one shared volume all land safely; concurrent writes to the SAME file
668
+ keep only the last committer, so key every task's output by `{i}`. Commits
669
+ ride each task session's close and can trail the task's "succeeded" state
670
+ by ~30s — read the volume after commits settle.
671
+
672
+ **Code.** With no `--from`/`--code`, map pins the current directory (upload
673
+ skip rules apply) and prints the path/file-count/byte summary before
674
+ uploading; it refuses to pin `$HOME` or a filesystem root implicitly.
675
+
676
+ **Fan-out width.** `--max-parallel N` caps how many tasks run at once;
677
+ without it the cap is **8**, and submitting more tasks than that runs them
678
+ in waves. Submit says so out loud, and the cap is changeable on a live
679
+ sweep: `fcloud sweep set <name> --max-parallel N`.
680
+
681
+ **Canary.** Task 0 runs first and gates the rest: a broken sweep costs one
682
+ task, not N. `fcloud map` blocks until the canary passes, then detaches (Ctrl-C
683
+ detaches the watch — it does NOT cancel). `--no-canary` / `--no-wait` opt out.
684
+ When one task takes minutes, `--canary-smoke 'python3 -c "import train"'`
685
+ gates on a cheap check instead and task 0 then runs its real command with
686
+ the rest — cheaper than `--no-canary`, which risks N bad tasks to save one.
687
+ Failures don't consume the retry budget when caused by spot preemption — the
688
+ task just requeues.
689
+
690
+ **Waiting for capacity is not failure.** A task with no host yet keeps its
691
+ queued session, charges no retry, and is reported as blocked:
692
+ `fcloud sweep status` prints `blocked on capacity: N task(s), oldest 6m12s`.
693
+ That is the fleet scaling up, not your sweep breaking — cancel if the wait
694
+ is longer than the work is worth.
695
+
696
+ **Tracking & recovery:**
697
+ ```bash
698
+ fcloud sweeps # all sweeps: state + done/run/fail counts
699
+ fcloud sweep status <name> # counts + failures clustered by error, with exemplar task
700
+ fcloud sweep status <name> --watch # same, repainted until terminal (--interval S)
701
+ fcloud sweep harvest <name> '*.json' ./out # every task's outputs → out/task-<i>/…
702
+ fcloud sweep logs <name> [--task N] # durable output (defaults to the exemplar failure; falls back to earlier attempts)
703
+ fcloud sweep set <name> --max-parallel N # change the fan-out cap on a live sweep
704
+ fcloud sweep retry <name> [--all] # re-run only failed tasks (fix code first: resubmit is idempotent)
705
+ fcloud sweep cancel <name> [--remaining] # stop; --remaining keeps running tasks (partial success)
706
+ fcloud sweep wait <name> # block until terminal; exit 0 on success
707
+ ```
708
+ `--webhook URL` (or account default) POSTs the status document on canary
709
+ pass/fail and completion — agents should submit with `--no-wait --json` and
710
+ wake on the webhook instead of polling.
711
+
712
+ **The status JSON is a versioned public contract** (`schema_version: 1`),
713
+ specified in `docs/sweep-status-schema.md`: `counts` is an open map keyed by
714
+ task state, the task→session mapping is `tasks[].session_id`, sweep state is
715
+ `job.state`, and per-session spend is under `cost`. Additive changes keep the
716
+ version; a removal or rename bumps it. Do not reverse-engineer it — and
717
+ prefer `fcloud sweep harvest` over re-deriving the task→session→download walk
718
+ by hand.
719
+
720
+ **SDK mirror** — `client.map(command, args=…)`. The *type* of `args` picks
721
+ the combinator (no `:::` syntax in the SDK):
722
+
723
+ | `args` | Tasks | CLI equivalent |
724
+ |---|---|---|
725
+ | `{"lr": [1e-4, 3e-4], "bs": [32, 64]}` | 4 — cartesian product, binds `{lr}`/`{bs}` | `::: lr=… ::: bs=…` |
726
+ | `[{"model": "a", "tok": "x"}, {"model": "b", "tok": "y"}]` | 2 — one per row, pre-paired | `::: … :::+ …` |
727
+ | `["f1.json", "f2.json"]` | 2 — one per item, binds `{}` | `::: f1.json f2.json` |
728
+ | `500` or `range(500)` | 500 — same command N times; index via `{i}`/`FCLOUD_TASK_ID`, nothing appended | `::: {0..499}` |
729
+ | `None` (default) | 1 | — |
730
+
731
+ ```python
732
+ sweep = client.map("python3 train.py --lr {lr} --bs {bs}",
733
+ args={"lr": [1e-4, 3e-4], "bs": [32, 64]}, # 4 tasks
734
+ name="lr-sweep", sku="gpu_1x_l4", code_dir=".")
735
+ sweep.wait_canary(); sweep.wait() # or poll sweep.status() / .retry_failed()
736
+ # from_session="s-…" instead of code_dir for Mode 1. Returns a SweepHandle.
737
+ ```
738
+
739
+ ---
740
+
741
+ ## Agent Workflow Patterns
742
+
743
+ ### Pattern 1: Quick one-off command
744
+
745
+ The simplest pattern. Run a command, get the result, done.
746
+
747
+ ```bash
748
+ fcloud exec --sku gpu_1x_l4 --json nvidia-smi -L
749
+ # {"stdout": "GPU 0: NVIDIA L4 ...\n", "returncode": 0, "status": "exited", ...}
750
+ ```
751
+
752
+ ### Pattern 2: Run a training script
753
+
754
+ Upload code, run it, get results. Session lifecycle is fully managed.
755
+
756
+ ```bash
757
+ fcloud run train.py --sku gpu_1x_l4 --json -- --epochs 50
758
+ # {"stdout": "...", "stderr": "", "returncode": 0, "duration_ms": 34521}
759
+ ```
760
+
761
+ For a project directory with dependencies:
762
+
763
+ ```bash
764
+ fcloud run . --script train.py --sku gpu_1x_l4 -- --batch-size 32
765
+ ```
766
+
767
+ ### Pattern 3: Persistent session (multi-step workflow)
768
+
769
+ Create a session once, interact with it across multiple commands. The session's
770
+ filesystem persists between invocations — no re-uploading. It comes online on the
771
+ first use and resumes transparently if it was reaped while idle.
772
+
773
+ ```bash
774
+ # Create (returns a session ID; cold, $0 until first used)
775
+ fcloud create --sku gpu_1x_l4 --json
776
+ # {"session_id": "s-abc123", "status": "closed", "sku": "gpu_1x_l4"}
777
+
778
+ # Install dependencies (brings the session online automatically)
779
+ fcloud exec --on s-abc123 pip install torch transformers
780
+
781
+ # Upload training data
782
+ fcloud upload s-abc123 ./data
783
+
784
+ # Run training
785
+ fcloud run train.py --on s-abc123 -- --epochs 50 --lr 1e-4
786
+
787
+ # Same persistent-session target using the positional alias
788
+ fcloud run s-abc123 train.py -- --epochs 50 --lr 1e-4
789
+
790
+ # Check results
791
+ fcloud exec --on s-abc123 cat /workspace/results.json
792
+
793
+ # Download model weights
794
+ fcloud download s-abc123 /workspace/model.pt ./model.pt
795
+
796
+ # Halt spend now (files kept; resumes on next use)
797
+ fcloud stop s-abc123
798
+ ```
799
+
800
+ Each command connects in ~1s, runs, and detaches. The session's files persist
801
+ across all invocations on the host's `/workspace/`.
802
+
803
+ This is the recommended pattern for agents running multi-step experiments —
804
+ cheaper and faster than creating a new session for every command.
805
+
806
+ Use `--wait` for commands that are expected to take longer than 30s but should
807
+ remain foreground from the agent's perspective. If the command detaches anyway,
808
+ follow the returned `process_id` instead of retrying the same command.
809
+
810
+ ### Pattern 4: SDK session (programmatic multi-step)
811
+
812
+ For multi-step workflows (install deps → upload data → train → download results),
813
+ use the Python SDK directly. A session stays alive across all operations.
814
+
815
+ ```python
816
+ from fcloud import Client, Image
817
+
818
+ client = Client()
819
+ image = Image.debian_slim().pip_install(["torch", "numpy"])
820
+ project = client.project("my-experiment", image=image)
821
+
822
+ with project.session(sku="gpu_1x_l4") as s:
823
+ # Install and verify
824
+ r = s.run(["python3", "-c", "import torch; print(torch.cuda.is_available())"])
825
+ print(r.stdout) # True
826
+
827
+ # Upload training data
828
+ s.upload("./data", "data")
829
+
830
+ # Run training
831
+ r = s.run(["python3", "/workspace/data/train.py"], timeout_ms=3600_000)
832
+ print(r.stdout)
833
+
834
+ # Download results
835
+ weights = s.download("model.pt")
836
+ open("model.pt", "wb").write(weights)
837
+
838
+ # Session closes automatically on exit
839
+ ```
840
+
841
+ ### Pattern 5: Long-running job with monitoring
842
+
843
+ For jobs that take minutes/hours, **lead with `spawn` + `wait`, not a maxed-out
844
+ `exec --wait`.** `spawn` returns a durable `process_id` immediately; `wait`
845
+ blocks on that id reading the durable log, so it survives host migration and
846
+ never depends on a foreground stream staying alive:
847
+
848
+ ```bash
849
+ # Start the job — prints a durable proc-id right away, no blocking.
850
+ fcloud spawn s-abc123 python3 /workspace/train.py
851
+ # Process proc-456 spawned in session s-abc123
852
+
853
+ # Block until it exits (reads durable logs; survives migration). --timeout 0
854
+ # waits indefinitely; a positive timeout exits 75 (still running) so you retry.
855
+ # A process id the service has no record of (typo'd or stale) errors out
856
+ # with "unknown process id" (exit 1) instead of telling you to keep waiting.
857
+ fcloud wait s-abc123 proc-456 --timeout 0
858
+
859
+ # Or watch it live:
860
+ fcloud logs s-abc123 proc-456 --follow
861
+ # Fetch the full log after it finishes (default stream is combined):
862
+ fcloud logs s-abc123 proc-456 --output all
863
+ # Stop it if needed:
864
+ fcloud kill s-abc123 proc-456
865
+ ```
866
+
867
+ **Don't max out `--wait` on a long job, and never pipe a live `exec` through
868
+ `| tail`.** `exec --wait 60m ... | tail` buffers all output until the process
869
+ ends (so you see nothing live) *and* swallows the detach line carrying the
870
+ `process_id` — leaving you unable to call `fcloud wait` / `fcloud logs --follow` at
871
+ all. If you must background a long `exec`, capture the id with `--emit-pid`
872
+ (`spawn` takes the same flag and writes the same file), which writes
873
+ `{session_id, process_id}` JSON to a file the instant the job starts —
874
+ readable even when stdout/stderr are piped or backgrounded:
875
+
876
+ ```bash
877
+ fcloud exec --on s-abc123 --emit-pid /tmp/job.json --wait 60m python3 train.py &
878
+ # ... the file exists as soon as the process starts:
879
+ PID=$(python3 -c 'import json;print(json.load(open("/tmp/job.json"))["process_id"])')
880
+ fcloud wait s-abc123 "$PID" --timeout 0
881
+ ```
882
+
883
+ > **Transient unreachability (`rc=75`).** A foreground `exec` can exit **`rc=75`**
884
+ > (EX_TEMPFAIL) when the session is briefly unreachable — most often the first
885
+ > exec right after a host boots, while the session is still binding to it, or a
886
+ > host reclaimed mid-run (spot preemption) that fcloud checkpoint-restores onto a
887
+ > new host. `exec` retries the attach for you and only reports `rc=75` once it
888
+ > still can't reach the session; the message states the real cause ("not
889
+ > reachable yet" vs. an ended session) instead of assuming a migration. Just
890
+ > retry — the next attempt lands on the ready/restored host. `fcloud wait` /
891
+ > `fcloud logs` ride a migration for you. After one, confirm the process is still
892
+ > alive (`fcloud logs <sid>` lists it as `running`) rather than assuming `rc=75`
893
+ > means your job is gone.
894
+ >
895
+ > **Migration rides honor `--wait`.** When a foreground `exec` rides a
896
+ > checkpoint/restore, the ride is bounded by your `--wait` budget — it never
897
+ > blocks past it (`--wait 0` disables the bound; the ride then uses the full
898
+ > ~10-minute restore window). If the budget runs out while the restore is
899
+ > still in flight, the result is **`status: "migrating"`** with **`rc=75`**:
900
+ > the process is neither confirmed alive nor dead yet. JSON callers can use
901
+ > the status to distinguish a ride-in-progress from a live process
902
+ > (`status: "running"`, `rc=0`, which means the command itself outlived
903
+ > `--wait`). Either way, follow up with `fcloud logs <sid> <pid> --follow` or
904
+ > `fcloud wait`.
905
+
906
+ With the SDK:
907
+
908
+ ```python
909
+ from fcloud import Client
910
+
911
+ client = Client()
912
+ project = client.project("long-job")
913
+
914
+ with project.session(sku="gpu_1x_l4") as s:
915
+ s.upload("./project")
916
+ proc = s.spawn(["python3", "/workspace/project/train.py"])
917
+
918
+ # Poll periodically
919
+ import time
920
+ while proc.status.value == "running":
921
+ proc.poll(lines=20)
922
+ print(proc.output[-200:]) # last 200 chars
923
+ time.sleep(60)
924
+
925
+ print(f"Done: exit_code={proc.exit_code}")
926
+ print(s.logs(proc, output_range="all").output)
927
+ ```
928
+
929
+ ### Pattern 6: Stop now and come back later (file persistence)
930
+
931
+ Files written during a session are synced to durable cloud storage. After you
932
+ stop it (or it is reaped while idle), the session comes back online automatically
933
+ the next time you use it, with all files restored. There is no manual resume step.
934
+
935
+ ```bash
936
+ # Create a session, do work
937
+ fcloud create --sku gpu_1x_l4
938
+ # Session s-abc123 created (cold — $0 until used)
939
+
940
+ fcloud exec --on s-abc123 python3 train.py
941
+ fcloud exec --on s-abc123 ls /workspace/
942
+ # model.pt metrics.json train.py
943
+
944
+ # Halt spend now (files sync to cloud storage)
945
+ fcloud stop s-abc123
946
+
947
+ # Hours later: just use it again — it comes back online automatically,
948
+ # with files restored from cloud storage.
949
+ fcloud exec --on s-abc123 ls /workspace/
950
+ # model.pt metrics.json train.py ← files restored from cloud storage
951
+ ```
952
+
953
+ With the Python SDK:
954
+
955
+ ```python
956
+ from fcloud import Client, Image
957
+
958
+ client = Client()
959
+ project = client.project("experiment", image=Image.debian_slim())
960
+
961
+ # First run: train on L4
962
+ with project.session(sku="gpu_1x_l4") as s:
963
+ s.run(["python3", "train.py"])
964
+ sid = s.session_id
965
+
966
+ # Later: just attach and use it again — a cold session comes back
967
+ # online automatically (rebuilt on a fresh host, files restored).
968
+ s2 = client.attach_session(sid)
969
+ s2.run(["ls", "/workspace/"]) # original files are here
970
+ s2.run(["python3", "finetune.py"])
971
+ s2.close()
972
+ ```
973
+
974
+ **What survives a resume**: `/workspace` always survives. Changes *outside*
975
+ `/workspace` (e.g. `pip install` into the image's site-packages) survive an
976
+ ordinary same-image resume, but are **reset to the image** when the session
977
+ resumes with a changed `fcloud.json` image spec (a re-baseline) or when the
978
+ previous close could not finish syncing — the session prints an
979
+ `environment reset` banner when that happens. For packages that must survive
980
+ unconditionally, install them into a venv under the workspace:
981
+
982
+ ```bash
983
+ fcloud exec --on s-abc123 'python3 -m venv /workspace/venv && /workspace/venv/bin/pip install torch'
984
+ fcloud exec --on s-abc123 /workspace/venv/bin/python train.py
985
+ ```
986
+
987
+ Better yet, put packages in the image spec (`pip_install([...])`) so every
988
+ resume rebuilds them from the content-addressed image cache.
989
+
990
+ ---
991
+
992
+ ## Image Builder
993
+
994
+ Images define the environment (base OS, packages, env vars). They're declarative
995
+ and content-addressed — same layers = cache hit, instant startup.
996
+
997
+ ```python
998
+ from fcloud import Image
999
+
1000
+ # GPU-ready image with PyTorch
1001
+ image = (
1002
+ Image.debian_slim()
1003
+ .apt_install(["git", "build-essential"])
1004
+ .pip_install(["torch", "transformers", "numpy"])
1005
+ .env({"PYTHONUNBUFFERED": "1"})
1006
+ )
1007
+
1008
+ project = client.project("my-project", image=image)
1009
+ ```
1010
+
1011
+ Use `Image.from_registry(...)` when the workload needs a specific container base:
1012
+
1013
+ ```python
1014
+ from fcloud import Image
1015
+
1016
+ # CUDA devel images include nvcc, ptxas, CUDA headers, and other build-time tools.
1017
+ image = (
1018
+ Image.from_registry("nvidia/cuda:12.8.1-devel-ubuntu24.04")
1019
+ .apt_install(["ninja-build"])
1020
+ .pip_install(["torch", "triton", "numpy"])
1021
+ .env({"PYTHONUNBUFFERED": "1"})
1022
+ )
1023
+ ```
1024
+
1025
+ Reach for a CUDA `devel` base when packages build CUDA code from source
1026
+ (`torch.utils.cpp_extension`, flash-attn, xformers, mamba, custom kernels). Runtime
1027
+ CUDA/driver availability is not enough for those workflows; they need the compiler
1028
+ toolchain inside the image.
1029
+
1030
+ For GPU workloads, the base image must also provide a glibc new enough to load the
1031
+ host NVIDIA driver libraries injected by the runtime. This is distro-agnostic:
1032
+ Debian, Ubuntu, SUSE, Rocky, etc. can all work if their glibc satisfies the NVIDIA
1033
+ library requirement. Do not choose old GPU bases casually; failures usually show up
1034
+ as `GLIBC_2.xx not found`, CUDA initialization failures, or `nvidia-smi`/torch CUDA
1035
+ errors. Prefer a recent CUDA base, for example an NVIDIA CUDA image based on Ubuntu
1036
+ 24.04, unless the user has a specific compatible base. For ordinary CPU/Python
1037
+ packages, `Image.debian_slim()` is usually faster to build and cache.
1038
+
1039
+ For PyTorch GPU images, `fcloud` may route pip installs through a PyTorch wheel
1040
+ index. Do not put system build tools such as `ninja` in pip requirements just
1041
+ because a package build mentions them; install `ninja-build` with apt instead.
1042
+ If a workload mixes PyTorch wheels with ordinary PyPI-only packages, split them
1043
+ into separate image layers or configure the package indexes explicitly.
1044
+
1045
+ Pre-built named images:
1046
+
1047
+ - `Image.from_name("agent-gpu")` — the unified default for every `gpu_*` SKU:
1048
+ `nvidia/cuda:12.8.1-devel-ubuntu24.04` (nvcc + CUDA headers, Python 3.12)
1049
+ with a pinned training stack (torch 2.8.0+cu128, transformers, trl, peft,
1050
+ accelerate, flash-attn prebuilt wheel). One image covers T4 through
1051
+ Blackwell (sm75–sm120). Note: flash-attn *kernels* need sm80+ — on T4 the
1052
+ import works but attention stays on sdpa, so avoid TRL
1053
+ `packing`/`padding_free` there. vLLM is part of the pinned stack
1054
+ (`import vllm` / `vllm serve` work out of the box; no extra installs).
1055
+ - `Image.from_name("agent-cpu")` — CPU-only ML stack
1056
+ - `Image.from_name("scientific")` — numpy, scipy, matplotlib, jupyter
1057
+
1058
+ The default image for CLI commands (`exec`, `run`) is `debian_slim` (Python 3.11).
1059
+
1060
+ ---
1061
+
1062
+ ## Project Config (`fcloud.json`)
1063
+
1064
+ Drop a `fcloud.json` in your project root to configure the image and default SKU.
1065
+ The CLI auto-discovers it by walking up from cwd (same as `.env`).
1066
+
1067
+ ```json
1068
+ {
1069
+ "sku": "gpu_1x_l4",
1070
+ "volumes": [{"name": "checkpoints", "mount": "/workspace/checkpoints"}],
1071
+ "image": {
1072
+ "base": "nvidia/cuda:12.8.1-devel-ubuntu24.04",
1073
+ "apt": ["git", "build-essential", "ninja-build", "ffmpeg"],
1074
+ "pip": ["torch", "triton", "numpy", "transformers"],
1075
+ "env": {"PYTHONUNBUFFERED": "1"},
1076
+ "run": ["echo setup-complete"]
1077
+ }
1078
+ }
1079
+ ```
1080
+
1081
+ All fields are optional. Explicit `--sku` flags override the file; `--volume`
1082
+ flags merge with (and re-map by name) the `volumes` defaults.
1083
+
1084
+
1085
+ | Field | Description |
1086
+ | ------------ | ------------------------------------------ |
1087
+ | `sku` | Default hardware SKU for `exec` and `run` |
1088
+ | `volumes` | Volumes auto-attached to every `exec`/`run` session. Each entry is `"name"`, `"name:/mount"`, or `{"name": ..., "mount": ...}` |
1089
+ | `image.base` | Base image (default: `python:3.11-slim`) |
1090
+ | `image.apt` | Packages to `apt-get install` |
1091
+ | `image.pip` | Packages to `pip install` |
1092
+ | `image.env` | Environment variables baked into the image |
1093
+ | `image.run` | Shell commands to run during build |
1094
+
1095
+
1096
+ When `fcloud.json` is present, `fcloud exec` and `fcloud run` automatically build the
1097
+ specified image. The image is cached by content hash — same config = instant startup.
1098
+
1099
+ ---
1100
+
1101
+ ## Key Concepts
1102
+
1103
+ **SKU**: Hardware type identifier. Format: `gpu_<N>x_<family>` or `cpu_<N>x_<family>`.
1104
+ Examples: `gpu_1x_l4`, `gpu_8x_h100`, `cpu_2x_epyc`.
1105
+
1106
+ **Session**: A persistent workspace (filesystem at `/workspace/`) that runs commands
1107
+ in a sandboxed container on a host. A session is a filesystem, not a held GPU:
1108
+
1109
+ - *Throwaway*: created and released per command (`fcloud exec`, `fcloud run` without
1110
+ `--on`) — the filesystem is still saved and downloadable afterwards.
1111
+ - *Persistent*: created with `fcloud create`, interacted with via `--on <sid>`. It is
1112
+ cold ($0) until used, comes online automatically on use, and `fcloud stop` halts spend
1113
+ without deleting it.
1114
+
1115
+ **Job**: An *ephemeral* run-to-completion session (`fcloud job run`): its workspace is
1116
+ never persisted, it cannot resume, and its post-close outputs are volume commits,
1117
+ the exit code, and logs. Cheaper (no storage sync); dies on preemption and re-runs
1118
+ (`--retries`) instead of migrating. See the Jobs section.
1119
+
1120
+ Sessions resume transparently — a stopped or idle-reaped session is rebuilt on a fresh
1121
+ host (files restored from cloud storage) the next time you use it. You never run a resume command.
1122
+
1123
+ **Warmth**: `fcloud sessions` reports a session's state — `hot` (running, spending),
1124
+ `warm` (idle host held, spending), `stopping` (finishing teardown), `cold`
1125
+ (stopped, $0, resumes on use), or `preparing` (coming online).
1126
+
1127
+ **Image**: Declarative container spec. Layers are cached by content hash. First run
1128
+ on a host triggers a build (30–120s); subsequent runs with the same image are instant.
1129
+
1130
+ **Project**: Groups sessions with a shared image definition. Created implicitly by
1131
+ CLI commands or explicitly via `client.project("name", image=...)`.
1132
+
1133
+ ---
1134
+
1135
+ ## Tips for Agents
1136
+
1137
+ 1. **Always use `--json`** when parsing output. Human-readable output may change format.
1138
+ 2. **Use `--sku`** to target specific hardware. Without it, the scheduler picks any
1139
+ available host.
1140
+ 3. **Use `fcloud create` for multi-step work**. One persistent session is faster and
1141
+ cheaper than creating a new ephemeral session for every command.
1142
+ 4. **Treat `returncode: null` as detached, not failed**. Read `session_id` and
1143
+ `process_id`, then call `fcloud logs <sid> <pid> --follow` or poll the process.
1144
+ If stdout was truncated, call `fcloud logs <sid> <pid> --output all`.
1145
+ 5. **Use `--wait` to express foreground patience**. Increase it for commands that
1146
+ should finish soon; let truly long jobs detach and monitor them by process ID.
1147
+ 6. **Pass argv normally; quote a single string (or use `sh -c`) for shell
1148
+ expressions**. A single quoted argument runs as shell text via `bash -lc`.
1149
+ 7. **Cached images are fast** (~2s). If your workflow creates sessions repeatedly with
1150
+ the same image, it's cheap.
1151
+ 8. **Files live in `/workspace/` — and it's your default working directory.**
1152
+ Uploaded files are accessible as relative paths (`python train.py`) or as
1153
+ `/workspace/<filename>` / `/workspace/<dirname>/...`. Commands already run
1154
+ from `/workspace`, so don't `cd /root` or hardcode `/root/...` paths.
1155
+ 9. **Pick a SKU explicitly** with `--sku`. If capacity is unavailable, the session
1156
+ queues until a host is provisioned rather than failing.
1157
+ 10. **Use `fcloud stop` to halt spend now**. Ephemeral sessions (`exec`, `run`
1158
+ without `--on`) release automatically. Persistent sessions (`create`) keep their
1159
+ files; they stop on their own when idle, so `stop` is only needed to halt spend
1160
+ immediately.
1161
+ 11. **Just use a session again to pick up where you left off**. Files persist across
1162
+ stop/idle via cloud storage, and the session comes back online automatically (no resume
1163
+ command). You can target different hardware with `--sku`.
1164
+ 12. **Gracefully stop GPU engines when possible**. Prefer the engine's shutdown
1165
+ method or `fcloud kill <sid> <pid>` over in-session `pkill -9`; if GPU memory
1166
+ remains pinned after a hard kill, recover with `fcloud stop <sid>` and then use the
1167
+ session again.
1168
+
1169
+ ## Anti-Patterns
1170
+
1171
+
1172
+ | Don't | Why |
1173
+ | ---------------------------------------------- | ------------------------------------------------------------------------------ |
1174
+ | Omit `--json` when parsing output | Human format is for display, not parsing |
1175
+ | Request unavailable SKUs | Session will queue until a host is provisioned; pick a SKU you expect to exist |
1176
+ | Retry after `returncode: null` | The original command is still running; follow its process ID |
1177
+ | Max out `exec --wait` on a long job | Prefer `spawn` + `fcloud wait`; a huge `--wait` blocks the foreground and depends on the stream staying alive |
1178
+ | Pipe a live `exec` through `\| tail` | Buffers until EOF (no live output) and swallows the detach line with the `process_id`. Use `--emit-pid FILE`, or `spawn` + `wait` |
1179
+ | Treat a migration `rc=75` as "job died" | Spot preemption restores the process on a new host; retry the exec, or use `fcloud wait`/`logs` which ride the migration |
1180
+ | Immediately retry a preempted job | The system automatically attempts to requeue it; resubmitting races the requeue and duplicates the run |
1181
+ | Put local shell syntax outside quotes | The local shell handles it before `fcloud`; quote it or use `sh -c` |
1182
+ | Use `pkill -9` as the first response to a hung GPU engine | Hard kills can bypass framework cleanup; try graceful shutdown or `fcloud kill`, then `fcloud stop` and reuse if needed |
1183
+ | Run commands without `--sku` in production | May land on a CPU when you need a GPU |
1184
+ | Write a job's outputs only to `/workspace` | A job's workspace is discarded at close — write results to a `--volume` (or download while it runs); only volumes, logs, and the exit code survive |
1185
+ | Expect `fcloud ls`/`download`/resume on a closed job | Jobs never save a manifest; those refuse by design. Use `fcloud volume files <name>` and `fcloud job logs <id>` |