Skip to content

Opt-in dotfiles refresh on attach - #220

Merged
blooop merged 2 commits into
mainfrom
wayfinder/devlaunch-183
Aug 15, 2026
Merged

blooop merged 2 commits into
mainfrom
wayfinder/devlaunch-183

Conversation

@blooop

@blooop blooop commented Aug 15, 2026 •

Copy link
Copy Markdown
Owner

Closes #183

devpod applies dotfiles when it provisions a workspace, so a workspace that has been up for a fortnight still has the dotfiles it was born with. DEVLAUNCH_DOTFILES_ON_ATTACH=1 makes dl run the existing refresh just before it hands over an interactive shell.

The three requirements

1. Off by default. The claim worth making is not that a flag exists but that the spawn counts did not move. test/test_devpod_spawn_counts.py is unedited — 0 deleted lines, 30 original tests still green — and the new pins sit beside them. Dropping the opt-in check (making it unconditional, as the first attempt did) fails test_attaching_to_a_running_workspace and test_an_interactive_attach_spawns_nothing_but_the_session, which are two of the five the ticket names. That mutation was run, not assumed.

The switch reads the vocabulary of denials DEVLAUNCH_NO_TTY and DEVLAUNCH_NO_GH_TOKEN already read ("", 0, false, no, case- and space-insensitive), rather than a third spelling. The same expression flips sense for free: unset reads as the empty string, which is a denial, so off-by-default falls out of the idiom instead of being stated separately.

2. A timeout. 60s, spent inside the container as timeout 60 bash -c '<payload>', not as a host-side subprocess deadline. That choice is load-bearing and was measured before it was made: timeout puts the managed command in its own process group and signals the group, so the git process actually blocked on the network dies with the shell that started it. Against a hanging stub the emitted payload returned 124 at 1.01s against a 1s bound, with no surviving grandchild — a host-side kill of the devpod ssh client would have left the remote process holding the session open, which is the failure mode the requirement exists to prevent.

3. Skipped for one-shot dl <ws> -- cmd. shell_command is None is the gate, matching the reasoning attach_workspace has always applied to that path — no prompt is rendered, no interactive shell is sourced, so a refresh in front of it buys the command nothing and costs it a round-trip. Pinned at the spawn boundary with the switch on: still exactly status + ssh --command.

The two extras, both declined

Cooldown — declined. It buys back seconds and costs a new piece of persistent per-workspace state: a timestamp store, a location for it, an expiry, and its own failure modes when it is stale or unwritable. The repo's taste is against machinery for costs measured in seconds, and the user opted in; attaching five times means five refreshes were asked for. Anyone who finds that too often has dl <ws> dotfiles and an unset variable.

Background refresh — declined, and on correctness rather than cost. Backgrounding it would be defensible if the only objection were latency, but it defeats what the feature is for. The point of refreshing on attach is that the shell being handed over is the one that reads the new dotfiles. A refresh that lands after the session starts is updating files that shell has already finished sourcing — so it would help only the next attach, which is what dl <ws> dotfiles already does. It would also interleave chezmoi output into a terminal devpod has taken over. Running it in front of the shell is the ordering the feature means, and one new test asserts that ordering.

Deviations declared

  • The refresh is not restricted to already-Running workspaces. attach_workspace is reached from the cold path too, where devpod has just installed dotfiles and the refresh is redundant. Narrowing it would mean threading workspace state into a function that deliberately does not know it, to save one bounded trip on a path that just spent ~60s building a container. The ticket lists three requirements and this is not among them; flagging rather than building it.
  • The bound is a module constant, not a second environment variable. One knob, not two. A tunable for a number nobody has been bitten by is a knob to maintain, and the failure it guards is survivable — the refresh is best-effort and the shell arrives either way.
  • dl <ws> dotfiles is deliberately left unbounded (timeout defaults to None). A typed refresh is in the foreground and interruptible, and a first pixi global sync is legitimately slow; killing it at a fixed second would abandon a half-finished sync to save nobody any time. Pinned by its own test so the asymmetry is intentional rather than incidental.
  • The enabled path also spawns devpod context options, via the existing dotfiles_update. It is cached on disk for an hour, so in practice it is a stat rather than a spawn — but it is visible in the new pin rather than hidden, which is why the pin lists four trips.

Tests

Nine new pins in test_devpod_spawn_counts.py (the file the ticket names as "the suite any implementation has to satisfy") plus two in test_dl.py. Every one was mutation-checked, each mutation verified to have actually applied — the first harness silently no-opped on a missing python and reported five false passes:

Mutation Caught by
Drop the one-shot guard test_a_one_shot_command_is_not_worth_a_refresh
Drop the opt-in check (the original bug) 8 tests, incl. 2 pre-existing unedited pins
Drop the timeout wrapper test_the_refresh_trip_is_bounded + the payload test
Read mere presence as consent all 6 denial cases
Move the refresh behind the session the ordering + bounded tests

The timeout is asserted as a literal in the emitted payload rather than by waiting out a real hang. Executing it for real is what proved the design, but it cannot be a test: workspace_ssh wraps payloads in bash -lc, and a login shell resets PATH — the verification run reached the host's real chezmoi and pixi straight past a stubbed one. A suite that did this would run real dotfiles commands on CI.

CI: pixi run ci exit 0, pylint 10.00/10, 1739 passed.

🤖 Generated with Claude Code

Summary by Sourcery

Add an opt-in mechanism to refresh dotfiles on workspace attach while preserving existing attach behavior by default.

New Features:

  • Introduce DEVLAUNCH_DOTFILES_ON_ATTACH environment flag to trigger a dotfiles refresh just before handing over an interactive shell.
  • Add a 60-second in-container timeout for automatic dotfiles refreshes on attach to prevent hangs on unreachable remotes.

Enhancements:

  • Skip the automatic dotfiles refresh for one-shot dl <ws> -- <command> invocations to avoid unnecessary overhead.
  • Extend dotfiles refresh logic to optionally wrap the full payload in a timeout while leaving manual dl <ws> dotfiles runs unbounded.

Documentation:

  • Document DEVLAUNCH_DOTFILES_ON_ATTACH, its behavior, timeout, and limitations in README.md.
  • Record the new dotfiles-on-attach feature and its opt-in, bounded semantics in the changelog.

Tests:

  • Add spawn-count tests covering opted-in attach refresh behavior, timeout usage, one-shot command exclusion, and falsey flag values.
  • Add unit tests asserting that manual dotfiles refreshes are unbounded and that bounded refreshes still run the full payload.

devpod applies dotfiles when it provisions a workspace, so a long-lived one
keeps whatever it was born with until somebody runs `dl <ws> dotfiles`. This
closes that gap for anyone who sets DEVLAUNCH_DOTFILES_ON_ATTACH=1, and for
nobody else.

Off by default is the requirement, not a precaution. The first attempt at this
refreshed unconditionally and charged every attach a ~1.7s devpod round-trip
plus a git pull; the spawn-count pins that measure the attach path are the
tests it failed, and they are unedited here and still green. Dropping the
opt-in check makes exactly those pins fail again, which is what "off by
default" is worth asserting as.

Skipped for `dl <ws> -- cmd` on the reasoning the attach has always applied to
that path: a one-shot renders no prompt and sources no interactive shell, so a
refresh in front of it buys the command nothing and costs it a round-trip.

Bounded at 60s, spent inside the container rather than as a host-side
subprocess deadline: `timeout` signals the whole process group, so the git
process actually blocked on the network dies with the shell that started it
instead of lingering with the session held open. Verified against a hanging
stub -- the payload returned 124 at 1.01s against a 1s bound and left no
survivor. A host-side kill of the devpod client would not have achieved that.

`dl <ws> dotfiles` typed by hand stays unbounded: a deadline is worth having on
the refresh nobody asked for, and is a way to abandon a half-finished
`pixi global sync` on the one somebody did.

Closes #183

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sorry @blooop, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@sourcery-ai

sourcery-ai Bot commented Aug 15, 2026

Copy link
Copy Markdown

Reviewer's Guide

Implements an opt-in, bounded dotfiles refresh that runs inside the container immediately before interactive attaches, while preserving existing spawn counts and skipping one-shot commands, and documents the new behavior and configuration flag.

Sequence diagram for opt-in dotfiles refresh on interactive attach

sequenceDiagram
    actor User
    participant dl
    participant attach_workspace
    participant refresh_on_attach_enabled
    participant dotfiles_update
    participant workspace_ssh

    User->>dl: dl workspace_id
    dl->>attach_workspace: attach_workspace(workspace_id, shell_command=None)

    attach_workspace->>refresh_on_attach_enabled: refresh_on_attach_enabled()
    alt dotfiles refresh enabled
        attach_workspace->>dotfiles_update: dotfiles_update(workspace_id, timeout=DOTFILES_ATTACH_TIMEOUT_SECONDS)
        dotfiles_update->>workspace_ssh: workspace_ssh(workspace_id, command=update_cmd)
        Note over dotfiles_update,workspace_ssh: update_cmd wrapped by timeout bash -c when timeout is not None
    else dotfiles refresh disabled
        Note over attach_workspace: No dotfiles_update call
    end

    attach_workspace->>workspace_ssh: workspace_ssh(workspace_id, shell_command=None)
    workspace_ssh-->>User: Interactive shell in workspace

    rect rgb(240,240,240)
        User->>dl: dl workspace_id -- command
        dl->>attach_workspace: attach_workspace(workspace_id, shell_command=command)
        Note over attach_workspace: shell_command is not None → dotfiles_update skipped
        attach_workspace->>workspace_ssh: workspace_ssh(workspace_id, shell_command=command)
    end
Loading

File-Level Changes

Change Details Files
Add an opt-in flag and helper to control whether dotfiles are refreshed on interactive attach, defaulting to off and sharing the existing "falsey" vocabulary.
  • Introduce DEVLAUNCH_DOTFILES_ON_ATTACH as the environment variable controlling refresh-on-attach behavior.
  • Define DOTFILES_ON_ATTACH_VAR, FALSEY, and DOTFILES_ATTACH_TIMEOUT_SECONDS constants to encapsulate configuration and timeout.
  • Implement refresh_on_attach_enabled() to interpret the environment variable using the shared falsey semantics from other DEVLAUNCH* flags.
devlaunch/dl.py
Wire the dotfiles refresh into the attach path so that, when enabled, it runs before the interactive shell and is skipped for one-shot commands.
  • Update attach_workspace() to call dotfiles_update() with a timeout when shell_command is None and refresh_on_attach_enabled() returns true.
  • Preserve the existing attach behavior by still delegating to workspace_ssh() for the actual shell and keeping the one-shot path unchanged.
devlaunch/dl.py
Make the dotfiles refresh payload optionally bounded via a container-side timeout that wraps the entire update sequence, while keeping manual refreshes unbounded by default.
  • Extend dotfiles_update() to accept an optional timeout parameter and wrap the composed update_cmd with timeout <secs> bash -c when provided.
  • Ensure the timeout applies to the whole payload (chezmoi update, pixi sync, and fallback clone) rather than individual steps, and default to no timeout for dl <ws> dotfiles.
  • Keep the existing context options, fallback behavior, and workspace_ssh invocation unchanged apart from the optional timeout wrapper.
devlaunch/dl.py
Add targeted tests that pin spawn counts, timeout behavior, opt-in semantics, and one-shot command behavior for the new refresh-on-attach feature.
  • Import devlaunch.dl as dl_module in the spawn-count tests to assert against DOTFILES_ATTACH_TIMEOUT_SECONDS.
  • Add TestOptInDotfilesRefreshOnAttach with tests that verify the extra devpod ssh trip ordering, payload contents, one-shot behavior, and environment-variable denials.
  • Add tests in test_dl.py that assert typed dotfiles refreshes remain unbounded and that a bounded refresh wraps the full payload without dropping steps.
test/test_devpod_spawn_counts.py
test/test_dl.py
Document the new opt-in dotfiles refresh behavior, configuration, and limits in user-facing README and CHANGELOG entries.
  • Document DEVLAUNCH_DOTFILES_ON_ATTACH in README environment table and add a dedicated "Refreshing dotfiles on attach" section describing behavior, opt-in nature, one-shot exclusion, and timeout semantics.
  • Add a CHANGELOG entry summarizing the feature, its off-by-default behavior, the 60-second container-side timeout, the one-shot exclusion, and the distinction between automatic and manual refreshes.
README.md
CHANGELOG.md

Assessment against linked issues

Issue Objective Addressed Explanation
#183 Implement an automatic dotfiles refresh on workspace attach that is opt-in and off by default, controlled via configuration/environment, with no change in attach latency for users who do not enable it. ✅
#183 Ensure the automatic dotfiles refresh on attach is bounded by a timeout on the network call(s), so an unreachable dotfiles remote fails fast and hands over the shell instead of hanging. ✅
#183 Skip the automatic dotfiles refresh entirely for one-shot commands (dl <ws> -- cmd), preserving the existing fast path behavior and spawn counts for that use case. ✅

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@codecov

codecov Bot commented Aug 15, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 92.80%. Comparing base (9681c6b) to head (5c417dd).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #220      +/-   ##
==========================================
+ Coverage   92.78%   92.80%   +0.01%     
==========================================
  Files          24       24              
  Lines        3672     3681       +9     
==========================================
+ Hits         3407     3416       +9     
  Misses        265      265              
Files with missing lines Coverage Δ
devlaunch/dl.py 95.00% <100.00%> (+0.02%) ⬆️

Impacted file tree graph

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

The review's blocking finding: with the switch exported, two pre-existing
attach-shape pins go red in exactly the shell of the developer who opted
in — the same argument timing_switch_off already states. Scrubbed in the
same fixture; verified 357 passed with the var exported.

@blooop blooop left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This was generated by AI during review.

Two-axis review of #220 against ticket #183 at merge-base 9681c6b. Preflight: all checks green, non-empty diff (5 files, +262/−1), MERGEABLE.

Standards

  • BLOCKING — the new env var was not scrubbed in test/conftest.py, so the suite goes red in the shell of the very user the feature is for. With DEVLAUNCH_DOTFILES_ON_ATTACH=1 exported, two pre-existing attach-shape pins fail. This is exactly the case timing_switch_off already argues for by name ("the developers this instrument exists for are the ones who will have it exported when they run the suite"). [Resolved during review — orchestrator commit 5c417dd adds the scrub to the same fixture; 357 passed with the var exported.]
  • Non-blocking — quoting composes correctly. Three shell layers, two shlex.quote applications; an apostrophe-bearing URL survives to git intact. One wrinkle: the inner shell is bash -c, not -lc, so it relies on PATH inheritance rather than the parity rule workspace_ssh documents at dl.py:3639.
  • Non-blocking — _FALSEY is now a fourth copy (tools.py, gh_auth.py, tty_session.py, dl.py), and refresh_on_attach_enabled lands bare in the 4000-line dl.py with a name that doesn't say what refreshes.
  • Non-blocking — the "~1.7s, ~99% connection setup" rationale is stated six times across docstrings/README/CHANGELOG/tests.
  • Non-blocking — the gate also fires on recreate/reset/first-provision, where devpod applied dotfiles seconds earlier (declared by the builder; opt-in only).
  • Non-blocking — test looseness: two new pins slice argv[:2] against the file's "exact sequence" convention; test_the_refresh_trip_is_bounded formats the constant into its own expectation, so the promised 60 is pinned nowhere.
  • Non-blocking — attach_workspace discards dotfiles_update's return silently; README's "Failure is a warning" is not literally true (dl logs nothing; the user sees the remote's own stderr).

Spec

Nothing blocks. All three hard requirements met and independently reproduced.

  • Req 1 — "Off by default… no change in attach latency." git diff --numstat on the spawn-count file is 92 0 — additions only. All 5 tests the ticket names as the first attempt's failures are intact and green (two were renamed on main pre-merge-base, not by this PR). Full suites: 357 passed.
  • Req 2 — "A timeout on the network call." Traced end-to-end with an apostrophe-bearing URL through all three shell levels; argv arrives intact. The "124 at 1.01s, no surviving grandchild" claim reproduces exactly on coreutils 9.4 — timeout signals the process group as stated. Measured, not asserted.
  • Req 3 — "Skipped entirely for one-shot." Pinned with the switch ON: exactly status + ssh --command.
  • Mutation reproduced: dropping the opt-in check fails 8 tests including 2 pre-existing unedited pins — the ticket's original failure, live again.
  • Non-blocking: falsey semantics (=off/=n enable it) are undocumented — consistent with NO_TTY/NO_GH_TOKEN precedent; README's "Failure is a warning" inaccurate (silent discard — a 60s timeout kill is an unattributed pause); undeclared dependency on a timeout binary in the container (absent → silent no-op).
  • Declinations both sound. Cooldown: state-cost argument holds against a "worth considering" extra. Background: correctly refused on correctness — a refresh landing after the session starts updates files that shell already sourced; the ordering is pinned.
  • Deviations all four sound (cold-path firing declared and opt-in-only; constant not env var; dl <ws> dotfiles deliberately unbounded and pinned; devpod context options surfaced in the pin rather than hidden).

Verdict

Request changes → resolved during review. The single blocking finding (missing conftest scrub) was a one-fixture addition; applied as commit 5c417dd and verified with the var exported (357 passed). With that in, no findings block. Riders for later touchers: pin the literal 60, tighten the two argv[:2] pins, document the falsey semantics and the timeout-binary dependency, soften README's "Failure is a warning" to what actually happens. GitHub refuses author-account review states, so this is posted as a comment; this written verdict governs. Merging with a merge commit once checks re-green on 5c417dd.

@blooop
blooop merged commit 82a60fe into main Aug 15, 2026
12 checks passed
@blooop
blooop deleted the wayfinder/devlaunch-183 branch August 15, 2026 04:36
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.

Opt-in dotfiles refresh on attach

1 participant