Add per-run container lifecycle implementation plan
Assisted-by: Claude Opus 4.7 via Claude Code
change srpvvypuplqyxxsvzyknuoqromkylmoq
commit 8bf4d07eab7302e2933aeaf6bcb070bba4d52cb0
author Alpha Chen <alpha@kejadlen.dev>
date
parent xtlrlkut
diff --git a/docs/plans/2026-05-04-per-run-container-lifecycle.md b/docs/plans/2026-05-04-per-run-container-lifecycle.md
new file mode 100644
index 0000000..34520a5
--- /dev/null
+++ b/docs/plans/2026-05-04-per-run-container-lifecycle.md
@@ -0,0 +1,902 @@
+# Per-run container lifecycle implementation plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` to implement this plan task-by-task. For each commit, use the `commit` skill (jj-based; see user CLAUDE.md). For each test cycle, use `superpowers:test-driven-development`.
+
+**Goal:** Land `lpmoszxo` (per-run container lifecycle) and `knmkqkvx` (route `sh` through docker exec) end-to-end, gated behind a `--executor host|docker` flag on `quire ci run` so host mode remains available for A/B comparison.
+
+**Architecture:** See `docs/plans/2026-05-04-per-run-container-lifecycle-design.md`. Summary:
+
+- `Run::execute` gains `workspace: &Path` and `executor: Executor` parameters.
+- Workspace materialization (`git archive | tar -x` to `$XDG_CACHE_HOME/quire/runs/<repo>/<run-id>/workspace/`) happens in the CLI layer before `Run::execute` is called.
+- Host mode runs `sh` locally with `cwd` defaulted to the workspace.
+- Docker mode builds `.quire/Dockerfile`, starts a long-lived container, dispatches each `sh` through `docker exec`, and tears down via RAII.
+
+**Tech stack:** Rust 2024, mlua + Fennel, std::process::Command (no new crates), jiff for timestamps, `xdg` crate (already in deps? — verify in Task 1).
+
+---
+
+## Pre-flight check
+
+Before Task 1, verify the design doc commit landed and the working copy is clean:
+
+```bash
+jj log -r 'main..@ | @' --limit 5
+```
+
+Expected: design doc commit (`Document per-run container lifecycle design`) on top of `main`, with `@` either empty or holding new edits.
+
+---
+
+## Phase 1 — Foundations (no docker required)
+
+### Task 1: Plumb `Executor` enum and `workspace` parameter through `Run::execute`
+
+This is a no-op signature change: `Executor::Host` is the only variant, and the workspace path is accepted but unused. Goal is to update every call site once so later tasks add behavior without touching signatures.
+
+**Files:**
+- Modify: `src/ci/mod.rs` — re-export `Executor`.
+- Modify: `src/ci/run.rs` — define `Executor` enum, update `Run::execute` signature, update all 14 in-module test call sites.
+- Modify: `src/bin/quire/commands/ci.rs:79` — pass `Executor::Host` and a workspace `&Path`.
+
+**Step 1: Add the enum**
+
+```rust
+// src/ci/run.rs (top, near RunState)
+/// The execution mode for a run. Host runs `sh` directly on the host.
+/// Docker materializes a container and routes `sh` through `docker exec`.
+#[derive(Debug, Clone)]
+pub enum Executor {
+    Host,
+    // Docker variant added in Task 5.
+}
+```
+
+**Step 2: Update `Run::execute` signature**
+
+Change to:
+```rust
+pub fn execute(
+    mut self,
+    pipeline: Pipeline,
+    secrets: HashMap<String, SecretString>,
+    git_dir: &std::path::Path,
+    workspace: &std::path::Path,
+    executor: Executor,
+) -> Result<HashMap<String, Vec<ShOutput>>>
+```
+
+Body unchanged. The `workspace` and `executor` parameters are not yet read.
+
+**Step 3: Update test call sites**
+
+Each test in `src/ci/run.rs` that calls `run.execute(pipeline, ..., Path::new("."))` becomes:
+```rust
+let workspace = _dir.path().join("ws");
+fs_err::create_dir_all(&workspace).expect("mkdir workspace");
+run.execute(pipeline, secrets, Path::new("."), &workspace, Executor::Host)
+```
+
+The 14 in-module call sites all use `tmp_quire()` which gives back a `_dir: TempDir`. Hoist the workspace creation into a helper if it gets repetitive.
+
+**Step 4: Update CLI call site (`src/bin/quire/commands/ci.rs:79`)**
+
+```rust
+let workspace = tmp.path().join("workspace");
+fs_err::create_dir_all(&workspace).into_diagnostic()?;
+let exec_result = run.execute(
+    pipeline,
+    secrets,
+    &repo_path.join(".git"),
+    &workspace,
+    Executor::Host,
+);
+```
+
+**Step 5: Verify**
+
+```bash
+cargo check --tests
+cargo test --lib ci::run
+```
+
+Expected: clean compile, all 14 `Run::execute` tests pass unchanged.
+
+**Step 6: Commit** — invoke the `commit` skill. Title: `Plumb Executor and workspace through Run::execute`.
+
+---
+
+### Task 2: Materialize workspace via `git archive | tar -x` in the CLI
+
+Add the materialization step in `commands::ci::run`. `Run::execute` still ignores the workspace path; this task is exclusively about CLI plumbing and a new helper.
+
+**Files:**
+- Create: helper `materialize_workspace(git_dir: &Path, sha: &str, workspace: &Path) -> Result<()>` in `src/ci/run.rs` (or a new `src/ci/workspace.rs` if you prefer — judgment call; mirror tests put their git-shelling in `mirror.rs`, so co-locating here is fine).
+- Modify: `src/bin/quire/commands/ci.rs` — call materialization before `run.execute`.
+- Test: in-module test in `src/ci/run.rs`.
+
+**Step 1: Failing test for materialization**
+
+Use the same `git init` pattern as `src/ci/mirror.rs:259-273`. Set up a temp repo with one file committed, capture the SHA, call `materialize_workspace`, and assert the file is present in the destination.
+
+```rust
+#[test]
+fn materialize_workspace_extracts_archive_at_sha() {
+    let dir = tempfile::tempdir().expect("tempdir");
+    let src_repo = dir.path().join("src");
+    fs_err::create_dir_all(&src_repo).expect("mkdir src");
+
+    let env_vars: [(&str, &str); 6] = [
+        ("GIT_AUTHOR_NAME", "test"),
+        ("GIT_AUTHOR_EMAIL", "test@test"),
+        ("GIT_COMMITTER_NAME", "test"),
+        ("GIT_COMMITTER_EMAIL", "test@test"),
+        ("GIT_CONFIG_GLOBAL", "/dev/null"),
+        ("GIT_CONFIG_SYSTEM", "/dev/null"),
+    ];
+
+    for cmd in [
+        vec!["init", "-b", "main"],
+        vec!["commit", "--allow-empty", "-m", "initial"],
+    ] {
+        let out = std::process::Command::new("git")
+            .args(&cmd)
+            .current_dir(&src_repo)
+            .envs(env_vars)
+            .output()
+            .expect("run git");
+        assert!(out.status.success());
+    }
+    fs_err::write(src_repo.join("hello.txt"), "hi\n").expect("write");
+    for cmd in [vec!["add", "."], vec!["commit", "-m", "add file"]] {
+        std::process::Command::new("git")
+            .args(&cmd)
+            .current_dir(&src_repo)
+            .envs(env_vars)
+            .output()
+            .expect("run git");
+    }
+    let sha_out = std::process::Command::new("git")
+        .args(["rev-parse", "HEAD"])
+        .current_dir(&src_repo)
+        .envs(env_vars)
+        .output()
+        .expect("rev-parse");
+    let sha = String::from_utf8(sha_out.stdout).unwrap().trim().to_string();
+
+    let workspace = dir.path().join("ws");
+    materialize_workspace(&src_repo.join(".git"), &sha, &workspace).expect("materialize");
+    assert_eq!(fs_err::read_to_string(workspace.join("hello.txt")).unwrap(), "hi\n");
+}
+```
+
+**Step 2: Run, verify failure**
+
+`cargo test --lib materialize_workspace_extracts_archive_at_sha` — expect failure (function doesn't exist).
+
+**Step 3: Implement**
+
+```rust
+/// Materialize a working tree at `sha` into `workspace` via
+/// `git archive | tar -x`. The workspace dir must exist and be empty.
+pub(crate) fn materialize_workspace(
+    git_dir: &Path,
+    sha: &str,
+    workspace: &Path,
+) -> Result<()> {
+    use std::process::{Command, Stdio};
+
+    fs_err::create_dir_all(workspace)?;
+
+    let mut archive = Command::new("git")
+        .arg("--git-dir")
+        .arg(git_dir)
+        .args(["archive", sha])
+        .stdout(Stdio::piped())
+        .spawn()?;
+    let archive_stdout = archive.stdout.take().expect("piped stdout");
+
+    let mut tar = Command::new("tar")
+        .args(["-x", "-C"])
+        .arg(workspace)
+        .stdin(Stdio::from(archive_stdout))
+        .spawn()?;
+
+    let archive_status = archive.wait()?;
+    let tar_status = tar.wait()?;
+    if !archive_status.success() || !tar_status.success() {
+        return Err(Error::WorkspaceMaterializationFailed {
+            // Wire `source` in Task 3 once the variant exists; for now
+            // use a placeholder Error::Io.
+            source: std::io::Error::other(format!(
+                "git archive exited {archive_status}, tar exited {tar_status}"
+            )),
+        });
+    }
+    Ok(())
+}
+```
+
+NOTE: `Error::WorkspaceMaterializationFailed` doesn't exist yet — Task 3 adds it. For this task, surface failures via the existing `Error::Io` (or `Error::Git` if more apt). Task 3 swaps it.
+
+**Step 4: Run test, verify passes**
+
+`cargo test --lib materialize_workspace_extracts_archive_at_sha` — expect pass.
+
+**Step 5: Wire into CLI**
+
+In `src/bin/quire/commands/ci.rs`, before `run.execute(...)`:
+
+```rust
+let workspace = tmp.path().join("workspace");
+quire::ci::materialize_workspace(&repo_path.join(".git"), &commit.sha, &workspace)
+    .into_diagnostic()?;
+```
+
+(Re-export `materialize_workspace` from `src/ci/mod.rs`.)
+
+**Step 6: Smoke test**
+
+```bash
+cargo run -- ci run
+```
+
+Expected: works as before — but workspace is now populated. Add a `ls $TMPDIR/.../workspace` check via `eprintln!` or just trust the unit test.
+
+**Step 7: Commit** — invoke `commit` skill. Title: `Materialize workspace via git archive before run`.
+
+---
+
+### Task 3: Add new error variants
+
+**Files:**
+- Modify: `src/error.rs` (or wherever `Error` is defined).
+- Modify: `src/ci/run.rs` — swap the placeholder `Error::Io` in `materialize_workspace` for the new variant.
+
+**Step 1: Locate the Error enum**
+
+`grep -n "pub enum Error" src/error.rs src/lib.rs` — find the canonical definition.
+
+**Step 2: Add three variants**
+
+```rust
+#[error("workspace materialization failed: {source}")]
+WorkspaceMaterializationFailed { source: std::io::Error },
+
+#[error("image build failed: {source}")]
+ImageBuildFailed { source: std::io::Error },
+
+#[error("container start failed: {source}")]
+ContainerStartFailed { source: std::io::Error },
+```
+
+(Match the existing thiserror/miette idiom in `src/error.rs`. If the project uses `Diagnostic` with codes, follow that pattern.)
+
+**Step 3: Rewire `materialize_workspace`**
+
+Swap the placeholder error in Task 2's implementation for `Error::WorkspaceMaterializationFailed`.
+
+**Step 4: Failing test for the error class**
+
+```rust
+#[test]
+fn materialize_workspace_errors_on_unknown_sha() {
+    let dir = tempfile::tempdir().expect("tempdir");
+    let src_repo = dir.path().join("src");
+    fs_err::create_dir_all(&src_repo).expect("mkdir");
+    // ... git init -b main in src_repo (use the env_vars block from Task 2) ...
+
+    let workspace = dir.path().join("ws");
+    let err = materialize_workspace(
+        &src_repo.join(".git"),
+        "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
+        &workspace,
+    )
+    .expect_err("expected failure on unknown SHA");
+    assert!(matches!(err, Error::WorkspaceMaterializationFailed { .. }));
+}
+```
+
+**Step 5: Run, verify pass**
+
+**Step 6: Commit** — Title: `Add container-lifecycle error variants`.
+
+---
+
+### Task 4: Use workspace as default `cwd` in host mode
+
+`Cmd::run` currently inherits the parent process's CWD when `opts.cwd` is `None`. Host mode should default to the materialized workspace.
+
+**Files:**
+- Modify: `src/ci/runtime.rs` — `Runtime::sh` passes a workspace fallback into `Cmd::run` opts.
+- Modify: `src/ci/run.rs` — `Run::execute` passes `workspace` into the runtime.
+- Test: extend an existing host-mode test to confirm default cwd is the workspace.
+
+**Step 1: Failing test**
+
+```rust
+// src/ci/run.rs tests
+#[test]
+fn host_mode_defaults_cwd_to_workspace() {
+    let (_dir, quire) = tmp_quire();
+    let runs = test_runs(&quire);
+    let run = runs.create(&test_meta()).expect("create");
+
+    let workspace = quire.base_dir().join("ws");
+    fs_err::create_dir_all(&workspace).expect("mkdir ws");
+    fs_err::write(workspace.join("marker"), "x").expect("write");
+
+    let pipeline = load(
+        r#"(local ci (require :quire.ci))
+(ci.job :pwd [:quire/push] (fn [{: sh}] (sh ["ls"])))"#,
+    );
+
+    let outputs = run
+        .execute(pipeline, HashMap::new(), Path::new("."), &workspace, Executor::Host)
+        .expect("execute");
+    let pwd = &outputs["pwd"];
+    assert!(pwd[0].stdout.contains("marker"), "expected workspace ls, got: {}", pwd[0].stdout);
+}
+```
+
+**Step 2: Run, verify failure**
+
+Currently `ls` runs in the parent process's CWD, so `marker` won't be in stdout.
+
+**Step 3: Implement workspace fallback**
+
+Plumb `workspace: PathBuf` into `Runtime::new` and store it. In `Runtime::sh`, when `opts.cwd` is `None`, set it to the workspace:
+
+```rust
+pub(super) fn sh(&self, cmd: Cmd, mut opts: ShOpts) -> crate::Result<ShOutput> {
+    if opts.cwd.is_none() {
+        opts.cwd = Some(self.workspace.to_string_lossy().into_owned());
+    }
+    let output = cmd.run(opts)?;
+    // ... rest unchanged
+}
+```
+
+`Run::execute` passes `workspace.to_path_buf()` into `Runtime::new`.
+
+**Step 4: Run test, verify pass**
+
+**Step 5: Audit — does this break existing tests?**
+
+The `sh_honors_cwd` test in `runtime.rs` sets `cwd` explicitly, so no break. Tests that use `pwd` or rely on parent CWD via implicit inheritance might break. Run `cargo test --lib` and inspect failures.
+
+**Step 6: Commit** — Title: `Default sh cwd to workspace in host mode`.
+
+---
+
+## Phase 2 — Docker primitives
+
+### Task 5: `docker_build` helper
+
+Implements `docker build -f <workspace>/.quire/Dockerfile -t quire-ci/<repo>:<run-id> <workspace>`.
+
+**Files:**
+- Create: `src/ci/docker.rs` — module for docker shell-out helpers.
+- Modify: `src/ci/mod.rs` — `pub(crate) mod docker;`.
+- Test: in-module test gated behind a `docker` runtime check.
+
+**Step 1: Decide on the docker-availability gate**
+
+Helper at top of `docker.rs`:
+```rust
+/// Returns true if `docker info` exits 0 within ~3s. Tests that
+/// require docker `return;` early when this returns false.
+pub(crate) fn is_available() -> bool {
+    std::process::Command::new("docker")
+        .arg("info")
+        .stdout(std::process::Stdio::null())
+        .stderr(std::process::Stdio::null())
+        .status()
+        .map(|s| s.success())
+        .unwrap_or(false)
+}
+```
+
+**Step 2: Failing test** (gated)
+
+```rust
+#[test]
+#[ignore = "requires docker"]
+fn docker_build_succeeds_with_minimal_dockerfile() {
+    if !is_available() {
+        eprintln!("docker not available, skipping");
+        return;
+    }
+    let dir = tempfile::tempdir().expect("tempdir");
+    let workspace = dir.path();
+    fs_err::create_dir_all(workspace.join(".quire")).expect("mkdir");
+    fs_err::write(
+        workspace.join(".quire/Dockerfile"),
+        "FROM alpine:3.19\nRUN echo built\n",
+    )
+    .expect("write Dockerfile");
+
+    let tag = "quire-ci/test-task5:test";
+    docker_build(workspace, tag).expect("build should succeed");
+
+    // Cleanup.
+    let _ = std::process::Command::new("docker")
+        .args(["rmi", tag])
+        .output();
+}
+```
+
+**Step 3: Run with `--ignored`**
+
+```bash
+cargo test --lib docker_build_succeeds_with_minimal_dockerfile -- --ignored
+```
+
+Expect failure (function doesn't exist).
+
+**Step 4: Implement**
+
+```rust
+pub(crate) fn docker_build(workspace: &Path, tag: &str) -> Result<()> {
+    let dockerfile = workspace.join(".quire/Dockerfile");
+    let output = std::process::Command::new("docker")
+        .arg("build")
+        .arg("-f")
+        .arg(&dockerfile)
+        .arg("-t")
+        .arg(tag)
+        .arg(workspace)
+        .output()
+        .map_err(|e| Error::ImageBuildFailed { source: e })?;
+    if !output.status.success() {
+        return Err(Error::ImageBuildFailed {
+            source: std::io::Error::other(String::from_utf8_lossy(&output.stderr).into_owned()),
+        });
+    }
+    Ok(())
+}
+```
+
+**Step 5: Run, verify pass**
+
+**Step 6: Failing-build test** (gated)
+
+```rust
+#[test]
+#[ignore = "requires docker"]
+fn docker_build_errors_on_bad_dockerfile() {
+    if !is_available() { return; }
+    let dir = tempfile::tempdir().expect("tempdir");
+    let workspace = dir.path();
+    fs_err::create_dir_all(workspace.join(".quire")).expect("mkdir");
+    fs_err::write(workspace.join(".quire/Dockerfile"), "GARBAGE\n").expect("write");
+
+    let err = docker_build(workspace, "quire-ci/test-task5-bad:test").expect_err("should fail");
+    assert!(matches!(err, Error::ImageBuildFailed { .. }));
+}
+```
+
+Run, verify pass.
+
+**Step 7: Commit** — Title: `Add docker_build helper`.
+
+---
+
+### Task 6: `docker_run` helper + `ContainerSession` with Drop
+
+`docker run -d --rm --mount type=bind,src=<workspace>,dst=/work -w /work <tag> sleep infinity`. Capture container ID. Drop calls `docker stop --time 5 <id>`.
+
+**Files:**
+- Modify: `src/ci/docker.rs`.
+
+**Step 1: Failing test for `docker_run`**
+
+```rust
+#[test]
+#[ignore = "requires docker"]
+fn docker_run_starts_container_and_returns_id() {
+    if !is_available() { return; }
+    let dir = tempfile::tempdir().expect("tempdir");
+    let workspace = dir.path();
+    // Use alpine directly — no build step needed for this test.
+    let tag = "alpine:3.19";
+
+    let session = ContainerSession::start(workspace, tag).expect("start");
+    assert!(!session.container_id.is_empty());
+    assert_eq!(session.container_id.len(), 64); // docker ID hex length
+
+    // session drops here, docker stop runs.
+}
+```
+
+**Step 2: Implement `ContainerSession`**
+
+```rust
+pub(crate) struct ContainerSession {
+    pub(crate) container_id: String,
+    pub(crate) image_tag: String,
+    pub(crate) container_started_at: jiff::Timestamp,
+    // run_dir + container.yml writer added in Task 7.
+}
+
+impl ContainerSession {
+    pub(crate) fn start(workspace: &Path, image_tag: &str) -> Result<Self> {
+        let mount = format!(
+            "type=bind,src={},dst=/work",
+            workspace.to_string_lossy()
+        );
+        let output = std::process::Command::new("docker")
+            .args(["run", "-d", "--rm", "--mount"])
+            .arg(&mount)
+            .args(["-w", "/work", image_tag, "sleep", "infinity"])
+            .output()
+            .map_err(|e| Error::ContainerStartFailed { source: e })?;
+        if !output.status.success() {
+            return Err(Error::ContainerStartFailed {
+                source: std::io::Error::other(
+                    String::from_utf8_lossy(&output.stderr).into_owned(),
+                ),
+            });
+        }
+        let container_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
+        Ok(Self {
+            container_id,
+            image_tag: image_tag.to_string(),
+            container_started_at: jiff::Timestamp::now(),
+        })
+    }
+}
+
+impl Drop for ContainerSession {
+    fn drop(&mut self) {
+        let result = std::process::Command::new("docker")
+            .args(["stop", "--time", "5"])
+            .arg(&self.container_id)
+            .output();
+        if let Err(e) = result {
+            tracing::error!(container_id = %self.container_id, error = %e, "docker stop failed");
+        } else if let Ok(out) = result {
+            if !out.status.success() {
+                tracing::error!(
+                    container_id = %self.container_id,
+                    stderr = %String::from_utf8_lossy(&out.stderr),
+                    "docker stop returned non-zero",
+                );
+            }
+        }
+    }
+}
+```
+
+**Step 3: Run, verify pass**
+
+**Step 4: Verify cleanup**
+
+After the test, `docker ps -a --filter id=<id>` should show no container (the `--rm` flag handled removal once stop completed).
+
+Add a follow-up test:
+```rust
+#[test]
+#[ignore = "requires docker"]
+fn container_session_drop_stops_container() {
+    if !is_available() { return; }
+    let dir = tempfile::tempdir().expect("tempdir");
+    let id = {
+        let session = ContainerSession::start(dir.path(), "alpine:3.19").expect("start");
+        session.container_id.clone()
+    }; // drop here
+
+    // Give docker a moment to clean up.
+    std::thread::sleep(std::time::Duration::from_millis(500));
+    let out = std::process::Command::new("docker")
+        .args(["ps", "-a", "-q", "--filter"])
+        .arg(format!("id={id}"))
+        .output()
+        .expect("docker ps");
+    assert!(
+        String::from_utf8_lossy(&out.stdout).trim().is_empty(),
+        "container should be removed after drop",
+    );
+}
+```
+
+**Step 5: Commit** — Title: `Add ContainerSession with RAII teardown`.
+
+---
+
+### Task 7: `container.yml` writes
+
+Persist build/start/stop timestamps. Each write is atomic via `write_yaml`.
+
+**Files:**
+- Modify: `src/ci/run.rs` — define `ContainerYaml` (or place in `docker.rs`).
+- Modify: `src/ci/docker.rs` — `ContainerSession` accepts a run-dir path and writes timestamps.
+
+**Step 1: Define the schema**
+
+```rust
+// src/ci/run.rs
+#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
+pub struct ContainerRecord {
+    pub image_tag: Option<String>,
+    pub container_id: Option<String>,
+    pub build_started_at: Option<jiff::Timestamp>,
+    pub build_finished_at: Option<jiff::Timestamp>,
+    pub container_started_at: Option<jiff::Timestamp>,
+    pub container_stopped_at: Option<jiff::Timestamp>,
+}
+
+impl Run {
+    pub(crate) fn write_container_record(&self, rec: &ContainerRecord) -> Result<()> {
+        write_yaml(&self.path().join("container.yml"), rec)
+    }
+    pub(crate) fn read_container_record(&self) -> Result<ContainerRecord> {
+        read_yaml(&self.path().join("container.yml"))
+    }
+}
+```
+
+**Step 2: Failing test**
+
+```rust
+#[test]
+fn container_record_round_trips_through_yaml() {
+    let (_dir, quire) = tmp_quire();
+    let runs = test_runs(&quire);
+    let run = runs.create(&test_meta()).expect("create");
+
+    let now = jiff::Timestamp::now();
+    let rec = ContainerRecord {
+        image_tag: Some("quire-ci/test:abc".into()),
+        container_id: Some("9f3b8a72c1d4".into()),
+        build_started_at: Some(now),
+        ..Default::default()
+    };
+    run.write_container_record(&rec).expect("write");
+    let read = run.read_container_record().expect("read");
+    assert_eq!(read.image_tag, Some("quire-ci/test:abc".into()));
+    assert_eq!(read.container_id, Some("9f3b8a72c1d4".into()));
+}
+```
+
+**Step 3: Run, verify pass**
+
+(Should be straightforward — leveraging existing `write_yaml`/`read_yaml`.)
+
+**Step 4: Wire `ContainerSession` to write timestamps**
+
+`ContainerSession::start` accepts `&Run` (or a `WriteContainerYaml` callback) and writes `container_started_at` after a successful `docker run`. `Drop` writes `container_stopped_at` before calling `docker stop`. Build timestamps come from a separate path (Task 8 sequence).
+
+This changes `ContainerSession::start`'s signature. Update the Task 6 tests accordingly — they can pass an option/callback that writes to a tempfile.
+
+**Step 5: Commit** — Title: `Persist container metadata to container.yml`.
+
+---
+
+## Phase 3 — Wire docker mode into the runtime
+
+### Task 8: Add `Executor::Docker` variant; build/start sequence in `Run::execute`
+
+Now the host-only `Executor` enum gains a docker variant, and `Run::execute` orchestrates build → start → run → drop.
+
+**Files:**
+- Modify: `src/ci/run.rs` — `Executor::Docker { tag_prefix: String }` (or similar opts struct).
+- Modify: `src/ci/run.rs` — `Run::execute` branches on `executor`.
+
+**Step 1: Extend `Executor`**
+
+```rust
+pub enum Executor {
+    Host,
+    Docker, // opts struct can land later if needed
+}
+```
+
+**Step 2: In `Run::execute`, branch the setup**
+
+After the existing `Runtime` construction, before the topo loop:
+
+```rust
+let _container = match executor {
+    Executor::Host => None,
+    Executor::Docker => {
+        let mut rec = ContainerRecord::default();
+        let tag = format!("quire-ci/{}:{}", repo_segment, self.id);
+        rec.build_started_at = Some(Timestamp::now());
+        self.write_container_record(&rec)?;
+
+        crate::ci::docker::docker_build(workspace, &tag)?;
+        rec.image_tag = Some(tag.clone());
+        rec.build_finished_at = Some(Timestamp::now());
+        self.write_container_record(&rec)?;
+
+        let session = crate::ci::docker::ContainerSession::start(workspace, &tag)?;
+        rec.container_id = Some(session.container_id.clone());
+        rec.container_started_at = Some(session.container_started_at);
+        self.write_container_record(&rec)?;
+
+        Some(session)
+    }
+};
+```
+
+`repo_segment` is derived from the run-dir path (or from `meta`-driven repo identity). Sanitize `/` → `_`.
+
+**Step 3: Stash session on `Runtime`**
+
+Add an `executor: ExecutorRuntime` field to `Runtime`:
+```rust
+pub(super) enum ExecutorRuntime {
+    Host,
+    Docker(crate::ci::docker::ContainerSession),
+}
+```
+
+Pass into `Runtime::new`. The drop semantics work because `Runtime` owns the session; when `Run::execute` returns, `Runtime` drops, session drops, container is stopped.
+
+**Step 4: Failing integration test** (gated)
+
+```rust
+#[test]
+#[ignore = "requires docker"]
+fn execute_docker_mode_runs_sh_in_container() {
+    if !crate::ci::docker::is_available() { return; }
+    // Set up a real git repo, materialize, and execute a pipeline in docker.
+    // ... (use the same git-init pattern as Task 2) ...
+    // Pipeline: (sh ["uname" "-a"]) — assert output contains "Linux"
+    //           even if host is macOS.
+}
+```
+
+**Step 5: Implement, run, verify**
+
+**Step 6: Commit** — Title: `Build and run the per-run container`.
+
+---
+
+### Task 9: Route `(sh ...)` through `docker exec`
+
+`Runtime::sh` rewrites the command when `executor: Docker` is active.
+
+**Files:**
+- Modify: `src/ci/runtime.rs` — `Runtime::sh` and `Cmd::into` (or a new method) emit `docker exec` argv.
+
+**Step 1: Faked-dispatcher unit test**
+
+A unit test that doesn't need real docker — points the executor at a shim `docker` script in a temp `$PATH` that just records its argv.
+
+```rust
+#[test]
+fn runtime_sh_in_docker_mode_invokes_docker_exec() {
+    let dir = tempfile::tempdir().expect("tempdir");
+    let bin = dir.path().join("bin");
+    fs_err::create_dir_all(&bin).expect("mkdir bin");
+
+    // Shim that prints its argv to stdout (one per line) and exits 0.
+    let shim = bin.join("docker");
+    fs_err::write(&shim, "#!/bin/sh\nfor a in \"$@\"; do echo \"$a\"; done\n").expect("write shim");
+    use std::os::unix::fs::PermissionsExt;
+    fs_err::set_permissions(&shim, fs_err::Permissions::from_mode(0o755)).expect("chmod");
+
+    // Construct an ExecutorRuntime::Docker with a synthetic ContainerSession.
+    // ... assert stdout contains exec, container-id, the program, and args ...
+}
+```
+
+(The synthetic `ContainerSession` requires a constructor that doesn't shell out — add a `pub(crate) fn for_test(id: String) -> Self`.)
+
+**Step 2: Implement docker-exec dispatch**
+
+In `Runtime::sh`, before calling `Cmd::run(opts)`:
+
+```rust
+let cmd = match &self.executor {
+    ExecutorRuntime::Host => cmd,
+    ExecutorRuntime::Docker(session) => cmd.wrap_in_docker_exec(&session.container_id, &opts),
+};
+```
+
+`wrap_in_docker_exec` constructs a new `Cmd::Argv` with prefix `["docker", "exec", "-i", "-w", &cwd, "-e", "K=V", ..., id, program, args...]`. After wrapping, clear `opts.cwd` and `opts.env` since they've been embedded into the argv.
+
+**Step 3: Run faked test, verify pass**
+
+**Step 4: Real-docker integration test** (extends Task 8's test)
+
+Confirms `(sh ["sh" "-c" "echo $HOSTNAME"])` returns the container's hostname, not the host's.
+
+**Step 5: Commit** — Title: `Route sh through docker exec in docker mode`.
+
+---
+
+## Phase 4 — CLI flag and verification
+
+### Task 10: Add `--executor host|docker` flag to `quire ci run`
+
+**Files:**
+- Modify: `src/bin/quire/main.rs` — add `--executor` to `CiCommands::Run`.
+- Modify: `src/bin/quire/commands/ci.rs` — accept `Executor`, materialize, pass through.
+
+**Step 1: Add the flag**
+
+```rust
+// src/bin/quire/main.rs
+#[derive(clap::ValueEnum, Clone, Debug)]
+enum CliExecutor { Host, Docker }
+
+CiCommands::Run {
+    sha: Option<String>,
+    #[arg(long, value_enum, default_value_t = CliExecutor::Host)]
+    executor: CliExecutor,
+}
+```
+
+**Step 2: Translate at the call site**
+
+In `main.rs:188`, pass `executor` through. In `commands::ci::run`, take a `CliExecutor` (or a translated `Executor`) and pass to `run.execute`.
+
+**Step 3: Smoke test both modes**
+
+```bash
+cargo run -- ci run                         # host
+cargo run -- ci run --executor docker       # docker (requires .quire/Dockerfile)
+```
+
+For the docker mode smoke test, ensure `.quire/Dockerfile` exists in the working repo (write a one-liner if not). The smoke test is informational — formal coverage comes from the gated integration tests in Tasks 8–9.
+
+**Step 4: Commit** — Title: `Add --executor flag to quire ci run`.
+
+---
+
+### Task 11: Verification pass
+
+**Step 1: Full test sweep**
+
+```bash
+cargo test --lib            # all unit tests
+cargo test                  # plus integration tests in tests/
+cargo test -- --ignored docker_  # gated docker tests, if docker is available
+```
+
+Expected: all green.
+
+**Step 2: Manual end-to-end**
+
+In a checkout with a working `.quire/Dockerfile`:
+1. `cargo run -- ci run` — host mode runs to completion.
+2. `cargo run -- ci run --executor docker` — docker mode builds, starts, runs jobs, tears down.
+3. `cat <run-dir>/container.yml` — confirm tag/id/timestamps populated.
+4. `docker ps -a` — confirm no leftover `quire-ci/*` containers.
+5. `docker images quire-ci/*` — confirm image is tagged with the run-id.
+
+**Step 3: Update task tracker**
+
+Mark `lpmoszxo` and `knmkqkvx` as done in ranger:
+
+```bash
+ranger task edit lpmoszxo --state done
+ranger task edit knmkqkvx --state done
+```
+
+**Step 4: Final commit if any cleanup landed**
+
+---
+
+## Out of scope (explicitly deferred)
+
+These follow-ups already exist as ranger icebox tasks; do **not** address in this plan:
+
+- `xkyuzkoz` — Resolve runtime image from declared `(ci.image ...)`.
+- `ptqsovvz` — Default base image when no declaration or Dockerfile.
+- `uvwnkwmx` — Prune old `quire-ci/*` images and run workspaces.
+- `lylszxrn` — Reconcile container orphans on quire startup.
+- `kstutwkw` — Investigate git worktree / jj workspace for materialization.
+- `xrupozur` — Streaming JSONL log persistence.
+- `zmtuqwly` — Distinct container-died failure mode.
+- `vzzrxntq` — May be archived once `--executor host` ships (decide at Task 11).
+
+---
+
+## Notes on jj usage
+
+- All commits via `jj commit` (use the `commit` skill).
+- The skill enforces `Assisted-by` trailer and avoids destructive operations.
+- Keep each task to one commit unless a task has been split mid-way for hygiene.