From bd4c709f612026e502263f808b966c97ef95566e Mon Sep 17 00:00:00 2001 From: Chris Atkins Date: Tue, 23 Jun 2026 13:23:24 +1000 Subject: [PATCH 1/4] Expose spawned agent IDs to startup hook --- clicommand/agent_start.go | 60 +++++++++++++++++++++++++---- clicommand/agent_start_test.go | 69 ++++++++++++++++++++++++++++++++-- docs/agent-start.md | 6 ++- 3 files changed, 122 insertions(+), 13 deletions(-) diff --git a/clicommand/agent_start.go b/clicommand/agent_start.go index 7a2fad21c8..0fc80a2cc7 100644 --- a/clicommand/agent_start.go +++ b/clicommand/agent_start.go @@ -26,6 +26,7 @@ import ( "github.com/buildkite/agent/v3/agent" "github.com/buildkite/agent/v3/api" "github.com/buildkite/agent/v3/core" + "github.com/buildkite/agent/v3/env" "github.com/buildkite/agent/v3/internal/agentapi" "github.com/buildkite/agent/v3/internal/awslib" "github.com/buildkite/agent/v3/internal/concurrently" @@ -1341,8 +1342,14 @@ var AgentStartCommand = cli.Command{ regReqs = append(regReqs, registerReq) } + type registeredAgentWorker struct { + agentID string + agentName string + worker *agent.AgentWorker + } + // Send register requests. - workers, err := concurrently.Map(ctx, regReqs, func(ctx context.Context, i int, regReq api.AgentRegisterRequest) (*agent.AgentWorker, error) { + registeredWorkers, err := concurrently.Map(ctx, regReqs, func(ctx context.Context, i int, regReq api.AgentRegisterRequest) (*registeredAgentWorker, error) { // Register the agent with the buildkite API reg, err := client.Register(ctx, regReq) if err != nil { @@ -1350,7 +1357,7 @@ var AgentStartCommand = cli.Command{ } // Create an agent worker to run the agent - return agent.NewAgentWorker( + worker := agent.NewAgentWorker( l.WithFields(logger.StringField("agent", reg.Name)), reg, mc, @@ -1364,12 +1371,28 @@ var AgentStartCommand = cli.Command{ SpawnIndex: i + 1, AgentStdout: os.Stdout, }, - ), nil + ) + + return ®isteredAgentWorker{ + agentID: reg.UUID, + agentName: reg.Name, + worker: worker, + }, nil }) if err != nil { return err } + workers := make([]*agent.AgentWorker, 0, len(registeredWorkers)) + registeredAgents := make([]registeredAgent, 0, len(registeredWorkers)) + for _, registeredWorker := range registeredWorkers { + workers = append(workers, registeredWorker.worker) + registeredAgents = append(registeredAgents, registeredAgent{ + ID: registeredWorker.agentID, + Name: registeredWorker.agentName, + }) + } + // Setup the agent pool that spawns agent workers pool := agent.NewAgentPool(workers, &agentConf) @@ -1377,7 +1400,7 @@ var AgentStartCommand = cli.Command{ defer agentShutdownHook(l, cfg) // Once the shutdown hook has been setup, trigger the startup hook. - if err := agentStartupHook(l, cfg); err != nil { + if err := agentStartupHook(l, cfg, registeredAgents); err != nil { return fmt.Errorf("failed to run startup hook: %w", err) } @@ -1543,18 +1566,38 @@ func (ps *poolSignals) handleLoop(ctx context.Context, signals chan os.Signal) { } } -func agentStartupHook(log logger.Logger, cfg AgentStartConfig) error { - return agentLifecycleHook("agent-startup", log, cfg) +type registeredAgent struct { + ID string + Name string +} + +func agentStartupHook(log logger.Logger, cfg AgentStartConfig, registeredAgents []registeredAgent) error { + return agentLifecycleHook("agent-startup", log, cfg, agentStartupHookEnv(registeredAgents)) +} + +func agentStartupHookEnv(registeredAgents []registeredAgent) *env.Environment { + environ := env.New() + agentIDs := make([]string, 0, len(registeredAgents)) + agentNames := make([]string, 0, len(registeredAgents)) + for _, agent := range registeredAgents { + agentIDs = append(agentIDs, agent.ID) + agentNames = append(agentNames, agent.Name) + } + + environ.Set("BUILDKITE_AGENT_IDS", strings.Join(agentIDs, ",")) + environ.Set("BUILDKITE_AGENT_NAMES", strings.Join(agentNames, ",")) + + return environ } func agentShutdownHook(log logger.Logger, cfg AgentStartConfig) { - _ = agentLifecycleHook("agent-shutdown", log, cfg) + _ = agentLifecycleHook("agent-shutdown", log, cfg, nil) } // agentLifecycleHook looks for a hook script in the hooks path // and executes it if found. Output (stdout + stderr) is streamed into the main // agent logger. Exit status failure is logged and returned for the caller to handle -func agentLifecycleHook(hookName string, log logger.Logger, cfg AgentStartConfig) error { +func agentLifecycleHook(hookName string, log logger.Logger, cfg AgentStartConfig, hookEnv *env.Environment) error { // search for hook (including .bat & .ps1 files on Windows) hooks := []string{} p, err := hook.Find(nil, cfg.HooksPath, hookName) @@ -1593,6 +1636,7 @@ func agentLifecycleHook(hookName string, log logger.Logger, cfg AgentStartConfig log.Errorf("creating shell for %q hook: %v", hookName, err) return err } + sh.Env.Merge(hookEnv) var wg sync.WaitGroup wg.Go(func() { diff --git a/clicommand/agent_start_test.go b/clicommand/agent_start_test.go index 42738d292f..5052ca9e86 100644 --- a/clicommand/agent_start_test.go +++ b/clicommand/agent_start_test.go @@ -43,6 +43,22 @@ func writeAgentHook(t *testing.T, dir, hookName, msg string) string { return filepath } +func writeAgentHookScript(t *testing.T, dir, hookName, script string) string { + t.Helper() + + filename := hookName + if runtime.GOOS == "windows" { + filename = hookName + ".bat" + } + + filepath := filepath.Join(dir, filename) + t.Logf("Creating %q", filepath) + if err := os.WriteFile(filepath, []byte(script), 0o755); err != nil { + t.Fatalf("%+v", err) + } + return filepath +} + func TestAgentStartupHook(t *testing.T) { t.Parallel() @@ -64,7 +80,7 @@ func TestAgentStartupHook(t *testing.T) { defer closer() filepath := writeAgentHook(t, hooksPath, "agent-startup", "hello world") log := logger.NewBuffer() - err := agentStartupHook(log, cfg(hooksPath)) + err := agentStartupHook(log, cfg(hooksPath), nil) if err != nil { t.Fatalf("%+v", log.Messages) } @@ -83,7 +99,7 @@ func TestAgentStartupHook(t *testing.T) { defer closer() log := logger.NewBuffer() - err := agentStartupHook(log, cfg(hooksPath)) + err := agentStartupHook(log, cfg(hooksPath), nil) if err != nil { t.Fatalf("%+v", log.Messages) } @@ -96,7 +112,7 @@ func TestAgentStartupHook(t *testing.T) { t.Parallel() log := logger.NewBuffer() - err := agentStartupHook(log, cfg("zxczxczxc")) + err := agentStartupHook(log, cfg("zxczxczxc"), nil) if err != nil { t.Fatalf("%+v", log.Messages) } @@ -133,7 +149,7 @@ func TestAgentStartupHookWithAdditionalPaths(t *testing.T) { defer additionalCloser() log := logger.NewBuffer() - err := agentStartupHook(log, cfg(hooksPath, additionalHooksPath)) + err := agentStartupHook(log, cfg(hooksPath, additionalHooksPath), nil) if err != nil { t.Fatalf("%+v", log.Messages) } @@ -148,6 +164,51 @@ func TestAgentStartupHookWithAdditionalPaths(t *testing.T) { }) } +func TestAgentStartupHookWithRegisteredAgentsEnv(t *testing.T) { + t.Parallel() + + cfg := func(hooksPath string) AgentStartConfig { + return AgentStartConfig{ + HooksPath: hooksPath, + GlobalConfig: GlobalConfig{NoColor: true}, + } + } + prompt := "$" + if runtime.GOOS == "windows" { + prompt = ">" + } + + hooksPath, closer := setupHooksPath(t) + defer closer() + + var script string + if runtime.GOOS == "windows" { + script = `@echo off +echo ids=%BUILDKITE_AGENT_IDS% +echo names=%BUILDKITE_AGENT_NAMES%` + } else { + script = `echo ids=$BUILDKITE_AGENT_IDS +echo names=$BUILDKITE_AGENT_NAMES` + } + filepath := writeAgentHookScript(t, hooksPath, "agent-startup", script) + + log := logger.NewBuffer() + err := agentStartupHook(log, cfg(hooksPath), []registeredAgent{ + {ID: "agent-123", Name: "test-agent-1"}, + {ID: "agent-456", Name: "test-agent-2"}, + }) + if err != nil { + t.Fatalf("%+v", log.Messages) + } + if diff := cmp.Diff(log.Messages, []string{ + "[info] " + prompt + " " + filepath, + "[info] ids=agent-123,agent-456", + "[info] names=test-agent-1,test-agent-2", + }); diff != "" { + t.Errorf("log.Messages diff (-got +want):\n%s", diff) + } +} + func TestAgentShutdownHook(t *testing.T) { t.Parallel() diff --git a/docs/agent-start.md b/docs/agent-start.md index f87452084a..eb35d1f4f3 100644 --- a/docs/agent-start.md +++ b/docs/agent-start.md @@ -22,6 +22,11 @@ goroutine which waits for all the workers to finish, then closes a channel. The effect is that `AgentPool` returns either `nil` once all workers have stopped without error, or the first non-nil error. +Once all workers have registered, the once-per-process `agent-startup` hook runs +before the `AgentPool` starts. The hook receives `BUILDKITE_AGENT_IDS` and +`BUILDKITE_AGENT_NAMES` as comma-separated lists in spawn order, allowing hook +scripts to identify the registered spawned agents without querying the API. + After connecting, `AgentWorker` runs two main goroutines: one periodically calls `Heartbeat`, the other more frequently calls `Ping`. `Ping` is how the worker discovers work from the API. @@ -40,4 +45,3 @@ helper goroutines: * Copying PTY output * Waiting on context cancellation in order to hard-terminate the process - From 5677240abb70a57c014a5dd38f985294aa62fde2 Mon Sep 17 00:00:00 2001 From: Chris Atkins Date: Tue, 23 Jun 2026 13:33:55 +1000 Subject: [PATCH 2/4] Tighten startup hook env coverage --- clicommand/agent_start.go | 6 ++-- clicommand/agent_start_test.go | 50 ++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/clicommand/agent_start.go b/clicommand/agent_start.go index 0fc80a2cc7..1475c3829f 100644 --- a/clicommand/agent_start.go +++ b/clicommand/agent_start.go @@ -1579,9 +1579,9 @@ func agentStartupHookEnv(registeredAgents []registeredAgent) *env.Environment { environ := env.New() agentIDs := make([]string, 0, len(registeredAgents)) agentNames := make([]string, 0, len(registeredAgents)) - for _, agent := range registeredAgents { - agentIDs = append(agentIDs, agent.ID) - agentNames = append(agentNames, agent.Name) + for _, registeredAgent := range registeredAgents { + agentIDs = append(agentIDs, registeredAgent.ID) + agentNames = append(agentNames, registeredAgent.Name) } environ.Set("BUILDKITE_AGENT_IDS", strings.Join(agentIDs, ",")) diff --git a/clicommand/agent_start_test.go b/clicommand/agent_start_test.go index 5052ca9e86..265cae5716 100644 --- a/clicommand/agent_start_test.go +++ b/clicommand/agent_start_test.go @@ -164,6 +164,56 @@ func TestAgentStartupHookWithAdditionalPaths(t *testing.T) { }) } +func TestAgentStartupHookEnv(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + desc string + agents []registeredAgent + wantIDs string + wantNames string + }{ + { + desc: "empty", + }, + { + desc: "single agent", + agents: []registeredAgent{{ID: "agent-123", Name: "test-agent-1"}}, + wantIDs: "agent-123", + wantNames: "test-agent-1", + }, + { + desc: "multiple agents", + agents: []registeredAgent{ + {ID: "agent-123", Name: "test-agent-1"}, + {ID: "agent-456", Name: "test-agent-2"}, + }, + wantIDs: "agent-123,agent-456", + wantNames: "test-agent-1,test-agent-2", + }, + } { + t.Run(tc.desc, func(t *testing.T) { + t.Parallel() + + env := agentStartupHookEnv(tc.agents) + gotIDs, hasIDs := env.Get("BUILDKITE_AGENT_IDS") + if !hasIDs { + t.Fatal("BUILDKITE_AGENT_IDS is not set") + } + if got := gotIDs; got != tc.wantIDs { + t.Errorf("BUILDKITE_AGENT_IDS = %q, want %q", got, tc.wantIDs) + } + gotNames, hasNames := env.Get("BUILDKITE_AGENT_NAMES") + if !hasNames { + t.Fatal("BUILDKITE_AGENT_NAMES is not set") + } + if got := gotNames; got != tc.wantNames { + t.Errorf("BUILDKITE_AGENT_NAMES = %q, want %q", got, tc.wantNames) + } + }) + } +} + func TestAgentStartupHookWithRegisteredAgentsEnv(t *testing.T) { t.Parallel() From 4dba65d63ed5c325967afcbdd26a61aeea18b736 Mon Sep 17 00:00:00 2001 From: Chris Atkins Date: Tue, 23 Jun 2026 14:00:11 +1000 Subject: [PATCH 3/4] Use worker identity for startup hook env --- agent/agent_worker.go | 10 +++++++ clicommand/agent_start.go | 51 ++++++++-------------------------- clicommand/agent_start_test.go | 30 ++++++++++++++------ 3 files changed, 43 insertions(+), 48 deletions(-) diff --git a/agent/agent_worker.go b/agent/agent_worker.go index cb0ccc170e..de7b73f1cb 100644 --- a/agent/agent_worker.go +++ b/agent/agent_worker.go @@ -217,6 +217,16 @@ func NewAgentWorker(l logger.Logger, reg *api.AgentRegisterResponse, m *metrics. } } +// AgentID returns the registered agent ID for this worker. +func (a *AgentWorker) AgentID() string { + return a.agent.UUID +} + +// AgentName returns the registered agent name for this worker. +func (a *AgentWorker) AgentName() string { + return a.agent.Name +} + const workerStatusPart = `{{if le .LastPing.Seconds 2.0}}✅{{else}}❌{{end}} Last ping: {{.LastPing}} ago
{{if le .LastHeartbeat.Seconds 60.0}}✅{{else}}❌{{end}} Last heartbeat: {{.LastHeartbeat}} ago
{{if .LastHeartbeatError}}❌{{else}}✅{{end}} Last heartbeat error: {{printf "%v" .LastHeartbeatError}}` diff --git a/clicommand/agent_start.go b/clicommand/agent_start.go index 1475c3829f..203c30635e 100644 --- a/clicommand/agent_start.go +++ b/clicommand/agent_start.go @@ -1342,14 +1342,8 @@ var AgentStartCommand = cli.Command{ regReqs = append(regReqs, registerReq) } - type registeredAgentWorker struct { - agentID string - agentName string - worker *agent.AgentWorker - } - // Send register requests. - registeredWorkers, err := concurrently.Map(ctx, regReqs, func(ctx context.Context, i int, regReq api.AgentRegisterRequest) (*registeredAgentWorker, error) { + workers, err := concurrently.Map(ctx, regReqs, func(ctx context.Context, i int, regReq api.AgentRegisterRequest) (*agent.AgentWorker, error) { // Register the agent with the buildkite API reg, err := client.Register(ctx, regReq) if err != nil { @@ -1357,7 +1351,7 @@ var AgentStartCommand = cli.Command{ } // Create an agent worker to run the agent - worker := agent.NewAgentWorker( + return agent.NewAgentWorker( l.WithFields(logger.StringField("agent", reg.Name)), reg, mc, @@ -1371,28 +1365,12 @@ var AgentStartCommand = cli.Command{ SpawnIndex: i + 1, AgentStdout: os.Stdout, }, - ) - - return ®isteredAgentWorker{ - agentID: reg.UUID, - agentName: reg.Name, - worker: worker, - }, nil + ), nil }) if err != nil { return err } - workers := make([]*agent.AgentWorker, 0, len(registeredWorkers)) - registeredAgents := make([]registeredAgent, 0, len(registeredWorkers)) - for _, registeredWorker := range registeredWorkers { - workers = append(workers, registeredWorker.worker) - registeredAgents = append(registeredAgents, registeredAgent{ - ID: registeredWorker.agentID, - Name: registeredWorker.agentName, - }) - } - // Setup the agent pool that spawns agent workers pool := agent.NewAgentPool(workers, &agentConf) @@ -1400,7 +1378,7 @@ var AgentStartCommand = cli.Command{ defer agentShutdownHook(l, cfg) // Once the shutdown hook has been setup, trigger the startup hook. - if err := agentStartupHook(l, cfg, registeredAgents); err != nil { + if err := agentStartupHook(l, cfg, workers); err != nil { return fmt.Errorf("failed to run startup hook: %w", err) } @@ -1566,22 +1544,17 @@ func (ps *poolSignals) handleLoop(ctx context.Context, signals chan os.Signal) { } } -type registeredAgent struct { - ID string - Name string -} - -func agentStartupHook(log logger.Logger, cfg AgentStartConfig, registeredAgents []registeredAgent) error { - return agentLifecycleHook("agent-startup", log, cfg, agentStartupHookEnv(registeredAgents)) +func agentStartupHook(log logger.Logger, cfg AgentStartConfig, workers []*agent.AgentWorker) error { + return agentLifecycleHook("agent-startup", log, cfg, agentStartupHookEnv(workers)) } -func agentStartupHookEnv(registeredAgents []registeredAgent) *env.Environment { +func agentStartupHookEnv(workers []*agent.AgentWorker) *env.Environment { environ := env.New() - agentIDs := make([]string, 0, len(registeredAgents)) - agentNames := make([]string, 0, len(registeredAgents)) - for _, registeredAgent := range registeredAgents { - agentIDs = append(agentIDs, registeredAgent.ID) - agentNames = append(agentNames, registeredAgent.Name) + agentIDs := make([]string, 0, len(workers)) + agentNames := make([]string, 0, len(workers)) + for _, worker := range workers { + agentIDs = append(agentIDs, worker.AgentID()) + agentNames = append(agentNames, worker.AgentName()) } environ.Set("BUILDKITE_AGENT_IDS", strings.Join(agentIDs, ",")) diff --git a/clicommand/agent_start_test.go b/clicommand/agent_start_test.go index 265cae5716..314dd5d1d6 100644 --- a/clicommand/agent_start_test.go +++ b/clicommand/agent_start_test.go @@ -7,6 +7,8 @@ import ( "runtime" "testing" + "github.com/buildkite/agent/v3/agent" + "github.com/buildkite/agent/v3/api" "github.com/buildkite/agent/v3/core" "github.com/buildkite/agent/v3/logger" "github.com/google/go-cmp/cmp" @@ -59,6 +61,16 @@ func writeAgentHookScript(t *testing.T, dir, hookName, script string) string { return filepath } +func testAgentWorker(id, name string) *agent.AgentWorker { + return agent.NewAgentWorker( + logger.Discard, + &api.AgentRegisterResponse{UUID: id, Name: name}, + nil, + api.NewClient(logger.Discard, api.Config{}), + agent.AgentWorkerConfig{}, + ) +} + func TestAgentStartupHook(t *testing.T) { t.Parallel() @@ -169,7 +181,7 @@ func TestAgentStartupHookEnv(t *testing.T) { for _, tc := range []struct { desc string - agents []registeredAgent + workers []*agent.AgentWorker wantIDs string wantNames string }{ @@ -178,15 +190,15 @@ func TestAgentStartupHookEnv(t *testing.T) { }, { desc: "single agent", - agents: []registeredAgent{{ID: "agent-123", Name: "test-agent-1"}}, + workers: []*agent.AgentWorker{testAgentWorker("agent-123", "test-agent-1")}, wantIDs: "agent-123", wantNames: "test-agent-1", }, { desc: "multiple agents", - agents: []registeredAgent{ - {ID: "agent-123", Name: "test-agent-1"}, - {ID: "agent-456", Name: "test-agent-2"}, + workers: []*agent.AgentWorker{ + testAgentWorker("agent-123", "test-agent-1"), + testAgentWorker("agent-456", "test-agent-2"), }, wantIDs: "agent-123,agent-456", wantNames: "test-agent-1,test-agent-2", @@ -195,7 +207,7 @@ func TestAgentStartupHookEnv(t *testing.T) { t.Run(tc.desc, func(t *testing.T) { t.Parallel() - env := agentStartupHookEnv(tc.agents) + env := agentStartupHookEnv(tc.workers) gotIDs, hasIDs := env.Get("BUILDKITE_AGENT_IDS") if !hasIDs { t.Fatal("BUILDKITE_AGENT_IDS is not set") @@ -243,9 +255,9 @@ echo names=$BUILDKITE_AGENT_NAMES` filepath := writeAgentHookScript(t, hooksPath, "agent-startup", script) log := logger.NewBuffer() - err := agentStartupHook(log, cfg(hooksPath), []registeredAgent{ - {ID: "agent-123", Name: "test-agent-1"}, - {ID: "agent-456", Name: "test-agent-2"}, + err := agentStartupHook(log, cfg(hooksPath), []*agent.AgentWorker{ + testAgentWorker("agent-123", "test-agent-1"), + testAgentWorker("agent-456", "test-agent-2"), }) if err != nil { t.Fatalf("%+v", log.Messages) From 9bc5b7ae27c0a3e20150ed593f859e31266d433b Mon Sep 17 00:00:00 2001 From: Chris Atkins Date: Tue, 23 Jun 2026 14:20:09 +1000 Subject: [PATCH 4/4] Pass agent identity to shutdown hook --- clicommand/agent_start.go | 10 ++++----- clicommand/agent_start_test.go | 40 ++++++++++++++++++++++++++++++---- docs/agent-start.md | 7 +++--- 3 files changed, 45 insertions(+), 12 deletions(-) diff --git a/clicommand/agent_start.go b/clicommand/agent_start.go index 203c30635e..a040434911 100644 --- a/clicommand/agent_start.go +++ b/clicommand/agent_start.go @@ -1375,7 +1375,7 @@ var AgentStartCommand = cli.Command{ pool := agent.NewAgentPool(workers, &agentConf) // Agent-wide shutdown hook. Once per agent, for all workers on the agent. - defer agentShutdownHook(l, cfg) + defer agentShutdownHook(l, cfg, workers) // Once the shutdown hook has been setup, trigger the startup hook. if err := agentStartupHook(l, cfg, workers); err != nil { @@ -1545,10 +1545,10 @@ func (ps *poolSignals) handleLoop(ctx context.Context, signals chan os.Signal) { } func agentStartupHook(log logger.Logger, cfg AgentStartConfig, workers []*agent.AgentWorker) error { - return agentLifecycleHook("agent-startup", log, cfg, agentStartupHookEnv(workers)) + return agentLifecycleHook("agent-startup", log, cfg, agentLifecycleHookEnv(workers)) } -func agentStartupHookEnv(workers []*agent.AgentWorker) *env.Environment { +func agentLifecycleHookEnv(workers []*agent.AgentWorker) *env.Environment { environ := env.New() agentIDs := make([]string, 0, len(workers)) agentNames := make([]string, 0, len(workers)) @@ -1563,8 +1563,8 @@ func agentStartupHookEnv(workers []*agent.AgentWorker) *env.Environment { return environ } -func agentShutdownHook(log logger.Logger, cfg AgentStartConfig) { - _ = agentLifecycleHook("agent-shutdown", log, cfg, nil) +func agentShutdownHook(log logger.Logger, cfg AgentStartConfig, workers []*agent.AgentWorker) { + _ = agentLifecycleHook("agent-shutdown", log, cfg, agentLifecycleHookEnv(workers)) } // agentLifecycleHook looks for a hook script in the hooks path diff --git a/clicommand/agent_start_test.go b/clicommand/agent_start_test.go index 314dd5d1d6..9d2b9a1c33 100644 --- a/clicommand/agent_start_test.go +++ b/clicommand/agent_start_test.go @@ -207,7 +207,7 @@ func TestAgentStartupHookEnv(t *testing.T) { t.Run(tc.desc, func(t *testing.T) { t.Parallel() - env := agentStartupHookEnv(tc.workers) + env := agentLifecycleHookEnv(tc.workers) gotIDs, hasIDs := env.Get("BUILDKITE_AGENT_IDS") if !hasIDs { t.Fatal("BUILDKITE_AGENT_IDS is not set") @@ -292,7 +292,7 @@ func TestAgentShutdownHook(t *testing.T) { defer closer() filepath := writeAgentHook(t, hooksPath, "agent-shutdown", "hello world") log := logger.NewBuffer() - agentShutdownHook(log, cfg(hooksPath)) + agentShutdownHook(log, cfg(hooksPath), nil) if diff := cmp.Diff(log.Messages, []string{ "[info] " + prompt + " " + filepath, @@ -309,7 +309,7 @@ func TestAgentShutdownHook(t *testing.T) { defer closer() log := logger.NewBuffer() - agentShutdownHook(log, cfg(hooksPath)) + agentShutdownHook(log, cfg(hooksPath), nil) if diff := cmp.Diff(log.Messages, []string{}); diff != "" { t.Errorf("log.Messages diff (-got +want):\n%s", diff) } @@ -319,11 +319,43 @@ func TestAgentShutdownHook(t *testing.T) { t.Parallel() log := logger.NewBuffer() - agentShutdownHook(log, cfg("zxczxczxc")) + agentShutdownHook(log, cfg("zxczxczxc"), nil) if diff := cmp.Diff(log.Messages, []string{}); diff != "" { t.Errorf("log.Messages diff (-got +want):\n%s", diff) } }) + + t.Run("with registered agents env", func(t *testing.T) { + t.Parallel() + + hooksPath, closer := setupHooksPath(t) + defer closer() + + var script string + if runtime.GOOS == "windows" { + script = `@echo off +echo ids=%BUILDKITE_AGENT_IDS% +echo names=%BUILDKITE_AGENT_NAMES%` + } else { + script = `echo ids=$BUILDKITE_AGENT_IDS +echo names=$BUILDKITE_AGENT_NAMES` + } + filepath := writeAgentHookScript(t, hooksPath, "agent-shutdown", script) + + log := logger.NewBuffer() + agentShutdownHook(log, cfg(hooksPath), []*agent.AgentWorker{ + testAgentWorker("agent-123", "test-agent-1"), + testAgentWorker("agent-456", "test-agent-2"), + }) + + if diff := cmp.Diff(log.Messages, []string{ + "[info] " + prompt + " " + filepath, + "[info] ids=agent-123,agent-456", + "[info] names=test-agent-1,test-agent-2", + }); diff != "" { + t.Errorf("log.Messages diff (-got +want):\n%s", diff) + } + }) } func TestAgentStartJobLocked_ExitCode28(t *testing.T) { diff --git a/docs/agent-start.md b/docs/agent-start.md index eb35d1f4f3..c8183f31ec 100644 --- a/docs/agent-start.md +++ b/docs/agent-start.md @@ -23,9 +23,10 @@ The effect is that `AgentPool` returns either `nil` once all workers have stopped without error, or the first non-nil error. Once all workers have registered, the once-per-process `agent-startup` hook runs -before the `AgentPool` starts. The hook receives `BUILDKITE_AGENT_IDS` and -`BUILDKITE_AGENT_NAMES` as comma-separated lists in spawn order, allowing hook -scripts to identify the registered spawned agents without querying the API. +before the `AgentPool` starts. The `agent-startup` and `agent-shutdown` hooks +receive `BUILDKITE_AGENT_IDS` and `BUILDKITE_AGENT_NAMES` as comma-separated +lists in spawn order, allowing hook scripts to identify the registered spawned +agents without querying the API. After connecting, `AgentWorker` runs two main goroutines: one periodically calls `Heartbeat`, the other more frequently calls `Ping`. `Ping` is how the