diff --git a/backend/internal/daemon/lifecycle_wiring.go b/backend/internal/daemon/lifecycle_wiring.go index 5113d9dcb6..d8de21df15 100644 --- a/backend/internal/daemon/lifecycle_wiring.go +++ b/backend/internal/daemon/lifecycle_wiring.go @@ -109,6 +109,7 @@ func startSession(cfg config.Config, runtime runtimeselect.Runtime, store *sqlit Messenger: messenger, Lifecycle: lcm, DataDir: cfg.DataDir, + RunFile: cfg.RunFilePath, Logger: log, }) scmProvider, err := newGitHubSCMProvider(log) diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index 4a808c24af..ad9448b93f 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -52,6 +52,13 @@ const ( EnvIssueID = "AO_ISSUE_ID" // EnvDataDir tells a spawned agent's AO hook commands where the store lives. EnvDataDir = "AO_DATA_DIR" + // EnvRunFile tells a spawned agent's AO hook commands where the daemon's + // running.json handshake lives, so hook delivery finds the SAME daemon that + // spawned the session even when AO runs with a non-default state location. + // Without it, `ao hooks` falls back to ~/.ao/running.json and every + // callback fails against a stale or missing default run file (observed + // 2026-07-04: all sessions no_signal, permission dialogs undetected). + EnvRunFile = "AO_RUN_FILE" ) // hookBinaryName is the executable name the workspace hook commands invoke: @@ -111,6 +118,7 @@ type Manager struct { messenger ports.AgentMessenger lcm lifecycleRecorder dataDir string + runFile string clock func() time.Time // lookPath is exec.LookPath in production; tests substitute a stub so // they don't need real binaries on PATH. Returns ports.ErrAgentBinaryNotFound @@ -134,6 +142,9 @@ type Deps struct { // DataDir is exported to spawned agents as AO_DATA_DIR so their hook // commands can open the same store. DataDir string + // RunFile is exported to spawned agents as AO_RUN_FILE so their hook + // commands locate this daemon's running.json rather than the default. + RunFile string Clock func() time.Time // LookPath overrides exec.LookPath for the pre-launch agent-binary check. // Production wiring leaves this nil and the manager defaults to @@ -159,6 +170,7 @@ func New(d Deps) *Manager { messenger: d.Messenger, lcm: d.Lifecycle, dataDir: d.DataDir, + runFile: d.RunFile, clock: d.Clock, lookPath: d.LookPath, executable: d.Executable, @@ -1088,8 +1100,8 @@ Keep branch names within your session's branch namespace so AO can track every P // spawnEnv builds the runtime environment: the per-project env vars first, then // the AO-internal vars last so they always win (a project cannot override // AO_SESSION_ID and friends). -func spawnEnv(id domain.SessionID, project domain.ProjectID, issue domain.IssueID, dataDir string, projectEnv map[string]string) map[string]string { - env := make(map[string]string, len(projectEnv)+4) +func spawnEnv(id domain.SessionID, project domain.ProjectID, issue domain.IssueID, dataDir, runFile string, projectEnv map[string]string) map[string]string { + env := make(map[string]string, len(projectEnv)+5) for k, v := range projectEnv { env[k] = v } @@ -1097,6 +1109,9 @@ func spawnEnv(id domain.SessionID, project domain.ProjectID, issue domain.IssueI env[EnvProjectID] = string(project) env[EnvIssueID] = string(issue) env[EnvDataDir] = dataDir + if runFile != "" { + env[EnvRunFile] = runFile + } return env } @@ -1108,7 +1123,7 @@ func spawnEnv(id domain.SessionID, project domain.ProjectID, issue domain.IssueI // When the pin cannot be applied the inherited PATH is kept and a warning is // logged so the degradation isn't silent. func (m *Manager) runtimeEnv(id domain.SessionID, project domain.ProjectID, issue domain.IssueID, projectEnv map[string]string) map[string]string { - env := spawnEnv(id, project, issue, m.dataDir, projectEnv) + env := spawnEnv(id, project, issue, m.dataDir, m.runFile, projectEnv) path, err := HookPATH(m.executable, os.Getenv, projectEnv) if err != nil { m.logger.Warn("session PATH not pinned to the daemon binary; `ao hooks` callbacks may resolve to a different ao and activity tracking will stall", diff --git a/backend/internal/session_manager/provision_test.go b/backend/internal/session_manager/provision_test.go index d82d6e351b..36799e14cc 100644 --- a/backend/internal/session_manager/provision_test.go +++ b/backend/internal/session_manager/provision_test.go @@ -11,10 +11,11 @@ import ( ) func TestSpawnEnvProjectVarsCannotOverrideInternal(t *testing.T) { - env := spawnEnv("mer-1", "mer", "issue-9", "/data", map[string]string{ + env := spawnEnv("mer-1", "mer", "issue-9", "/data", "/state/running.json", map[string]string{ "FOO": "bar", EnvSessionID: "hacked", // a project must not override AO-internal vars EnvProjectID: "hacked", + EnvRunFile: "hacked", }) if env["FOO"] != "bar" { t.Fatalf("FOO = %q, want bar", env["FOO"]) @@ -25,6 +26,18 @@ func TestSpawnEnvProjectVarsCannotOverrideInternal(t *testing.T) { if env[EnvProjectID] != "mer" { t.Fatalf("AO_PROJECT_ID = %q, want mer (internal wins)", env[EnvProjectID]) } + if env[EnvRunFile] != "/state/running.json" { + t.Fatalf("AO_RUN_FILE = %q, want /state/running.json (internal wins; hook delivery must find THIS daemon)", env[EnvRunFile]) + } +} + +// A daemon with no resolved run-file path must not export an empty AO_RUN_FILE +// (empty would override a useful inherited value with garbage). +func TestSpawnEnvOmitsEmptyRunFile(t *testing.T) { + env := spawnEnv("mer-1", "mer", "", "/data", "", nil) + if _, ok := env[EnvRunFile]; ok { + t.Fatalf("AO_RUN_FILE exported despite empty run-file path") + } } func TestHookPATH(t *testing.T) {