| 1 | package control |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "fmt" |
| 6 | "strings" |
| 7 | |
| 8 | "reasonix/internal/agent" |
| 9 | "reasonix/internal/sessioninbox" |
| 10 | ) |
| 11 | |
| 12 | func steerAlreadyAdmitted(state sessioninbox.InboxState) bool { |
| 13 | switch state { |
| 14 | case sessioninbox.StateRunning, sessioninbox.StateSteerAccepted, sessioninbox.StateSteerConsumed: |
| 15 | return true |
| 16 | default: |
| 17 | return false |
| 18 | } |
| 19 | } |
| 20 | |
| 21 | func (c *Controller) readSteerCandidate(st *sessioninbox.Store, id string) (sessioninbox.InboxItemMeta, sessioninbox.PromptEnvelope, error) { |
| 22 | meta, env, err := st.ReadItem(id) |
| 23 | if err != nil || (meta.State != sessioninbox.StateRunning && meta.State != sessioninbox.StateSteerAccepted && meta.State != sessioninbox.StateSteerConsumed) { |
| 24 | return meta, env, err |
| 25 | } |
| 26 | recovered, err := st.RecoverOrphanedInFlightOwnedBy(c.inbox.ownsItem) |
| 27 | if err != nil { |
| 28 | return sessioninbox.InboxItemMeta{}, sessioninbox.PromptEnvelope{}, err |
| 29 | } |
| 30 | if recovered > 0 { |
| 31 | sessioninbox.NoteRecovered(recovered) |
| 32 | } |
| 33 | return st.ReadItem(id) |
| 34 | } |
| 35 | |
| 36 | func (c *Controller) unlockInboxSteerAdmission(dispatch *bool) { |
| 37 | c.inbox.admissionMu.Unlock() |
| 38 | if *dispatch { |
| 39 | c.maybeDispatchInbox() |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | func inboxSteerLoader(st *sessioninbox.Store, itemID string) func() (string, error) { |
| 44 | return func() (string, error) { |
| 45 | _, env, err := st.ReadItem(itemID) |
| 46 | if err != nil { |
| 47 | if errors.Is(err, sessioninbox.ErrNotFound) { |
| 48 | return "", agent.ErrSteerWithdrawn |
| 49 | } |
| 50 | return "", err |
| 51 | } |
| 52 | text := strings.TrimSpace(env.SubmitText) |
| 53 | if text == "" { |
| 54 | text = strings.TrimSpace(env.DisplayText) |
| 55 | } |
| 56 | if text == "" { |
| 57 | return "", fmt.Errorf("inbox item %s has empty body", itemID) |
| 58 | } |
| 59 | materialized, images, block, materializeErr := applyInboxReferences(env) |
| 60 | if materializeErr != nil { |
| 61 | return "", materializeErr |
| 62 | } |
| 63 | if block != "" { |
| 64 | return "", fmt.Errorf("frozen reference unavailable: %s", block) |
| 65 | } |
| 66 | if len(images) > 0 { |
| 67 | return "", fmt.Errorf("image guidance requires a follow-up turn") |
| 68 | } |
| 69 | // This compare-and-transition is the durable hand-off boundary and |
| 70 | // closes the loader-vs-cancel gap after TrySteerInboxItem returns. |
| 71 | if err := st.MarkSteerConsumed(itemID); err != nil { |
| 72 | if errors.Is(err, sessioninbox.ErrNotFound) { |
| 73 | return "", agent.ErrSteerWithdrawn |
| 74 | } |
| 75 | return "", err |
| 76 | } |
| 77 | return firstNonEmptyStr(materialized, text), nil |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | // TrySteerInboxItem persists intent=steer (if needed) and attempts mid-turn |
| 82 | // admission. Rejected steers stay queued as follow-up. |
| 83 | // |
| 84 | // The agent loader only captures the item ID and re-reads the blob on consume |
| 85 | // so large steer bodies do not accumulate in the agent heap. |
| 86 | func (c *Controller) TrySteerInboxItem(id string) (sessioninbox.InboxReceipt, error) { |
| 87 | return c.trySteerInboxItem(id, "") |
| 88 | } |
| 89 | |
| 90 | // TrySteerInboxItemForTurn applies an existing durable item only to the exact |
| 91 | // active turn. A stale target falls back to queued-follow-up semantics. |
| 92 | func (c *Controller) TrySteerInboxItemForTurn(turnID, id string) (sessioninbox.InboxReceipt, error) { |
| 93 | turnID = strings.TrimSpace(turnID) |
| 94 | if turnID == "" { |
| 95 | return sessioninbox.InboxReceipt{}, fmt.Errorf("turnId is required") |
| 96 | } |
| 97 | return c.trySteerInboxItem(id, turnID) |
| 98 | } |
| 99 | |
| 100 | func (c *Controller) trySteerInboxItem(id, expectedTurnID string) (sessioninbox.InboxReceipt, error) { |
| 101 | c.inbox.admissionMu.Lock() |
| 102 | dispatchAfterUnlock := false |
| 103 | defer c.unlockInboxSteerAdmission(&dispatchAfterUnlock) |
| 104 | st, err := c.ensureInbox() |
| 105 | if err != nil { |
| 106 | return sessioninbox.InboxReceipt{}, err |
| 107 | } |
| 108 | meta, env, err := c.readSteerCandidate(st, id) |
| 109 | if err != nil { |
| 110 | return sessioninbox.InboxReceipt{}, err |
| 111 | } |
| 112 | // RetryInboxItem may start this item while the frontend holds stale running=true. |
| 113 | // Treat the follow-up Steer as idempotent: the current turn already owns the |
| 114 | // durable body, so it must not be applied twice or reported as a false failure. |
| 115 | if steerAlreadyAdmitted(meta.State) { |
| 116 | return sessioninbox.InboxReceipt{ |
| 117 | ItemID: id, |
| 118 | Disposition: sessioninbox.DispositionSteerAccepted, |
| 119 | Paused: st.Snapshot().Paused, |
| 120 | Capacity: st.Snapshot().Capacity, |
| 121 | Idempotent: true, |
| 122 | }, nil |
| 123 | } |
| 124 | if meta.State != sessioninbox.StateQueued && meta.State != sessioninbox.StateUncertain { |
| 125 | return sessioninbox.InboxReceipt{}, sessioninbox.ErrInvalidState |
| 126 | } |
| 127 | snapshot := st.Snapshot() |
| 128 | if snapshot.Paused { |
| 129 | return sessioninbox.InboxReceipt{}, sessioninbox.ErrPaused |
| 130 | } |
| 131 | if meta.State == sessioninbox.StateUncertain { |
| 132 | if err := st.SetState(id, sessioninbox.StateQueued, ""); err != nil { |
| 133 | return sessioninbox.InboxReceipt{}, err |
| 134 | } |
| 135 | } |
| 136 | cap := snapshot.Capacity |
| 137 | c.mu.Lock() |
| 138 | rotating := c.rotating |
| 139 | closed := c.closed |
| 140 | c.mu.Unlock() |
| 141 | if closed { |
| 142 | return sessioninbox.InboxReceipt{ItemID: id, Disposition: sessioninbox.DispositionRejectedClosed, Capacity: cap}, nil |
| 143 | } |
| 144 | if rotating { |
| 145 | dispatchAfterUnlock = true |
| 146 | return sessioninbox.InboxReceipt{ItemID: id, Disposition: sessioninbox.DispositionRejectedRotating, Capacity: cap}, nil |
| 147 | } |
| 148 | // Capture only the store pointer + item id. Load body from disk at consume. |
| 149 | loader := inboxSteerLoader(st, id) |
| 150 | // Persist the admission boundary before exposing the loader to the agent. |
| 151 | // Holding c.mu for the short in-memory enqueue serializes active tracking |
| 152 | // with finishGuardedTurn, so TurnDone cannot overtake an accepted steer. |
| 153 | c.inbox.trackAdmission(id) |
| 154 | defer c.inbox.untrackAdmission(id) |
| 155 | if len(env.FrozenImages) == 0 { |
| 156 | if err := st.SetState(id, sessioninbox.StateSteerAccepted, ""); err != nil { |
| 157 | return sessioninbox.InboxReceipt{}, err |
| 158 | } |
| 159 | } |
| 160 | c.mu.Lock() |
| 161 | turnMatches := true |
| 162 | if expectedTurnID != "" { |
| 163 | turnMatches = false |
| 164 | if ledger := c.turnEventLedger(); ledger != nil { |
| 165 | turnMatches = ledger.ActiveTurnID() == expectedTurnID |
| 166 | } |
| 167 | } |
| 168 | accepted := turnMatches && !c.closed && !c.rotating && c.bodyActiveLocked() && c.executor != nil && len(env.FrozenImages) == 0 && c.executor.SteerItem(id, loader) |
| 169 | if accepted { |
| 170 | c.inbox.mu.Lock() |
| 171 | c.inbox.trackActive(id) |
| 172 | c.inbox.mu.Unlock() |
| 173 | } |
| 174 | c.mu.Unlock() |
| 175 | if accepted { |
| 176 | sessioninbox.NoteSteerAccepted() |
| 177 | return sessioninbox.InboxReceipt{ |
| 178 | ItemID: id, |
| 179 | Disposition: sessioninbox.DispositionSteerAccepted, |
| 180 | Paused: st.Snapshot().Paused, |
| 181 | Capacity: cap, |
| 182 | }, nil |
| 183 | } |
| 184 | // Rejected: keep as follow-up. |
| 185 | if len(env.FrozenImages) == 0 { |
| 186 | if err := st.SetState(id, sessioninbox.StateQueued, ""); err != nil { |
| 187 | _ = st.ForcePause(true, 1) |
| 188 | return sessioninbox.InboxReceipt{}, err |
| 189 | } |
| 190 | } |
| 191 | if err := st.ConvertIntent(id, sessioninbox.IntentFollowup); err != nil { |
| 192 | return sessioninbox.InboxReceipt{}, err |
| 193 | } |
| 194 | sessioninbox.NoteSteerRejected() |
| 195 | dispatchAfterUnlock = true |
| 196 | return sessioninbox.InboxReceipt{ |
| 197 | ItemID: id, |
| 198 | Disposition: sessioninbox.DispositionQueuedFollowup, |
| 199 | Paused: st.Snapshot().Paused, |
| 200 | Capacity: cap, |
| 201 | }, nil |
| 202 | } |
| 203 |