Skip to content

Add truss train exec to run a local directory as a Baseten training job - #2629

Draft
claude[bot] wants to merge 8 commits into
mainfrom
claude/lps-1193-truss-train-exec
Draft

Add truss train exec to run a local directory as a Baseten training job#2629
claude[bot] wants to merge 8 commits into
mainfrom
claude/lps-1193-truss-train-exec

Conversation

@claude

@claude claude Bot commented Aug 27, 2026

Copy link
Copy Markdown

Requested by Raymond Cano · Slack thread

Linear: LPS-1193

🚀 What

Before: running a local client remotely meant doing it by hand — write a config.py describing a training project, point it at a base image and compute, wire up a start command, then truss train push config.py. So people just ran the thing on their laptop instead, holding thousands of connections open from a machine that sleeps, and only assembled a real training job when a run mattered enough to justify the setup.

After: truss train exec -- python my_script.py tars the directory you are standing in, ships it to a Baseten training job, and runs that command there. No config file, no template to copy; the flags cover the parts you would otherwise have written out, and SSH into the job is available on demand.

One sentence: truss train exec launches the current directory as a Baseten training job running a command you supply, with no persistent storage and no checkpointing.

The motivating case is running a Loops client remotely instead of on a laptop, but nothing about the command is Loops-specific.

💻 How

truss/cli/train/exec/ is a package with one job per module — builder.py assembles the TrainingProject, secrets.py owns --env/--secret, uv.py holds everything that knows what uv is, project.py defines the Project protocol and get_project_type, and __init__.py is a thin re-export (matching deploy_checkpoints/ next door). A project type supplies the base image it wants and the setup steps it contributes, so build_start_commands no longer knows about uv and adding pip or poetry means a sibling module plus a branch in get_project_type. The CLI command sits next to truss train workstation in truss/cli/train_commands.py and stays thin; build_exec_project is keyword-only with no defaults, so a caller cannot build a partially-specified project. START_COMMAND is collected with nargs=-1 + click.UNPROCESSED under ignore_unknown_options, shlex.joined, and becomes the job's single start command. The push goes through truss_train.public_api.push with source_dir=Path.cwd() — the one real divergence from workstation, which deliberately pushes a temp dir. Team resolution, --remote, --tail and the printed connect hint follow workstation.

Flags: --accelerator (choice, default none → CPU-only), --gpu-count (1–8, default 1, requires --accelerator), --cpu-count / --memory (defaulting to the Compute model's own defaults), --project-name, --image, --workspace-root / --exclude-dir / --external-dir, --env KEY=VALUE, --secret KEY=SECRET_NAME, --with-uv, --remote, --team, --tail/--no-tail.

--env and --secret fill Runtime.environment_variables — plain strings and SecretReference(name=...) respectively. This matters because a training job gets no Baseten credential automatically; SecretReference was config-file-only until now, with no CLI flag on any truss train command, which would have left exec unable to run anything that calls the Baseten API. Both split on the first = only, so values containing = survive; a key set by both is an error. --secret names are also checked against the workspace before the push, warning (never blocking) when one is missing.

--with-uv makes uv available in the job image and nothing else — the user writes uv run themselves, e.g. truss train exec --with-uv -- uv run python my_script.py. On the CPU path it selects the official uv image; on the GPU path (and for any user-supplied --image, since we cannot know whether it ships uv) it prepends a skip-if-present install step, { command -v uv || curl -LsSf https://astral.sh/uv/install.sh | sh ; }, so it is a no-op on an image that already has uv. Without the flag: plain python:3.12-slim on the CPU path, nothing injected, command verbatim. If the flag is absent but the directory has a uv.lock or pyproject.toml, the command warns before pushing that uv will not be in the image — it does not fail and does not auto-enable, because auto-detection would make the same command behave differently depending on directory contents.

The upload already excludes .venv (packaged truss/util/.truss_ignore), so the local virtualenv is never shipped and the job builds its own environment.

Base image, by path:

base image start_commands
CPU, no --with-uv python:3.12-slim command verbatim
CPU + --with-uv ghcr.io/astral-sh/uv:0.12.6-python3.12-trixie-slim command verbatim (image ships uv, no install)
GPU, no --with-uv CUDA, accelerator-dependent (below) command verbatim
GPU + --with-uv CUDA, accelerator-dependent (below) install step, then command
--image X, no --with-uv X command verbatim
--image X + --with-uv X install step, then command

The GPU image is not a single tag: default_base_image delegates to workstation.default_base_image, which returns nvidia/cuda:13.0.3-devel-ubuntu24.04 for B300 and GB300 and nvidia/cuda:12.8.1-devel-ubuntu24.04 for every other accelerator. GPU jobs therefore inherit whatever workstation picks, by design, rather than pinning their own tag here.

The two CPU tags are pinned and were verified to exist in their registries. uv stopped publishing bookworm-slim variants for current versions, so the pinned uv tag is the trixie-slim one.

Reviewer decision points

  • On-demand SSH, not on-startup. The job gets InteractiveSession(trigger=ON_DEMAND, session_provider=SSH), where workstation uses ON_STARTUP (workstation.py:94-97). To be precise about what is sent: the builder sets no timeout, but InteractiveSession.timeout_minutes carries a model default of 480 (definitions.py:164), so the built object still populates and sends timeout_minutes=480, along with auth_provider=MICROSOFT. Per the platform owner the timeout applies once the job ends rather than governing a running job, so under ON_DEMAND it is not a concern for a long-running job — but a value is populated either way, not omitted. truss-train/truss_train/definitions.py is untouched; widening that field's type to make omission expressible was considered and rejected.
  • The printed connect hint could not be fully verified. This repo has no endpoint that starts an interactive session on demand — only GET .../auth_codes (truss train isession) and PATCH of the session's trigger/timeout — and the SSH cert path (sign_ssh_certificate) does not check session state client-side. So whether ssh training-job-JOB_ID-0.ssh.baseten.co works immediately for an ON_DEMAND job, or needs the session requested first, is not knowable here. The hint points at truss train isession --job-id JOB_ID before the ssh line, which is accurate either way; someone with backend context should confirm the wording.
  • -- is required in practice. ignore_unknown_options means a mistyped flag before -- is silently folded into the start command rather than rejected. That is inherent to variadic pass-through; the help and docstring both show the -- form.
  • Does the platform run every entry in Runtime.start_commands, or only the first? I could not establish this from the repo. Every producer emits exactly one entry — workstation.py:79,81, the --entrypoint override at deployment.py:386 (which collapses the list to [entrypoint]), all five truss-train/tests/import/ fixtures — and Runtime.start_commands is a bare List[str] (definitions.py:172) passed straight to the backend with no client-side interpretation. There is no schema, contract, or doc describing multi-entry semantics. Where the repo itself needed two sequenced steps it chained them with && inside one /bin/sh -c entry (truss/templates/train/config.py:18-20), so the uv-install path does the same. If multiple entries do execute, that path could be split into two entries for cleaner log separation.
  • --project-name, not --project-id. workstation calls the same thing --project-id even though it is used as the project name (workstation.py:106 does TrainingProject(name=project_id)), and --project-id elsewhere in the CLI means an actual ID. --project-name is named for what it is; say the word if consistency with workstation is preferred. It defaults to the current directory's name so repeated runs from the same checkout group into one project.
  • Push-time secret validation is client-side, and now a hard error. --secret names are looked up via BasetenApi.get_all_secrets before the push. A name absent from a listing we successfully read fails the command (click.UsageError, no push), since the job would fail to start anyway. A failed or unparseable listing still proceeds — that is an API problem, not evidence the secret is missing. Because this can now block a valid run, the caveat matters more: there is no team-scoped secrets endpoint in the client (only GET/POST v1/secrets, neither taking team_id, versus v1/teams/{team_id}/… for training projects), so whether the workspace listing is genuinely a superset of a team's secrets is not knowable from this repo and wants confirmation from someone with backend context. The error text says so and asks the user to report a mismatch.
  • --tail is opt-in, matching push and workstation — the motivating use case is a client that runs for hours, where blocking the terminal is the wrong default. When it is passed, exec checks the poller's terminal status and exits non-zero on TRAINING_JOB_FAILED / TRAINING_JOB_DEPLOY_FAILED, so truss train exec --tail -- pytest is not green in CI regardless of outcome; truss train push --tail does not do this, and that divergence remains and is deliberate.
  • --workspace-root is validated client-side. truss_train's own containment check runs inside push, after upsert_training_project, so a bad value leaves a stray empty project behind; exec checks before pushing. That ordering inside push is pre-existing and unchanged — only exec's flag is covered.
  • Noted follow-up, deliberately left out of this PR. The four-line tail-logs block is now duplicated across _handle_post_create_logic, workstation and exec; extracting a shared helper would mean editing two existing commands, so it belongs in its own change rather than here.
  • Monorepos are deliberately deferred. A boolean --with-uv cannot point at a package that lives below the invocation root. --workspace-root is the natural place to build that later.

🔬 Testing

New truss/tests/cli/train/test_exec.py — 107 tests, builder and CLI. Builder: CPU defaults and image, GPU path, every supported accelerator, on-demand SSH fields, cache/checkpointing left off, workspace None unless a directory flag is passed, --env/--secret parsing (values containing =, missing =, duplicate keys, SecretReference rather than a literal), and the exact start_commands for each uv combination — no flag, flag on the uv image, flag on the GPU image, flag on a custom image — asserting the user's command is last and unmodified in every case. CLI: -- pass-through (including a case where the command reuses our own --memory/--tail flag names, proving they are not consumed by the parser), missing start command, --gpu-count without --accelerator, accelerator normalization, source_dir equal to the cwd, project-name default and override, --image winning over both uv paths, the uv warning firing only for a real uv project, tail opt-in and the non-zero exit on a failed job, markup escaping in the launch line, --cpu-count bounds, and --workspace-root rejection. The three fixes most worth guarding were mutation-checked: reverting the escape, the exit check, or the failed guard each makes a specific test fail.

CLI message assertions compare against ANSI-stripped, whitespace-collapsed output: rich_click renders errors and warnings as colorized, width-wrapped panels and turns color on under GITHUB_ACTIONS, which splits --flag tokens with escape codes, so a plain substring match passes locally and fails on every CI runner.

uv run ruff check, uv run ruff format, and uv run mypy are clean on all three changed files. Full non-integration suite: 1772 passed, 26 skipped (uv run pytest --durations=0 -m 'not integration'), run both normally and with GITHUB_ACTIONS=true to match CI's color handling, with test_workstation.py, test_cli.py and test_loops_cli.py green. The generated uv-install shell string was verified to parse as valid POSIX sh (sh -n) with the command's own quoting intact.

pre-commit's hygiene hooks pass. Its three local hooks (ruff, ruff-format, mypy) could not run in my sandbox — they shell out to bare uv run, which tries to fetch the CPython pinned in .python-version from GitHub releases and gets a 403 through the environment's proxy. I ran those same three tools directly against the system interpreter instead, clean; CI will run the hooks for real.

🤖 Generated with Claude Code

https://claude.ai/code/session_012vALt4HhhRV2VtW4Yxv53X

`truss train exec -- <command>` tars the directory the command is invoked
from, ships it to a Baseten training job, and runs the command there with an
on-demand SSH session available.

The builder lives in truss/cli/train/exec.py, mirroring the existing
workstation builder, and the CLI command sits next to `truss train
workstation` in train_commands.py. Everything after `--` is passed through
verbatim as the job's single start command.

Unlike workstation, the job runs from the invocation directory
(source_dir=Path.cwd()) and gets no cache and no checkpointing. CPU-only is
the default; --accelerator opts into a GPU. --with-uv makes uv available in
the job image so the command can invoke `uv run` itself.
@linear

linear Bot commented Aug 27, 2026

Copy link
Copy Markdown

LPS-1193

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

claude added 2 commits August 27, 2026 02:29
rich_click renders errors and warnings as colorized, width-wrapped panels and
turns color on under GITHUB_ACTIONS, so `--gpu-count requires --accelerator`
reaches result.output with each `--flag` token wrapped in ANSI codes. Two
assertions matched the plain substring and failed on every CI matrix entry
while passing locally, where rich emits no color.

Compare against output with ANSI codes and panel borders stripped and
whitespace collapsed, so the assertions hold regardless of terminal width or
color support.
The comment claimed on-demand "avoids imposing" a session timeout, but the
builder passes no timeout and InteractiveSession.timeout_minutes defaults to
480, so a timeout is populated and sent regardless. State the actual reason
on-demand is fine here -- the timeout applies once the job ends -- and that
the model default still applies.
@claude

claude Bot commented Aug 27, 2026

Copy link
Copy Markdown
Author

CI failure on 57e0407 is not from this PR

Failing check: Test bindings on x86_64-apple-darwin - node@18 (workflow baseten-performance-client-nodejs, run 33034240537)

It died before any test body ran. The job failed in step 4, Install dependencies (yarn install), with Process completed with exit code 138 — 128 + 10 = SIGBUS. Steps 5-7 (Download artifacts, List packages, Test bindings) were all skipped. No test assertion was ever evaluated.

attempt 1 attempt 2 (re-run)
job 98394050633 98394459884
Install dependencies failure, exit 138 failure, exit 138
Test bindings skipped skipped
runner GitHub Actions 1000807291 GitHub Actions 1000807305

Why it is not this PR's:

  • The PR's entire diff against main is three Python files: truss/cli/train/exec.py, truss/cli/train_commands.py, truss/tests/cli/train/test_exec.py. It touches nothing under baseten-performance-client/ and nothing under .github/ — no package.json, no yarn.lock, no .rs/.js/.ts, no workflow file.
  • The head commit 57e0407 is comment-only: 2 insertions, 1 deletion, all of them comment lines in exec.py. No executable line changed.
  • This exact job — same name, same macos-latest host, same node 18 — passed on the immediately previous head b9ffaba 15 minutes earlier, with every step green including Install dependencies and Test bindings.
  • Test bindings on x86_64-apple-darwin - node@20 — the same target and the same yarn install step, in the same workflow run — passed. So did the other 15 binding-test jobs.

A Python comment cannot affect yarn install in baseten-performance-client/node_bindings.

Why the re-run reproduced it identically. Setup node uses actions/setup-node@v4 with cache: yarn, keyed on baseten-performance-client/node_bindings/yarn.lock plus runner OS and node version — all inputs this PR leaves untouched. Re-running failed jobs within the same workflow run restores the same cache archive, so the retry is not an independent sample; it replays the same restored state. The node@20 job has a different cache key (the key includes the node version), which is consistent with it passing. The job also sets architecture: x64 on macos-latest, which is arm64, so an x64 Node 18 runs under Rosetta — SIGBUS is a characteristic crash signature there.

What I did: re-ran the single failed job once (attempt 2). It failed the same way, in the same step, with the same exit code. I have not re-run again.

Suggested next step (not taken here): this needs a fresh workflow run rather than another re-run of run 33034240537 — a new push to the branch will do it, or clear the setup-node/yarn cache entry for macos-latest + node 18 so the install step is not fed the same restored archive. I did not push an empty commit, and I changed no code, since there is no causal link to this diff.

For the record, main at 7d4daa1 does not run this workflow at all (it is path-filtered to baseten-performance-client/), so there is no base-branch comparison for this job; main does carry 5 unrelated pre-existing failures (4 × all-tests / truss-integration-tests, plus prebuild).


Generated by Claude Code

claude added 3 commits August 27, 2026 22:10
Nothing validated SecretReference names before push, so a typo surfaced only
once the job failed to start. Look the names up via the existing (previously
uncalled) BasetenApi.get_all_secrets and warn, pointing at the settings page,
which is the only way to create a workspace secret.

Warn-only and fail-open on purpose. GET v1/secrets takes no team scoping while
training projects are team-scoped, so a name absent from the listing is not
proof it is absent for the job; any error or an unreadable payload skips the
check silently. The lookup is skipped entirely when no --secret was passed.

The response shape is not pinned down by any test or doc in the repo, so the
parser accepts the plausible shapes and returns None -- meaning "don't check"
-- rather than guessing and warning about a secret that is really there.
Secret names reach rich escaped, since a stray bracket would be read as markup.
- exec now exits non-zero when a tailed job ends in a failure state, so
  `truss train exec -- pytest` is not green in CI regardless of outcome.
  TrainingPollerMixin gains a public `failed` property rather than callers
  reaching for `_current_status`. Uses sys.exit, not click's Exit, which
  subclasses RuntimeError and would be caught by common_options' error handler.
- The uv install no longer pipes curl into sh: a pipeline reports the last
  command's status, so a failed download fed an empty script to a shell that
  exited 0 and the job died later with an opaque `uv: not found`.
- `--secret KEY=` is rejected instead of yielding SecretReference(name="").
  An empty --env value stays legal.
- The secret lookup's except now wraps only the API call, so a parser bug
  surfaces instead of looking like an unreachable API.
- The launch line escapes interpolated text; a directory named `myproj[v2]`
  was reported as `myproj`, and `[/cyan]` in a path raised MarkupError.
- The missing-secret warning no longer asserts absence, since GET v1/secrets
  takes no team parameter while training projects are team-scoped.
- `--cpu-count` uses IntRange(min=1); Compute(cpu_count=-4) constructed fine.
- uv detection requires a uv.lock or a [tool.uv] section, not just any
  pyproject.toml, which poetry, hatch, PDM and setuptools all ship.
- uv detection and the new client-side --workspace-root validation both look
  at the directory that actually gets archived, so a bad root fails before
  the training project is created rather than leaving a stray empty one.
The motivating use case is a client that runs for hours, where blocking the
terminal is the wrong default. This also lines exec up with `truss train push`
and `truss train workstation`, where --tail is opt-in.

The non-zero exit on a failed job is unchanged; it applies when --tail is
passed, which is the narrower CI case that motivated it. Kept as the paired
--tail/--no-tail form so --no-tail still parses and --help shows the default.

@rcano-baseten rcano-baseten left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

left some thoughts


if accelerator:
accelerator = accelerator.upper()
gpu_count = gpu_count or 1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this means gpu count will default to 1 - should that be the case when a user uses compute type?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — the CLI computed every one of those before calling, so the defaults were never exercised and just duplicated the click declarations. build_exec_project is now keyword-only with no defaults at all. Optional survives only where None is a value rather than "unspecified": accelerator (CPU-only), base_image (derive it), project (plain image, no setup steps), workspace_root (archive the invocation directory).

DEFAULT_CPU_COUNT / DEFAULT_MEMORY now have exactly one home as the click defaults, still read from Compute.model_fields so they can't drift from the model. Keyword-only seemed worth it at twelve parameters. Tests use a _build(**overrides) helper that supplies a complete baseline, so each test overrides only what it exercises, and two new tests assert a partial or positional call raises TypeError.


Generated by Claude Code

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apologies — my previous reply on this thread was misplaced; it belongs to your build_exec_project signature comment and is now posted there. This --gpu-count question is still open and is being answered separately.


Generated by Claude Code

Comment thread truss/cli/train/exec.py Outdated
Comment thread truss/cli/train/exec.py Outdated
def warn_about_missing_secrets(
api: BasetenApi, environment_variables: Mapping[str, Union[str, SecretReference]]
) -> None:
"""Warn when a `--secret` names a secret that isn't in the workspace listing.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we should hard error here. If we can't see the secrets at all, we know the job will fail

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a team-scoped secrets api at all? we shoudl use that - i think otherwise, this set of secrets is a superset of the team secrets - lmk if that's not the case - and so we should fail closed if we can't find the secret

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to a hard error (click.UsageError, no push) when a named secret is absent from a listing we successfully read.

On your question: no, there is no team-scoped secrets API in the client — only GET v1/secrets (get_all_secrets, which takes no arguments at all) and POST v1/secrets, neither with a team_id, and both were uncalled before this PR. Compare upsert_training_project, which does branch to v1/teams/{team_id}/training_projects. So there is nothing team-scoped for me to switch to. Whether the server offers one, and whether the workspace listing is genuinely a superset of a team's secrets, isn't knowable from this repo — your reading is plausible and matches the naming, but the code can't confirm it, so someone with backend context should. The error text says the listing isn't team-scoped and asks the user to report a mismatch, and I'll swap it for a team-scoped route the moment one exists.

One deliberate split I'd flag so you can overrule it: I kept fail-open when the listing call itself fails or returns a payload we can't parse. That's an API error rather than evidence the secret is missing, so hard-erroring there would break the command on a blip or a permissions quirk. Absent-from-a-readable-listing is the case that now fails closed.


Generated by Claude Code

Comment thread truss/cli/train/exec.py Outdated
Comment thread truss/cli/train/exec.py Outdated
claude added 2 commits August 28, 2026 00:44
exec.py becomes a package with one job per module: builder.py assembles the
TrainingProject, secrets.py owns --env/--secret, uv.py holds everything that
knows what uv is, project.py defines the Project protocol and get_project_type.
__init__.py is a thin re-export, matching deploy_checkpoints/ next door.

A Project protocol now supplies the base image a project type wants and the
setup steps it contributes, with UvProject as the first implementation and
get_project_type detecting it. No build() -- nothing is built client-side.
build_start_commands no longer knows about uv. Behavior is unchanged: --with-uv
still selects UvProject directly and only affects the image, the command still
runs last and verbatim, and no uv sync is injected.

A --secret naming a secret absent from a listing we successfully read is now a
hard error instead of a warning. A failed or unparseable listing still proceeds:
that is an API problem, not evidence the secret is missing.

build_exec_project is keyword-only with no defaults, so a caller cannot build a
partially-specified project and the CLI stays the single home for defaults.

Also dropped rich escaping from the secret error: click.UsageError messages are
not markup-parsed, so escaping leaked a literal backslash into the name shown to
the user.
`value` was bound as str from _parse_key_value_flag, then rebound from the
entries list as Union[str, SecretReference], so mypy rejected the assignment.
It only surfaced in the whole-repo run, where truss_train is in the file set and
SecretReference resolves precisely; checking the file alone passes either way.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants