fix(agentic): align workspace mount and repo sync

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-03-31 14:54:32 +00:00
parent 9d90e7532f
commit 130b2c84d1
8 changed files with 148 additions and 23 deletions

View file

@ -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 {

View file

@ -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:

View file

@ -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)
}

View file

@ -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 ---

View file

@ -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")

View file

@ -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}
}

View file

@ -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 {

View file

@ -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)