From 3d72e53f3cfc4ef6ba210c151e21e9a58e260c67 Mon Sep 17 00:00:00 2001 From: DBT Date: Tue, 24 Feb 2026 02:07:06 +0000 Subject: [PATCH] add autonomy trigger-audit logging and structured block reason persistence --- pkg/autonomy/engine.go | 79 +++++++++++++++++++++++++++++++++----- pkg/autonomy/task_store.go | 1 + 2 files changed, 71 insertions(+), 9 deletions(-) diff --git a/pkg/autonomy/engine.go b/pkg/autonomy/engine.go index cde480b..cb31e5b 100644 --- a/pkg/autonomy/engine.go +++ b/pkg/autonomy/engine.go @@ -40,6 +40,7 @@ type taskState struct { Priority string DueAt string Status string // idle|running|waiting|blocked|completed + BlockReason string LastRunAt time.Time LastAutonomyAt time.Time RetryAfter time.Time @@ -120,6 +121,18 @@ func (e *Engine) tick() { stored, _ := e.taskStore.Load() e.mu.Lock() + if e.hasManualPause() { + for _, st := range e.state { + if st.Status == "running" { + st.Status = "waiting" + st.BlockReason = "manual_pause" + e.writeReflectLog("waiting", st, "paused by manual switch") + } + } + e.persistStateLocked() + e.mu.Unlock() + return + } if e.hasRecentUserActivity(now) { for _, st := range e.state { if st.Status == "running" { @@ -208,6 +221,7 @@ func (e *Engine) tick() { } if st.Status == "waiting" { st.Status = "idle" + st.BlockReason = "" e.writeReflectLog("resume", st, "user conversation idle, autonomy resumed") } if st.Status == "blocked" { @@ -216,6 +230,7 @@ func (e *Engine) tick() { } if now.Sub(st.LastRunAt) >= blockedRetryBackoff(st.ConsecutiveStall, e.opts.MinRunIntervalSec) { st.Status = "idle" + st.BlockReason = "" } else { continue } @@ -227,6 +242,7 @@ func (e *Engine) tick() { st.ConsecutiveStall++ if st.ConsecutiveStall > e.opts.MaxConsecutiveStalls { st.Status = "blocked" + st.BlockReason = "max_consecutive_stalls" st.RetryAfter = now.Add(blockedRetryBackoff(st.ConsecutiveStall, e.opts.MinRunIntervalSec)) e.sendFailureNotification(st, "max consecutive stalls reached") continue @@ -235,9 +251,11 @@ func (e *Engine) tick() { e.dispatchTask(st) st.Status = "running" + st.BlockReason = "" st.LastRunAt = now st.LastAutonomyAt = now e.writeReflectLog("dispatch", st, "task dispatched to agent loop") + e.writeTriggerAudit("dispatch", st, "") dispatched++ } e.persistStateLocked() @@ -348,6 +366,7 @@ func (e *Engine) dispatchTask(st *taskState) { func (e *Engine) sendCompletionNotification(st *taskState) { e.writeReflectLog("complete", st, "task marked completed") + e.writeTriggerAudit("complete", st, "") if !e.shouldNotify("done:" + st.ID) { return } @@ -360,6 +379,7 @@ func (e *Engine) sendCompletionNotification(st *taskState) { func (e *Engine) sendFailureNotification(st *taskState, reason string) { e.writeReflectLog("blocked", st, reason) + e.writeTriggerAudit("blocked", st, reason) if !e.shouldNotify("blocked:" + st.ID) { return } @@ -387,6 +407,30 @@ func (e *Engine) shouldNotify(key string) bool { return true } +func (e *Engine) writeTriggerAudit(action string, st *taskState, errText string) { + if strings.TrimSpace(e.opts.Workspace) == "" || st == nil { + return + } + path := filepath.Join(e.opts.Workspace, "memory", "trigger-audit.jsonl") + _ = os.MkdirAll(filepath.Dir(path), 0755) + row := map[string]interface{}{ + "time": time.Now().UTC().Format(time.RFC3339), + "trigger": "autonomy", + "action": action, + "session": "autonomy:" + st.ID, + } + if strings.TrimSpace(errText) != "" { + row["error"] = errText + } + if b, err := json.Marshal(row); err == nil { + f, oErr := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if oErr == nil { + _, _ = f.Write(append(b, '\n')) + _ = f.Close() + } + } +} + func (e *Engine) writeReflectLog(stage string, st *taskState, outcome string) { if strings.TrimSpace(e.opts.Workspace) == "" || st == nil { return @@ -457,15 +501,16 @@ func (e *Engine) persistStateLocked() { retryAfter = st.RetryAfter.UTC().Format(time.RFC3339) } items = append(items, TaskItem{ - ID: st.ID, - Content: st.Content, - Priority: st.Priority, - DueAt: st.DueAt, - Status: status, - RetryAfter: retryAfter, - Source: "memory_todo", - DedupeHits: st.DedupeHits, - UpdatedAt: nowRFC3339(), + ID: st.ID, + Content: st.Content, + Priority: st.Priority, + DueAt: st.DueAt, + Status: status, + BlockReason: st.BlockReason, + RetryAfter: retryAfter, + Source: "memory_todo", + DedupeHits: st.DedupeHits, + UpdatedAt: nowRFC3339(), }) } _ = e.taskStore.Save(items) @@ -526,6 +571,22 @@ func dueWeight(dueAt string) int64 { return 0 } +func (e *Engine) pauseFilePath() string { + if strings.TrimSpace(e.opts.Workspace) == "" { + return "" + } + return filepath.Join(e.opts.Workspace, "memory", "autonomy.pause") +} + +func (e *Engine) hasManualPause() bool { + p := e.pauseFilePath() + if p == "" { + return false + } + _, err := os.Stat(p) + return err == nil +} + func (e *Engine) hasRecentUserActivity(now time.Time) bool { if e.opts.UserIdleResumeSec <= 0 || strings.TrimSpace(e.opts.Workspace) == "" { return false diff --git a/pkg/autonomy/task_store.go b/pkg/autonomy/task_store.go index d87fdac..dc908ed 100644 --- a/pkg/autonomy/task_store.go +++ b/pkg/autonomy/task_store.go @@ -15,6 +15,7 @@ type TaskItem struct { Priority string `json:"priority"` DueAt string `json:"due_at,omitempty"` Status string `json:"status"` // todo|doing|waiting|blocked|done + BlockReason string `json:"block_reason,omitempty"` RetryAfter string `json:"retry_after,omitempty"` Source string `json:"source"` DedupeHits int `json:"dedupe_hits,omitempty"`