diff --git a/CHANGELOG.md b/CHANGELOG.md index 61fc259442..c706476ef2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 target as Startup and clicks it through the existing Enter submit dispatcher (#5771). Compact/quiet composers still omit the control. +- TUI/CLI: Pod is the public roster surface. User-facing Fleet wording + moves to Pod; durable receipt keys stay compatible (#5776). + ### Added - Website: the public site moves to the Tideline deep-ocean design language diff --git a/computer/snapshots/cloud-agent/Dockerfile b/computer/snapshots/cloud-agent/Dockerfile new file mode 100644 index 0000000000..735223f425 --- /dev/null +++ b/computer/snapshots/cloud-agent/Dockerfile @@ -0,0 +1,99 @@ +# syntax=docker/dockerfile:1 +# codewhale-cloud-agent — Daytona snapshot image for Codewhale cloud agents. +# +# Product truth first: this image installs the RELEASED v0.9.11 Linux x86_64 +# binary (static musl, no glibc floor) from the GitHub release by exact URL, +# verifies its sha256 against codewhale-artifacts-sha256.txt, and records the +# commit + digest as OCI labels (PRD 4.5: commit- and digest-pinned Linux +# binary inside the Computer). No secrets are baked in. Daytona create-time +# environment is server-visible, so a provider key must never be injected at +# create time. A future dispatcher must deliver provider secrets only after +# creation, over a post-create execution channel from stdin (never argv), and +# remove them during teardown. `CODEWHALE_API_KEY` is an account/machine token, +# not an inference-provider credential; current cloud dispatch has no +# server-side account-token-to-provider-key resolution. +# +# Build (Daytona, amd64 only; daytona snapshot create has no --build-arg, so +# every pin is inline): +# daytona snapshot create codewhale-cloud-agent \ +# -f Dockerfile --cpu 4 --memory 8 --disk 10 (plan max; resources bind to the snapshot) +# +# Current dispatcher boundary: this is an image definition, not a wired cloud +# execution path. `crates/tui/src/cloud_dispatch.rs` currently creates a +# Daytona sandbox with a name and labels only; it does not select this snapshot, +# clone a repository, inject any sandbox environment, or execute `codewhale`. +# A manual image build or Daytona probe is therefore not Cloud Agent launch +# evidence. Do not add a create-time provider-key shortcut while that product +# wiring is built. + +FROM debian:bookworm-slim + +ARG DEBIAN_FRONTEND=noninteractive + +# ---- pins (release v0.9.11, tag commit 96d13a0bc3f40280ea3865280ad5ccf0e2845e6f) +ENV CODEWHALE_VERSION=0.9.11 \ + CODEWHALE_COMMIT=96d13a0bc3f40280ea3865280ad5ccf0e2845e6f \ + CODEWHALE_ASSET_URL=https://github.com/Hmbown/CodeWhale/releases/download/v0.9.11/codewhale-linux-x64 \ + CODEWHALE_ASSET_SHA256=c02969556e51e138afa3fe9c97a1359878cd3d1986b1ce1f5fa96c93c6909416 \ + NODE_MAJOR=22 + +# Base toolchain for an agent doing code work: git, curl, TLS roots, ripgrep, +# python3, node LTS (22), build-essential. procps for `ps`/`pkill` used by the +# engine's process tooling; sudo is deliberately NOT installed. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates curl git gnupg ripgrep procps \ + python3 python3-pip python3-venv \ + build-essential pkg-config \ + jq unzip xz-utils less \ + && mkdir -p /etc/apt/keyrings \ + && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ + | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" \ + > /etc/apt/sources.list.d/nodesource.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* + +# Download the released binary by exact URL and refuse anything whose sha256 +# does not match the release checksum. Static musl: no interpreter, no glibc. +RUN set -eu; \ + curl -fsSL --retry 3 -o /tmp/codewhale "${CODEWHALE_ASSET_URL}"; \ + echo "${CODEWHALE_ASSET_SHA256} /tmp/codewhale" | sha256sum -c -; \ + install -m 0755 -o root -g root /tmp/codewhale /usr/local/bin/codewhale; \ + ln -s /usr/local/bin/codewhale /usr/local/bin/codew; \ + rm -f /tmp/codewhale; \ + codewhale --version | tee /etc/codewhale-version; \ + grep -qx "codewhale 0.9.11 (96d13a0bc3f4)" /etc/codewhale-version + +# Non-root agent user. /work is the generic Computer mount; /workspace is the +# path #5712's runner clones into and runs the harness from — both owned by +# the agent so a non-root toolbox user can write them. +RUN groupadd --gid 1000 agent \ + && useradd --uid 1000 --gid 1000 --create-home --home-dir /home/agent --shell /bin/bash agent \ + && mkdir -p /work /workspace /home/agent/.codewhale \ + && chown -R agent:agent /work /workspace /home/agent + +ENV HOME=/home/agent \ + CODEWHALE_HOME=/home/agent/.codewhale \ + PATH=/home/agent/.local/bin:/usr/local/bin:/usr/bin:/bin \ + GIT_TERMINAL_PROMPT=0 \ + CI=1 \ + TERM=xterm-256color + +LABEL org.opencontainers.image.title="codewhale-cloud-agent" \ + org.opencontainers.image.description="Codewhale cloud agent Computer: released codewhale CLI preinstalled for dispatched turns" \ + org.opencontainers.image.version="0.9.11" \ + org.opencontainers.image.revision="96d13a0bc3f40280ea3865280ad5ccf0e2845e6f" \ + org.opencontainers.image.source="https://github.com/Hmbown/CodeWhale" \ + net.codewhale.binary.asset="codewhale-linux-x64" \ + net.codewhale.binary.sha256="c02969556e51e138afa3fe9c97a1359878cd3d1986b1ce1f5fa96c93c6909416" \ + net.codewhale.binary.commit="96d13a0bc3f40280ea3865280ad5ccf0e2845e6f" \ + net.codewhale.binary.version="0.9.11" \ + net.codewhale.release.image="ghcr.io/hmbown/codewhale:0.9.11@sha256:6de13fe5e62fb3cb815c423bcb17455bef4d9f7db2107888beb88fe4b7c9ac14" + +USER agent +WORKDIR /work + +# Daytona injects its own toolbox daemon; keep the container alive for it. +ENTRYPOINT ["sleep", "infinity"] diff --git a/computer/snapshots/cloud-agent/README.md b/computer/snapshots/cloud-agent/README.md new file mode 100644 index 0000000000..ec6c8bac99 --- /dev/null +++ b/computer/snapshots/cloud-agent/README.md @@ -0,0 +1,135 @@ +# `codewhale-cloud-agent` — Daytona Computer snapshot + +This directory is the product-owned definition of the Daytona snapshot that a +Cloud Agent acquires as its Computer (PRODUCT_PRD §4.5). The Codewhale Engine +is the sole runtime inside the Computer and is installed as a commit- and +digest-pinned Linux binary; nothing else in the image runs agent logic. + +## What the image is + +- Base: `debian:bookworm-slim` (linux/amd64 — Daytona builds amd64 only). +- Engine: released `codewhale-linux-x64` from GitHub release `v0.9.11`, + fetched by exact URL and verified against the release checksum before + install: + - commit `96d13a0bc3f40280ea3865280ad5ccf0e2845e6f` (tag `v0.9.11`) + - sha256 `c02969556e51e138afa3fe9c97a1359878cd3d1986b1ce1f5fa96c93c6909416` + - static musl build (no glibc floor), installed at `/usr/local/bin/codewhale` + with a `codew` symlink; the build fails if `codewhale --version` does not + print `codewhale 0.9.11 (96d13a0bc3f4)`. +- Toolchain for agent work: git, curl, CA roots, ripgrep, procps, python3 + (+pip, venv), build-essential, pkg-config, jq, unzip, xz-utils, less, + Node.js 22 (NodeSource). No sudo. +- User: non-root `agent` (uid/gid 1000), `HOME=/home/agent`, + `CODEWHALE_HOME=/home/agent/.codewhale`. `/work` and `/workspace` exist and + are owned by `agent`. +- Entrypoint: `sleep infinity` (Daytona injects its own toolbox daemon). +- No provider credentials are baked in or supplied at sandbox create time. + Daytona create-time environment is server-visible, so a provider secret + must never appear in `daytona create -e …` or an SDK `envVars` payload. + +The pins are recorded as OCI labels (`org.opencontainers.image.revision`, +`net.codewhale.binary.sha256`, ...) so a running Computer can be audited +against the release it claims to run. + +## Current dispatcher state — not a runtime contract + +The current product dispatcher wiring for this snapshot is absent. +`crates/tui/src/cloud_dispatch.rs` creates a Daytona sandbox with a generated +name and labels, then records its ID. It does **not** select +`codewhale-cloud-agent`, clone into `/workspace`, inject sandbox environment, +call the Daytona toolbox execution endpoint, or run `codewhale`. It also does +not contain a server-side account-token-to-provider-credential resolution path. + +This directory is consequently an image definition and a bounded manual +inspection aid, not an end-to-end Cloud Agent implementation. A snapshot build, +manual `daytona create`, or manual `codewhale exec` proves only the specific +image/operator step observed; none is launch proof for dispatcher, entitlement, +credential custody, Engine execution, lifecycle, metering, or customer use. + +## Provider credentials inside the Computer + +> **Credential exposure note (verified 2026-08-30).** Daytona persists +> `daytona create -e KEY=VALUE` / SDK `envVars` server-side and returns the +> environment through its API (`GET /sandbox/{id}`). Create-time environment is +> therefore server-visible. Provider secrets must be injected only after the +> Computer is created, through a post-create execution channel from stdin +> (never argv and never create-time environment), then removed at teardown. +> This image and the current dispatcher do not implement that bridge. +> +> `api_key_env` accepts the **name of an environment variable**, not a file +> path or file contents. Do not point it at a `0600` secret file. If a future +> product-owned bridge uses a temporary file, it must separately and explicitly +> map the stdin-delivered secret into the engine process without placing the +> secret in Daytona create-time environment. +> +> The #5712 `CODEWHALE_API_KEY` account/machine-token caveat remains: it is not +> an inference-provider credential and current cloud dispatch does not resolve +> it server-side into one. A machine token alone cannot make this image run a +> provider-backed Engine turn. + +The following are configuration references for a future supported post-create +bridge, not current dispatcher wiring and not permission to use create-time +environment: + +| Provider (config name) | Env var | Example model identifiers | +|----------------------------|----------------------------------------|------------------------------| +| `modelstudio-token-plan` | `MODELSTUDIO_API_KEY` (or `DASHSCOPE_API_KEY`) | `qwen3.8-flash`, `deepseek-v4-pro` | +| `deepseek` | `DEEPSEEK_API_KEY` | `deepseek-v4-pro` | + +`CODEWHALE_PROVIDER` / `CODEWHALE_MODEL` select an Engine route when the Engine +is launched; they do not make the current dispatcher launch this snapshot or +deliver a provider credential. + +## Build + +`daytona snapshot create` (CLI v0.205.x) has no `--build-arg`, so every pin is +inline in the Dockerfile. Resources are set at snapshot creation and are the +plan maximum: + +```sh +cd computer/snapshots/cloud-agent +daytona snapshot create codewhale-cloud-agent -f Dockerfile --cpu 4 --memory 8 --disk 10 +``` + +To roll the engine forward: bump `CODEWHALE_VERSION`, `CODEWHALE_COMMIT`, +`CODEWHALE_ASSET_URL`, `CODEWHALE_ASSET_SHA256`, the `grep -qx` version +assertion, and the OCI labels together, then rebuild under a new snapshot +name (snapshots are immutable once active). + +## Probe a Computer (manual image evidence only) + +```sh +daytona create --snapshot codewhale-cloud-agent \ + -l owner=cw-integrator -l lane=cloud-agent-e2e --ttl 30 --auto-delete 0 --name cw-probe +daytona exec cw-probe -- sh -c 'id -u; codewhale --version; git --version; node --version; df -h /; sha256sum /usr/local/bin/codewhale' +daytona delete cw-probe +``` + +The sha256 printed by the probe must equal the pinned +`c02969556e51e138afa3fe9c97a1359878cd3d1986b1ce1f5fa96c93c6909416`. This is +not product-dispatch or launch acceptance evidence. + +## Image build and manual probe receipt (2026-08-30; not launch proof) + +Built with the command above; snapshot id `b9275f82-0ead-4855-9707-21859aa186b4`, +state ACTIVE, 0.70 GB, cpu 4 / memory 8 / disk 10. Probe sandbox +(`daytona create --snapshot codewhale-cloud-agent`, labels +`owner=cw-integrator,lane=cloud-agent-e2e`, ttl 30, auto-delete 0) reported: + +``` +uid=1000 user=agent HOME=/home/agent CODEWHALE_HOME=/home/agent/.codewhale PWD=/work +codewhale 0.9.11 (96d13a0bc3f4) +git version 2.39.5 +v22.23.2 (node) +Python 3.11.2 +ripgrep 13.0.0 +overlay 10G used 24K avail 10G (/ , /work, /workspace) +cpu.max 400000 100000 ; memory.max 8589934592 +c02969556e51e138afa3fe9c97a1359878cd3d1986b1ce1f5fa96c93c6909416 /usr/local/bin/codewhale +/workspace writable ; /work writable +``` + +This shows only that the Daytona toolbox executed the listed manual commands +as the image `USER` (uid 1000) with the image `ENV` honored. It does not prove +current product dispatcher wiring, provider-secret custody, Engine execution, +or any launch acceptance condition. diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index e9834fb0ec..8ee4729212 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -20,6 +20,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 target as Startup and clicks it through the existing Enter submit dispatcher (#5771). Compact/quiet composers still omit the control. +- TUI/CLI: Pod is the public roster surface. User-facing Fleet wording + moves to Pod; durable receipt keys stay compatible (#5776). + ### Added - Website: the public site moves to the Tideline deep-ocean design language diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index fa94d4a2cd..4381f8d493 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -976,6 +976,31 @@ fn resolve_exec_resume_route( Ok(saved.metadata.model.clone()) } +/// Fold the dispatcher-forwarded launch overrides (`CODEWHALE_PROVIDER` / +/// `CODEWHALE_MODEL`, set by `codewhale --provider X --model Y exec ...`) +/// into the explicit route signals `exec --resume`/`--continue` honour. +/// +/// Exec-level flags win when both are present; either source counts as +/// "the user named a route for this run", so a resume must not silently +/// restore the saved provider/model over it. +fn exec_resume_route_overrides( + exec_provider: Option<&str>, + exec_model: Option<&str>, + launch_provider: Option<&str>, + launch_model: Option<&str>, +) -> (bool, Option) { + let non_empty = |value: Option<&str>| { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + }; + let explicit_provider = + non_empty(exec_provider).is_some() || non_empty(launch_provider).is_some(); + let explicit_model = non_empty(exec_model).or_else(|| non_empty(launch_model)); + (explicit_provider, explicit_model) +} + #[derive(Args, Debug, Clone, Default)] struct SetupArgs { /// Initialize MCP configuration at the configured path @@ -2277,12 +2302,24 @@ async fn run_async_main_dispatch( explicit_reasoning.is_some(), )?; } + // The `codewhale` dispatcher refuses `--provider`/`--model` + // after `exec` and forwards the top-level flags as + // `CODEWHALE_PROVIDER` / `CODEWHALE_MODEL` instead, so a + // resume must treat those launch overrides as explicit or it + // silently restores the saved route (cloud-agent e2e, + // 2026-08-30). + let (resume_explicit_provider, resume_explicit_model) = exec_resume_route_overrides( + explicit_provider, + explicit_model, + crate::config::explicit_launch_provider_override().as_deref(), + crate::config::explicit_launch_model_override().as_deref(), + ); let model = if let Some(saved) = resume_session.as_ref() { resolve_exec_resume_route( &mut config, saved, - explicit_provider.is_some(), - explicit_model, + resume_explicit_provider, + resume_explicit_model.as_deref(), )? } else { resolve_exec_model(&config, explicit_model) @@ -14922,6 +14959,144 @@ reasoning = "high" assert_eq!(missing.provider, before); } + #[test] + fn exec_resume_honours_dispatcher_forwarded_launch_overrides() { + // `codewhale --provider X --model Y exec --resume ID ...` reaches this + // binary with X/Y only in CODEWHALE_PROVIDER / CODEWHALE_MODEL; a + // resume must treat them as explicit instead of restoring the saved + // route. + assert_eq!( + exec_resume_route_overrides(None, None, None, None), + (false, None) + ); + assert_eq!( + exec_resume_route_overrides(None, None, Some("modelstudio-token-plan"), None), + (true, None) + ); + assert_eq!( + exec_resume_route_overrides(None, None, None, Some("qwen3.8-flash")), + (false, Some("qwen3.8-flash".to_string())) + ); + assert_eq!( + exec_resume_route_overrides( + None, + None, + Some("modelstudio-token-plan"), + Some(" qwen3.8-flash ") + ), + (true, Some("qwen3.8-flash".to_string())) + ); + // Exec-level flags still win over the forwarded launch env. + assert_eq!( + exec_resume_route_overrides( + Some("deepseek"), + Some("deepseek-v4-pro"), + Some("x"), + Some("y") + ), + (true, Some("deepseek-v4-pro".to_string())) + ); + // Blank values are not overrides. + assert_eq!( + exec_resume_route_overrides(None, None, Some(" "), Some("")), + (false, None) + ); + + let saved = saved_exec_session("custom-a", crate::config::ZAI_GLM_5_2_MODEL); + let mut launch_model_only = custom_exec_config("custom-b"); + let (explicit_provider, explicit_model) = + exec_resume_route_overrides(None, None, None, Some("override-model")); + let model = resolve_exec_resume_route( + &mut launch_model_only, + &saved, + explicit_provider, + explicit_model.as_deref(), + ) + .expect("launch model override keeps saved provider"); + assert_eq!(launch_model_only.provider.as_deref(), Some("custom-a")); + assert_eq!(model, "override-model"); + + let mut launch_provider = custom_exec_config("custom-b"); + let (explicit_provider, explicit_model) = + exec_resume_route_overrides(None, None, Some("custom-b"), None); + let model = resolve_exec_resume_route( + &mut launch_provider, + &saved, + explicit_provider, + explicit_model.as_deref(), + ) + .expect("launch provider override keeps the launched route"); + assert_eq!(launch_provider.provider.as_deref(), Some("custom-b")); + assert_eq!(model, "model-b"); + } + + #[test] + fn exec_resume_uses_dispatcher_env_route_loaded_by_config() { + // Exercise the production sequence without starting an Engine turn: + // `Config::load` applies the dispatcher-forwarded environment, then + // the resume seam must treat the same launch env as explicit and skip + // the unavailable persisted route rather than restoring it. + let _env_lock = crate::test_support::lock_test_env(); + let _legacy_provider = crate::test_support::EnvVarGuard::remove("DEEPSEEK_PROVIDER"); + let _legacy_model = crate::test_support::EnvVarGuard::remove("DEEPSEEK_MODEL"); + let _provider = crate::test_support::EnvVarGuard::set("CODEWHALE_PROVIDER", "launch-route"); + let _model = crate::test_support::EnvVarGuard::set("CODEWHALE_MODEL", "dispatcher-model"); + let tmp = tempfile::tempdir().expect("config tempdir"); + let codewhale_home = tmp.path().join("codewhale-home"); + std::fs::create_dir_all(&codewhale_home).expect("isolated Codewhale home"); + let _codewhale_home = + crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home); + let config_path = tmp.path().join("config.toml"); + std::fs::write( + &config_path, + r#"provider = "stored-route" + +[providers.launch-route] +kind = "openai-compatible" +base_url = "https://launch.example.test/v1" +model = "configured-launch-model" +api_key = "test-only-key" +"#, + ) + .expect("write config"); + + let mut config = Config::load(Some(config_path), None).expect("load launch config"); + assert_eq!(config.provider.as_deref(), Some("launch-route")); + assert_eq!(config.default_model(), "dispatcher-model"); + + // The saved route deliberately has no live config table. This control + // proves that a non-explicit resume would fail closed instead of + // silently falling back; the dispatcher overrides must prevent that + // restore attempt. + let saved = saved_exec_session("stored-route", "stored-model"); + let mut restore_attempt = config.clone(); + let restore_error = resolve_exec_resume_route(&mut restore_attempt, &saved, false, None) + .expect_err("unavailable saved route must not be restored"); + assert!( + restore_error.to_string().contains("stored-route"), + "{restore_error}" + ); + + let (explicit_provider, explicit_model) = exec_resume_route_overrides( + None, + None, + crate::config::explicit_launch_provider_override().as_deref(), + crate::config::explicit_launch_model_override().as_deref(), + ); + assert!(explicit_provider); + assert_eq!(explicit_model.as_deref(), Some("dispatcher-model")); + + let model = resolve_exec_resume_route( + &mut config, + &saved, + explicit_provider, + explicit_model.as_deref(), + ) + .expect("dispatcher environment must keep the launch route on resume"); + assert_eq!(config.provider.as_deref(), Some("launch-route")); + assert_eq!(model, "dispatcher-model"); + } + #[test] fn exec_model_reads_wait_for_foreign_test_env_overrides_to_restore() { let (started_tx, started_rx) = std::sync::mpsc::channel(); diff --git a/crates/tui/src/runtime_chat_relay.rs b/crates/tui/src/runtime_chat_relay.rs index f61e40b31c..1d30c1f1fd 100644 --- a/crates/tui/src/runtime_chat_relay.rs +++ b/crates/tui/src/runtime_chat_relay.rs @@ -11,6 +11,7 @@ use std::{ fs::{self, File}, path::{Path, PathBuf}, sync::Arc, + time::{Duration, Instant}, }; use anyhow::{Context, Result, bail}; @@ -1556,16 +1557,42 @@ impl RelayScopeLock { } #[cfg(unix)] { - use std::os::fd::AsRawFd as _; use std::os::unix::fs::PermissionsExt as _; file.set_permissions(fs::Permissions::from_mode(0o600)) .context("protect Runtime Chat owner lock")?; + } + // Same-process drop-then-reopen can observe WouldBlock for a brief + // window while the previous fd is still closing (#5735). Retry only + // that contention; a lock that stays held is still ownership. + let deadline = Instant::now() + Duration::from_millis(25); + loop { + match Self::try_lock_exclusive(&file) { + Ok(()) => break, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if Instant::now() >= deadline { + return Err(error).context("acquire Runtime Chat owner lock"); + } + std::thread::yield_now(); + std::thread::sleep(Duration::from_millis(1)); + } + Err(error) => { + return Err(error).context("acquire Runtime Chat owner lock"); + } + } + } + Ok(Self { _file: file }) + } + + fn try_lock_exclusive(file: &File) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::os::fd::AsRawFd as _; // SAFETY: `file` owns a valid descriptor for the duration of the // call and remains alive in `Self` for the full lock lifetime. if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 { - return Err(std::io::Error::last_os_error()) - .context("acquire Runtime Chat owner lock"); + return Err(std::io::Error::last_os_error()); } + Ok(()) } #[cfg(windows)] { @@ -1573,11 +1600,39 @@ impl RelayScopeLock { use windows_sys::Win32::Storage::FileSystem::LockFile; // SAFETY: `file` owns a valid handle that remains alive in `Self`. if unsafe { LockFile(file.as_raw_handle() as _, 0, 0, u32::MAX, u32::MAX) } == 0 { - return Err(std::io::Error::last_os_error()) - .context("acquire Runtime Chat owner lock"); + return Err(std::io::Error::last_os_error()); + } + Ok(()) + } + #[cfg(not(any(unix, windows)))] + { + let _ = file; + Ok(()) + } + } +} + +impl Drop for RelayScopeLock { + fn drop(&mut self) { + // close() also releases, but unlocking first lets a same-process + // reopen proceed without racing the previous fd's teardown (#5735). + #[cfg(unix)] + { + use std::os::fd::AsRawFd as _; + // SAFETY: Drop runs only while `_file` still owns this descriptor. + unsafe { + libc::flock(self._file.as_raw_fd(), libc::LOCK_UN); + } + } + #[cfg(windows)] + { + use std::os::windows::io::AsRawHandle as _; + use windows_sys::Win32::Storage::FileSystem::UnlockFile; + // SAFETY: Drop runs only while `_file` still owns this handle. + unsafe { + UnlockFile(self._file.as_raw_handle() as _, 0, 0, u32::MAX, u32::MAX); } } - Ok(Self { _file: file }) } } diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 03b7e179d5..4bad768ddb 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -6480,11 +6480,14 @@ impl SubAgentManager { .map(str::trim) .filter(|name| !name.is_empty()) { - if let Some(existing) = self - .agents - .values() - .find(|existing| existing.session_name == name) - { + // Names are scoped to the live session: a completed worker + // hydrated from a previous session's ledger is invisible to + // `status`/`peek`/`followup`, so it must not reserve the name + // either (cloud-agent e2e, 2026-08-30: a fresh `exec` in the same + // workspace could not spawn `worker-a` again). + if let Some(existing) = self.agents.values().find(|existing| { + existing.session_name == name && !self.is_from_prior_session(existing) + }) { // #3020: Include elapsed time so the parent can distinguish a // live worker from a stale/failed earlier spawn (#2656). let elapsed = existing.started_at.elapsed(); diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index af8290e747..ec94146d55 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -8724,6 +8724,7 @@ async fn spawn_duplicate_session_name_error_names_conflicting_agent() { // #2656: the duplicate-name error must identify the conflicting agent so a // model can recover deterministically (reuse the id, or pick a new name). let manager = Arc::new(RwLock::new(SubAgentManager::new(PathBuf::from("."), 5))); + let boot_id = manager.read().await.session_boot_id().to_string(); let (input_tx, _input_rx) = mpsc::unbounded_channel(); let mut existing = SubAgent::new( "test_agent_existing".to_string(), @@ -8735,7 +8736,7 @@ async fn spawn_duplicate_session_name_error_names_conflicting_agent() { Some(vec!["read_file".to_string()]), input_tx, PathBuf::from("."), - "boot_test".to_string(), + boot_id, ); existing.session_name = "researcher".to_string(); existing.status = SubAgentStatus::Running; @@ -8779,6 +8780,66 @@ async fn spawn_duplicate_session_name_error_names_conflicting_agent() { ); } +#[tokio::test] +async fn spawn_session_name_held_by_prior_session_agent_does_not_collide() { + // A completed worker hydrated from an earlier session's ledger is not + // addressable by `status`/`peek`/`followup` in this session, so it must + // not reserve its name either: a fresh `codewhale exec` in the same + // workspace has to be able to spawn `researcher` again. + let tmp = tempdir().expect("tempdir"); + let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 5); + let (input_tx, _input_rx) = mpsc::unbounded_channel(); + let mut stale = SubAgent::new( + "test_agent_stale".to_string(), + FleetRole::Scout, + "scan".to_string(), + make_assignment(), + "deepseek-v4-flash".to_string(), + Some("Blue".to_string()), + Some(vec!["read_file".to_string()]), + input_tx, + tmp.path().to_path_buf(), + "boot_stale_other".to_string(), + ); + stale.session_name = "researcher".to_string(); + stale.status = SubAgentStatus::Completed; + let stale_id = stale.id.clone(); + { + let mut guard = manager.write().await; + guard.agents.insert(stale_id.clone(), stale); + assert!(guard.is_from_prior_session(&guard.agents[&stale_id])); + } + + let mut runtime = stub_runtime(); + runtime.manager = Arc::clone(&manager); + runtime.context = ToolContext::new(tmp.path()); + let spawned = { + let mut guard = manager.write().await; + guard + .spawn_background_with_assignment_options( + manager.clone(), + runtime, + FleetRole::Scout, + "new work".to_string(), + make_assignment(), + Some(vec!["read_file".to_string()]), + SubAgentSpawnOptions { + name: Some("researcher".to_string()), + ..Default::default() + }, + ) + .expect("a prior-session holder must not reject a fresh same-name spawn") + }; + assert_ne!(spawned.agent_id, stale_id); + let guard = manager.read().await; + let fresh = guard + .agents + .get(&spawned.agent_id) + .expect("fresh agent registered"); + assert_eq!(fresh.session_name, "researcher"); + assert!(!guard.is_from_prior_session(fresh)); +} + #[tokio::test] async fn shared_write_claim_is_registered_before_parallel_launch_and_manifested() { let tmp = tempdir().expect("tempdir");