| 1 | package control |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | |
| 7 | "reasonix/internal/event" |
| 8 | "reasonix/internal/session" |
| 9 | ) |
| 10 | |
| 11 | // runSynchronousTurn owns the blocking transport lifecycle. Durable steer |
| 12 | // acknowledgement is intentionally shared with the asynchronous completion |
| 13 | // path, while follow-up dispatch remains owned by the synchronous frontend so |
| 14 | // its response sink stays bound for every queued turn. |
| 15 | func (c *Controller) runSynchronousTurn( |
| 16 | ctx context.Context, |
| 17 | onAdmitted func() error, |
| 18 | run func(context.Context) error, |
| 19 | ) error { |
| 20 | releaseAdmission := c.trySubmissionAdmissionLock() |
| 21 | if releaseAdmission == nil { |
| 22 | return ErrTurnRunning |
| 23 | } |
| 24 | defer releaseAdmission() |
| 25 | if err := c.authentication.admissionError(); err != nil { |
| 26 | return err |
| 27 | } |
| 28 | if err := c.ensureWriteAuthorityReady(); err != nil { |
| 29 | return err |
| 30 | } |
| 31 | if ledger := c.turnEventLedger(); ledger != nil && ledger.CurrentStatus() == event.TurnRecoveryRequired { |
| 32 | return ErrRecoveryRequired |
| 33 | } |
| 34 | parent := ctx |
| 35 | c.mu.Lock() |
| 36 | // Finishing is part of the gate: TurnDone is still fanning out. Closed |
| 37 | // seals a torn-down controller. Blocking callers get an error rather than |
| 38 | // parking because they already own and enforce the request boundary. |
| 39 | if c.bodyActiveLocked() || c.finalizingLocked() || c.rotating || c.closed || c.recoveryRequiredLocked() { |
| 40 | c.mu.Unlock() |
| 41 | return ErrTurnRunning |
| 42 | } |
| 43 | if c.rejectDrainingGenerationLocked() { |
| 44 | c.mu.Unlock() |
| 45 | c.emitDrainingNotice() |
| 46 | return ErrRuntimeDraining |
| 47 | } |
| 48 | ctx, cancel, admitted := c.startTurnLocked(ctx, queuedTurn{}) |
| 49 | if !admitted { |
| 50 | c.mu.Unlock() |
| 51 | c.emitDrainingNotice() |
| 52 | return ErrRuntimeDraining |
| 53 | } |
| 54 | c.mu.Unlock() |
| 55 | if parent != nil { |
| 56 | stop := context.AfterFunc(parent, func() { c.signalTurnCancel() }) |
| 57 | defer stop() |
| 58 | } |
| 59 | c.refreshRuntimeState(event.Event{}) |
| 60 | finish := func() { |
| 61 | c.mu.Lock() |
| 62 | if c.turns.done != nil { |
| 63 | close(c.turns.done) |
| 64 | c.turns.done = nil |
| 65 | } |
| 66 | c.turns.cancel = nil |
| 67 | c.turns.cancelRequested = false |
| 68 | closing := c.closed |
| 69 | recovery := c.turns.phase == session.RuntimeRecoveryRequired |
| 70 | c.turns.finishingBound.end() |
| 71 | c.turns.finishingBound.endIdle() |
| 72 | if !recovery { |
| 73 | c.turns.lastToken = c.turns.token |
| 74 | if closing { |
| 75 | c.turns.phase = session.RuntimeClosed |
| 76 | } else { |
| 77 | c.turns.phase = session.RuntimeIdle |
| 78 | } |
| 79 | c.turns.turnID = "" |
| 80 | c.noteExecutionLocked(session.RuntimeIdle, "") |
| 81 | } |
| 82 | c.mu.Unlock() |
| 83 | if closing { |
| 84 | c.finalizeControllerClose() |
| 85 | } |
| 86 | c.refreshRuntimeState(event.Event{}) |
| 87 | c.kickGoalDriver() |
| 88 | cancel() |
| 89 | } |
| 90 | if onAdmitted != nil { |
| 91 | if err := onAdmitted(); err != nil { |
| 92 | finish() |
| 93 | return err |
| 94 | } |
| 95 | } |
| 96 | defer event.RecordTurnCompletion(c.sink) |
| 97 | defer func() { |
| 98 | finish() |
| 99 | c.onInboxTurnDone() |
| 100 | }() |
| 101 | // Blocking transports use the same host-owned turn boundary as interactive |
| 102 | // submissions. The agent's TurnStarted notification is deliberately a |
| 103 | // duplicate display event; it cannot create runtime ownership or a durable |
| 104 | // turn by itself. |
| 105 | run = c.prepareTurnAdmission(run) |
| 106 | releaseAdmission() |
| 107 | runErr := run(ctx) |
| 108 | c.authentication.recordFailure(runErr, c.ModelRef()) |
| 109 | // Keep the execution binding through the synchronous terminal commit just |
| 110 | // like the asynchronous loop. Close may make the public controller view |
| 111 | // closed here, but it cannot release the ledger/session underneath TurnDone. |
| 112 | c.mu.Lock() |
| 113 | if c.turns.done != nil { |
| 114 | close(c.turns.done) |
| 115 | c.turns.done = nil |
| 116 | } |
| 117 | c.turns.cancel = nil |
| 118 | if c.turns.phase != session.RuntimeRecoveryRequired { |
| 119 | c.turns.phase = session.RuntimeFinalizing |
| 120 | c.turns.finishingBound.begin(true) |
| 121 | c.noteExecutionLocked(session.RuntimeFinalizing, "turn") |
| 122 | } |
| 123 | c.mu.Unlock() |
| 124 | c.refreshRuntimeState(event.Event{}) |
| 125 | if ledger := c.turnEventLedger(); ledger != nil && ledger.ActiveTurnID() != "" && !ledger.CurrentStatus().Terminal() { |
| 126 | cancelled := errors.Is(ctx.Err(), context.Canceled) |
| 127 | done := event.Event{Kind: event.TurnDone, Err: runErr, Cancelled: cancelled, Outcome: turnOutcome(runErr)} |
| 128 | if cancelled { |
| 129 | done.Status = event.TurnInterrupted |
| 130 | } else if runErr != nil { |
| 131 | done.Status = event.TurnFailed |
| 132 | } else { |
| 133 | done.Status = event.TurnCompleted |
| 134 | } |
| 135 | if terminalErr := c.emitTurnEventChecked(done); terminalErr != nil { |
| 136 | runErr = errors.Join(runErr, terminalErr) |
| 137 | } |
| 138 | } |
| 139 | return runErr |
| 140 | } |
| 141 |