refactor(agentic): write workspace files atomically

This commit is contained in:
Virgil 2026-04-02 18:07:53 +00:00
parent 9f68a74491
commit 583abea788
6 changed files with 68 additions and 17 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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