fix(agentic): surface persistence failures

Add warnings for silent filesystem write/delete failures in agentic persistence helpers and record two adjacent hardening gaps for follow-up.\n\nCo-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-04-17 20:36:14 +01:00
parent db3ddc133e
commit 7bb5c31746
6 changed files with 89 additions and 41 deletions

View file

@ -0,0 +1,2 @@
- @hardening pkg/agentic/prep.go:892 — `fs.EnsureDir(specsDir)` ignores failures while seeding workspace specs, so template scaffolding can silently half-complete.
- @hardening pkg/agentic/prep.go:904 — `fs.EnsureDir(core.PathDir(dst))` ignores failures during template copy, which can leave copied files missing without a warning.

View file

@ -593,7 +593,9 @@ func (s *PrepSubsystem) spawnAgent(agent, prompt, workspaceDir string) (int, str
metaDir := WorkspaceMetaDir(workspaceDir)
outputFile := agentOutputFile(workspaceDir, agent)
fs.Delete(WorkspaceBlockedPath(workspaceDir))
if deleteResult := fs.Delete(WorkspaceBlockedPath(workspaceDir)); !deleteResult.OK {
core.Warn("agentic: failed to remove blocked marker", "path", WorkspaceBlockedPath(workspaceDir), "reason", deleteResult.Value)
}
if !isNativeAgent(agent) {
runtimeName := resolveContainerRuntime(s.dispatchRuntime())

View file

@ -80,20 +80,20 @@ type QAReport struct {
//
// Usage example: `report := DispatchReport{Summary: map[string]any{"finding": 12, "tool_run": 3}, Findings: findings, Tools: tools, BuildPassed: true, TestPassed: true}`
type DispatchReport struct {
Workspace string `json:"workspace"`
Commit string `json:"commit,omitempty"`
Summary map[string]any `json:"summary"`
Findings []QAFinding `json:"findings,omitempty"`
Tools []QAToolRun `json:"tools,omitempty"`
BuildPassed bool `json:"build_passed"`
TestPassed bool `json:"test_passed"`
LintPassed bool `json:"lint_passed"`
Passed bool `json:"passed"`
GeneratedAt time.Time `json:"generated_at"`
New []map[string]any `json:"new,omitempty"`
Resolved []map[string]any `json:"resolved,omitempty"`
Persistent []map[string]any `json:"persistent,omitempty"`
Clusters []DispatchCluster `json:"clusters,omitempty"`
Workspace string `json:"workspace"`
Commit string `json:"commit,omitempty"`
Summary map[string]any `json:"summary"`
Findings []QAFinding `json:"findings,omitempty"`
Tools []QAToolRun `json:"tools,omitempty"`
BuildPassed bool `json:"build_passed"`
TestPassed bool `json:"test_passed"`
LintPassed bool `json:"lint_passed"`
Passed bool `json:"passed"`
GeneratedAt time.Time `json:"generated_at"`
New []map[string]any `json:"new,omitempty"`
Resolved []map[string]any `json:"resolved,omitempty"`
Persistent []map[string]any `json:"persistent,omitempty"`
Clusters []DispatchCluster `json:"clusters,omitempty"`
}
// DispatchCluster groups similar findings together so human reviewers can see
@ -103,11 +103,11 @@ type DispatchReport struct {
//
// Usage example: `cluster := DispatchCluster{Tool: "gosec", Severity: "error", Category: "security", Count: 3, RuleID: "G101"}`
type DispatchCluster struct {
Tool string `json:"tool,omitempty"`
Severity string `json:"severity,omitempty"`
Category string `json:"category,omitempty"`
RuleID string `json:"rule_id,omitempty"`
Count int `json:"count"`
Tool string `json:"tool,omitempty"`
Severity string `json:"severity,omitempty"`
Category string `json:"category,omitempty"`
RuleID string `json:"rule_id,omitempty"`
Count int `json:"count"`
Samples []DispatchClusterSample `json:"samples,omitempty"`
}
@ -461,14 +461,18 @@ func writeDispatchReport(workspaceDir string, report DispatchReport) {
return
}
metaDir := WorkspaceMetaDir(workspaceDir)
if !fs.EnsureDir(metaDir).OK {
if ensureResult := fs.EnsureDir(metaDir); !ensureResult.OK {
core.Warn("agentic: failed to prepare dispatch report directory", "path", metaDir, "reason", ensureResult.Value)
return
}
payload := core.JSONMarshalString(report)
if payload == "" {
return
}
fs.WriteAtomic(core.JoinPath(metaDir, "report.json"), payload)
reportPath := core.JoinPath(metaDir, "report.json")
if writeResult := fs.WriteAtomic(reportPath, payload); !writeResult.OK {
core.Warn("agentic: failed to write dispatch report", "path", reportPath, "reason", writeResult.Value)
}
}
// stringOutput extracts the process output from a core.Result, returning an
@ -760,4 +764,3 @@ func clusterLess(left, right DispatchCluster) bool {
}
return left.RuleID < right.RuleID
}

View file

@ -389,12 +389,17 @@ func (s *PrepSubsystem) buildReviewCommand(repoDir, reviewer string) (string, []
// s.storeReviewOutput(repoDir, "go-io", "coderabbit", output)
func (s *PrepSubsystem) storeReviewOutput(repoDir, repo, reviewer, output string) {
dataDir := core.JoinPath(HomeDir(), ".core", "training", "reviews")
fs.EnsureDir(dataDir)
if ensureResult := fs.EnsureDir(dataDir); !ensureResult.OK {
core.Warn("reviewQueue: failed to prepare review output directory", "path", dataDir, "reason", ensureResult.Value)
return
}
timestamp := time.Now().Format("2006-01-02T15-04-05")
filename := core.Sprintf("%s_%s_%s.txt", repo, reviewer, timestamp)
fs.Write(core.JoinPath(dataDir, filename), output)
outputPath := core.JoinPath(dataDir, filename)
if writeResult := fs.Write(outputPath, output); !writeResult.OK {
core.Warn("reviewQueue: failed to write review output", "path", outputPath, "reason", writeResult.Value)
}
entry := map[string]string{
"repo": repo,
@ -411,6 +416,7 @@ func (s *PrepSubsystem) storeReviewOutput(repoDir, repo, reviewer, output string
jsonlPath := core.JoinPath(dataDir, "reviews.jsonl")
r := fs.Append(jsonlPath)
if !r.OK {
core.Warn("reviewQueue: failed to open review journal", "path", jsonlPath, "reason", r.Value)
return
}
core.WriteAll(r.Value, core.Concat(jsonLine, "\n"))

View file

@ -101,14 +101,20 @@ func (s *PrepSubsystem) persistRuntimeState() {
}
if len(state.Backoff) == 0 && len(state.FailCount) == 0 {
fs.Delete(runtimeStatePath())
if deleteResult := fs.Delete(runtimeStatePath()); !deleteResult.OK {
core.Warn("agentic: failed to delete runtime state file", "path", runtimeStatePath(), "reason", deleteResult.Value)
}
s.stateStoreDelete(stateRuntimeGroup, "backoff")
s.stateStoreDelete(stateRuntimeGroup, "fail_count")
return
}
fs.EnsureDir(runtimeStateDir())
fs.WriteAtomic(runtimeStatePath(), core.JSONMarshalString(state))
if ensureResult := fs.EnsureDir(runtimeStateDir()); !ensureResult.OK {
core.Warn("agentic: failed to prepare runtime state directory", "path", runtimeStateDir(), "reason", ensureResult.Value)
}
if writeResult := fs.WriteAtomic(runtimeStatePath(), core.JSONMarshalString(state)); !writeResult.OK {
core.Warn("agentic: failed to write runtime state", "path", runtimeStatePath(), "reason", writeResult.Value)
}
// Mirror the authoritative JSON to the go-store cache so restarts see
// the same state even when the JSON file is archived or rotated.

View file

@ -335,11 +335,18 @@ func readSyncLedger() map[string]string {
// can skip workspaces that have already been pushed.
func writeSyncLedger(ledger map[string]string) {
if len(ledger) == 0 {
fs.Delete(syncLedgerPath())
if deleteResult := fs.Delete(syncLedgerPath()); !deleteResult.OK {
core.Warn("agentic: failed to delete sync ledger", "path", syncLedgerPath(), "reason", deleteResult.Value)
}
return
}
fs.EnsureDir(syncStateDir())
fs.WriteAtomic(syncLedgerPath(), core.JSONMarshalString(ledger))
if ensureResult := fs.EnsureDir(syncStateDir()); !ensureResult.OK {
core.Warn("agentic: failed to prepare sync ledger directory", "path", syncStateDir(), "reason", ensureResult.Value)
return
}
if writeResult := fs.WriteAtomic(syncLedgerPath(), core.JSONMarshalString(ledger)); !writeResult.OK {
core.Warn("agentic: failed to write sync ledger", "path", syncLedgerPath(), "reason", writeResult.Value)
}
}
// markDispatchesSynced records which dispatches were successfully pushed so
@ -438,11 +445,18 @@ func readSyncQueue() []syncQueuedPush {
func writeSyncQueue(queued []syncQueuedPush) {
if len(queued) == 0 {
fs.Delete(syncQueuePath())
if deleteResult := fs.Delete(syncQueuePath()); !deleteResult.OK {
core.Warn("agentic: failed to delete sync queue", "path", syncQueuePath(), "reason", deleteResult.Value)
}
return
}
fs.EnsureDir(syncStateDir())
fs.WriteAtomic(syncQueuePath(), core.JSONMarshalString(queued))
if ensureResult := fs.EnsureDir(syncStateDir()); !ensureResult.OK {
core.Warn("agentic: failed to prepare sync queue directory", "path", syncStateDir(), "reason", ensureResult.Value)
return
}
if writeResult := fs.WriteAtomic(syncQueuePath(), core.JSONMarshalString(queued)); !writeResult.OK {
core.Warn("agentic: failed to write sync queue", "path", syncQueuePath(), "reason", writeResult.Value)
}
}
// syncQueueStoreKey is the canonical key for the sync queue inside go-store —
@ -499,8 +513,13 @@ func readSyncContext() []map[string]any {
}
func writeSyncContext(contextData []map[string]any) {
fs.EnsureDir(syncStateDir())
fs.WriteAtomic(syncContextPath(), core.JSONMarshalString(contextData))
if ensureResult := fs.EnsureDir(syncStateDir()); !ensureResult.OK {
core.Warn("agentic: failed to prepare sync context directory", "path", syncStateDir(), "reason", ensureResult.Value)
return
}
if writeResult := fs.WriteAtomic(syncContextPath(), core.JSONMarshalString(contextData)); !writeResult.OK {
core.Warn("agentic: failed to write sync context", "path", syncContextPath(), "reason", writeResult.Value)
}
}
func syncContextPayload(payload map[string]any) []map[string]any {
@ -573,8 +592,13 @@ func readSyncStatusState() syncStatusState {
}
func writeSyncStatusState(state syncStatusState) {
fs.EnsureDir(syncStateDir())
fs.WriteAtomic(syncStatusPath(), core.JSONMarshalString(state))
if ensureResult := fs.EnsureDir(syncStateDir()); !ensureResult.OK {
core.Warn("agentic: failed to prepare sync status directory", "path", syncStateDir(), "reason", ensureResult.Value)
return
}
if writeResult := fs.WriteAtomic(syncStatusPath(), core.JSONMarshalString(state)); !writeResult.OK {
core.Warn("agentic: failed to write sync status", "path", syncStatusPath(), "reason", writeResult.Value)
}
}
func syncRecordsPath() string {
@ -597,8 +621,13 @@ func readSyncRecords() []SyncRecord {
}
func writeSyncRecords(records []SyncRecord) {
fs.EnsureDir(syncStateDir())
fs.WriteAtomic(syncRecordsPath(), core.JSONMarshalString(records))
if ensureResult := fs.EnsureDir(syncStateDir()); !ensureResult.OK {
core.Warn("agentic: failed to prepare sync records directory", "path", syncStateDir(), "reason", ensureResult.Value)
return
}
if writeResult := fs.WriteAtomic(syncRecordsPath(), core.JSONMarshalString(records)); !writeResult.OK {
core.Warn("agentic: failed to write sync records", "path", syncRecordsPath(), "reason", writeResult.Value)
}
}
func recordSyncHistory(direction, agentID string, fleetNodeID, payloadSize, itemsCount int, at time.Time) {