coast assign --worktree <absolute path> destroys /workspace in the instance
Version: 0.1.53 (still present at a62f512)
Runtime: local dind, Linux
Passing an absolute path to -w/--worktree silently unmounts the instance's
/workspace and leaves nothing in its place. Passing the bare worktree name
works fine. The command reports ok for the worktree step either way.
Repro
coast run lane1
coast assign lane1 -w /abs/path/to/repo/.worktrees/my-worktree # ❌
coast exec lane1 -- grep -c " /workspace " /proc/self/mountinfo # 0
coast exec lane1 -- sh -c 'ls /workspace | wc -l' # 0
coast unassign lane1
coast assign lane1 -w my-worktree # ✅ same worktree, name only
coast exec lane1 -- grep -c " /workspace " /proc/self/mountinfo # 1
coast exec lane1 -- sh -c 'ls /workspace | wc -l' # 49
Downstream, services can't start — mounting the file frankenphp/Caddyfile
onto the stub directory Docker auto-creates under the empty /workspace:
error mounting "/workspace/frankenphp/Caddyfile" to rootfs at "/etc/frankenphp/Caddyfile":
not a directory
coast doctor reports "Everything looks good. No orphaned state found."
Cause 1 — the gate and the mount source are built differently
coast-daemon/src/handlers/assign/services.rs:361-363:
let candidate = project_root.join(dir).join(worktree_name);
if candidate.exists() {
let mount_src = format!("/host-project/{dir}/{worktree_name}");
Path::join replaces the base when its argument is absolute; format! just
concatenates. With an absolute --worktree:
candidate = /abs/path/to/repo/.worktrees/my-worktree → exists() == true, gate passes
mount_src = /host-project/.worktrees//abs/path/to/repo/.worktrees/my-worktree → does not exist
coast-cli/src/commands/assign.rs:43-67 passes --worktree through verbatim
with no is_absolute() guard. All five detection sites share the pattern
(services.rs:320-321, :341, :361, :577, :864), and lib.rs:1815-1823
filters on host_path.exists() (true) before binding container_mount_src
(garbage), so no fallback catches it.
Cause 2 — the remount is not fail-safe, so a bad source loses the good mount
services.rs:1166-1170:
let mount_cmd = format!(
"{unmount_cache}{unmount_private}{clear_private}umount -l /workspace 2>/dev/null; mount --bind {mount_src} /workspace && \
mount --make-rshared /workspace && \
mkdir -p '{parent}' && ln -sfn /host-project '{host_root}'{private_cmds}{cache_cmds}"
);
The umount -l is unconditional and chained with ;; everything after is &&.
So a failing bind leaves /workspace with zero mounts rather than the
previous one. That's what turns a bad argument into a destroyed instance.
Cause 3 — the failure never surfaces
ensure_worktree_exists (services.rs:903-978) validates the correct host
path and returns Ok, so the step prints ok. The remount failure is only
warned (services.rs:638-645) and the return value is discarded at
services.rs:890-897. The assign then fails later, opaquely, at the health check.
Why --force-sync doesn't repair it
It only clears an ignored-file marker and never touches mounts
(services.rs:1065-1069), then recomputes the identical bad mount_src.
Why unassign does repair it
unassign.rs:301-303 hardcodes mount --bind /host-project /workspace — no
detection, no concatenation, and /host-project always exists.
Suggested fixes
- Fail safe. Verify the source exists in-container before unmounting, and
fall back to the previous bind if the new one fails.
- Derive
mount_src from the validated host path, not the raw argument, at
all five sites — invariant:
container_mount_src == "/host-project/" + host_path.strip_prefix(project_root).
Anything not expressible that way is unreachable and should be a hard error.
- Propagate the remount result — make
remount_workspace return Result and
check it at services.rs:890. A failed /workspace remount must fail the
assign rather than print ok.
- Reject or canonicalize a path-shaped
--worktree at the CLI boundary —
the cheapest guard, and it catches the reported invocation outright.
Related: instances stuck in unassigning forever
Two compounding issues:
unassign.rs:510-522 writes InstanceStatus::Unassigning before the
container_id … .ok_or_else(…)?, so that ? strands the row.
assign/mod.rs gets this ordering right (check :103, write :108).
Unassigning is absorbing: validate_unassignable accepts it, prev_status
is read pre-write, and the success path only special-cases Idle
(unassign.rs:570-574) — so a successful retry writes unassigning back.
No startup reconciliation covers transitional statuses.
The fix already exists in assign/mod.rs:373-376:
let final_status = match instance_status {
InstanceStatus::Assigning | InstanceStatus::Unassigning => InstanceStatus::Running,
other => other.clone(),
};
Applying that shape at unassign.rs:570 would let a stuck instance self-heal on
the next unassign.
coast assign --worktree <absolute path>destroys/workspacein the instanceVersion: 0.1.53 (still present at
a62f512)Runtime: local
dind, LinuxPassing an absolute path to
-w/--worktreesilently unmounts the instance's/workspaceand leaves nothing in its place. Passing the bare worktree nameworks fine. The command reports
okfor the worktree step either way.Repro
Downstream, services can't start — mounting the file
frankenphp/Caddyfileonto the stub directory Docker auto-creates under the empty
/workspace:coast doctorreports "Everything looks good. No orphaned state found."Cause 1 — the gate and the mount source are built differently
coast-daemon/src/handlers/assign/services.rs:361-363:Path::joinreplaces the base when its argument is absolute;format!justconcatenates. With an absolute
--worktree:coast-cli/src/commands/assign.rs:43-67passes--worktreethrough verbatimwith no
is_absolute()guard. All five detection sites share the pattern(
services.rs:320-321,:341,:361,:577,:864), andlib.rs:1815-1823filters on
host_path.exists()(true) before bindingcontainer_mount_src(garbage), so no fallback catches it.
Cause 2 — the remount is not fail-safe, so a bad source loses the good mount
services.rs:1166-1170:The
umount -lis unconditional and chained with;; everything after is&&.So a failing bind leaves
/workspacewith zero mounts rather than theprevious one. That's what turns a bad argument into a destroyed instance.
Cause 3 — the failure never surfaces
ensure_worktree_exists(services.rs:903-978) validates the correct hostpath and returns
Ok, so the step printsok. The remount failure is onlywarned (
services.rs:638-645) and the return value is discarded atservices.rs:890-897. The assign then fails later, opaquely, at the health check.Why
--force-syncdoesn't repair itIt only clears an ignored-file marker and never touches mounts
(
services.rs:1065-1069), then recomputes the identical badmount_src.Why
unassigndoes repair itunassign.rs:301-303hardcodesmount --bind /host-project /workspace— nodetection, no concatenation, and
/host-projectalways exists.Suggested fixes
fall back to the previous bind if the new one fails.
mount_srcfrom the validated host path, not the raw argument, atall five sites — invariant:
container_mount_src == "/host-project/" + host_path.strip_prefix(project_root).Anything not expressible that way is unreachable and should be a hard error.
remount_workspacereturnResultandcheck it at
services.rs:890. A failed/workspaceremount must fail theassign rather than print
ok.--worktreeat the CLI boundary —the cheapest guard, and it catches the reported invocation outright.
Related: instances stuck in
unassigningforeverTwo compounding issues:
unassign.rs:510-522writesInstanceStatus::Unassigningbefore thecontainer_id … .ok_or_else(…)?, so that?strands the row.assign/mod.rsgets this ordering right (check:103, write:108).Unassigningis absorbing:validate_unassignableaccepts it,prev_statusis read pre-write, and the success path only special-cases
Idle(
unassign.rs:570-574) — so a successful retry writesunassigningback.No startup reconciliation covers transitional statuses.
The fix already exists in
assign/mod.rs:373-376:Applying that shape at
unassign.rs:570would let a stuck instance self-heal onthe next
unassign.