refactor(agentic): write workspace files atomically
This commit is contained in:
parent
9f68a74491
commit
583abea788
6 changed files with 68 additions and 17 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
51
pkg/mcp/agentic/write_atomic.go
Normal file
51
pkg/mcp/agentic/write_atomic.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue