dev: gate dogfood restarts with isolated smoke

This commit is contained in:
2026-08-13 02:01:17 +09:00
parent 1d4ffa875a
commit cb683beef8
5 changed files with 366 additions and 8 deletions
+5 -7
View File
@@ -19,12 +19,6 @@ Workerの状態から純粋に再現可能で、且つ揮発性の無い操作
--- ---
## 実際のセッションを読んでデバッグする
`~/.yoi/sessions`にすべてのセッションがある。jsonlなので、いい感じにBashで読むこと。
---
## 検証 ## 検証
開発中は、変更した契約を証明する最小の target / filter から実行する。 開発中は、変更した契約を証明する最小の target / filter から実行する。
@@ -50,7 +44,6 @@ workspace全体、E2E、Nix/Docker buildなどの重い検証は、変更した
Yoi Worker で作業する場合、Ticket の authority・ライフサイクル・操作方法は Yoi Yoi Worker で作業する場合、Ticket の authority・ライフサイクル・操作方法は Yoi
system instructions と、その Worker に提供された typed Ticket tools に従うこと。 system instructions と、その Worker に提供された typed Ticket tools に従うこと。
backend や CLI の具体的な手順はこのリポジトリの `AGENTS.md` では重複して定義しない。
Codex など typed Ticket tools が提供されていないクライアントでは、`yoi ticket` Codex など typed Ticket tools が提供されていないクライアントでは、`yoi ticket`
CLI や保存先の直接操作で Ticket tools を代替しないこと。Ticket の作成・更新は CLI や保存先の直接操作で Ticket tools を代替しないこと。Ticket の作成・更新は
@@ -59,3 +52,8 @@ Yoi Worker に委ねる。
--- ---
YoiでYoiを開発している際、AI自身のフィードバックを元に改善を回すために `docs/report/`ディレクトリに感じた障壁や改善案等を書き残す形にした。 明確に力不足な点/ツールの問題があった場合や、ユーザーからの指示があった際に作ること。 YoiでYoiを開発している際、AI自身のフィードバックを元に改善を回すために `docs/report/`ディレクトリに感じた障壁や改善案等を書き残す形にした。 明確に力不足な点/ツールの問題があった場合や、ユーザーからの指示があった際に作ること。
---
絶対に自身が動作しているプロセスを止めないこと。
マージ後のドッグフーディング環境の更新は必ずユーザーの操作で行う。
+23
View File
@@ -451,6 +451,11 @@ impl ResolvedWorkspaceBackendConfig {
self self
} }
pub fn with_backend_base_url(mut self, base_url: impl Into<String>) -> Self {
self.server.backend_base_url = Some(base_url.into().trim_end_matches('/').to_string());
self
}
pub fn with_listen(mut self, listen: SocketAddr) -> Self { pub fn with_listen(mut self, listen: SocketAddr) -> Self {
self.listen = listen; self.listen = listen;
self self
@@ -579,6 +584,24 @@ mod tests {
); );
} }
#[test]
fn backend_base_url_is_explicit_and_normalized() {
let dir = tempfile::tempdir().unwrap();
let listen = "127.0.0.1:48787".parse().unwrap();
let resolved = WorkspaceBackendConfigFile::load_for_workspace(dir.path())
.unwrap()
.resolve(dir.path(), identity())
.unwrap()
.with_listen(listen)
.with_backend_base_url("http://127.0.0.1:48787/");
assert_eq!(resolved.listen, listen);
assert_eq!(
resolved.server.backend_base_url.as_deref(),
Some("http://127.0.0.1:48787")
);
}
#[test] #[test]
fn rejects_unknown_fields() { fn rejects_unknown_fields() {
let error = WorkspaceBackendConfigFile::parse_str("[server]\nunknown = true\n", "test") let error = WorkspaceBackendConfigFile::parse_str("[server]\nunknown = true\n", "test")
+5 -1
View File
@@ -571,11 +571,15 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
} }
let listener = TcpListener::bind(resolved.listen).await?; let listener = TcpListener::bind(resolved.listen).await?;
let local_addr = listener.local_addr()?;
if resolved.server.backend_base_url.is_none() {
resolved = resolved.with_backend_base_url(format!("http://{local_addr}"));
}
eprintln!( eprintln!(
"yoi-server: serving workspace `{}` from server DB `{}` on http://{}", "yoi-server: serving workspace `{}` from server DB `{}` on http://{}",
workspace.workspace_id, workspace.workspace_id,
database_path.display(), database_path.display(),
listener.local_addr()? local_addr
); );
serve(resolved.server, store, listener).await?; serve(resolved.server, store, listener).await?;
Ok(()) Ok(())
+32
View File
@@ -2,6 +2,38 @@
This repository is developed with Yoi itself. Dogfooding is valuable because it exposes orchestration, memory, TUI, and workflow problems under real use. This repository is developed with Yoi itself. Dogfooding is valuable because it exposes orchestration, memory, TUI, and workflow problems under real use.
## Pre-restart gate
Never use the live dogfood Server or Runtime as the first startup test for a new
binary. A dogfood restart is allowed only after this sequence succeeds:
1. Build the production entrypoints:
`cargo build -p worker-runtime --bin yoi-runtime -p yoi-workspace-server --bin yoi-server`.
2. Run the focused and dependent tests for the changed contracts, followed by
`cargo fmt --all -- --check` and `git diff --check HEAD`.
3. Run `scripts/isolated-startup-smoke.sh` from an external shell/process.
4. Inspect any failed run's retained `/tmp/yoi-isolated-startup-smoke.*` logs;
do not restart dogfood until the cause is fixed and the smoke passes.
5. Have an external supervisor or operator restart Server and Runtime. A Worker
hosted by the target Runtime must never terminate its own Runtime.
6. Verify post-restart readiness through the Workspace Runtime projection and a
real restored Worker operation before treating the environment as healthy.
The smoke harness runs the normal `yoi-server` and `yoi-runtime` binaries using
separate `HOME`, `XDG_DATA_HOME`, `XDG_CONFIG_HOME`, temporary Git repository,
Server database, Runtime fs store, identity/trust material, and non-dogfood
ports. It fails if either port is already occupied, if state escapes the
temporary root, if a process exits unexpectedly, if Runtime readiness is not
visible through Server, or if startup logs contain a panic, migration collision,
or Worker execution restore failure. It also proves that a listening Server
without its configured Runtime is not readiness and restarts the isolated
Runtime once to exercise persistence reopen.
Override `YOI_SMOKE_SERVER_BIN`, `YOI_SMOKE_RUNTIME_BIN`,
`YOI_SMOKE_SERVER_PORT`, or `YOI_SMOKE_RUNTIME_PORT` only when a separate build
or port is intentionally under test. Set `YOI_SMOKE_KEEP=1` to retain successful
artifacts. Failed artifacts are retained automatically.
## What to record ## What to record
When a tool limitation, workflow obstacle, or model-facing policy problem blocks work, record it under `docs/report/` or a work item artifact. Do not turn every minor annoyance into a maintained design doc. When a tool limitation, workflow obstacle, or model-facing policy problem blocks work, record it under `docs/report/` or a work item artifact. Do not turn every minor annoyance into a maintained design doc.
+301
View File
@@ -0,0 +1,301 @@
#!/usr/bin/env bash
set -euo pipefail
# Starts production Server/Runtime binaries against disposable state and ports.
# This script must never read or write the caller's Yoi data/config directories.
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
server_bin=${YOI_SMOKE_SERVER_BIN:-"$repo_root/target/debug/yoi-server"}
runtime_bin=${YOI_SMOKE_RUNTIME_BIN:-"$repo_root/target/debug/yoi-runtime"}
server_port=${YOI_SMOKE_SERVER_PORT:-48787}
runtime_port=${YOI_SMOKE_RUNTIME_PORT:-48800}
keep=${YOI_SMOKE_KEEP:-0}
fail() {
printf 'isolated-startup-smoke: %s\n' "$*" >&2
exit 1
}
for command in curl git node ss; do
command -v "$command" >/dev/null || fail "required command is unavailable: $command"
done
[[ -x "$server_bin" ]] || fail "Server binary is not executable: $server_bin"
[[ -x "$runtime_bin" ]] || fail "Runtime binary is not executable: $runtime_bin"
[[ "$server_port" =~ ^[0-9]+$ ]] || fail "invalid Server port: $server_port"
[[ "$runtime_port" =~ ^[0-9]+$ ]] || fail "invalid Runtime port: $runtime_port"
[[ "$server_port" != "$runtime_port" ]] || fail "Server and Runtime ports must differ"
port_is_listening() {
local port=$1
ss -H -ltn "sport = :$port" | grep -q .
}
port_is_listening "$server_port" && fail "Server smoke port is already in use: $server_port"
port_is_listening "$runtime_port" && fail "Runtime smoke port is already in use: $runtime_port"
root=$(mktemp -d "${TMPDIR:-/tmp}/yoi-isolated-startup-smoke.XXXXXX")
server_pid=
runtime_pid=
stop_pid() {
local pid=${1:-}
[[ -n "$pid" ]] || return 0
kill -TERM "$pid" 2>/dev/null || true
for _ in $(seq 1 50); do
kill -0 "$pid" 2>/dev/null || break
sleep 0.1
done
if kill -0 "$pid" 2>/dev/null; then
kill -KILL "$pid" 2>/dev/null || true
fi
wait "$pid" 2>/dev/null || true
}
cleanup() {
local status=$?
trap - EXIT INT TERM
stop_pid "$runtime_pid"
stop_pid "$server_pid"
if [[ "$keep" == 1 ]]; then
printf 'isolated-startup-smoke: kept artifacts at %s\n' "$root" >&2
elif [[ $status -eq 0 ]]; then
rm -rf "$root"
else
printf 'isolated-startup-smoke: failed; artifacts kept at %s\n' "$root" >&2
fi
exit "$status"
}
trap cleanup EXIT INT TERM
mkdir -p "$root/home" "$root/data" "$root/config" "$root/repository" "$root/logs"
export HOME="$root/home"
export XDG_DATA_HOME="$root/data"
export XDG_CONFIG_HOME="$root/config"
unset YOI_DATA_DIR YOI_CONFIG_HOME
# Fail closed if isolation variables no longer point below the disposable root.
case "$HOME:$XDG_DATA_HOME:$XDG_CONFIG_HOME" in
"$root"/*:"$root"/*:"$root"/*) ;;
*) fail "HOME/XDG isolation guard failed" ;;
esac
server_url="http://127.0.0.1:$server_port"
runtime_url="http://127.0.0.1:$runtime_port"
server_id=isolated-smoke-server
runtime_id=isolated-smoke-runtime
git -C "$root/repository" init -q
git -C "$root/repository" config user.email smoke@example.invalid
git -C "$root/repository" config user.name 'Yoi isolated smoke'
printf '# isolated smoke\n' >"$root/repository/README.md"
git -C "$root/repository" add README.md
git -C "$root/repository" commit -qm 'test: initialize isolated smoke repository'
"$server_bin" identity init --server-id "$server_id" >"$root/logs/server-identity-init.log" 2>&1
"$runtime_bin" identity init --runtime-id "$runtime_id" >"$root/logs/runtime-identity-init.log" 2>&1
server_identity=$("$server_bin" identity show --json)
runtime_identity=$("$runtime_bin" identity show --json)
server_key=$(printf '%s' "$server_identity" | node -e 'const fs=require("fs"); process.stdout.write(JSON.parse(fs.readFileSync(0,"utf8")).public_key)')
runtime_key=$(printf '%s' "$runtime_identity" | node -e 'const fs=require("fs"); process.stdout.write(JSON.parse(fs.readFileSync(0,"utf8")).public_key)')
"$server_bin" trust-runtime add \
--runtime-id "$runtime_id" \
--public-key "$runtime_key" \
--base-url "$runtime_url" >"$root/logs/server-trust-runtime.log" 2>&1
"$runtime_bin" trust-server add \
--server-id "$server_id" \
--public-key "$server_key" >"$root/logs/runtime-trust-server.log" 2>&1
"$server_bin" init --workspace "$root/repository" >"$root/logs/server-init.log" 2>&1
workspace_id=$(sed -n 's/^workspace_id = "\([^"]*\)"/\1/p' "$root/repository/.yoi/workspace.toml")
[[ -n "$workspace_id" ]] || fail "workspace init did not write workspace_id"
# Runtime Git materialization requires a clean source repository. Commit both
# local bootstrap markers inside this disposable repository.
git -C "$root/repository" add .yoi/workspace.toml .yoi/workspace-backend.local.toml
git -C "$root/repository" commit -qm 'test: record isolated Yoi workspace markers'
runtime_store="$XDG_DATA_HOME/yoi/runtime"
mkdir -p "$runtime_store/workers"
cat >"$runtime_store/runtime.json" <<'JSON'
{
"schema_version": 1,
"display_name": "isolated startup smoke",
"backend": "fs_store",
"status": "running",
"next_worker_sequence": 1,
"next_diagnostic_id": 1,
"config_bundles": {},
"workspace_owners": {},
"diagnostics": []
}
JSON
start_server() {
: >"$root/logs/server.log"
"$server_bin" serve --listen "127.0.0.1:$server_port" >"$root/logs/server.log" 2>&1 &
server_pid=$!
}
start_runtime() {
: >"$root/logs/runtime.log"
"$runtime_bin" --bind "127.0.0.1:$runtime_port" >"$root/logs/runtime.log" 2>&1 &
runtime_pid=$!
}
wait_for_listener() {
local pid=$1
local port=$2
local name=$3
for _ in $(seq 1 150); do
kill -0 "$pid" 2>/dev/null || fail "$name exited before listening; inspect $root/logs"
port_is_listening "$port" && return 0
sleep 0.1
done
fail "$name did not listen on port $port within 15 seconds"
}
runtime_projection() {
curl --fail --silent --show-error \
"$server_url/api/w/$workspace_id/runtimes"
}
projection_is_ready() {
node -e '
const fs = require("fs");
const runtimeId = process.argv[1];
const body = JSON.parse(fs.readFileSync(0, "utf8"));
const runtime = body.items.find((item) => item.runtime_id === runtimeId);
if (!runtime || runtime.status !== "running") process.exit(1);
if (!runtime.capabilities?.can_list_workers) process.exit(1);
if ((runtime.diagnostics ?? []).length !== 0) process.exit(1);
if ((body.diagnostics ?? []).length !== 0) process.exit(1);
' "$runtime_id"
}
wait_for_projection_state() {
local expected=$1
local body=
for _ in $(seq 1 150); do
kill -0 "$server_pid" 2>/dev/null || fail "Server exited during readiness check"
body=$(runtime_projection 2>/dev/null || true)
if [[ -n "$body" ]]; then
if printf '%s' "$body" | projection_is_ready 2>/dev/null; then
[[ "$expected" == ready ]] && return 0
else
[[ "$expected" == not-ready ]] && return 0
fi
fi
sleep 0.1
done
printf '%s\n' "$body" >"$root/logs/last-runtime-projection.json"
fail "Runtime projection did not become $expected within 15 seconds"
}
assert_clean_logs() {
if grep -Eiq 'panicked at|thread .* panicked|UNIQUE constraint failed|worker_execution_restore_failed' \
"$root/logs/server.log" "$root/logs/runtime.log"; then
fail "panic, migration collision, or restore failure found in startup logs"
fi
}
start_server
wait_for_listener "$server_pid" "$server_port" Server
# Negative control: a listening Server is not readiness. The configured remote
# Runtime must be rejected while it is absent.
wait_for_projection_state not-ready
start_runtime
wait_for_listener "$runtime_pid" "$runtime_port" Runtime
wait_for_projection_state ready
assert_clean_logs
# Listener/catalog readiness is insufficient. Materialize a real Workdir and
# require the normal Server -> Runtime Worker spawn path to create a persisted
# Worker with an execution handle. This catches adapter panics that startup
# alone cannot observe.
repositories=$(curl --fail --silent --show-error \
"$server_url/api/w/$workspace_id/repositories")
repository_id=$(printf '%s' "$repositories" | node -e '
const fs = require("fs");
const body = JSON.parse(fs.readFileSync(0, "utf8"));
if (body.items.length !== 1) process.exit(1);
process.stdout.write(body.items[0].id);
')
workdir_response=$(curl --fail --silent --show-error \
--request POST \
--header 'content-type: application/json' \
--data "{\"runtime_id\":\"$runtime_id\",\"repository_id\":\"$repository_id\"}" \
"$server_url/api/w/$workspace_id/runtimes/$runtime_id/working-directories") || \
fail "isolated Workdir materialization failed"
working_directory_id=$(printf '%s' "$workdir_response" | node -e '
const fs = require("fs");
const body = JSON.parse(fs.readFileSync(0, "utf8"));
if (body.item?.status !== "active" || body.item?.cleanliness !== "clean") process.exit(1);
process.stdout.write(body.item.working_directory_id);
')
cat >"$root/worker-create.json" <<JSON
{
"runtime_id": "$runtime_id",
"display_name": "isolated restore smoke",
"profile": "builtin:companion",
"initial_submit": [],
"working_directory": {
"working_directory_id": "$working_directory_id"
}
}
JSON
worker_response=$(curl --fail --silent --show-error \
--request POST \
--header 'content-type: application/json' \
--data @"$root/worker-create.json" \
"$server_url/api/w/$workspace_id/workers") || \
fail "isolated Worker spawn failed; listener readiness is not sufficient"
worker_id=$(printf '%s' "$worker_response" | node -e '
const fs = require("fs");
const body = JSON.parse(fs.readFileSync(0, "utf8"));
if (body.runtime_id !== process.argv[1] || !body.worker_id) process.exit(1);
process.stdout.write(String(body.worker_id));
' "$runtime_id")
node -e '
const fs = require("fs");
const record = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
const expectedBase = process.argv[2];
const profileUrl = record.request?.profile_source?.location?.url;
const workspaceUrl = record.request?.workspace_api?.base_url;
if (!profileUrl?.startsWith(`${expectedBase}/`)) {
console.error(`profile callback escaped isolated Server: ${profileUrl}`);
process.exit(1);
}
if (workspaceUrl !== expectedBase) {
console.error(`Workspace API escaped isolated Server: ${workspaceUrl}`);
process.exit(1);
}
' "$runtime_store/workers/$worker_id/worker.json" "$server_url" || \
fail "persisted Worker callback URLs are not isolated"
assert_clean_logs
# Exercise persistence reopen with a real persisted Worker and require the
# Server projection to recover. The Worker record must remain addressable after
# Runtime restart; restore failures and adapter panics are rejected by log scan.
stop_pid "$runtime_pid"
runtime_pid=
wait_for_projection_state not-ready
start_runtime
wait_for_listener "$runtime_pid" "$runtime_port" Runtime
wait_for_projection_state ready
curl --fail --silent --show-error \
"$server_url/api/w/$workspace_id/runtimes/$runtime_id/workers/$worker_id" \
>"$root/logs/restored-worker.json" || fail "persisted Worker is unavailable after Runtime restart"
assert_clean_logs
# Prove that this run used only disposable state paths.
grep -Fq "$root/data/yoi/server/server.db" "$root/logs/server.log" || \
fail "Server log does not identify the isolated database"
if grep -Fq '/home/hare/.local/share/yoi' "$root/logs/server.log" "$root/logs/runtime.log"; then
fail "startup logs reference a non-isolated Yoi data path"
fi
printf 'isolated-startup-smoke: PASS (workspace=%s, server=%s, runtime=%s)\n' \
"$workspace_id" "$server_url" "$runtime_url"