Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion cmd/gc/worker_handle.go
Original file line number Diff line number Diff line change
Expand Up @@ -484,11 +484,17 @@ func resolvedWorkerRuntimeWithConfigAndMetadata(cityPath string, cfg *config.Cit
if err != nil {
return nil, err
}
// Reseed the city-anchored env vars (GC_CITY, GC_CITY_PATH,
// GC_CITY_RUNTIME_DIR) on top of the provider env. Without this,
// session restart paths drop the city anchor — bd, mailboxes, and
// other city-relative tooling then fail inside the spawned session.
// Regression for upstream gastownhall/gascity#101 (re-opened).
sessionEnv := mergeEnv(resolved.Env, cityRuntimeEnvMapForCity(cityPath))
return &worker.ResolvedRuntime{
Command: command,
WorkDir: workDir,
Provider: firstNonEmptyGCString(info.Provider, resolved.Name),
SessionEnv: resolved.Env,
SessionEnv: sessionEnv,
Hints: runtime.Config{
WorkDir: workDir,
ReadyPromptPrefix: resolved.ReadyPromptPrefix,
Expand Down
55 changes: 55 additions & 0 deletions cmd/gc/worker_handle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,61 @@ func TestResolvedWorkerRuntimeResumesPoolSessionPreservesLaunchFlags(t *testing.
}
}

// TestResolvedWorkerRuntimeWithConfigSeedsCityRuntimeEnv is a regression
// test for upstream gastownhall/gascity#101 (re-opened): on session
// restart, the worker resolver reseeded the session env from
// resolved.Env (provider-only). That dropped the city-anchored env vars
// (GC_CITY, GC_CITY_PATH, GC_CITY_RUNTIME_DIR), so spawned/restarted
// agent sessions could not locate their city — bd commands failed,
// mailboxes resolved against the wrong path, and downstream tooling
// behaved as if no city was configured. The CLI-side defense in
// cmd/gc/main.go resolveContext (#2062) masked the symptom; this
// resolver-level fix is the root cause: the resolved runtime must
// always carry the city anchor vars so any restart path is sound.
func TestResolvedWorkerRuntimeWithConfigSeedsCityRuntimeEnv(t *testing.T) {
cityDir := t.TempDir()
gcDir := filepath.Join(cityDir, ".gc")
if err := os.MkdirAll(gcDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(gcDir, "settings.json"), []byte(`{}`), 0o644); err != nil {
t.Fatal(err)
}
claude := config.BuiltinProviders()["claude"]
cfg := &config.City{
Workspace: config.Workspace{Name: "test-city"},
Agents: []config.Agent{{
Name: "worker",
Provider: "claude",
}},
Providers: map[string]config.ProviderSpec{
"claude": claude,
},
}

resolved, err := resolvedWorkerRuntimeWithConfig(cityDir, cfg, session.Info{
Template: "worker",
WorkDir: cityDir,
}, "")
if err != nil {
t.Fatalf("resolvedWorkerRuntimeWithConfig: %v", err)
}
if resolved == nil {
t.Fatal("resolvedWorkerRuntimeWithConfig() = nil")
}

if got := resolved.SessionEnv["GC_CITY"]; got != cityDir {
t.Errorf("SessionEnv[GC_CITY] = %q, want %q", got, cityDir)
}
if got := resolved.SessionEnv["GC_CITY_PATH"]; got != cityDir {
t.Errorf("SessionEnv[GC_CITY_PATH] = %q, want %q", got, cityDir)
}
wantRuntimeDir := filepath.Join(cityDir, ".gc", "runtime")
if got := resolved.SessionEnv["GC_CITY_RUNTIME_DIR"]; got != wantRuntimeDir {
t.Errorf("SessionEnv[GC_CITY_RUNTIME_DIR] = %q, want %q", got, wantRuntimeDir)
}
}

func TestShouldPreserveStoredRuntimeCommandForTransportRejectsExecutableOnlyMatch(t *testing.T) {
if shouldPreserveStoredRuntimeCommandForTransport(
"claude",
Expand Down
4 changes: 2 additions & 2 deletions internal/api/handler_session_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ func (s *Server) handleSessionCreate(w http.ResponseWriter, r *http.Request) {
// starts the agent process on the next tick. This avoids blocking the
// HTTP response for 10-30s while the agent boots in tmux, and lets real-world apps
// show the session in the sidebar immediately via optimistic UI.
resolvedCfg, err := resolvedSessionConfigForProvider(alias, createCtx.ExplicitName, template, title, transport, extraMeta, resolved, command, workDir, mcpServers)
resolvedCfg, err := resolvedSessionConfigForProvider(s.state.CityPath(), alias, createCtx.ExplicitName, template, title, transport, extraMeta, resolved, command, workDir, mcpServers)
if err != nil {
s.idem.unreserve(idemKey)
writeSessionManagerError(w, err)
Expand Down Expand Up @@ -364,7 +364,7 @@ func (s *Server) createProviderSession(w http.ResponseWriter, r *http.Request, s
}
}

resolvedCfg, err := resolvedSessionConfigForProvider(alias, "", template, title, transport, extraMeta, resolved, command, workDir, mcpServers)
resolvedCfg, err := resolvedSessionConfigForProvider(s.state.CityPath(), alias, "", template, title, transport, extraMeta, resolved, command, workDir, mcpServers)
if err != nil {
s.idem.unreserve(idemKey)
writeSessionManagerError(w, err)
Expand Down
3 changes: 2 additions & 1 deletion internal/api/huma_handlers_sessions_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ func (s *Server) humaHandleSessionCreate(ctx context.Context, input *SessionCrea
}
}
resolvedCfg, cfgErr := resolvedSessionConfigForProvider(
s.state.CityPath(),
alias,
explicitName,
template,
Expand Down Expand Up @@ -319,7 +320,7 @@ func (s *Server) humaCreateProviderSession(_ context.Context, store beads.Store,
}
go func() {
defer s.recoverAsRequestFailed(reqID, RequestOperationSessionCreate)
resolvedCfg, cfgErr := resolvedSessionConfigForProvider(alias, "", template, title, transport, extraMeta, resolved, command, workDir, mcpServers)
resolvedCfg, cfgErr := resolvedSessionConfigForProvider(s.state.CityPath(), alias, "", template, title, transport, extraMeta, resolved, command, workDir, mcpServers)
if cfgErr != nil {
s.emitSessionCreateFailed(reqID, "create_failed", cfgErr.Error())
return
Expand Down
4 changes: 2 additions & 2 deletions internal/api/session_resolved_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
)

func resolvedSessionConfigForProvider(
alias, explicitName, template, title, transport string,
cityPath, alias, explicitName, template, title, transport string,
metadata map[string]string,
resolved *config.ResolvedProvider,
command, workDir string,
Expand Down Expand Up @@ -47,7 +47,7 @@ func resolvedSessionConfigForProvider(
Command: firstNonEmptyString(command, resolvedCommand, resolved.Name),
WorkDir: workDir,
Provider: resolved.Name,
SessionEnv: resolved.Env,
SessionEnv: cityAnchoredSessionEnv(cityPath, resolved.Env),
Resume: session.ProviderResume{
ResumeFlag: resolved.ResumeFlag,
ResumeStyle: resolved.ResumeStyle,
Expand Down
49 changes: 49 additions & 0 deletions internal/api/session_resolved_config_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package api

import (
"path/filepath"
"testing"

"github.com/gastownhall/gascity/internal/config"
Expand Down Expand Up @@ -34,6 +35,7 @@ func TestResolvedSessionConfigForProviderBuildsNormalizedConfig(t *testing.T) {
}

cfg, err := resolvedSessionConfigForProvider(
"/tmp/test-city",
"worker",
"worker-named",
"myrig/worker",
Expand Down Expand Up @@ -92,6 +94,7 @@ func TestResolvedSessionConfigForProviderBuildsNormalizedConfig(t *testing.T) {

func TestResolvedSessionConfigForProviderRejectsNilProvider(t *testing.T) {
if _, err := resolvedSessionConfigForProvider(
"/tmp/test-city",
"worker",
"",
"myrig/worker",
Expand All @@ -107,8 +110,54 @@ func TestResolvedSessionConfigForProviderRejectsNilProvider(t *testing.T) {
}
}

// TestResolvedSessionConfigForProviderSeedsCityRuntimeEnv is a
// regression test for upstream gastownhall/gascity#101 (re-opened):
// session-create paths through the API resolver dropped the
// city-anchored env vars (GC_CITY, GC_CITY_PATH, GC_CITY_RUNTIME_DIR)
// because they only forwarded resolved.Env (provider-only). The
// spawned shell then could not locate the city, so bd, mailboxes, and
// related tooling failed. Non-conflicting provider env vars are
// preserved; this test documents the merge contract.
func TestResolvedSessionConfigForProviderSeedsCityRuntimeEnv(t *testing.T) {
cityPath := t.TempDir()
cfg, err := resolvedSessionConfigForProvider(
cityPath,
"worker",
"",
"myrig/worker",
"Worker",
"",
nil,
&config.ResolvedProvider{
Name: "stub",
Command: "/bin/echo",
Env: map[string]string{"PROVIDER_TOKEN": "ok"},
},
"",
cityPath,
nil,
)
if err != nil {
t.Fatalf("resolvedSessionConfigForProvider: %v", err)
}
if got := cfg.Runtime.SessionEnv["GC_CITY"]; got != cityPath {
t.Errorf("SessionEnv[GC_CITY] = %q, want %q", got, cityPath)
}
if got := cfg.Runtime.SessionEnv["GC_CITY_PATH"]; got != cityPath {
t.Errorf("SessionEnv[GC_CITY_PATH] = %q, want %q", got, cityPath)
}
wantRuntimeDir := filepath.Join(cityPath, ".gc", "runtime")
if got := cfg.Runtime.SessionEnv["GC_CITY_RUNTIME_DIR"]; got != wantRuntimeDir {
t.Errorf("SessionEnv[GC_CITY_RUNTIME_DIR] = %q, want %q", got, wantRuntimeDir)
}
if got := cfg.Runtime.SessionEnv["PROVIDER_TOKEN"]; got != "ok" {
t.Errorf("SessionEnv[PROVIDER_TOKEN] = %q, want %q (provider env preserved)", got, "ok")
}
}

func TestResolvedSessionConfigForProviderSkipsStoredMCPMetadataForTmuxTransport(t *testing.T) {
cfg, err := resolvedSessionConfigForProvider(
"/tmp/test-city",
"worker",
"",
"myrig/worker",
Expand Down
28 changes: 27 additions & 1 deletion internal/api/session_runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os/exec"
"strings"

"github.com/gastownhall/gascity/internal/citylayout"
"github.com/gastownhall/gascity/internal/config"
"github.com/gastownhall/gascity/internal/materialize"
"github.com/gastownhall/gascity/internal/runtime"
Expand All @@ -14,6 +15,31 @@ import (
"github.com/gastownhall/gascity/internal/worker"
)

// cityAnchoredSessionEnv returns the provider env merged with the
// city-anchored env vars (GC_CITY, GC_CITY_PATH, GC_CITY_RUNTIME_DIR).
// City anchors win on conflicts to mirror the canonical create-time
// layering in cmd/gc/template_resolve.go where the per-agent env (which
// carries the same anchors) is applied after the resolved provider env.
//
// Without these anchors, sessions spawned or restarted via the API code
// paths cannot locate their city — bd, mailboxes, and other
// city-relative tooling fail inside the spawned shell. Regression for
// upstream gastownhall/gascity#101 (re-opened).
func cityAnchoredSessionEnv(cityPath string, providerEnv map[string]string) map[string]string {
anchors := citylayout.CityRuntimeEnvMapForRuntimeDir(cityPath, citylayout.TrustedAmbientCityRuntimeDir(cityPath))
if len(providerEnv) == 0 && len(anchors) == 0 {
return nil
}
out := make(map[string]string, len(providerEnv)+len(anchors))
for k, v := range providerEnv {
out[k] = v
}
for k, v := range anchors {
out[k] = v
}
return out
}

var errAmbiguousLegacyACPTransport = errors.New("legacy session transport is ambiguous")

func (s *Server) sessionLogPaths() []string {
Expand Down Expand Up @@ -370,7 +396,7 @@ func (s *Server) resolveWorkerSessionRuntimeWithMetadata(info session.Info, _ st
Command: command,
WorkDir: firstNonEmptyString(info.WorkDir, workDir),
Provider: firstNonEmptyString(info.Provider, resolved.Name),
SessionEnv: resolved.Env,
SessionEnv: cityAnchoredSessionEnv(s.state.CityPath(), resolved.Env),
Hints: sessionResumeHints(resolved, firstNonEmptyString(workDir, info.WorkDir), mcpServers),
Resume: session.ProviderResume{
ResumeFlag: firstNonEmptyString(resolved.ResumeFlag, info.ResumeFlag),
Expand Down
Loading