| 1 | package turnevent |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "fmt" |
| 6 | "reasonix/internal/event" |
| 7 | "reasonix/internal/eventwire" |
| 8 | "reasonix/internal/provider" |
| 9 | ) |
| 10 | |
| 11 | type OrphanTool struct { |
| 12 | ID, Name string |
| 13 | Started bool |
| 14 | } |
| 15 | type OrphanRecovery struct { |
| 16 | TurnID string |
| 17 | Tools []OrphanTool |
| 18 | } |
| 19 | |
| 20 | func (l *Ledger) OrphanRecovery() *OrphanRecovery { |
| 21 | if l == nil { |
| 22 | return nil |
| 23 | } |
| 24 | l.mu.Lock() |
| 25 | defer l.mu.Unlock() |
| 26 | if l.active == "" { |
| 27 | return nil |
| 28 | } |
| 29 | o := &OrphanRecovery{TurnID: l.active} |
| 30 | for _, r := range l.records { |
| 31 | if r.TurnID == l.active && r.Source == "ledger_reopen" && r.Kind == "tool_result" && r.Event.Tool != nil { |
| 32 | o.Tools = append(o.Tools, OrphanTool{ID: r.Event.Tool.ID, Name: r.Event.Tool.Name, Started: r.Event.Tool.RunState == provider.ToolRunUnknown}) |
| 33 | } |
| 34 | } |
| 35 | if len(o.Tools) == 0 { |
| 36 | return nil |
| 37 | } |
| 38 | return o |
| 39 | } |
| 40 | |
| 41 | func (l *Ledger) recoverToolEffects(pendingTools map[string]eventwire.Tool, pendingToolOrder []string) error { |
| 42 | if l.active != "" && !l.terminal { |
| 43 | hasUnknownEffect := false |
| 44 | for _, id := range pendingToolOrder { |
| 45 | tool, ok := pendingTools[id] |
| 46 | if !ok { |
| 47 | continue |
| 48 | } |
| 49 | state := provider.ToolRunUnknown |
| 50 | if tool.RunState == provider.ToolRunPending { |
| 51 | state = provider.ToolRunCancelled |
| 52 | } |
| 53 | if state == provider.ToolRunUnknown && !tool.ReadOnly { |
| 54 | hasUnknownEffect = true |
| 55 | } |
| 56 | result := event.Event{Kind: event.ToolResult, TurnID: l.active, Source: "ledger_reopen", Tool: event.Tool{ |
| 57 | RunState: state, AttemptID: tool.AttemptID, |
| 58 | ID: tool.ID, Name: tool.Name, ResolvedName: tool.ResolvedName, |
| 59 | CapabilityID: tool.CapabilityID, ReadOnly: tool.ReadOnly, ParentID: tool.ParentID, |
| 60 | Err: "interrupted: runtime restarted before the tool completed", |
| 61 | }} |
| 62 | if _, ok, appendErr := l.appendLocked(result, l.status); appendErr != nil || !ok { |
| 63 | return fmt.Errorf("recover orphaned tool %s in turn %s: %w", id, l.active, appendErr) |
| 64 | } |
| 65 | } |
| 66 | status := event.TurnInterrupted |
| 67 | e := event.Event{Kind: event.TurnDone, TurnID: l.active, Source: "ledger_reopen", Err: errors.New("runtime restarted before the turn reached a terminal event")} |
| 68 | if hasUnknownEffect { |
| 69 | // Preserve the uncertainty for history and model guidance without |
| 70 | // turning it into a recovery gate or a user-decision state. |
| 71 | e.Recovery = &event.RecoveryStatus{State: "unknown", Reason: "runtime_restart"} |
| 72 | } |
| 73 | e.Status = status |
| 74 | if _, ok, appendErr := l.appendLocked(e, status); appendErr != nil || !ok { |
| 75 | return fmt.Errorf("recover orphaned turn %s: %w", l.active, appendErr) |
| 76 | } |
| 77 | } |
| 78 | return nil |
| 79 | } |
| 80 |