feat(daemons): give each daemon a stable URL per git worktree - #13368
Conversation
*AI-assisted — Tool: Codex; model: OpenAI/GPT-5; version: unavailable.*
*AI-assisted — Tool: Codex; model: OpenAI/GPT-5; version: unavailable.*
…ject Project daemons previously lived in a namespace derived from the project directory name and a hash of its path. That never collides between unrelated checkouts, but it cannot be written down, so one project could not depend on another project's daemon. Two additions fix that. `[daemons_settings] namespace` replaces the hashed default with a fixed name, validated with the same rules as daemon names so a value pitchfork would reject fails at load instead of at start. Because that name lives in a configuration file shared by every linked git worktree of a repository, an explicit namespace gets a per-worktree suffix by default; `namespace_per_worktree = false` opts into sharing one set of daemons across worktrees. A daemon table with `project` runs a daemon that another project declares. The referenced directory is loaded through the ordinary trust gate, and the daemon is registered under that project's namespace and root, so its working directory, state, data, and `mise x` environment belong to the project that owns it. Its tool and exported environment variables stay there too. A short `depends` entry naming an import is rewritten to the qualified ID, since pitchfork resolves bare names only within one namespace. A root reloaded from its own configuration hierarchy is now restricted to the daemons the invoking project inherited or imported, so a daemon that a child config overrides is no longer also registered under its declaring root. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resolves lockfile conflicts by taking main's Cargo.lock and mise.lock. This branch was cut from two aube dependency commits that never landed on main and pinned aube 2.2.16; main now carries 2.2.17, and the Cargo.toml requirement is "2" on both sides, so main's lockfiles are the correct resolution. The daemons change carries no dependency updates of its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…space Three problems in the cross-project daemon support, found in review. Starting an imported daemon rewrote the referenced project's generated pitchfork configuration from a set reduced to the imported names. That file is rewritten as a whole, so the referenced project's other daemons vanished from it and any that were running were orphaned. The referenced root is now prepared with its complete set, and the restriction moved to selection: another project's daemons are registered but only the imported ones are started or stopped. Import discovery read only the configuration files inside the referenced directory, while preparing that root reloads it through the full configuration hierarchy. A project that inherits its daemon from a parent config was reported missing, and one that inherits only `[daemons_settings].namespace` produced a hashed ID at discovery and an inherited one at registration, so the daemon could not be selected. Discovery now walks the same ancestors. `[daemons_settings]` replaced the whole table per file, so a mise.local.toml setting only `namespace_per_worktree` dropped the namespace from mise.toml and brought back the hashed default. The table now merges key by key, in both ordinary loading and import loading. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Import discovery fed every path from the config list to the TOML parser, and that list includes `.tool-versions`. A referenced project containing one failed with "Invalid TOML in config file" before its mise config was ever read, so the import could not resolve. Only TOML configs can carry [daemons], so the walk now skips the rest. Also reformats the schema's new `not.anyOf` block to what `mise run render` produces; the hand-written compact form failed the lint job's render check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…visibility `[daemons_settings]` was looked up by the daemon's own root, so a table declared in a parent configuration never reached a daemon declared in a nested directory. Importing such a daemon folded the whole hierarchy into one table and resolved the parent namespace, while preparing that root fell back to the hashed default, leaving `depends` and `mise daemons start` naming an ID pitchfork never registered. Both paths now resolve the table by walking a root's ancestors with the nearest declaration winning, so the two agree. Registering another project's daemons is not the same as acting on them. The complete set is still what gets registered, because that config is rewritten whole, but listing and tool validation now use only what this project imported or inherited: `mise daemons ls` no longer shows a referenced project's other daemons, and a sibling preset whose tool is missing no longer blocks starting an import. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mise renders a daemon's port into its command line, readiness check, and [env] exports while configuration loads, so a second checkout of the same project rendered the same fixed port and failed to start. Pitchfork's bump cannot help, because it resolves long after mise has already exported the endpoint. port = "auto" derives the port from the project root instead. The primary checkout keeps the base port, so single-checkout projects are unchanged, and each linked git worktree gets a stable offset derived from its path. A worktree is recognised by its .git entry being a file rather than a directory, so nothing shells out to git during config load. Allocations are persisted in state.json with the base and stride they came from, so a later change to the slot derivation cannot move a running daemon while an edited base still re-derives. Registration fails when another project root on this machine already claims the port, naming that root. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…licate IDs A global config's root is the home directory, so once settings were inherited down a tree, a `[daemons_settings] namespace` there reached every project on the machine and gave them all one pitchfork namespace. A project importing from such a project would also have disagreed, because import discovery reads project configuration only. The table is now ignored in global and system configuration, with a warning naming the file, which keeps both paths reading the same set. Because a namespace is inherited, two projects can now resolve to one qualified daemon ID, which an import makes reachable in a single load. That is reported with both declaring roots instead of one silently shadowing the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ed-lichterman-1adebf # Conflicts: # Cargo.lock # mise.lock
Five review findings on the cross-project daemon support. `mise daemons start <name>` validated every daemon at that root, so an unrelated daemon whose tool was missing failed a command that was never going to start it. Validation now covers the named daemons and, because pitchfork starts them together, whatever they depend on. Starting without names is unchanged. `mise daemons ls` labelled currently declared daemons with the namespace from prior state, so after a namespace change it showed an ID that the next start would not register. Declared daemons now use the configured namespace, while previously registered IDs keep theirs so their status still resolves. Two imports whose projects share a namespace produced one qualified ID, and the second silently replaced the first with both local names pointing at the later project. That is now rejected, naming both projects. The check that already existed ran after insertion, so it could not see the daemon that was replaced. A `depends` array accepted non-string entries, which were passed through to pitchfork unvalidated; `depends` is now checked whether or not anything is imported. `restricted_to` matched daemons by name alone, which is only unique within one project, and now matches the root as well. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ports Two defects in the port allocation, both found in review. A project root is the directory holding mise.toml, not the checkout root, so a nested config such as packages/api/mise.toml saw no adjacent .git and every worktree was classified as primary. All of them then resolved to the same base port, which is exactly the collision the feature exists to prevent. Detection now walks ancestors to the enclosing checkout, and only a gitdir under a worktrees directory counts, so a submodule and a separate-git-dir clone keep the base port instead of being offset as if they were copies. The conflict check also treated any persisted claim as exclusive. Because state.json outlives the daemon it describes, a stopped project reserved its port forever, and two projects could no longer take turns on a default port such as 5432. Liveness is now probed before failing, and only for a root whose port actually matches, so the scan stays cheap and an unreachable supervisor leaves the port available. A shared lock spans the scan and the claim, so two projects starting at once cannot both find the port free. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ction Three follow-ups from review. Worktree detection duplicated logic that git.rs already had, and did it worse: it matched any gitdir path containing a worktrees component, so a submodule under a directory of that name was offset off its base port, and it never resolved a relative gitdir. git.rs gains in_linked_worktree, sharing the gitdir parsing with main_checkout_root, which tests the parent directory name and so distinguishes a real worktree from a submodule or a --separate-git-dir clone. It also accepts worktrees of a bare repository, which have no main checkout to map onto but are still separate working copies. The conflict check probed project-wide liveness, so a stopped Postgres in a project still running Redis, or merely holding an open shell session, blocked another project from taking 5432 and the error named the stopped daemon as running. It now asks about the daemon holding the port. The shared lock is gone. It claimed to make a claim durable, but once conflicts require the other daemon to be live, a recorded claim reserves nothing and the lock bought only contention. The check is documented for what it is: a diagnostic that replaces an opaque bind failure with one naming the other project, with binding itself remaining the arbiter. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The docs claimed two worktrees never collide. Offsets are hashed into 511 slots rather than assigned in sequence, so two can share a slot well before the slots run out: roughly a 1% chance with four worktrees and 5% with eight. The section now states that, and what happens when it occurs, since a user planning several worktrees deserves the real number rather than a promise the allocator does not make. Worktree detection also accepted a gitdir whose target did not exist, so a pruned or hand-written marker shaped like a worktree path earned its own port. The private dir must now carry a commondir file, which is what git actually writes, and the test fixtures build that layout instead of a bare path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review raised a separate-git-dir clone whose git directory sits directly under a directory named worktrees. Checking the layout against real git confirms the current code already rejects it: git writes commondir only into a worktree's private directory, never into an ordinary git directory, and both normal and bare-repo worktrees do have it. The existing tests did not pin that down. A pruned marker names a directory that does not exist, so it passes even if only existence were checked. This case has a real git directory present and is distinguished solely by the missing commondir, which is what makes it a regression test rather than a restatement. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…name Detection accepted any private directory under worktrees/ that merely contained a file named commondir, so a hand-made directory could earn a worktree port offset. The file is now read and resolved, and its target must be an existing directory. Worktrees of a bare repository still qualify, since their commondir resolves to the bare repo. main_checkout_root already did this resolution inline and now shares it, so the two paths cannot drift. The daemons docs also undersold the conflict error, which names the daemon holding the port as well as its project root. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Resolving commondir accepted any existing directory, so a worktree marker pointing at an ordinary folder still earned a worktree port offset. The previous test made this plain: it created a directory with no git metadata and asserted the result was a linked worktree. The target must now contain a HEAD file, which git writes in an ordinary .git and at the top of a bare repository, so both valid cases keep working. The test now asserts the opposite of what it did: an existing but empty target is rejected, and adding HEAD is what flips it, pinning the check to git metadata rather than to a path existing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…tories A forged commondir could name an unrelated repository that happened to contain a HEAD file. git puts a worktree's private dir at <common>/worktrees/<name>, so the pointer must lead back to the directory it sits under; that is now checked, verified against real git for ordinary worktrees, bare-repository worktrees, and this repository's own worktree. The ancestor walk also skipped every non-worktree .git file, so an independent repository nested inside a worktree inherited that worktree's offset even though the docs promise it keeps the base port. It now stops there, exactly as a nested .git directory already did. A submodule still does not stop the walk, and a test now pins that. A submodule belongs to the checkout containing it, so the same submodule in two worktrees is two working copies and each needs its own port. Stopping there would give both slot 0 and reintroduce the collision this feature exists to prevent. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Name a set of project daemons in [daemon_groups] and use that name wherever mise daemons accepts a daemon name. Members are daemons or other groups in the same project, validated when configuration loads, so a group can never select a daemon outside the project. mise daemons now accepts --group for declared groups instead of rejecting the flag outright, and a bare mise daemons start runs the default group when one is declared. Groups are rendered into the generated pitchfork configuration with qualified IDs so pitchfork start --group works natively. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The submodule check matched any gitdir with a modules component, so an independent repository whose git directory merely sat under a directory of that name was read as a submodule, kept walking, and inherited the enclosing worktree's port offset instead of its base port. git keeps a submodule's git dir at <enclosing>/modules/<name>, where the enclosing dir is the superproject's git dir, or that worktree's private dir for a submodule inside a worktree. The path before modules must now be a git directory, which is what separates the two. Both shapes were checked against real git. The worktree fixture also lacked the HEAD file git writes into a private directory, which is what a nested submodule anchors against. Adding it makes the fixture match reality rather than only the code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Submodule detection checked the directory enclosing the modules component but not the submodule git directory itself, so a marker naming a directory that was not there still continued the ancestor walk and picked up the enclosing worktree's port offset. The test encoded that gap: it pointed at a submodule git directory it never created, so it passed without the referenced directory existing. It now asserts the marker is ignored until that directory is present, which is what makes the enclosing relationship meaningful. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… referencer Three problems where importing a daemon reached further than it should. A missing or untrusted sibling failed every mise command in the referencing project. Daemons load on every config load, so a contributor without the other checkout lost `mise x`, `mise run`, `mise env` and the activation hook, and CI with one repo cloned lost its tools. Resolving a sibling can now fail softly: the imported daemon is dropped, the failure is recorded, and `mise daemons` reports it with the path and setting. A mistake in the local declaration itself, such as `project` alongside `run`, stays fatal like any other config error. `mise x`, `mise run`, `mise install`, `mise watch` and `mise daemons start` mark the active config implicitly trusted, and reading the referenced project through that branch granted it durable trust with no prompt, so a later `cd` into it would run its env, hooks and templates. An import now requires trust that already exists and otherwise says to run `mise trust`. The activation hook prepared every root it could see, including a sibling's, using only the daemons this project imported. Since a root's generated pitchfork file is rewritten whole, entering the referencing project deregistered the sibling's other daemons. Automatic lifecycle now stops at this project's boundary. Preparing another project's root also no longer stamps this project's MISE_ENV into its state, which had made the two look like conflicting profiles. Also honours `ignored_config_paths` and symlink deduplication when reading a referenced project's hierarchy, matching ordinary config loading. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review found three ways group selection could reach outside the project that declared the group. A --group selector became an ordinary name selector, so in a multi-root configuration it also matched a daemon of the same name in an unrelated project. Selectors now carry their kind: --group resolves only through a group, while a positional argument resolves per root, preferring that project's group. A bare mise daemons start pushed the default group into the shared selector list whenever any root declared one, which silently skipped daemons in projects without a default group. Each root now resolves that shorthand against its own groups. Group declarations were keyed by name alone, so a child project's group replaced a parent's group of the same name. They are keyed by root and name, letting nested projects each declare their own default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An auto-allocated port reached pitchfork and nothing else. A custom daemon has no exports, so `mise env` produced nothing for it, and the process could only learn its port through pitchfork's own injection. The point of resolving at config load is that the endpoint is visible without a discovery step, and that only held for presets. A custom daemon now exports `<NAME>_PORT`, upper-cased with punctuation replaced by underscores, for an integer port as well as an auto one. Presets keep exporting their tool's conventional variables. Documents the export, that `ready_port` cannot follow an allocated port, that a bare repository with worktrees leaves nobody on the base port, and that the pin is created by the first start rather than by the declaration, with how to drop it. Softens the stability claim, which was stronger than a hash of a path warrants for a worktree that has never started. Adds the coverage review found missing: a persisted claim flowing through `load()`, the exports themselves, bare-repository slots, and an e2e asserting `mise env` and `daemons ls --json` agree in both a checkout and a worktree. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The group tests compared a loaded daemon root against a literal path, which only holds on Unix: config_root absolutizes, so /project becomes D:\project on Windows and for_root matched nothing. Take the root from the loaded set instead, which keeps the same assertions on both platforms. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…able worktree **A parent's group took a child's daemon name.** `resolve_bare` walked the ancestors checking groups, aliases and failed imports, but never daemons, so a group declared further up claimed a word a nearer project had declared as a daemon. That was harmless while a bare name stayed a name selector and still matched by name; mapping it onto `Selector::Group` made it fatal, because a group selector never falls back to a daemon. The walk now stops at the nearest project that declares the name as a daemon, which is where the word already belonged. **A worktree with no usable label took the primary checkout's hostname.** When a linked worktree's directory name or `worktree_label` folds to nothing, the worktree component was simply absent, which is the shape of a primary checkout, so both advertised `<daemon>.<project>.<tld>` for two different ports. Without a name for this copy there is no hostname for it, and none is offered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit stopped `resolve_bare` at a nearer daemon, but returning None was not enough: the caller treats None as "no local resolution" and falls back to scanning every loaded group, so an ancestor's group took the word again and the daemon stayed unstartable by its short name. The test only exercised `resolve_bare`, which is why it passed. `BareName::Daemon` says the word is claimed, and the fallback is skipped for it. The decision moved into `bare_selector`, so it can be tested where the defect actually was; the new test fails against the previous commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Both prerequisites have landed and the branch has absorbed their tips, so this is the last one standing. One gate medium is current and I agree with it: the pitchfork-version notice in AI-assisted review (Claude Code, claude-fable-5-1). |
Both new tests spelled their project roots as literals and compared them with the roots `load` had derived. Those agree on Linux and did not on the Windows runner, so the walk found the ancestor's group without first finding the child's daemon and both tests failed there while passing here. They take the roots from the loaded set now, which is what the rest of these tests do, so the comparison is against the same value the code uses whatever the platform spells it. Each also asserts the two roots are distinct and nested, so a platform that collapsed them would say so rather than quietly testing nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three places treated a daemon reached through `project = "..."` as though it belonged to this project. An imported preset's exports never reach this environment, so a local daemon whose derived variable matched one of them gave way to a clash that does not happen here. The pitchfork version notice fired whenever any root had labels, which is every root, so it appeared on projects that route nothing. And the stack and project page URLs were built without the length check a daemon's own hostname gets, offering a link DNS cannot carry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mise named such a worktree's project after the directory holding the bare repository. Pitchfork refuses to: that directory holds unrelated repositories as often as sibling worktrees, so naming the project after it puts them on one label. Pitchfork gives the worktree a label of its own and no worktree component beneath it. Mise exported a hostname pitchfork would never route, which is invisible until the URL fails to resolve. It now resolves the case the same way, from the same rule: without a main checkout there is nothing above the worktree to name the project. An explicit namespace still groups such worktrees deliberately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The notice is now gated on whether any daemon actually got a host, not on Chasing the third one turned up a real divergence from #880 that I had On the rebase: I merged main instead, and I would rather you overrule me than AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5; version: 2.1.270. |
The advice to set an explicit namespace produced the collision it was meant to avoid. Those checkouts have no worktree component, so a namespace they all share is the whole hostname, and `worktree_label` cannot tell them apart because it names a component they do not have. Point at a per-checkout namespace in a gitignored file instead, and pin the shape in a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A collision with a daemon reached through `project` is not symmetric. That daemon is registered by the project declaring it, from its own configuration, where nothing collides, and mise rewrites that file whole rather than overruling it from here. Pitchfork does serve that hostname, and only the importing project's own daemon goes without. The warning claimed pitchfork routes neither, which described a hostname that was in fact being served. It now names the side that keeps it when exactly one side is imported, and keeps the old wording for the two cases that really do leave the proxy nothing to route. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ters The withdrawal cleared the hostname from every claimant, including one reached through `project`. That daemon's own project writes the file that registers it, from a hierarchy where nothing collides, so the state here claimed to have taken away a hostname the proxy goes on serving, contradicting the warning beside it. Only local claimants are withdrawn now. Claimants are also gathered per hostname rather than compared pairwise, so three of them are settled together and warned about once. A lone imported claimant is served however many local ones stand against it, and a second imported claimant leaves the proxy a contest it will not settle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…name Withdrawal covered only local claimants, so a hostname claimed by two projects this one imports from was taken from neither. Pitchfork serves neither either, having two claimants, and the warning said so, while `mise daemons ls` and `mise daemons urls` went on offering a URL that answers for no one. Every claimant but the one the proxy serves is withdrawn now. A lone imported claimant still keeps its hostname, because its own project registers it from a hierarchy where nothing collides. The display needed the same rule. A daemon reached with `project` is shown from its declaring project's view, which cannot see the sibling import it collides with, so `restricted_to` now takes that decision from the importing project's view: the one that settles what is registered is not the one that settles what is advertised. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Instruction countsNothing was compared, and so nothing was gated. No series appears on both sides: either the base has no measurements recorded, or the two were measured on different runner classes, which are deliberately not comparable — counts shift between machine types by more than a real regression does. New, nothing to compare against: Only instruction counts gate. Wall clock is shown for context — on identical hardware it moves 4-20% run to run. Measured by tak — instruction-counted CLI benchmarks, stored in this repository's git notes.
|
A checkout with no main checkout above it names itself, so its hostnames carry no worktree component. A namespace its siblings inherit is then the whole hostname for all of them, and because each worktree runs in its own process, no load ever sees the pair. Every other hostname collision on this branch is reported; this one passed in silence, leaving two daemons registered for one route. The repository's `worktrees/` registry says whether a sibling exists, which is cheap and only consulted for a checkout in that shape with an explicit namespace. A worktree of a `--separate-git-dir` clone is the same shape, its common dir not being a checkout's `.git`, and pitchfork refuses it for the same reason; both are now pinned by tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sibling check opened `.git` at the project root, which a monorepo root inside a worktree does not have, so the warning stayed silent for exactly the nested layout hostnames already support. The question goes to the checkout now, which is what holds the `.git` naming the repository. It also counted every directory in the worktree registry, including entries git leaves behind when a worktree is removed without a prune. A checkout that no longer exists cannot inherit a namespace, and sending someone off to rename one that nothing else shares is worse than saying nothing. Liveness is decided the way git decides it, by whether the `gitdir` pointer still names something. The decision moved into `namespace_reaches_siblings` so a test can hold it to the checkout rather than the root; asking from the root again fails it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 083ff2c. Configure here.
`worktree.useRelativePaths` and `git worktree add --relative-paths` write the registry's `gitdir` pointer relative to the entry holding it, as `commondir` and a worktree's own `.git` are written. Resolving it against the process directory instead made every live worktree of such a repository look pruned, so the sibling warning never fired for the setups most likely to need it. It is resolved against its entry now, and the test covers a repository written either way, including that a relative pointer to a removed worktree is still recognised as stale. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

https://entire.io/gh/jdx/mise/trails/24
The problem
port = "auto"lets several git worktrees of one project run their daemons atonce, but it leaves every service having to be told which port its neighbours
ended up on. That is fine for a database and painful for HTTP services, which is
most of a stack.
Pitchfork's reverse proxy removes that step: it routes a stable hostname to
whatever port a daemon actually bound. This teaches mise to derive the same
hostname while configuration loads, and to export it, so nothing in the project
has to do port arithmetic.
What you get
Every daemon that configures a
portis reachable at a hostname, and miseexports it as
<NAME>_URLbeside the existing<NAME>_PORT:In
~/src/shopthat givesAPI_URL=https://api.shop.localhost. A linkedworktree at
~/src/shop-pr-42givesAPI_URL=https://api.shop-pr-42.shop.localhost. Both run at once, and neitherURL changes when a port moves.
Databases keep
port = "auto". The proxy speaks HTTP and a Postgres or Redisclient does not, so the
postgresandredispresets opt out of it and keepexporting
PGPORT,DATABASE_URL, andREDIS_URLunchanged.New declaration keys
Two per-daemon keys, validated by mise and forwarded to pitchfork:
proxytakes a DNS label,false, ortrue(the name-derived label, which ishow a preset's
proxy = falseis turned back on).proxy_tlstakes"terminate"(the proxy's behaviour today) or"passthrough". Both arerejected at config load with a message naming the daemon when they are not one
of those.
Seeing the URLs
The proxy column is the daemon's
proxy_tlsmode, oroffwhen it has nohostname. Such a daemon is listed with its port alone rather than omitted, so a
database is visible here too.
mise daemons ls --jsongainshost,url,proxy, androot.Keeping in step with pitchfork
Every hostname here is derived the way pitchfork #880
derives it, because mise exporting a URL the proxy does not serve is worse than
exporting nothing:
api.shop.localhost. Only alinked worktree adds one.
[daemons_settings] namespacebeforeany per-worktree suffix, otherwise the primary checkout's directory name.
worktree_labelfrom a pitchfork config in that checkout, which is where#880 reads it. mise reads it from the same place.
worktree beside it names itself and gets no worktree component:
api.main.localhost, notapi.main.shop.localhost. The directory holding abare repository often holds unrelated ones too, and #880 declines to put them
on one label for the same reason.
portis routed.-, by the same rules. Acollision routes neither side, so mise withholds both URLs and warns.
The scheme, TLD and port come from
/etc/pitchfork/config.tomland~/.config/pitchfork/config.tomlwithPITCHFORK_PROXY_*taking precedence,parsed as pitchfork parses them, including its case-insensitive boolean
spellings. With no pitchfork configuration at all, mise assumes pitchfork's
defaults, so a URL exists before the proxy is enabled.
Adoption
Experimental, with the rest of
[daemons]. Hostname routing needs the pitchforkrelease that carries #880; the generated config says so in a comment. An older
supervisor starts the daemons normally and does not serve the hostnames.
Set
proxy = falseon a custom daemon that does not speak HTTP. Ahand-written
[daemons.redis]with a port would otherwise get anhttps://hostname and a
REDIS_URLpointing at it, which is not what a Redis clientexpects. The database presets already do this; the docs say so where the keys
are introduced.
Known divergence
The project component agrees with pitchfork when the project root is the
checkout root and mise has registered the primary checkout. Pitchfork resolves
an explicit namespace from the primary checkout's own configuration or its
registered external configs, so a worktree-only project, or a monorepo root
below the checkout, can still differ. Worth settling alongside #880.
Validation
cargo test --bin mise daemons— 88 pass. Unit coverage for the primary andworktree hostname shapes, a nested monorepo root,
worktree_labelread fromthe checkout's own config, a portless daemon,
proxy = true,proxy = false,a declared label, every collision shape, a hostname too long for its TLD, the
environment and boolean spellings pitchfork accepts, an import not claiming a
local daemon's variables, a preset export not being replaced, and the
generated pitchfork config carrying the keys.
mise run test:e2e e2e/cli/test_daemonspasses, including assertions thatmise envcontainsAPI_URLwith no worktree component in the main checkoutand with one in a worktree, that
worktree_labelin the worktree'spitchfork.tomlchanges it, that a portless daemon gets none, and that thegenerated config carries
proxyandproxy_tls.cargo clippy --workspace --all-features --all-targets -- -D warnings,mise run lintandmise run renderare clean.e2e/cli/test_daemons_pitchforkfails in my sandbox on an assertion aboutshell session counts. It fails identically on feat(daemons): set a fixed namespace and run daemons from another project #13339's branch without this
change, so it is not from this work.
Depends on
#13339 and #13347 have landed on main. #13342 is still open, so this branch
merges it; rebase onto main once it lands.
Fixes that belong to #13342 but landed here
Three defects in the port work gated this PR, so they were fixed on this branch
rather than left red. Recording them here so the history is findable if #13342
is read on its own:
Two daemons in one project could claim one port.
ports::resolvederivesthe port from the project root alone, so two instances of one preset share a
base and land on the same number, and two explicit ports can simply be equal.
Mise pins what it resolves and sets
bump = false, so the second daemon failsto bind at start with nothing naming the first. The claims are checked once
every daemon is built, which is what covers the preset path, and the load
fails naming both daemons and the port. Keyed by the canonical root, since
that is what the port was derived from.
This is a behaviour change: a project declaring two Postgres presets with no
port override used to load and break at start, and now does not load. Settled
in review as the right trade, since the daemon cannot run either way and
check_port_conflictsalready errors across projects.The conflict check saw only the daemons named directly. Pitchfork starts a
daemon's dependencies with it, so their ports are bound by the same command. A
task passed its required daemons, the shell hook passed its
auto = ["start"]names, and
mise daemons startpassed what the selectors matched, so adependency could collide with another project unannounced. Each now passes the
dependency closure it already computes, including for a root reached only
because something depends on it, which arrives with no recorded ids.
A dry run validated more than the run would. The real run narrows
validate_tasksto the dependency closure for a foreign root so an unrelateddaemon of theirs cannot fail a
mise run; thedry_runbranch validated thatroot's whole set. It now narrows the same way.
One finding was left for #13342:
ports::tests::nested_project_roots_inherit_their_checkoutasserts two random tempdir paths land in different slots out of 511, so it fails
about one run in 511.
AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5; version: unavailable.
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
mise daemons urlsto display stable daemon URLs, ports, proxy modes, and statuses, with JSON output support.Documentation
Bug Fixes
Note
Medium Risk
Experimental daemon loading, env exports, and pitchfork registration change behavior (collisions, duplicate ports, dependency port checks); misconfiguration could break multi-worktree stacks until users adjust
proxysettings.Overview
Adds stable per-worktree HTTP URLs for project daemons by deriving pitchfork proxy hostnames at config load and exporting
<NAME>_URLnext to<NAME>_PORT, so services can reference neighbours without port math.New per-daemon
proxyandproxy_tlssettings (schema-validated, forwarded to pitchfork);postgres/redispresets default toproxy = false. Introducesmise daemons urlsand extendsmise daemons ls --jsonwithhost,url,proxy, androot.Daemon loading now detects hostname and same-project port collisions (warns or fails load), withdraws contested URLs, and includes dependency daemons in port conflict checks on start, hooks, and tasks. Docs, usage spec, man page, and e2e coverage follow.
Reviewed by Cursor Bugbot for commit 1d15512. Bugbot is set up for automated code reviews on this repo. Configure here.