From 790ab92524dbe9dafc68a076edd0d80ae36cf7ff Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 27 Jul 2026 13:57:02 -0500 Subject: [PATCH 1/4] fix(daemon): honor shutdownAction none on first up The inactivity daemon decided whether to auto-stop a workspace from a global --shutdown-action flag baked in at install time. On the first up that flag was empty: installDaemon runs before the devcontainer config is resolved, and the host has no LastDevContainerConfig to send yet. The daemon therefore ignored shutdownAction "none" and stopped the workspace once activity went stale, failing "shutdownAction none suppresses inactivity timeout". Resolve shutdownAction per-workspace at patrol time instead: - persist the build-resolved devcontainer config back to workspace.json - daemon reads the per-workspace shutdownAction, falling back to the flag Also fix writeWorkspaceInfo marshaling the original instead of the clone, which persisted CLIOptions despite the intent to drop them. --- cmd/internal/agent_daemon.go | 16 ++++++++- cmd/internal/agent_daemon_test.go | 32 ++++++++++++++++++ cmd/internal/agentworkspace/up.go | 18 ++++++++++ pkg/agent/agent.go | 14 +++++++- pkg/agent/persist_test.go | 56 +++++++++++++++++++++++++++++++ 5 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 pkg/agent/persist_test.go diff --git a/cmd/internal/agent_daemon.go b/cmd/internal/agent_daemon.go index 2ef6a5654..5dad5ac47 100644 --- a/cmd/internal/agent_daemon.go +++ b/cmd/internal/agent_daemon.go @@ -173,7 +173,7 @@ func (cmd *DaemonCmd) checkAndShutdown( latestActivity time.Time, workspace *provider2.AgentWorkspaceInfo, ) { - if cmd.ShutdownAction == config.ShutdownActionNone { + if cmd.effectiveShutdownAction(workspace) == config.ShutdownActionNone { return } @@ -201,6 +201,20 @@ func (cmd *DaemonCmd) checkAndShutdown( cmd.runShutdownCommand(ctx, workspace) } +// effectiveShutdownAction prefers the workspace's resolved config, falling back +// to the daemon's install-time flag when the workspace has none yet. +func (cmd *DaemonCmd) effectiveShutdownAction( + workspace *provider2.AgentWorkspaceInfo, +) string { + if workspace != nil && + workspace.LastDevContainerConfig != nil && + workspace.LastDevContainerConfig.Config != nil && + workspace.LastDevContainerConfig.Config.ShutdownAction != "" { + return workspace.LastDevContainerConfig.Config.ShutdownAction + } + return cmd.ShutdownAction +} + func (cmd *DaemonCmd) runShutdownCommand( ctx context.Context, workspace *provider2.AgentWorkspaceInfo, diff --git a/cmd/internal/agent_daemon_test.go b/cmd/internal/agent_daemon_test.go index 0e83476f9..4e71adcd9 100644 --- a/cmd/internal/agent_daemon_test.go +++ b/cmd/internal/agent_daemon_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/devsy-org/devsy/pkg/agent" + "github.com/devsy-org/devsy/pkg/devcontainer/config" provider2 "github.com/devsy-org/devsy/pkg/provider" "github.com/devsy-org/devsy/pkg/types" "github.com/stretchr/testify/assert" @@ -89,6 +90,37 @@ func TestFindLatestActivity_PicksLatest(t *testing.T) { assert.Equal(t, recentTime, *activity) } +func workspaceWithShutdownAction(action string) *provider2.AgentWorkspaceInfo { + ws := &provider2.AgentWorkspaceInfo{Workspace: &provider2.Workspace{ID: "ws-test"}} + if action != "" { + ws.LastDevContainerConfig = &config.DevContainerConfigWithPath{ + Config: &config.DevContainerConfig{ + DevContainerConfigBase: config.DevContainerConfigBase{ShutdownAction: action}, + }, + } + } + return ws +} + +func TestEffectiveShutdownAction(t *testing.T) { + t.Run("prefers per-workspace config over flag", func(t *testing.T) { + cmd := &DaemonCmd{ShutdownAction: config.ShutdownActionStopContainer} + ws := workspaceWithShutdownAction(config.ShutdownActionNone) + assert.Equal(t, config.ShutdownActionNone, cmd.effectiveShutdownAction(ws)) + }) + + t.Run("falls back to flag when config has no action", func(t *testing.T) { + cmd := &DaemonCmd{ShutdownAction: config.ShutdownActionNone} + ws := workspaceWithShutdownAction("") + assert.Equal(t, config.ShutdownActionNone, cmd.effectiveShutdownAction(ws)) + }) + + t.Run("falls back to flag for nil workspace", func(t *testing.T) { + cmd := &DaemonCmd{ShutdownAction: config.ShutdownActionStopContainer} + assert.Equal(t, config.ShutdownActionStopContainer, cmd.effectiveShutdownAction(nil)) + }) +} + func TestEffectiveActivity(t *testing.T) { orig := activityFilePath t.Cleanup(func() { activityFilePath = orig }) diff --git a/cmd/internal/agentworkspace/up.go b/cmd/internal/agentworkspace/up.go index 8ce715a0d..7261d5854 100644 --- a/cmd/internal/agentworkspace/up.go +++ b/cmd/internal/agentworkspace/up.go @@ -162,6 +162,10 @@ func (cmd *UpCmd) up( return err } + // Persist so the daemon, started before the build resolved the config, can + // read the workspace's shutdownAction on the first up. + persistResolvedConfig(workspaceInfo, result) + // runner.Up can return (result, nil) where result carries a structured // Error forwarded from the inner container-setup step. Treat that as a // failure so the agent process exits non-zero and the host doesn't try @@ -747,6 +751,20 @@ func installDaemon(workspaceInfo *provider.AgentWorkspaceInfo) error { ) } +func persistResolvedConfig( + workspaceInfo *provider.AgentWorkspaceInfo, + result *config2.Result, +) { + if result == nil || result.Error != "" || result.DevContainerConfigWithPath == nil { + return + } + + workspaceInfo.LastDevContainerConfig = result.DevContainerConfigWithPath + if err := agent.PersistAgentWorkspaceInfo(workspaceInfo); err != nil { + log.Errorf("persist resolved devcontainer config: %v", err) + } +} + func downloadLocalFolder( ctx context.Context, workspaceDir string, diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index da247b570..3c30ed296 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -319,6 +319,18 @@ func DeleteWorkspaceBusyFile(folder string) { _ = os.Remove(filepath.Join(folder, config.WorkspaceBusyFile)) } +// PersistAgentWorkspaceInfo writes the workspace info back to workspace.json +// under workspaceInfo.Origin. +func PersistAgentWorkspaceInfo(workspaceInfo *provider2.AgentWorkspaceInfo) error { + if workspaceInfo == nil || workspaceInfo.Origin == "" { + return errors.New("workspace origin is not set") + } + return writeWorkspaceInfo( + filepath.Join(workspaceInfo.Origin, provider2.WorkspaceConfigFile), + workspaceInfo, + ) +} + func writeWorkspaceInfo(file string, workspaceInfo *provider2.AgentWorkspaceInfo) error { // copy workspace info cloned := provider2.CloneAgentWorkspaceInfo(workspaceInfo) @@ -327,7 +339,7 @@ func writeWorkspaceInfo(file string, workspaceInfo *provider2.AgentWorkspaceInfo cloned.CLIOptions = provider2.CLIOptions{} // encode workspace info - encoded, err := json.Marshal(workspaceInfo) + encoded, err := json.Marshal(cloned) if err != nil { return err } diff --git a/pkg/agent/persist_test.go b/pkg/agent/persist_test.go new file mode 100644 index 000000000..a8904fe72 --- /dev/null +++ b/pkg/agent/persist_test.go @@ -0,0 +1,56 @@ +package agent + +import ( + "path/filepath" + "testing" + + "github.com/devsy-org/devsy/pkg/devcontainer/config" + provider2 "github.com/devsy-org/devsy/pkg/provider" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPersistAgentWorkspaceInfo_RoundTripsResolvedConfig(t *testing.T) { + dir := t.TempDir() + info := &provider2.AgentWorkspaceInfo{ + Origin: dir, + Workspace: &provider2.Workspace{ID: "ws-test"}, + LastDevContainerConfig: &config.DevContainerConfigWithPath{ + Config: &config.DevContainerConfig{ + DevContainerConfigBase: config.DevContainerConfigBase{ + ShutdownAction: config.ShutdownActionNone, + }, + }, + }, + } + + require.NoError(t, PersistAgentWorkspaceInfo(info)) + + got, err := ParseAgentWorkspaceInfo(filepath.Join(dir, provider2.WorkspaceConfigFile)) + require.NoError(t, err) + require.NotNil(t, got.LastDevContainerConfig) + require.NotNil(t, got.LastDevContainerConfig.Config) + assert.Equal(t, config.ShutdownActionNone, got.LastDevContainerConfig.Config.ShutdownAction) +} + +func TestPersistAgentWorkspaceInfo_RequiresOrigin(t *testing.T) { + assert.Error(t, PersistAgentWorkspaceInfo(&provider2.AgentWorkspaceInfo{})) + assert.Error(t, PersistAgentWorkspaceInfo(nil)) +} + +func TestPersistAgentWorkspaceInfo_DoesNotPersistCLIOptions(t *testing.T) { + dir := t.TempDir() + info := &provider2.AgentWorkspaceInfo{ + Origin: dir, + Workspace: &provider2.Workspace{ID: "ws-test"}, + CLIOptions: provider2.CLIOptions{DaemonInterval: "3s"}, + } + + require.NoError(t, PersistAgentWorkspaceInfo(info)) + + got, err := ParseAgentWorkspaceInfo(filepath.Join(dir, provider2.WorkspaceConfigFile)) + require.NoError(t, err) + assert.Equal(t, "ws-test", got.Workspace.ID) + assert.Empty(t, got.CLIOptions.DaemonInterval, "CLIOptions must not be persisted") + assert.Equal(t, "3s", info.CLIOptions.DaemonInterval, "caller's struct must not be mutated") +} From cc424eecd3ba98845c0681c93888463918eea269 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 27 Jul 2026 14:34:38 -0500 Subject: [PATCH 2/4] test(daemon): cover empty per-workspace shutdownAction fallback --- cmd/internal/agent_daemon_test.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/cmd/internal/agent_daemon_test.go b/cmd/internal/agent_daemon_test.go index 4e71adcd9..75277f0e5 100644 --- a/cmd/internal/agent_daemon_test.go +++ b/cmd/internal/agent_daemon_test.go @@ -109,12 +109,23 @@ func TestEffectiveShutdownAction(t *testing.T) { assert.Equal(t, config.ShutdownActionNone, cmd.effectiveShutdownAction(ws)) }) - t.Run("falls back to flag when config has no action", func(t *testing.T) { + t.Run("falls back to flag when workspace has no config", func(t *testing.T) { cmd := &DaemonCmd{ShutdownAction: config.ShutdownActionNone} ws := workspaceWithShutdownAction("") assert.Equal(t, config.ShutdownActionNone, cmd.effectiveShutdownAction(ws)) }) + t.Run("falls back to flag when config action is empty", func(t *testing.T) { + cmd := &DaemonCmd{ShutdownAction: config.ShutdownActionNone} + ws := &provider2.AgentWorkspaceInfo{ + Workspace: &provider2.Workspace{ID: "ws-test"}, + LastDevContainerConfig: &config.DevContainerConfigWithPath{ + Config: &config.DevContainerConfig{}, + }, + } + assert.Equal(t, config.ShutdownActionNone, cmd.effectiveShutdownAction(ws)) + }) + t.Run("falls back to flag for nil workspace", func(t *testing.T) { cmd := &DaemonCmd{ShutdownAction: config.ShutdownActionStopContainer} assert.Equal(t, config.ShutdownActionStopContainer, cmd.effectiveShutdownAction(nil)) From f6a071dd0a238030b93409b76ea4271c0e31d80a Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 27 Jul 2026 14:38:41 -0500 Subject: [PATCH 3/4] log(daemon): clarify persist failure falls back to default shutdown action --- cmd/internal/agentworkspace/up.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/internal/agentworkspace/up.go b/cmd/internal/agentworkspace/up.go index 7261d5854..64f0fc4ef 100644 --- a/cmd/internal/agentworkspace/up.go +++ b/cmd/internal/agentworkspace/up.go @@ -761,7 +761,7 @@ func persistResolvedConfig( workspaceInfo.LastDevContainerConfig = result.DevContainerConfigWithPath if err := agent.PersistAgentWorkspaceInfo(workspaceInfo); err != nil { - log.Errorf("persist resolved devcontainer config: %v", err) + log.Errorf("persist resolved config, daemon keeps default shutdown action: %v", err) } } From 4b378ee0cd136ed9a37e44d8c43d359fa660edb7 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 27 Jul 2026 14:39:35 -0500 Subject: [PATCH 4/4] log(daemon): revert persist failure message to original --- cmd/internal/agentworkspace/up.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/internal/agentworkspace/up.go b/cmd/internal/agentworkspace/up.go index 64f0fc4ef..7261d5854 100644 --- a/cmd/internal/agentworkspace/up.go +++ b/cmd/internal/agentworkspace/up.go @@ -761,7 +761,7 @@ func persistResolvedConfig( workspaceInfo.LastDevContainerConfig = result.DevContainerConfigWithPath if err := agent.PersistAgentWorkspaceInfo(workspaceInfo); err != nil { - log.Errorf("persist resolved config, daemon keeps default shutdown action: %v", err) + log.Errorf("persist resolved devcontainer config: %v", err) } }