From 130b2c84d15a9e1280a84f676309203330bf0a44 Mon Sep 17 00:00:00 2001 From: Virgil Date: Tue, 31 Mar 2026 14:54:32 +0000 Subject: [PATCH] fix(agentic): align workspace mount and repo sync Co-Authored-By: Virgil --- pkg/agentic/dispatch.go | 27 +++++----- pkg/agentic/dispatch_sync_example_test.go | 2 +- pkg/agentic/dispatch_sync_test.go | 7 ++- pkg/agentic/dispatch_test.go | 4 +- pkg/agentic/logic_test.go | 16 +++--- pkg/monitor/monitor.go | 9 ++++ pkg/monitor/sync.go | 65 +++++++++++++++++++++++ pkg/monitor/sync_test.go | 41 ++++++++++++++ 8 files changed, 148 insertions(+), 23 deletions(-) diff --git a/pkg/agentic/dispatch.go b/pkg/agentic/dispatch.go index ed9024d..9f8c744 100644 --- a/pkg/agentic/dispatch.go +++ b/pkg/agentic/dispatch.go @@ -146,8 +146,8 @@ func agentCommandResult(agent, prompt string) core.Result { const defaultDockerImage = "core-dev" -// command, args := containerCommand("local", "codex", []string{"exec", "--oss"}, "/srv/.core/workspace/core/go-io/task-5/repo", "/srv/.core/workspace/core/go-io/task-5/.meta") -func containerCommand(command string, args []string, repoDir, metaDir string) (string, []string) { +// command, args := containerCommand("codex", []string{"exec", "--model", "gpt-5.4"}, "/srv/.core/workspace/core/go-io/task-5", "/srv/.core/workspace/core/go-io/task-5/.meta") +func containerCommand(command string, args []string, workspaceDir, metaDir string) (string, []string) { image := core.Env("AGENT_DOCKER_IMAGE") if image == "" { image = defaultDockerImage @@ -158,9 +158,9 @@ func containerCommand(command string, args []string, repoDir, metaDir string) (s dockerArgs := []string{ "run", "--rm", "--add-host=host.docker.internal:host-gateway", - "-v", core.Concat(repoDir, ":/workspace"), + "-v", core.Concat(workspaceDir, ":/workspace"), "-v", core.Concat(metaDir, ":/workspace/.meta"), - "-w", "/workspace", + "-w", "/workspace/repo", "-v", core.Concat(core.JoinPath(home, ".codex"), ":/home/dev/.codex:ro"), "-e", "OPENAI_API_KEY", "-e", "ANTHROPIC_API_KEY", @@ -188,11 +188,15 @@ func containerCommand(command string, args []string, repoDir, metaDir string) (s } quoted := core.NewBuilder() - quoted.WriteString(command) - for _, a := range args { - quoted.WriteString(" '") - quoted.WriteString(core.Replace(a, "'", "'\\''")) - quoted.WriteString("'") + quoted.WriteString("if [ ! -d /workspace/repo ]; then echo 'missing /workspace/repo' >&2; exit 1; fi") + if command != "" { + quoted.WriteString("; ") + quoted.WriteString(command) + for _, a := range args { + quoted.WriteString(" '") + quoted.WriteString(core.Replace(a, "'", "'\\''")) + quoted.WriteString("'") + } } quoted.WriteString("; chmod -R a+w /workspace /workspace/.meta 2>/dev/null; true") @@ -341,13 +345,12 @@ func (s *PrepSubsystem) spawnAgent(agent, prompt, workspaceDir string) (int, str return 0, "", "", err } - repoDir := WorkspaceRepoDir(workspaceDir) metaDir := WorkspaceMetaDir(workspaceDir) outputFile := agentOutputFile(workspaceDir, agent) fs.Delete(WorkspaceBlockedPath(workspaceDir)) - command, args = containerCommand(command, args, repoDir, metaDir) + command, args = containerCommand(command, args, workspaceDir, metaDir) processResult := s.Core().Service("process") if !processResult.OK { @@ -360,7 +363,7 @@ func (s *PrepSubsystem) spawnAgent(agent, prompt, workspaceDir string) (int, str proc, err := procSvc.StartWithOptions(context.Background(), process.RunOptions{ Command: command, Args: args, - Dir: repoDir, + Dir: workspaceDir, Detach: true, }) if err != nil { diff --git a/pkg/agentic/dispatch_sync_example_test.go b/pkg/agentic/dispatch_sync_example_test.go index ad61e1a..be57791 100644 --- a/pkg/agentic/dispatch_sync_example_test.go +++ b/pkg/agentic/dispatch_sync_example_test.go @@ -5,7 +5,7 @@ package agentic import core "dappco.re/go/core" func Example_containerCommand() { - cmd, args := containerCommand("codex", []string{"--model", "gpt-5.4"}, "/workspace", "/meta") + cmd, args := containerCommand("codex", []string{"--model", "gpt-5.4"}, "/workspace/task-5", "/workspace/task-5/.meta") core.Println(cmd) core.Println(len(args) > 0) // Output: diff --git a/pkg/agentic/dispatch_sync_test.go b/pkg/agentic/dispatch_sync_test.go index ea2f9b9..f84fd5e 100644 --- a/pkg/agentic/dispatch_sync_test.go +++ b/pkg/agentic/dispatch_sync_test.go @@ -9,13 +9,16 @@ import ( ) func TestDispatchsync_ContainerCommand_Good(t *testing.T) { - cmd, args := containerCommand("codex", []string{"--model", "gpt-5.4"}, "/workspace", "/meta") + cmd, args := containerCommand("codex", []string{"--model", "gpt-5.4"}, "/workspace/task-5", "/workspace/task-5/.meta") assert.Equal(t, "docker", cmd) assert.Contains(t, args, "run") + assert.Contains(t, args, "/workspace/task-5:/workspace") + assert.Contains(t, args, "/workspace/task-5/.meta:/workspace/.meta") + assert.Contains(t, args, "/workspace/repo") } func TestDispatchsync_ContainerCommand_Bad_UnknownAgent(t *testing.T) { - cmd, args := containerCommand("unknown", nil, "/workspace", "/meta") + cmd, args := containerCommand("unknown", nil, "/workspace/task-5", "/workspace/task-5/.meta") assert.Equal(t, "docker", cmd) assert.NotEmpty(t, args) } diff --git a/pkg/agentic/dispatch_test.go b/pkg/agentic/dispatch_test.go index f915675..7f4eef7 100644 --- a/pkg/agentic/dispatch_test.go +++ b/pkg/agentic/dispatch_test.go @@ -584,11 +584,13 @@ func TestDispatch_ContainerCommand_Bad(t *testing.T) { t.Setenv("DIR_HOME", "/home/dev") // Empty command string — docker still runs, just with no command after image - cmd, args := containerCommand("", []string{}, "/ws/repo", "/ws/.meta") + cmd, args := containerCommand("", []string{}, "/ws", "/ws/.meta") assert.Equal(t, "docker", cmd) assert.Contains(t, args, "run") // The image should still be present in args assert.Contains(t, args, defaultDockerImage) + assert.Contains(t, args, "/ws:/workspace") + assert.Contains(t, args, "/workspace/repo") } // --- canDispatchAgent --- diff --git a/pkg/agentic/logic_test.go b/pkg/agentic/logic_test.go index 830bcf7..e58f7db 100644 --- a/pkg/agentic/logic_test.go +++ b/pkg/agentic/logic_test.go @@ -121,14 +121,16 @@ func TestDispatch_ContainerCommand_Good_Codex(t *testing.T) { t.Setenv("AGENT_DOCKER_IMAGE", "") t.Setenv("DIR_HOME", "/home/dev") - cmd, args := containerCommand("codex", []string{"exec", "--dangerously-bypass-approvals-and-sandbox", "do it"}, "/ws/repo", "/ws/.meta") + cmd, args := containerCommand("codex", []string{"exec", "--dangerously-bypass-approvals-and-sandbox", "do it"}, "/ws", "/ws/.meta") assert.Equal(t, "docker", cmd) assert.Contains(t, args, "run") assert.Contains(t, args, "--rm") - assert.Contains(t, args, "/ws/repo:/workspace") + assert.Contains(t, args, "/ws:/workspace") assert.Contains(t, args, "/ws/.meta:/workspace/.meta") + assert.Contains(t, args, "/workspace/repo") // Command is wrapped in sh -c for chmod cleanup shCmd := args[len(args)-1] + assert.Contains(t, shCmd, "missing /workspace/repo") assert.Contains(t, shCmd, "codex") // Should use default image assert.Contains(t, args, defaultDockerImage) @@ -138,7 +140,7 @@ func TestDispatch_ContainerCommand_Good_CustomImage(t *testing.T) { t.Setenv("AGENT_DOCKER_IMAGE", "my-custom-image:latest") t.Setenv("DIR_HOME", "/home/dev") - cmd, args := containerCommand("codex", []string{"exec"}, "/ws/repo", "/ws/.meta") + cmd, args := containerCommand("codex", []string{"exec"}, "/ws", "/ws/.meta") assert.Equal(t, "docker", cmd) assert.Contains(t, args, "my-custom-image:latest") } @@ -147,7 +149,7 @@ func TestDispatch_ContainerCommand_Good_ClaudeMountsConfig(t *testing.T) { t.Setenv("AGENT_DOCKER_IMAGE", "") t.Setenv("DIR_HOME", "/home/dev") - _, args := containerCommand("claude", []string{"-p", "do it"}, "/ws/repo", "/ws/.meta") + _, args := containerCommand("claude", []string{"-p", "do it"}, "/ws", "/ws/.meta") joined := strings.Join(args, " ") assert.Contains(t, joined, ".claude:/home/dev/.claude:ro") } @@ -156,7 +158,7 @@ func TestDispatch_ContainerCommand_Good_GeminiMountsConfig(t *testing.T) { t.Setenv("AGENT_DOCKER_IMAGE", "") t.Setenv("DIR_HOME", "/home/dev") - _, args := containerCommand("gemini", []string{"-p", "do it"}, "/ws/repo", "/ws/.meta") + _, args := containerCommand("gemini", []string{"-p", "do it"}, "/ws", "/ws/.meta") joined := strings.Join(args, " ") assert.Contains(t, joined, ".gemini:/home/dev/.gemini:ro") } @@ -165,7 +167,7 @@ func TestDispatch_ContainerCommand_Good_CodexNoClaudeMount(t *testing.T) { t.Setenv("AGENT_DOCKER_IMAGE", "") t.Setenv("DIR_HOME", "/home/dev") - _, args := containerCommand("codex", []string{"exec"}, "/ws/repo", "/ws/.meta") + _, args := containerCommand("codex", []string{"exec"}, "/ws", "/ws/.meta") joined := strings.Join(args, " ") // codex agent must NOT mount .claude config assert.NotContains(t, joined, ".claude:/home/dev/.claude:ro") @@ -175,7 +177,7 @@ func TestDispatch_ContainerCommand_Good_APIKeysPassedByRef(t *testing.T) { t.Setenv("AGENT_DOCKER_IMAGE", "") t.Setenv("DIR_HOME", "/home/dev") - _, args := containerCommand("codex", []string{"exec"}, "/ws/repo", "/ws/.meta") + _, args := containerCommand("codex", []string{"exec"}, "/ws", "/ws/.meta") joined := strings.Join(args, " ") assert.Contains(t, joined, "OPENAI_API_KEY") assert.Contains(t, joined, "ANTHROPIC_API_KEY") diff --git a/pkg/monitor/monitor.go b/pkg/monitor/monitor.go index be0f752..67482e1 100644 --- a/pkg/monitor/monitor.go +++ b/pkg/monitor/monitor.go @@ -93,6 +93,13 @@ func (m *Subsystem) handleAgentCompleted(ev messages.AgentCompleted) { go m.checkIdleAfterDelay() } +func (m *Subsystem) handleWorkspacePushed(ev messages.WorkspacePushed) { + if m.ServiceRuntime == nil { + return + } + m.syncWorkspacePush(ev.Repo, ev.Branch, ev.Org) +} + // c.ACTION(messages.AgentStarted{Agent: "codex", Repo: "go-io", Workspace: "core/go-io/task-5"}) // c.ACTION(messages.AgentCompleted{Agent: "codex", Repo: "go-io", Workspace: "core/go-io/task-5", Status: "completed"}) func (m *Subsystem) HandleIPCEvents(_ *core.Core, msg core.Message) core.Result { @@ -101,6 +108,8 @@ func (m *Subsystem) HandleIPCEvents(_ *core.Core, msg core.Message) core.Result m.handleAgentCompleted(ev) case messages.AgentStarted: m.handleAgentStarted(ev) + case messages.WorkspacePushed: + m.handleWorkspacePushed(ev) } return core.Result{OK: true} } diff --git a/pkg/monitor/sync.go b/pkg/monitor/sync.go index 3ce662c..764b2d3 100644 --- a/pkg/monitor/sync.go +++ b/pkg/monitor/sync.go @@ -100,6 +100,71 @@ func (m *Subsystem) syncRepos() string { return core.Sprintf("Synced %d repo(s): %s", len(pulled), core.Join(", ", pulled...)) } +func (m *Subsystem) syncWorkspacePush(repo, branch, org string) bool { + if m.ServiceRuntime == nil { + return false + } + + repoDir := localRepoDir(org, repo) + if repoDir == "" || !fs.Exists(repoDir) || fs.IsFile(repoDir) { + return false + } + + targetBranch := core.Trim(branch) + if targetBranch == "" { + targetBranch = m.detectBranch(repoDir) + } + if targetBranch == "" { + targetBranch = m.defaultBranch(repoDir) + } + if targetBranch == "" { + return false + } + + if !m.gitOK(repoDir, "fetch", "origin", targetBranch) { + return false + } + + currentBranch := m.detectBranch(repoDir) + if currentBranch != "" && currentBranch != targetBranch { + return true + } + + return m.gitOK(repoDir, "reset", "--hard", core.Concat("origin/", targetBranch)) +} + +func localRepoDir(org, repo string) string { + basePath := core.Env("CODE_PATH") + if basePath == "" { + basePath = core.JoinPath(agentic.HomeDir(), "Code") + } + + normalisedRepo := core.Replace(repo, "\\", "/") + repoName := core.PathBase(normalisedRepo) + orgName := core.PathBase(core.Replace(org, "\\", "/")) + repoParts := core.Split(normalisedRepo, "/") + if orgName == "" && len(repoParts) > 1 { + orgName = repoParts[0] + } + + candidates := []string{} + if orgName != "" { + candidates = append(candidates, core.JoinPath(basePath, orgName, repoName)) + } + candidates = append(candidates, core.JoinPath(basePath, repoName)) + + for _, candidate := range candidates { + if fs.Exists(candidate) && !fs.IsFile(candidate) { + return candidate + } + } + + if len(candidates) == 0 { + return "" + } + return candidates[0] +} + func (m *Subsystem) initSyncTimestamp() { m.mu.Lock() if m.lastSyncTimestamp == 0 { diff --git a/pkg/monitor/sync_test.go b/pkg/monitor/sync_test.go index f0d75ce..4dcec8a 100644 --- a/pkg/monitor/sync_test.go +++ b/pkg/monitor/sync_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "dappco.re/go/agent/pkg/messages" core "dappco.re/go/core" "github.com/stretchr/testify/assert" ) @@ -143,6 +144,46 @@ func TestSync_SyncRepos_Good_PullsChangedRepo(t *testing.T) { assert.Contains(t, msg, "test-repo") } +func TestSync_HandleWorkspacePushed_Good_ResetsTrackedRepo(t *testing.T) { + remoteDir := core.JoinPath(t.TempDir(), "remote") + fs.EnsureDir(remoteDir) + run(t, remoteDir, "git", "init", "--bare") + + codeDir := t.TempDir() + orgDir := core.JoinPath(codeDir, "core") + fs.EnsureDir(orgDir) + repoDir := core.JoinPath(orgDir, "test-repo") + run(t, orgDir, "git", "clone", remoteDir, "test-repo") + run(t, repoDir, "git", "checkout", "-b", "main") + fs.Write(core.JoinPath(repoDir, "README.md"), "# test") + run(t, repoDir, "git", "add", ".") + run(t, repoDir, "git", "commit", "-m", "init") + run(t, repoDir, "git", "push", "-u", "origin", "main") + + cloneParent := t.TempDir() + tmpClone := core.JoinPath(cloneParent, "clone2") + run(t, cloneParent, "git", "clone", remoteDir, "clone2") + run(t, tmpClone, "git", "checkout", "main") + fs.Write(core.JoinPath(tmpClone, "new.go"), "package main\n") + run(t, tmpClone, "git", "add", ".") + run(t, tmpClone, "git", "commit", "-m", "agent work") + run(t, tmpClone, "git", "push", "origin", "main") + + t.Setenv("CODE_PATH", codeDir) + + mon := New() + mon.ServiceRuntime = testMon.ServiceRuntime + + result := mon.HandleIPCEvents(mon.Core(), messages.WorkspacePushed{ + Repo: "test-repo", + Branch: "main", + Org: "core", + }) + assert.True(t, result.OK) + assert.True(t, fs.Exists(core.JoinPath(repoDir, "new.go"))) + assert.Equal(t, mon.gitOutput(tmpClone, "rev-parse", "HEAD"), mon.gitOutput(repoDir, "rev-parse", "HEAD")) +} + func TestSync_SyncRepos_Good_NormalisesWindowsRepoPath(t *testing.T) { remoteDir := core.JoinPath(t.TempDir(), "remote") fs.EnsureDir(remoteDir)