| 1 | package control |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | |
| 8 | "reasonix/internal/agent" |
| 9 | "reasonix/internal/event" |
| 10 | "reasonix/internal/extension" |
| 11 | "reasonix/internal/extension/dispatch" |
| 12 | ) |
| 13 | |
| 14 | // ClearSession discards the current conversation without preserving it in |
| 15 | // resume/history, then rotates to a clean session carrying the same base system |
| 16 | // prompt and no pinned context. |
| 17 | func (c *Controller) ClearSession() error { |
| 18 | if c.executor == nil { |
| 19 | return nil |
| 20 | } |
| 21 | // Same rotation gate as NewSession: hold it across the whole |
| 22 | // destroy-then-swap so a turn cannot start during the sequence and have its |
| 23 | // live session replaced. |
| 24 | if err := c.beginRotation(); err != nil { |
| 25 | if errors.Is(err, errTurnRunningRotation) { |
| 26 | return fmt.Errorf("cannot clear while a turn is running: %w", err) |
| 27 | } |
| 28 | return err |
| 29 | } |
| 30 | defer c.endRotation() |
| 31 | if c.sessionEngineEnabled() { |
| 32 | return c.rotateExclusiveSession(true) |
| 33 | } |
| 34 | c.mu.Lock() |
| 35 | oldPath := c.sessionPath |
| 36 | c.mu.Unlock() |
| 37 | preMarkedCleanup := c.hasUnfinishedSessionJobs(oldPath) |
| 38 | if preMarkedCleanup { |
| 39 | if err := agent.MarkCleanupPending(oldPath, "clear"); err != nil { |
| 40 | return err |
| 41 | } |
| 42 | } |
| 43 | // Retire the old recovery state before deleting its artifacts. Async gate |
| 44 | // snapshots are path-bound, so wait for every already-scheduled old-path |
| 45 | // write; otherwise one can recreate the sidecar after removeSessionArtifacts. |
| 46 | c.loadRecoveryState("") |
| 47 | c.flushRecoveryPersistence(oldPath) |
| 48 | // Let session_policy rule before destroying artifacts. A required failure |
| 49 | // keeps the old session intact; the fresh path arrives with session.start. |
| 50 | if err := c.extensionSessionPhase(context.Background(), extension.PointSessionRotate, dispatch.PhaseRotate, oldPath); err != nil { |
| 51 | return err |
| 52 | } |
| 53 | // Hold snapshotMu from artifact removal through the swap: a save slipping |
| 54 | // in between would resurrect the just-removed transcript, and one that |
| 55 | // overlapped the swap could pair the old path with the fresh session. |
| 56 | c.snapshotMu.Lock() |
| 57 | c.detachInboxForDiscard(oldPath) |
| 58 | destroy := c.BeginDestroySession(oldPath) |
| 59 | if !destroy.Async { |
| 60 | if err := removeSessionArtifacts(oldPath); err != nil { |
| 61 | destroy.Finish() |
| 62 | c.snapshotMu.Unlock() |
| 63 | c.rebindInbox() |
| 64 | return err |
| 65 | } |
| 66 | destroy.Finish() |
| 67 | } |
| 68 | freshPath := oldPath |
| 69 | if c.sessionDir != "" { |
| 70 | freshPath = agent.NewSessionPath(c.sessionDir, c.label) |
| 71 | } |
| 72 | freshSession := agent.NewSession(c.basePrompt()) |
| 73 | commitTransition, err := c.prepareSessionTransition(freshPath, "clear", freshSession) |
| 74 | if err != nil { |
| 75 | if destroy.Async { |
| 76 | destroy.Finish() |
| 77 | } |
| 78 | c.snapshotMu.Unlock() |
| 79 | c.rebindInbox() |
| 80 | return fmt.Errorf("bind cleared session: %w", err) |
| 81 | } |
| 82 | c.hooks.SessionEnd(context.Background(), "clear") |
| 83 | c.extensionSessionEvent(extension.PointSessionEnd, dispatch.PhaseEnd, oldPath) |
| 84 | commitTransition.publish() |
| 85 | c.bindExecutorProjection(c.SessionPath(), false) |
| 86 | if c.guardianSess != nil { |
| 87 | c.guardianSess.Reset() |
| 88 | } |
| 89 | c.ResetPlannerSession() |
| 90 | c.rebindCheckpoints(freshPath) |
| 91 | seedErr := c.seedSessionEventsFromExecutor("session-clear") |
| 92 | c.resetRecoveryForNewSession(freshPath) |
| 93 | c.rotateSessionTemp() |
| 94 | c.snapshotMu.Unlock() |
| 95 | c.rebindInbox() |
| 96 | // Same contract as NewSession: the fresh session starts with no active goal. |
| 97 | c.ClearGoal() |
| 98 | c.mu.Lock() |
| 99 | c.startedOnce = true |
| 100 | c.mu.Unlock() |
| 101 | c.hooks.SetSessionID(c.parentSessionID()) |
| 102 | c.enqueueHookContexts(c.hooks.SessionStart(context.Background(), "clear")) |
| 103 | c.extensionSessionEvent(extension.PointSessionStart, dispatch.PhaseStart, c.SessionPath()) |
| 104 | c.clearSessionWriteAccess() |
| 105 | if destroy.Async { |
| 106 | go func() { |
| 107 | result := destroy.Wait() |
| 108 | if result.HasTimedOut() && destroy.WaitAll != nil { |
| 109 | if err := agent.MarkCleanupPending(oldPath, "clear"); err != nil { |
| 110 | c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "mark cleanup pending failed: " + err.Error()}) |
| 111 | } |
| 112 | destroy.WaitAll() |
| 113 | } |
| 114 | if err := removeSessionArtifacts(oldPath); err != nil { |
| 115 | c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "clear session cleanup failed: " + err.Error()}) |
| 116 | } |
| 117 | destroy.Finish() |
| 118 | }() |
| 119 | } |
| 120 | if seedErr != nil { |
| 121 | return fmt.Errorf("seed cleared session events: %w", seedErr) |
| 122 | } |
| 123 | return nil |
| 124 | } |
| 125 | |
| 126 | // detachInboxForDiscard releases the old inbox transaction lock before a |
| 127 | // destructive session clear. Windows does not permit removing an open lock |
| 128 | // file, while Unix silently unlinks it, so the ordering must be explicit. |
| 129 | func (c *Controller) detachInboxForDiscard(sessionPath string) { |
| 130 | c.inbox.scanMu.Lock() |
| 131 | defer c.inbox.scanMu.Unlock() |
| 132 | c.inbox.mu.Lock() |
| 133 | defer c.inbox.mu.Unlock() |
| 134 | if c.inbox.store == nil || c.inbox.store.SessionPath() != sessionPath { |
| 135 | return |
| 136 | } |
| 137 | c.inbox.store.Close() |
| 138 | c.inbox.store = nil |
| 139 | c.inbox.clearActive() |
| 140 | } |
| 141 |