From cf6ec42c7ba2283878fef8127e969469ae933b15 Mon Sep 17 00:00:00 2001 From: CL Kao Date: Sat, 13 Jun 2026 13:33:10 -0700 Subject: [PATCH] test(live): fan out the claude shared scenarios in parallel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four claude shared scenarios (gate / rejection-flow / 3-cycle-escalation / merge-hook) each run a multi-minute live claude journey. Serially that makes the claude-opus lane the SUM of the four (~27m) — and since the live job runs the host lanes as parallel matrix jobs, claude-opus is the whole job's long pole. t.Parallel collapses it toward the slowest single scenario (~9m). The cheap canary stays: TestLiveEnsignCycle runs as an earlier step (separate go test invocation), so a systemic failure (auth/install) still fails fast before this fan-out spends 4x concurrent API. Isolation for the concurrent sessions: each scenario already gets its own workflowRoot (t.TempDir); run() now also gives each its own CLAUDE_CONFIG_DIR nested under the runner's base dir (via withClaudeConfigDir, a non-mutating copy), so the parallel claude sessions never share config/session state. The CI artifact upload widens to the per-scenario projects subdirs. codex/pi lanes are not the wall-clock bottleneck (codex ~6m, pi smoke), so they stay serial for now — same pattern applies if needed. Validated to compile (go vet -tags live); the parallel isolation gets its first live exercise on the next live run. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/runtime-live-e2e.yml | 1 + .../ensigncycle/claude_live_runner_test.go | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/.github/workflows/runtime-live-e2e.yml b/.github/workflows/runtime-live-e2e.yml index dafd611e..9e202150 100644 --- a/.github/workflows/runtime-live-e2e.yml +++ b/.github/workflows/runtime-live-e2e.yml @@ -202,6 +202,7 @@ jobs: ./live-e2e-transcript.txt ./claude-shared-scenarios-transcript.txt ${{ runner.temp }}/spacedock-claude-config/${{ matrix.model }}/projects + ${{ runner.temp }}/spacedock-claude-config/${{ matrix.model }}/*/projects live-artifacts/claude/** live-artifacts/journey-metrics/** if-no-files-found: warn diff --git a/internal/ensigncycle/claude_live_runner_test.go b/internal/ensigncycle/claude_live_runner_test.go index 763020e2..ea0ec412 100644 --- a/internal/ensigncycle/claude_live_runner_test.go +++ b/internal/ensigncycle/claude_live_runner_test.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" "time" ) @@ -58,8 +59,17 @@ type claudeLiveScenario struct { func TestLiveClaudeSharedScenarios(t *testing.T) { runner := newClaudeLiveRunner(t) + // The scenarios fan out in parallel: each is an independent multi-minute live + // claude journey, so running them serially makes the lane wall-time the SUM of + // the four (~27m on opus). t.Parallel collapses it toward the slowest single + // scenario. The cheap canary (TestLiveEnsignCycle) runs as an earlier step, so a + // systemic failure (auth/install) still fails fast before this fan-out. Each + // scenario gets its own workflowRoot (t.TempDir) and its own CLAUDE_CONFIG_DIR + // (run(), keyed by scenario name) so the concurrent sessions never share claude + // config/session state. for _, scenario := range claudeLiveScenarios(t) { t.Run(scenario.name, func(t *testing.T) { + t.Parallel() scenario.run(t, runner, scenario.sharedRuntimeScenario) }) } @@ -230,7 +240,16 @@ func (r claudeLiveRunner) run(t *testing.T, scenario sharedRuntimeScenario, work "--model", r.model, ) cmd.Dir = workflowRoot + // Per-scenario CLAUDE_CONFIG_DIR so parallel scenarios never share claude's + // session/config state. It nests under the runner's base config dir (the + // archivable CI path), so the artifact upload — which grabs the whole + // per-model config dir — still captures each scenario's projects/*.jsonl. A + // fresh slice (never a mutation of the shared r.env) keeps the parallel + // invocations race-free. cmd.Env = r.env + if base, ok := envValue(r.env, "CLAUDE_CONFIG_DIR"); ok { + cmd.Env = withClaudeConfigDir(r.env, filepath.Join(base, scenario.name)) + } // stdout carries the stream-json transcript the watcher drains for liveness; // stderr is folded into the same pipe so a launch error (e.g. a stale-token 401 @@ -294,3 +313,17 @@ func claudeLiveArtifactDir(t *testing.T, name string) string { } return dir } + +// withClaudeConfigDir returns a COPY of env with CLAUDE_CONFIG_DIR replaced by +// dir. It never mutates the input slice, so parallel scenarios sharing the +// runner's base env each derive their own isolated config dir race-free. +func withClaudeConfigDir(env []string, dir string) []string { + out := make([]string, 0, len(env)+1) + for _, e := range env { + if strings.HasPrefix(e, "CLAUDE_CONFIG_DIR=") { + continue + } + out = append(out, e) + } + return append(out, "CLAUDE_CONFIG_DIR="+dir) +}