# 远程临时实验 Smoke

## 适用范围

本模板用于一次性远程 build、Docker GPU、模型下载或硬件 smoke。目标是让执行者在一个实验目录内完成：

- 一次 preflight；
- 一个或多个串行后台 job；
- 一次原入口 smoke；
- 一份有界 evidence；
- 一次幂等 cleanup。

正式 CI/CD、YAML-first apply、常驻服务、Secret 下发和公网暴露继续使用对应专项 CLI 或 skill，不能套用本模板。

## 必填参数

开始写入目标前必须明确以下参数，不得根据宿主名、历史目录或上一任务猜测：

| 参数 | 约束 |
| --- | --- |
| `route` | 任务上下文提供的 `Target:absoluteWorkspace` 或对应 k3s route。 |
| `work_dir` | 目标允许范围内的绝对临时目录，默认位于 `/tmp`。 |
| `resource_prefix` | 容器、镜像、网络、PID 和证据文件共享的任务前缀。 |
| `job_id` | 当前长步骤的稳定短标识，例如 `model-download` 或 `image-build`。 |
| `command` | 写入 job 脚本的单一长命令，不在同步 `trans` 中等待完成。 |
| `artifact_allowlist` | 允许保留的模型缓存、WAV、JSON 或摘要文件。 |
| `stop_condition` | OOM、ABI、许可证、上游 ref 或硬件能力等首次确定停止条件。 |

参数缺失时先返回可操作错误：

- 不要创建第二条执行路径；
- 不要把 Secret、token 或完整环境变量写入参数、脚本、日志和 evidence。

## 单次 Smoke 流程

### 1. Preflight

只做一次有界探测，确认目录、资源前缀、依赖入口和停止条件：

```bash
trans <Target>:/tmp sh <<'SH'
set -eu
work_dir=<absolute-work-dir>
resource_prefix=<resource-prefix>

test "${work_dir#/tmp/}" != "$work_dir"
case "$resource_prefix" in
  ''|*[!a-zA-Z0-9_.-]*) echo 'invalid resource_prefix' >&2; exit 2 ;;
esac

mkdir -p "$work_dir/jobs" "$work_dir/artifacts"
printf 'host='; hostname
df -h "$work_dir" | tail -1
docker version --format 'docker={{.Server.Version}}' 2>/dev/null || true
nvidia-smi --query-gpu=name,driver_version,memory.total,memory.free --format=csv,noheader 2>/dev/null || true
docker ps -a --filter "label=unidesk.task=$resource_prefix" --format '{{.Names}} {{.Status}} {{.Image}}' 2>/dev/null || true
SH
```

若目标范围不是 `/tmp`，必须由 owning issue 或任务上下文明确授权，再相应替换目录断言。

### 2. 写入 Job 脚本

远端文本修改只使用 `apply-patch`。每个长步骤的脚本必须写最终 exit code 和 done 标记：

```bash
trans <Target>:<absolute-work-dir> apply-patch <<'PATCH'
*** Begin Patch
*** Add File: jobs/<job-id>.sh
+#!/bin/sh
+set +e
+
+<one-long-command>
+code=$?
+printf '%s\n' "$code" > /absolute/work-dir/jobs/<job-id>.exit
+touch /absolute/work-dir/jobs/<job-id>.done
+exit "$code"
*** End Patch
PATCH
```

job 脚本只能引用任务已确认的绝对目录、资源前缀和公开依赖身份。需要 Secret 的任务不使用本模板自行读取运行面值。

### 3. Submit

submit 只负责清理旧状态、后台启动和返回 PID，不等待长命令：

```bash
trans <Target>:<absolute-work-dir> sh <<'SH'
set -eu
job_id=<job-id>
job_dir=/absolute/work-dir/jobs

rm -f "$job_dir/$job_id.done" "$job_dir/$job_id.exit" "$job_dir/$job_id.stdout" "$job_dir/$job_id.stderr"
chmod +x "$job_dir/$job_id.sh"
nohup sh "$job_dir/$job_id.sh" \
  > "$job_dir/$job_id.stdout" \
  2> "$job_dir/$job_id.stderr" \
  < /dev/null &
printf '%s\n' "$!" > "$job_dir/$job_id.pid"
printf 'job=%s state=submitted pid=%s\n' "$job_id" "$(cat "$job_dir/$job_id.pid")"
SH
```

### 4. Poll

每次 poll 只输出状态和有界日志尾部。不得重复 submit，也不得恢复完整 stdout dump：

```bash
trans <Target>:<absolute-work-dir> sh <<'SH'
set -eu
job_id=<job-id>
job_dir=/absolute/work-dir/jobs
pid=$(cat "$job_dir/$job_id.pid" 2>/dev/null || true)

if [ -f "$job_dir/$job_id.done" ]; then
  state=finished
  exit_code=$(cat "$job_dir/$job_id.exit")
elif [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
  state=running
  exit_code=-
else
  state=lost
  exit_code=-
fi

printf 'job=%s state=%s exit=%s pid=%s\n' "$job_id" "$state" "$exit_code" "${pid:--}"
printf '%s\n' '--- stdout tail ---'
tail -n 20 "$job_dir/$job_id.stdout" 2>/dev/null || true
printf '%s\n' '--- stderr tail ---'
tail -n 20 "$job_dir/$job_id.stderr" 2>/dev/null || true
SH
```

`state=lost` 表示 PID 已消失但没有 done 标记：

- 先保留状态文件和日志；
- 不要自动重提；
- 根据首个确定错误决定停止或创建新的 `job_id`。

### 5. 原入口 Smoke 与 Evidence

只执行 owning issue 要求的一个 smoke。通用 evidence 至少包含：

- `sourceRef`、模型或依赖 ref、许可证；
- 容器基础镜像 digest、构建镜像 ID、runtime 和 GPU request；
- 精确 API 或命令参数；
- 首次调用和热调用耗时；
- GPU 基线、峰值显存和 OOM 状态；
- 生成物大小、哈希和原生解码/检查摘要；
- 首个失败点和是否触发 `stop_condition`；
- cleanup 结果和允许保留的 artifact 路径。

证据文件只写摘要、presence、fingerprint、ID、digest 和有界日志尾部，不复制 Secret、完整环境、模型正文或无界 stdout。

### 6. Cleanup

cleanup 必须幂等，并按资源前缀或 label 精确删除：

```bash
trans <Target>:<absolute-work-dir> sh <<'SH'
set -eu
work_dir=/absolute/work-dir
resource_prefix=<resource-prefix>

for container in $(docker ps -aq --filter "label=unidesk.task=$resource_prefix"); do
  docker rm -f "$container"
done
for image in $(docker images -q --filter "label=unidesk.task=$resource_prefix"); do
  docker image rm "$image" || true
done
for network in $(docker network ls -q --filter "label=unidesk.task=$resource_prefix"); do
  docker network rm "$network" || true
done

find "$work_dir/jobs" -type f \( -name '*.pid' -o -name '*.done' -o -name '*.exit' \) -delete

printf '%s\n' 'containers:'
docker ps -a --filter "label=unidesk.task=$resource_prefix" --format '{{.Names}} {{.Status}} {{.Image}}'
printf '%s\n' 'images:'
docker images --filter "label=unidesk.task=$resource_prefix" --format '{{.Repository}}:{{.Tag}} {{.ID}} {{.Size}}'
printf '%s\n' 'networks:'
docker network ls --filter "label=unidesk.task=$resource_prefix" --format '{{.Name}}'
SH
```

只保留 `artifact_allowlist` 明确列出的缓存和证据。临时容器、镜像、网络、PID 和非复用 scratch 不得伪装成正式运行面。

## 停止判定

- 首个确定的 OOM、ABI、许可证、上游 ref 或硬件能力错误触发 `stop_condition` 后立即停止同族试配。
- 依赖下载失败与模型失败必须分层记录，不能把网络、registry 或包 ABI 问题误判为模型不可用。
- smoke 未进入原入口时，首次调用、热调用、峰值显存和生成物必须明确写 `未执行`，不能填推测值。
- 后续候选模型或依赖组合使用新的 issue、TaskTree Task、资源前缀和实验目录，不能在当前 smoke 中无界扩展；遗留 MDTODO 按 `$unidesk-tasktree` 迁移。
