Problem
The wheels deploy orchestration layer has correctness defects spanning the lock, fresh-host bootstrap, output handling, ssh-config resolution, and several roll-up bugs. They share the command-construction layer with the deploy-security issue and should be fixed after that architecture decision so the same files are not churned twice. Paths relative to cli/lucli/services/deploy/; verified against origin/develop @ 8971094.
1. Deploy lock is silently bypassed on any multi-host fleet (DEP-1, High)
cli/DeployMainCli.cfc:82-86, 107, 452-455 acquires the lock via $dispatchAny → SshPool.onAny (lib/SshPool.cfc:101-109), first-success-wins, catching every per-host exception. The lock works by ln -s failing when it exists (commands/LockCommands.cfc:29-39), so on a 2+ host fleet, contention on host 1 raises, onAny swallows it, acquires a fresh lock on host 2, and the concurrent deploy proceeds — mutual exclusion holds only for single-host configs. Release is also $dispatchAny, so the released host may differ from the acquired host, stranding stale locks. Fix: acquire/release on a deterministic primary host via onEach/sequential dispatch with raise=true, mirroring Kamal's primary-host semantics.
2. Fresh-host deploy/setup cannot work (DEP-5, High)
(a) deploy() boots the proxy with proxy.details() & " || " & proxy.boot() (cli/DeployMainCli.cfc:90, 437-461); details() is docker ps --filter name=kamal-proxy, which exits 0 whether or not anything matches — so boot() is unreachable and kamal-proxy is never started. (b) The check is dispatched via $dispatchAny to ONE host, but every traffic-serving host needs its own proxy. (c) docker network create kamal exists (commands/DockerCommands.cfc:14-20) but has zero call sites, while --network kamal is required by AppCommands.run, AccessoryCommands.run, and ProxyCommands.boot — first run on a bootstrapped host fails with "network kamal not found". Fix: use docker start kamal-proxy || docker run ..., dispatch proxy boot to every traffic-serving host (onEach), and wire create_network into bootstrap/setup.
3. Every read verb discards remote stdout; the audit log is never written (DEP-6, Medium)
All dispatch closures call ssh.run(c, {raise: doRaise}) and drop the result; the only writeOutput is the hook-output line (cli/DeployMainCli.cfc:500). So audit (:171-187), details (:217-244), app logs (cli/DeployAppCli.cfc:67-77), lock status (cli/DeployLockCli.cfc:44-54) return only a summary, never the actual content in live mode. Compounding it, AuditorCommands.record() (commands/AuditorCommands.cfc:15-19) has zero call sites, so /tmp/kamal-audit.log is never written — audit tails a file this tool never creates. Fix: surface remote stdout from read verbs; wire AuditorCommands.record() into the deploy flows.
4. Flat aliases wheels deploy bootstrap / wheels deploy exec ignore ssh: config (DEP-7, Medium)
cli/lucli/Module.cfc:2150 and :2164 construct a bare new SshPool() instead of $deployBuildSshPool(opts.configPath) as the nested server branch does (:2183-2185). The flat aliases exist BECAUSE picocli absorbs the nested server form before module dispatch (#2677), so for real CLI users the broken form is the only reachable one. Any deploy.yml with non-root ssh.user, custom port, or a keys: path gets root@host:22/ssh-agent instead. Fix: build the pool via $deployBuildSshPool(opts.configPath) in the flat aliases.
5. Lock metadata never expands (DEP-10, Low)
commands/LockCommands.cfc:33-37: acquire wraps the entire symlink target in single quotes while the comment claims $(hostname)/$(date) are "resolved by the remote shell". Single quotes suppress command substitution, so the lock metadata is the literal user@$(hostname)/$(date ...)/message. Contrast AuditorCommands.record (:18), which correctly uses double quotes. Fix: use double quotes for the symlink target.
6. Roll-up: same-version redeploy, extract key match, proxy volume path (DEP-11, Low)
(a) deploy() issues docker run --name <service>-<role>-<version> (commands/AppCommands.cfc:18-30) without removing an existing same-name container; redeploy with an unchanged git SHA hits a guaranteed name conflict, and old-version containers are never stopped after cutover. (b) CFML == at cli/DeploySecretsCli.cfc:68 makes extract path match PATH — diverges from Kamal's exact match. (c) ProxyCommands.boot mounts /home/#ssh.user()#/.config/kamal-proxy (commands/ProxyCommands.cfc:30); for the default root user the home is /root, so the mount path is wrong on the default config. Fix: remove/replace same-name containers and stop old-version containers after cutover; exact (case-sensitive) key matching in extract; resolve the remote home directory.
Impact
Concurrent deploys on multi-host fleets are not mutually excluded; fresh-host deploy/setup fails outright; operators see only summaries from read verbs while the audit log is never written; the only CLI-reachable bootstrap/exec aliases ignore ssh config; lock metadata is unusable; and same-version redeploys always fail.
Suggested approach
Fix after the deploy-security architecture decision (DEP-2/3/4) lands, since these touch the same DeployMainCli/AppCommands/LockCommands/Module dispatch and command-construction layer. Apply the per-finding fixes above, mirroring Kamal's primary-host and network-bootstrap semantics.
Acceptance criteria
Source
Internal multi-agent framework review 2026-06-09, wave 2 (issues phase). Findings: followups DEP-1, DEP-5, DEP-6, DEP-7, DEP-10, DEP-11.
Re-scope 2026-06-12 (verified against develop @ 271931f)
Rationale: zero DEP items have shipped, but the epic's stated blocker is gone —
the body says "fix after the deploy-security architecture decision (DEP-2/3/4) lands";
#3008 landed it (stdin secrets, Base.shellEscape, validate-not-quote). The items
cluster naturally into four small waves that each touch a disjoint-enough file set,
matching the proven point-fix campaign mechanics from #3142–#3146 (TDD red-first on
FakeSshPool/dry-run, full CLI suite in the lucee7 docker harness).
Draft replacement body section (paste into #2957)
Status 2026-06-12 — re-scoped to campaign waves (scoping pass against develop @ 271931f)
The sequencing blocker is resolved: the #2956 command-builder architecture landed in #3008 (Base.shellEscape, SSH-stdin secret delivery, $rejectEnvSecrets fail-fast, name format validation). Every DEP item below was re-verified still reproducing in source at develop HEAD. The 2026-06-12 audit-confirmation comment adds live-harness evidence per item. None of this week's point fixes (#3142–#3146) touched these items.
Wave 1 — lock correctness (DeployMainCli.cfc, LockCommands.cfc, SshPool.cfc)
Wave 2 — fresh-host bootstrap + setup (DeployMainCli.cfc, ProxyCommands.cfc, DockerCommands.cfc, AppCommands.cfc)
Wave 3 — observability + ssh-config (DeployMainCli.cfc, Deploy*Cli.cfc, Module.cfc, AuditorCommands.cfc)
Wave 4 — roll-up point fixes (independent, any order)
Acceptance criteria unchanged from the original body, plus: setup on a pristine docker-sshd fixture host succeeds end-to-end (E2EDeploySpec extension), and wheels deploy rollback --destination=X --dry-run targets the overlay hosts.
Problem
The
wheels deployorchestration layer has correctness defects spanning the lock, fresh-host bootstrap, output handling, ssh-config resolution, and several roll-up bugs. They share the command-construction layer with the deploy-security issue and should be fixed after that architecture decision so the same files are not churned twice. Paths relative tocli/lucli/services/deploy/; verified againstorigin/develop@8971094.1. Deploy lock is silently bypassed on any multi-host fleet (DEP-1, High)
cli/DeployMainCli.cfc:82-86, 107, 452-455acquires the lock via$dispatchAny→SshPool.onAny(lib/SshPool.cfc:101-109), first-success-wins, catching every per-host exception. The lock works byln -sfailing when it exists (commands/LockCommands.cfc:29-39), so on a 2+ host fleet, contention on host 1 raises,onAnyswallows it, acquires a fresh lock on host 2, and the concurrent deploy proceeds — mutual exclusion holds only for single-host configs. Release is also$dispatchAny, so the released host may differ from the acquired host, stranding stale locks. Fix: acquire/release on a deterministic primary host viaonEach/sequential dispatch withraise=true, mirroring Kamal's primary-host semantics.2. Fresh-host deploy/setup cannot work (DEP-5, High)
(a)
deploy()boots the proxy withproxy.details() & " || " & proxy.boot()(cli/DeployMainCli.cfc:90, 437-461);details()isdocker ps --filter name=kamal-proxy, which exits 0 whether or not anything matches — soboot()is unreachable andkamal-proxyis never started. (b) The check is dispatched via$dispatchAnyto ONE host, but every traffic-serving host needs its own proxy. (c)docker network create kamalexists (commands/DockerCommands.cfc:14-20) but has zero call sites, while--network kamalis required byAppCommands.run,AccessoryCommands.run, andProxyCommands.boot— first run on a bootstrapped host fails with "network kamal not found". Fix: usedocker start kamal-proxy || docker run ..., dispatch proxy boot to every traffic-serving host (onEach), and wirecreate_networkinto bootstrap/setup.3. Every read verb discards remote stdout; the audit log is never written (DEP-6, Medium)
All dispatch closures call
ssh.run(c, {raise: doRaise})and drop the result; the onlywriteOutputis the hook-output line (cli/DeployMainCli.cfc:500). Soaudit(:171-187),details(:217-244),app logs(cli/DeployAppCli.cfc:67-77),lock status(cli/DeployLockCli.cfc:44-54) return only a summary, never the actual content in live mode. Compounding it,AuditorCommands.record()(commands/AuditorCommands.cfc:15-19) has zero call sites, so/tmp/kamal-audit.logis never written —audittails a file this tool never creates. Fix: surface remote stdout from read verbs; wireAuditorCommands.record()into the deploy flows.4. Flat aliases
wheels deploy bootstrap/wheels deploy execignore ssh: config (DEP-7, Medium)cli/lucli/Module.cfc:2150and:2164construct a barenew SshPool()instead of$deployBuildSshPool(opts.configPath)as the nestedserverbranch does (:2183-2185). The flat aliases exist BECAUSE picocli absorbs the nestedserverform before module dispatch (#2677), so for real CLI users the broken form is the only reachable one. Anydeploy.ymlwith non-rootssh.user, custom port, or akeys:path getsroot@host:22/ssh-agent instead. Fix: build the pool via$deployBuildSshPool(opts.configPath)in the flat aliases.5. Lock metadata never expands (DEP-10, Low)
commands/LockCommands.cfc:33-37: acquire wraps the entire symlink target in single quotes while the comment claims$(hostname)/$(date)are "resolved by the remote shell". Single quotes suppress command substitution, so the lock metadata is the literaluser@$(hostname)/$(date ...)/message. ContrastAuditorCommands.record(:18), which correctly uses double quotes. Fix: use double quotes for the symlink target.6. Roll-up: same-version redeploy, extract key match, proxy volume path (DEP-11, Low)
(a)
deploy()issuesdocker run --name <service>-<role>-<version>(commands/AppCommands.cfc:18-30) without removing an existing same-name container;redeploywith an unchanged git SHA hits a guaranteed name conflict, and old-version containers are never stopped after cutover. (b) CFML==atcli/DeploySecretsCli.cfc:68makesextract pathmatchPATH— diverges from Kamal's exact match. (c)ProxyCommands.bootmounts/home/#ssh.user()#/.config/kamal-proxy(commands/ProxyCommands.cfc:30); for the default root user the home is/root, so the mount path is wrong on the default config. Fix: remove/replace same-name containers and stop old-version containers after cutover; exact (case-sensitive) key matching in extract; resolve the remote home directory.Impact
Concurrent deploys on multi-host fleets are not mutually excluded; fresh-host deploy/setup fails outright; operators see only summaries from read verbs while the audit log is never written; the only CLI-reachable bootstrap/exec aliases ignore ssh config; lock metadata is unusable; and same-version redeploys always fail.
Suggested approach
Fix after the deploy-security architecture decision (DEP-2/3/4) lands, since these touch the same
DeployMainCli/AppCommands/LockCommands/Moduledispatch and command-construction layer. Apply the per-finding fixes above, mirroring Kamal's primary-host and network-bootstrap semantics.Acceptance criteria
kamalnetwork and boots the proxy on every traffic-serving host.audit,details,app logs,lock status) surface remote stdout; the audit log is actually written.bootstrap/execaliases honorssh:config.$(hostname)/$(date).extractkey match is case-sensitive; proxy volume resolves the real remote home.Source
Internal multi-agent framework review 2026-06-09, wave 2 (issues phase). Findings: followups DEP-1, DEP-5, DEP-6, DEP-7, DEP-10, DEP-11.
Re-scope 2026-06-12 (verified against develop @ 271931f)
Rationale: zero DEP items have shipped, but the epic's stated blocker is gone —
the body says "fix after the deploy-security architecture decision (DEP-2/3/4) lands";
#3008 landed it (stdin secrets,
Base.shellEscape, validate-not-quote). The itemscluster naturally into four small waves that each touch a disjoint-enough file set,
matching the proven point-fix campaign mechanics from #3142–#3146 (TDD red-first on
FakeSshPool/dry-run, full CLI suite in the lucee7 docker harness).Draft replacement body section (paste into #2957)