From 583abea788a444229e4fd4e5cecfbc26b92e028f Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 18:07:53 +0000 Subject: [PATCH] refactor(agentic): write workspace files atomically --- pkg/mcp/agentic/plan.go | 2 +- pkg/mcp/agentic/prep.go | 24 ++++++++-------- pkg/mcp/agentic/resume.go | 2 +- pkg/mcp/agentic/review_queue.go | 4 +-- pkg/mcp/agentic/status.go | 2 +- pkg/mcp/agentic/write_atomic.go | 51 +++++++++++++++++++++++++++++++++ 6 files changed, 68 insertions(+), 17 deletions(-) create mode 100644 pkg/mcp/agentic/write_atomic.go diff --git a/pkg/mcp/agentic/plan.go b/pkg/mcp/agentic/plan.go index 318c0a2..1485618 100644 --- a/pkg/mcp/agentic/plan.go +++ b/pkg/mcp/agentic/plan.go @@ -483,7 +483,7 @@ func writePlan(dir string, plan *Plan) (string, error) { return "", err } - return path, coreio.Local.Write(path, string(data)) + return path, writeAtomic(path, string(data)) } func validPlanStatus(status string) bool { diff --git a/pkg/mcp/agentic/prep.go b/pkg/mcp/agentic/prep.go index fdfa121..16402da 100644 --- a/pkg/mcp/agentic/prep.go +++ b/pkg/mcp/agentic/prep.go @@ -278,20 +278,20 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques // 2. Copy CLAUDE.md and GEMINI.md to workspace claudeMdPath := filepath.Join(repoPath, "CLAUDE.md") if data, err := coreio.Local.Read(claudeMdPath); err == nil { - coreio.Local.Write(filepath.Join(wsDir, "src", "CLAUDE.md"), data) + _ = writeAtomic(filepath.Join(wsDir, "src", "CLAUDE.md"), data) out.ClaudeMd = true } // Copy GEMINI.md from core/agent (ethics framework for all agents) agentGeminiMd := filepath.Join(s.codePath, "core", "agent", "GEMINI.md") if data, err := coreio.Local.Read(agentGeminiMd); err == nil { - coreio.Local.Write(filepath.Join(wsDir, "src", "GEMINI.md"), data) + _ = writeAtomic(filepath.Join(wsDir, "src", "GEMINI.md"), data) } // Copy persona if specified if persona != "" { personaPath := filepath.Join(s.codePath, "core", "agent", "prompts", "personas", persona+".md") if data, err := coreio.Local.Read(personaPath); err == nil { - coreio.Local.Write(filepath.Join(wsDir, "src", "PERSONA.md"), data) + _ = writeAtomic(filepath.Join(wsDir, "src", "PERSONA.md"), data) } } @@ -301,7 +301,7 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques } else if input.Task != "" { todo := fmt.Sprintf("# TASK: %s\n\n**Repo:** %s/%s\n**Status:** ready\n\n## Objective\n\n%s\n", input.Task, input.Org, input.Repo, input.Task) - coreio.Local.Write(filepath.Join(wsDir, "src", "TODO.md"), todo) + _ = writeAtomic(filepath.Join(wsDir, "src", "TODO.md"), todo) } // 4. Generate CONTEXT.md from OpenBrain @@ -434,7 +434,7 @@ Do NOT push. Commit only — a reviewer will verify and push. prompt = "Read TODO.md and complete the task. Work in src/.\n" } - coreio.Local.Write(filepath.Join(wsDir, "src", "PROMPT.md"), prompt) + _ = writeAtomic(filepath.Join(wsDir, "src", "PROMPT.md"), prompt) } // --- Plan template rendering --- @@ -512,7 +512,7 @@ func (s *PrepSubsystem) writePlanFromTemplate(templateSlug string, variables map plan.WriteString("\n**Commit after completing this phase.**\n\n---\n\n") } - coreio.Local.Write(filepath.Join(wsDir, "src", "PLAN.md"), plan.String()) + _ = writeAtomic(filepath.Join(wsDir, "src", "PLAN.md"), plan.String()) } // --- Helpers (unchanged) --- @@ -579,7 +579,7 @@ func (s *PrepSubsystem) pullWiki(ctx context.Context, org, repo, wsDir string) i return '-' }, page.Title) + ".md" - coreio.Local.Write(filepath.Join(wsDir, "src", "kb", filename), string(content)) + _ = writeAtomic(filepath.Join(wsDir, "src", "kb", filename), string(content)) count++ } @@ -593,7 +593,7 @@ func (s *PrepSubsystem) copySpecs(wsDir string) int { for _, file := range specFiles { src := filepath.Join(s.specsPath, file) if data, err := coreio.Local.Read(src); err == nil { - coreio.Local.Write(filepath.Join(wsDir, "src", "specs", file), data) + _ = writeAtomic(filepath.Join(wsDir, "src", "specs", file), data) count++ } } @@ -645,7 +645,7 @@ func (s *PrepSubsystem) generateContext(ctx context.Context, repo, wsDir string) content.WriteString(fmt.Sprintf("### %d. %s [%s] (score: %.3f)\n\n%s\n\n", i+1, memProject, memType, score, memContent)) } - coreio.Local.Write(filepath.Join(wsDir, "src", "CONTEXT.md"), content.String()) + _ = writeAtomic(filepath.Join(wsDir, "src", "CONTEXT.md"), content.String()) return len(result.Memories) } @@ -682,7 +682,7 @@ func (s *PrepSubsystem) findConsumers(repo, wsDir string) int { content += "- " + c + "\n" } content += fmt.Sprintf("\n**Breaking change risk: %d consumers.**\n", len(consumers)) - coreio.Local.Write(filepath.Join(wsDir, "src", "CONSUMERS.md"), content) + _ = writeAtomic(filepath.Join(wsDir, "src", "CONSUMERS.md"), content) } return len(consumers) @@ -699,7 +699,7 @@ func (s *PrepSubsystem) gitLog(repoPath, wsDir string) int { lines := strings.Split(strings.TrimSpace(string(output)), "\n") if len(lines) > 0 && lines[0] != "" { content := "# Recent Changes\n\n```\n" + string(output) + "```\n" - coreio.Local.Write(filepath.Join(wsDir, "src", "RECENT.md"), content) + _ = writeAtomic(filepath.Join(wsDir, "src", "RECENT.md"), content) } return len(lines) @@ -735,5 +735,5 @@ func (s *PrepSubsystem) generateTodo(ctx context.Context, org, repo string, issu content += fmt.Sprintf("**Repo:** %s/%s\n\n---\n\n", org, repo) content += "## Objective\n\n" + issueData.Body + "\n" - coreio.Local.Write(filepath.Join(wsDir, "src", "TODO.md"), content) + _ = writeAtomic(filepath.Join(wsDir, "src", "TODO.md"), content) } diff --git a/pkg/mcp/agentic/resume.go b/pkg/mcp/agentic/resume.go index c47c539..5bd1d6b 100644 --- a/pkg/mcp/agentic/resume.go +++ b/pkg/mcp/agentic/resume.go @@ -80,7 +80,7 @@ func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, inpu if input.Answer != "" { answerPath := filepath.Join(srcDir, "ANSWER.md") content := fmt.Sprintf("# Answer\n\n%s\n", input.Answer) - if err := coreio.Local.Write(answerPath, content); err != nil { + if err := writeAtomic(answerPath, content); err != nil { return nil, ResumeOutput{}, coreerr.E("resume", "failed to write ANSWER.md", err) } } diff --git a/pkg/mcp/agentic/review_queue.go b/pkg/mcp/agentic/review_queue.go index f0fc81d..b782287 100644 --- a/pkg/mcp/agentic/review_queue.go +++ b/pkg/mcp/agentic/review_queue.go @@ -236,7 +236,7 @@ func (s *PrepSubsystem) storeReviewOutput(repoDir, repo, reviewer, output string } name := fmt.Sprintf("%s-%s-%d.json", repo, reviewer, time.Now().Unix()) - _ = coreio.Local.Write(filepath.Join(dataDir, name), string(data)) + _ = writeAtomic(filepath.Join(dataDir, name), string(data)) } func (s *PrepSubsystem) saveRateLimitState(info *RateLimitInfo) { @@ -246,7 +246,7 @@ func (s *PrepSubsystem) saveRateLimitState(info *RateLimitInfo) { if err != nil { return } - _ = coreio.Local.Write(path, string(data)) + _ = writeAtomic(path, string(data)) } func (s *PrepSubsystem) loadRateLimitState() *RateLimitInfo { diff --git a/pkg/mcp/agentic/status.go b/pkg/mcp/agentic/status.go index 78ffd58..b8c8ec6 100644 --- a/pkg/mcp/agentic/status.go +++ b/pkg/mcp/agentic/status.go @@ -56,7 +56,7 @@ func writeStatus(wsDir string, status *WorkspaceStatus) error { if err != nil { return err } - return coreio.Local.Write(filepath.Join(wsDir, "status.json"), string(data)) + return writeAtomic(filepath.Join(wsDir, "status.json"), string(data)) } func readStatus(wsDir string) (*WorkspaceStatus, error) { diff --git a/pkg/mcp/agentic/write_atomic.go b/pkg/mcp/agentic/write_atomic.go new file mode 100644 index 0000000..5faf83a --- /dev/null +++ b/pkg/mcp/agentic/write_atomic.go @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "os" + "path/filepath" + + coreio "forge.lthn.ai/core/go-io" +) + +// writeAtomic writes content to path by staging it in a temporary file and +// renaming it into place. +// +// This avoids exposing partially written workspace files to agents that may +// read status, prompt, or plan documents while they are being updated. +func writeAtomic(path, content string) error { + dir := filepath.Dir(path) + if err := coreio.Local.EnsureDir(dir); err != nil { + return err + } + + tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp") + if err != nil { + return err + } + tmpPath := tmp.Name() + + cleanup := func() { + _ = tmp.Close() + _ = os.Remove(tmpPath) + } + + if _, err := tmp.WriteString(content); err != nil { + cleanup() + return err + } + if err := tmp.Sync(); err != nil { + cleanup() + return err + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpPath) + return err + } + if err := os.Rename(tmpPath, path); err != nil { + _ = os.Remove(tmpPath) + return err + } + return nil +}