| 1 | package control |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "fmt" |
| 6 | "log/slog" |
| 7 | "strings" |
| 8 | "sync" |
| 9 | |
| 10 | "reasonix/internal/agent" |
| 11 | ) |
| 12 | |
| 13 | // SessionLeaseKeeper owns at most one session lease on behalf of a frontend |
| 14 | // that binds session files for writing (the CLI chat/run commands, `reasonix |
| 15 | // serve`, one ACP session). Desktop tabs keep their own per-tab lease |
| 16 | // management; this keeper is the equivalent for the single-session surfaces: |
| 17 | // it follows the active session path across resumes, forks, and fresh-session |
| 18 | // rotations, holding exactly one lease at a time. |
| 19 | // |
| 20 | // The zero value is not ready for use; construct with NewSessionLeaseKeeper. |
| 21 | type SessionLeaseKeeper struct { |
| 22 | mu sync.Mutex |
| 23 | lease *agent.SessionLease |
| 24 | controller *Controller |
| 25 | retired []<-chan struct{} |
| 26 | ownershipBinder func(*Controller, *SessionLeaseKeeper) |
| 27 | } |
| 28 | |
| 29 | func NewSessionLeaseKeeper() *SessionLeaseKeeper { |
| 30 | return &SessionLeaseKeeper{} |
| 31 | } |
| 32 | |
| 33 | // Rebind points the keeper at path: it acquires path's session lease and only |
| 34 | // then releases the previously held one, so the outgoing session stays |
| 35 | // protected until the new one is secured. Rebinding to the path already held |
| 36 | // is a no-op; an empty path (session persistence disabled) just releases. |
| 37 | // On failure the keeper is unchanged — the caller still holds its previous |
| 38 | // lease and must not bind path for writing. A held path surfaces as an error |
| 39 | // wrapping agent.ErrSessionLeaseHeld; format it with SessionInUseMessage. |
| 40 | func (k *SessionLeaseKeeper) Rebind(path string) error { |
| 41 | return k.rebindWith(path, agent.TryAcquireSessionLease) |
| 42 | } |
| 43 | |
| 44 | // RebindWithHandoff consumes an explicit cross-process lease reservation. It |
| 45 | // has the same failure-atomic ownership semantics as Rebind. |
| 46 | func (k *SessionLeaseKeeper) RebindWithHandoff(path, sourceWriterID, handoffID string) error { |
| 47 | return k.rebindWith(path, func(target string) (*agent.SessionLease, error) { |
| 48 | return agent.TryAcquireSessionLeaseWithHandoff(target, sourceWriterID, handoffID) |
| 49 | }) |
| 50 | } |
| 51 | |
| 52 | func (k *SessionLeaseKeeper) rebindWith(path string, acquire func(string) (*agent.SessionLease, error)) error { |
| 53 | if k == nil { |
| 54 | return nil |
| 55 | } |
| 56 | k.mu.Lock() |
| 57 | defer k.mu.Unlock() |
| 58 | if strings.TrimSpace(path) == "" { |
| 59 | k.releaseLocked() |
| 60 | return nil |
| 61 | } |
| 62 | if k.lease != nil && k.lease.Path() == agent.CanonicalSessionPath(path) { |
| 63 | return nil |
| 64 | } |
| 65 | lease, err := acquire(path) |
| 66 | if err != nil { |
| 67 | return err |
| 68 | } |
| 69 | k.releaseLocked() |
| 70 | k.lease = lease |
| 71 | return nil |
| 72 | } |
| 73 | |
| 74 | // ReleaseForHandoff drops the keeper's current ownership into a reservation |
| 75 | // for targetWriterID. The controller loses write authority only after the |
| 76 | // reservation is durably published. |
| 77 | func (k *SessionLeaseKeeper) ReleaseForHandoff(targetWriterID, handoffID string) error { |
| 78 | if k == nil { |
| 79 | return nil |
| 80 | } |
| 81 | k.mu.Lock() |
| 82 | defer k.mu.Unlock() |
| 83 | if k.lease == nil { |
| 84 | return fmt.Errorf("no session lease held") |
| 85 | } |
| 86 | if err := k.lease.ReleaseForHandoff(targetWriterID, handoffID); err != nil { |
| 87 | return err |
| 88 | } |
| 89 | k.lease = nil |
| 90 | k.unbindControllerLocked() |
| 91 | return nil |
| 92 | } |
| 93 | |
| 94 | // RebindReturningCurrent acquires path before returning the currently held |
| 95 | // session through its reverse reservation. It is the failure-atomic switch |
| 96 | // primitive for an external writer that wants to leave a mirrored session: |
| 97 | // if either the target acquire or reservation publication fails, the keeper |
| 98 | // remains bound to the current session. |
| 99 | func (k *SessionLeaseKeeper) RebindReturningCurrent(path, targetWriterID, handoffID string) error { |
| 100 | return k.rebindReturningCurrentWith(path, targetWriterID, handoffID, agent.TryAcquireSessionLease) |
| 101 | } |
| 102 | |
| 103 | // RebindWithHandoffReturningCurrent is the two-sided handoff variant: acquire |
| 104 | // the new session through its forward reservation, then return the current |
| 105 | // session through its reverse reservation as one keeper transaction. If both |
| 106 | // the source return and the target rollback reservation fail, the source is |
| 107 | // restored and pending retains the live target lease for retry. Callers must |
| 108 | // keep pending until RetireDetachedForHandoff succeeds. |
| 109 | func (k *SessionLeaseKeeper) RebindWithHandoffReturningCurrent( |
| 110 | path, sourceWriterID, acquireHandoffID, acquiredReturnHandoffID, targetWriterID, returnHandoffID string, |
| 111 | ) (pending *SessionLeaseKeeper, err error) { |
| 112 | return k.rebindWithHandoffReturningCurrentWith( |
| 113 | path, |
| 114 | func(target string) (*agent.SessionLease, error) { |
| 115 | return agent.TryAcquireSessionLeaseWithHandoff(target, sourceWriterID, acquireHandoffID) |
| 116 | }, |
| 117 | func(previous *SessionLeaseKeeper) error { |
| 118 | return previous.RetireDetachedForHandoff(targetWriterID, returnHandoffID) |
| 119 | }, |
| 120 | func(target *agent.SessionLease) error { |
| 121 | return target.ReleaseForHandoff(sourceWriterID, acquiredReturnHandoffID) |
| 122 | }, |
| 123 | ) |
| 124 | } |
| 125 | |
| 126 | func (k *SessionLeaseKeeper) rebindWithHandoffReturningCurrentWith( |
| 127 | path string, |
| 128 | acquire func(string) (*agent.SessionLease, error), |
| 129 | returnPrevious func(*SessionLeaseKeeper) error, |
| 130 | returnTarget func(*agent.SessionLease) error, |
| 131 | ) (*SessionLeaseKeeper, error) { |
| 132 | previous, err := k.rebindDetachingWith(path, acquire) |
| 133 | if err != nil || previous == nil { |
| 134 | return nil, err |
| 135 | } |
| 136 | if err := returnPrevious(previous); err != nil { |
| 137 | pending, rollbackErr := k.restoreDetachedReturningCurrentWith(previous, returnTarget) |
| 138 | if rollbackErr != nil { |
| 139 | return pending, errors.Join(err, fmt.Errorf("return acquired target: %w", rollbackErr)) |
| 140 | } |
| 141 | return nil, err |
| 142 | } |
| 143 | return nil, nil |
| 144 | } |
| 145 | |
| 146 | func (k *SessionLeaseKeeper) rebindReturningCurrentWith(path, targetWriterID, handoffID string, acquire func(string) (*agent.SessionLease, error)) error { |
| 147 | if k == nil { |
| 148 | return nil |
| 149 | } |
| 150 | k.mu.Lock() |
| 151 | defer k.mu.Unlock() |
| 152 | if k.lease == nil { |
| 153 | return fmt.Errorf("no session lease held") |
| 154 | } |
| 155 | canonical := agent.CanonicalSessionPath(path) |
| 156 | if strings.TrimSpace(canonical) == "" { |
| 157 | return fmt.Errorf("target session path is empty") |
| 158 | } |
| 159 | if k.lease.Path() == canonical { |
| 160 | return nil |
| 161 | } |
| 162 | next, err := acquire(canonical) |
| 163 | if err != nil { |
| 164 | return err |
| 165 | } |
| 166 | if err := k.lease.ReleaseForHandoff(targetWriterID, handoffID); err != nil { |
| 167 | next.Release() |
| 168 | return err |
| 169 | } |
| 170 | k.lease = next |
| 171 | k.unbindControllerLocked() |
| 172 | return nil |
| 173 | } |
| 174 | |
| 175 | // HandleSessionRecovered moves the single-session frontend lease before a |
| 176 | // controller commits to a recovery branch. It is suitable for |
| 177 | // Options.OnSessionRecovered in CLI chat/run/serve surfaces. Rebind acquires the |
| 178 | // recovery path before releasing the original lease, so a failed handoff keeps |
| 179 | // the previous session protected. |
| 180 | func (k *SessionLeaseKeeper) HandleSessionRecovered(info SessionRecoveryInfo) error { |
| 181 | _, err := k.handleSessionRecovered(nil, false, info) |
| 182 | return err |
| 183 | } |
| 184 | |
| 185 | // HandleSessionRecoveredFor applies a recovery only while this keeper still |
| 186 | // owns c. Multi-session frontends use the boolean to retry against the |
| 187 | // controller's newly published keeper when a captured callback races an |
| 188 | // ownership transfer. |
| 189 | func (k *SessionLeaseKeeper) HandleSessionRecoveredFor(c *Controller, info SessionRecoveryInfo) (bool, error) { |
| 190 | return k.handleSessionRecovered(c, true, info) |
| 191 | } |
| 192 | |
| 193 | func (k *SessionLeaseKeeper) handleSessionRecovered(c *Controller, requireOwner bool, info SessionRecoveryInfo) (bool, error) { |
| 194 | recoveryPath := strings.TrimSpace(info.RecoveryPath) |
| 195 | if k == nil || recoveryPath == "" { |
| 196 | return true, nil |
| 197 | } |
| 198 | k.mu.Lock() |
| 199 | if requireOwner && k.controller != c { |
| 200 | k.mu.Unlock() |
| 201 | return false, nil |
| 202 | } |
| 203 | if k.lease != nil && k.lease.Path() == agent.CanonicalSessionPath(recoveryPath) { |
| 204 | k.mu.Unlock() |
| 205 | return true, nil |
| 206 | } |
| 207 | lease, err := agent.TryAcquireSessionLease(recoveryPath) |
| 208 | if err == nil && k.controller != nil { |
| 209 | err = k.controller.BindSessionWriteAuthority(lease) |
| 210 | } |
| 211 | if err != nil { |
| 212 | if lease != nil { |
| 213 | lease.Release() |
| 214 | } |
| 215 | k.mu.Unlock() |
| 216 | if errors.Is(err, agent.ErrSessionLeaseHeld) { |
| 217 | return true, fmt.Errorf("bind recovery session: %s; %s", |
| 218 | SessionInUseMessage(err), SessionLeaseCloseHint) |
| 219 | } |
| 220 | // The detailed error can contain a machine-local path. Keep it in |
| 221 | // diagnostics and return path-free text to every frontend. |
| 222 | slog.Error("control: bind recovery session lease", "err", err) |
| 223 | return true, fmt.Errorf("bind recovery session: unable to secure recovered transcript") |
| 224 | } |
| 225 | old := k.lease |
| 226 | k.lease = lease |
| 227 | var retired chan struct{} |
| 228 | if old != nil { |
| 229 | retired = make(chan struct{}) |
| 230 | k.retired = append(k.retired, retired) |
| 231 | } |
| 232 | k.mu.Unlock() |
| 233 | // Recovery callbacks run inside the authority-guarded save that still owns |
| 234 | // old. Releasing synchronously here would wait on that same save forever. |
| 235 | // Retirement is bounded to one goroutine per committed path handoff. |
| 236 | if old != nil { |
| 237 | go func() { |
| 238 | old.Release() |
| 239 | close(retired) |
| 240 | }() |
| 241 | } |
| 242 | return true, nil |
| 243 | } |
| 244 | |
| 245 | // HandleSessionTransition acquires and binds an intentional path-change target |
| 246 | // before the controller swaps Sessions. Acquisition is failure-atomic: the old |
| 247 | // lease remains held unless the target lease and candidate authority are ready. |
| 248 | func (k *SessionLeaseKeeper) HandleSessionTransition(info SessionTransitionInfo) error { |
| 249 | return k.handleSessionTransitionWith(info, agent.TryAcquireSessionLease) |
| 250 | } |
| 251 | |
| 252 | // HandleSessionTransitionWithHandoff binds a private transition candidate with |
| 253 | // an explicitly reserved lease before the controller publishes it. |
| 254 | func (k *SessionLeaseKeeper) HandleSessionTransitionWithHandoff(info SessionTransitionInfo, sourceWriterID, handoffID string) error { |
| 255 | return k.handleSessionTransitionWith(info, func(path string) (*agent.SessionLease, error) { |
| 256 | return agent.TryAcquireSessionLeaseWithHandoff(path, sourceWriterID, handoffID) |
| 257 | }) |
| 258 | } |
| 259 | |
| 260 | func (k *SessionLeaseKeeper) handleSessionTransitionWith(info SessionTransitionInfo, acquire func(string) (*agent.SessionLease, error)) error { |
| 261 | targetPath := strings.TrimSpace(info.TargetPath) |
| 262 | if k == nil || targetPath == "" { |
| 263 | return nil |
| 264 | } |
| 265 | k.mu.Lock() |
| 266 | canonical := agent.CanonicalSessionPath(targetPath) |
| 267 | if k.lease != nil && k.lease.Path() == canonical { |
| 268 | err := info.BindWriteAuthority(k.lease) |
| 269 | k.mu.Unlock() |
| 270 | return err |
| 271 | } |
| 272 | lease, err := acquire(targetPath) |
| 273 | if err == nil { |
| 274 | err = info.BindWriteAuthority(lease) |
| 275 | } |
| 276 | if err != nil { |
| 277 | if lease != nil { |
| 278 | lease.Release() |
| 279 | } |
| 280 | k.mu.Unlock() |
| 281 | if errors.Is(err, agent.ErrSessionLeaseHeld) { |
| 282 | return fmt.Errorf("bind target session: %s; %s", |
| 283 | SessionInUseMessage(err), SessionLeaseCloseHint) |
| 284 | } |
| 285 | slog.Error("control: bind target session lease", "reason", info.Reason, "err", err) |
| 286 | return fmt.Errorf("bind target session: unable to secure transcript") |
| 287 | } |
| 288 | old := k.lease |
| 289 | k.lease = lease |
| 290 | k.mu.Unlock() |
| 291 | if old != nil { |
| 292 | old.Release() |
| 293 | } |
| 294 | return nil |
| 295 | } |
| 296 | |
| 297 | // Release drops the held lease, if any. Idempotent; call it on frontend |
| 298 | // teardown after the controller has finished its final writes. |
| 299 | func (k *SessionLeaseKeeper) Release() { |
| 300 | if k == nil { |
| 301 | return |
| 302 | } |
| 303 | k.mu.Lock() |
| 304 | k.releaseLocked() |
| 305 | retired := append([]<-chan struct{}(nil), k.retired...) |
| 306 | k.mu.Unlock() |
| 307 | for _, done := range retired { |
| 308 | <-done |
| 309 | } |
| 310 | } |
| 311 | |
| 312 | // WaitForRetiredLeases waits until the most recent recovery handoff has |
| 313 | // released its outgoing lease. Runtime paths do not need to call it; tests and |
| 314 | // shutdown use it when they require deterministic cleanup observation. |
| 315 | func (k *SessionLeaseKeeper) WaitForRetiredLeases() { |
| 316 | if k == nil { |
| 317 | return |
| 318 | } |
| 319 | k.mu.Lock() |
| 320 | retired := append([]<-chan struct{}(nil), k.retired...) |
| 321 | k.mu.Unlock() |
| 322 | for _, done := range retired { |
| 323 | <-done |
| 324 | } |
| 325 | } |
| 326 | |
| 327 | // HeldPath reports the canonical session path the keeper currently guards, |
| 328 | // or "" when it holds nothing. |
| 329 | func (k *SessionLeaseKeeper) HeldPath() string { |
| 330 | if k == nil { |
| 331 | return "" |
| 332 | } |
| 333 | k.mu.Lock() |
| 334 | defer k.mu.Unlock() |
| 335 | if k.lease == nil { |
| 336 | return "" |
| 337 | } |
| 338 | return k.lease.Path() |
| 339 | } |
| 340 | |
| 341 | // Lease returns the held lease for authority issuance. Callers must not |
| 342 | // Release it; use Release/Rebind on the keeper instead. |
| 343 | func (k *SessionLeaseKeeper) Lease() *agent.SessionLease { |
| 344 | if k == nil { |
| 345 | return nil |
| 346 | } |
| 347 | k.mu.Lock() |
| 348 | defer k.mu.Unlock() |
| 349 | return k.lease |
| 350 | } |
| 351 | |
| 352 | // BindControllerAuthority issues a fresh write authority from the held lease |
| 353 | // onto c. Safe no-op when the keeper holds nothing. |
| 354 | func (k *SessionLeaseKeeper) BindControllerAuthority(c *Controller) error { |
| 355 | if k == nil || c == nil { |
| 356 | return nil |
| 357 | } |
| 358 | k.mu.Lock() |
| 359 | defer k.mu.Unlock() |
| 360 | if err := c.BindSessionWriteAuthority(k.lease); err != nil { |
| 361 | return err |
| 362 | } |
| 363 | k.controller = c |
| 364 | c.SetOnSessionTransition(k.HandleSessionTransition) |
| 365 | if k.ownershipBinder != nil { |
| 366 | k.ownershipBinder(c, k) |
| 367 | } |
| 368 | return nil |
| 369 | } |
| 370 | |
| 371 | // BindSessionAuthority issues the held lease's next write generation directly |
| 372 | // onto a private session candidate. Resume callers use it before publishing |
| 373 | // that candidate through their controller; binding the controller itself would |
| 374 | // only update the outgoing executor session that Resume is about to replace. |
| 375 | func (k *SessionLeaseKeeper) BindSessionAuthority(sess *agent.Session) error { |
| 376 | if k == nil || sess == nil { |
| 377 | return nil |
| 378 | } |
| 379 | k.mu.Lock() |
| 380 | defer k.mu.Unlock() |
| 381 | sess.RequireWriteAuthority() |
| 382 | if k.lease == nil { |
| 383 | sess.ClearWriteAuthority() |
| 384 | return agent.ErrSessionWriteAuthorityMissing |
| 385 | } |
| 386 | return k.lease.Writer().Bind(sess, agent.NextSessionWriteGeneration()) |
| 387 | } |
| 388 | |
| 389 | // SetControllerOwnershipBinder lets an owning frontend compose its routing |
| 390 | // callbacks with lease handoff. The binder follows a controller through |
| 391 | // Split, RebindDetaching, and Adopt instead of those transfers replacing the |
| 392 | // frontend callback with the keeper-only default. |
| 393 | func (k *SessionLeaseKeeper) SetControllerOwnershipBinder(bind func(*Controller, *SessionLeaseKeeper)) { |
| 394 | if k == nil { |
| 395 | return |
| 396 | } |
| 397 | k.mu.Lock() |
| 398 | k.ownershipBinder = bind |
| 399 | if k.controller != nil && bind != nil { |
| 400 | bind(k.controller, k) |
| 401 | } |
| 402 | k.mu.Unlock() |
| 403 | } |
| 404 | |
| 405 | func (k *SessionLeaseKeeper) bindTransferredController(c *Controller) { |
| 406 | if c == nil { |
| 407 | return |
| 408 | } |
| 409 | c.SetOnSessionTransition(k.HandleSessionTransition) |
| 410 | if k.ownershipBinder != nil { |
| 411 | k.ownershipBinder(c, k) |
| 412 | } else { |
| 413 | c.SetOnSessionRecovered(k.HandleSessionRecovered) |
| 414 | } |
| 415 | } |
| 416 | |
| 417 | // Split moves the held lease and controller binding into a new keeper without |
| 418 | // releasing either. Multi-session Serve uses this when a busy controller moves |
| 419 | // to the background and must keep saving its own transcript to completion. |
| 420 | func (k *SessionLeaseKeeper) Split() *SessionLeaseKeeper { |
| 421 | if k == nil { |
| 422 | return nil |
| 423 | } |
| 424 | k.mu.Lock() |
| 425 | defer k.mu.Unlock() |
| 426 | if k.lease == nil && k.controller == nil && len(k.retired) == 0 { |
| 427 | return nil |
| 428 | } |
| 429 | dst := &SessionLeaseKeeper{lease: k.lease, controller: k.controller, retired: k.retired, ownershipBinder: k.ownershipBinder} |
| 430 | if dst.controller != nil { |
| 431 | dst.bindTransferredController(dst.controller) |
| 432 | } |
| 433 | k.lease, k.controller, k.retired = nil, nil, nil |
| 434 | return dst |
| 435 | } |
| 436 | |
| 437 | // RebindDetaching acquires path and returns the previous binding in a separate |
| 438 | // keeper. Acquisition is failure-atomic: on error the receiver is unchanged. |
| 439 | func (k *SessionLeaseKeeper) RebindDetaching(path string) (*SessionLeaseKeeper, error) { |
| 440 | return k.rebindDetachingWith(path, agent.TryAcquireSessionLease) |
| 441 | } |
| 442 | |
| 443 | // RebindDetachingWithHandoff is RebindDetaching for a targeted reservation. |
| 444 | func (k *SessionLeaseKeeper) RebindDetachingWithHandoff(path, sourceWriterID, handoffID string) (*SessionLeaseKeeper, error) { |
| 445 | return k.rebindDetachingWith(path, func(target string) (*agent.SessionLease, error) { |
| 446 | return agent.TryAcquireSessionLeaseWithHandoff(target, sourceWriterID, handoffID) |
| 447 | }) |
| 448 | } |
| 449 | |
| 450 | // RetireDetached releases a keeper returned by RebindDetaching after the |
| 451 | // caller has authorized the replacement session. Unlike Release, it does not |
| 452 | // clear the shared controller's authority or callbacks: the receiving keeper |
| 453 | // binds those immediately after publishing the replacement session. |
| 454 | func (k *SessionLeaseKeeper) RetireDetached() { |
| 455 | if k == nil { |
| 456 | return |
| 457 | } |
| 458 | k.mu.Lock() |
| 459 | if k.lease != nil { |
| 460 | k.lease.Release() |
| 461 | k.lease = nil |
| 462 | } |
| 463 | k.controller = nil |
| 464 | retired := append([]<-chan struct{}(nil), k.retired...) |
| 465 | k.retired = nil |
| 466 | k.mu.Unlock() |
| 467 | for _, done := range retired { |
| 468 | <-done |
| 469 | } |
| 470 | } |
| 471 | |
| 472 | // RetireDetachedForHandoff is RetireDetached with a durable target-writer |
| 473 | // reservation. A persistence failure leaves the detached keeper unchanged. |
| 474 | func (k *SessionLeaseKeeper) RetireDetachedForHandoff(targetWriterID, handoffID string) error { |
| 475 | if k == nil { |
| 476 | return fmt.Errorf("no detached session lease held") |
| 477 | } |
| 478 | k.mu.Lock() |
| 479 | if k.lease == nil { |
| 480 | k.mu.Unlock() |
| 481 | return fmt.Errorf("no detached session lease held") |
| 482 | } |
| 483 | if err := k.lease.ReleaseForHandoff(targetWriterID, handoffID); err != nil { |
| 484 | k.mu.Unlock() |
| 485 | return err |
| 486 | } |
| 487 | k.lease = nil |
| 488 | k.controller = nil |
| 489 | retired := append([]<-chan struct{}(nil), k.retired...) |
| 490 | k.retired = nil |
| 491 | k.mu.Unlock() |
| 492 | for _, done := range retired { |
| 493 | <-done |
| 494 | } |
| 495 | return nil |
| 496 | } |
| 497 | |
| 498 | // RestoreDetachedReturningCurrent rolls a RebindDetaching transaction back to |
| 499 | // previous while returning the receiver's newly acquired lease through a |
| 500 | // reverse reservation. The old binding is restored even when publishing that |
| 501 | // reservation fails. In that case the returned detached keeper still owns the |
| 502 | // target lease and must be retained and retried with RetireDetachedForHandoff; |
| 503 | // releasing it while the process remains alive would reopen a third-writer |
| 504 | // race before the intended owner can consume the reservation. |
| 505 | // |
| 506 | // Callers use this before publishing a controller for the receiver's target. |
| 507 | func (k *SessionLeaseKeeper) RestoreDetachedReturningCurrent(previous *SessionLeaseKeeper, targetWriterID, handoffID string) (*SessionLeaseKeeper, error) { |
| 508 | return k.restoreDetachedReturningCurrentWith(previous, func(lease *agent.SessionLease) error { |
| 509 | return lease.ReleaseForHandoff(targetWriterID, handoffID) |
| 510 | }) |
| 511 | } |
| 512 | |
| 513 | func (k *SessionLeaseKeeper) restoreDetachedReturningCurrentWith(previous *SessionLeaseKeeper, release func(*agent.SessionLease) error) (*SessionLeaseKeeper, error) { |
| 514 | if k == nil || previous == nil || k == previous { |
| 515 | return nil, fmt.Errorf("invalid detached session rollback") |
| 516 | } |
| 517 | k.mu.Lock() |
| 518 | previous.mu.Lock() |
| 519 | defer previous.mu.Unlock() |
| 520 | defer k.mu.Unlock() |
| 521 | if k.lease == nil { |
| 522 | return nil, fmt.Errorf("no current session lease held") |
| 523 | } |
| 524 | if previous.lease == nil && previous.controller == nil && len(previous.retired) == 0 { |
| 525 | return nil, fmt.Errorf("no previous session binding held") |
| 526 | } |
| 527 | if k.controller != nil || len(k.retired) != 0 { |
| 528 | return nil, fmt.Errorf("current session binding was already published") |
| 529 | } |
| 530 | |
| 531 | current := k.lease |
| 532 | releaseErr := release(current) |
| 533 | var pending *SessionLeaseKeeper |
| 534 | if releaseErr != nil { |
| 535 | pending = &SessionLeaseKeeper{lease: current, ownershipBinder: k.ownershipBinder} |
| 536 | } |
| 537 | |
| 538 | inLease, inCtrl, inRetired, inBinder := previous.lease, previous.controller, previous.retired, previous.ownershipBinder |
| 539 | previous.lease, previous.controller, previous.retired = nil, nil, nil |
| 540 | if k.ownershipBinder == nil { |
| 541 | k.ownershipBinder = inBinder |
| 542 | } |
| 543 | k.lease, k.controller, k.retired = inLease, inCtrl, inRetired |
| 544 | if inCtrl != nil { |
| 545 | k.bindTransferredController(inCtrl) |
| 546 | } |
| 547 | return pending, releaseErr |
| 548 | } |
| 549 | |
| 550 | func (k *SessionLeaseKeeper) rebindDetachingWith(path string, acquire func(string) (*agent.SessionLease, error)) (*SessionLeaseKeeper, error) { |
| 551 | if k == nil { |
| 552 | return nil, nil |
| 553 | } |
| 554 | if strings.TrimSpace(path) == "" { |
| 555 | return k.Split(), nil |
| 556 | } |
| 557 | k.mu.Lock() |
| 558 | canonical := agent.CanonicalSessionPath(path) |
| 559 | if k.lease != nil && k.lease.Path() == canonical { |
| 560 | k.mu.Unlock() |
| 561 | return nil, nil |
| 562 | } |
| 563 | lease, err := acquire(path) |
| 564 | if err != nil { |
| 565 | k.mu.Unlock() |
| 566 | return nil, err |
| 567 | } |
| 568 | var dst *SessionLeaseKeeper |
| 569 | if k.lease != nil || k.controller != nil || len(k.retired) > 0 { |
| 570 | dst = &SessionLeaseKeeper{lease: k.lease, controller: k.controller, retired: k.retired, ownershipBinder: k.ownershipBinder} |
| 571 | } |
| 572 | // dst stays nil when the keeper holds nothing at all (e.g. its lease was |
| 573 | // released by a session handoff); there is no controller to rebind then. |
| 574 | if dst != nil && dst.controller != nil { |
| 575 | dst.bindTransferredController(dst.controller) |
| 576 | } |
| 577 | k.lease, k.controller, k.retired = lease, nil, nil |
| 578 | k.mu.Unlock() |
| 579 | return dst, nil |
| 580 | } |
| 581 | |
| 582 | // Adopt transfers another keeper's lease and controller binding into the |
| 583 | // receiver. The source is emptied; any receiver binding is released first. |
| 584 | func (k *SessionLeaseKeeper) Adopt(other *SessionLeaseKeeper) { |
| 585 | if k == nil || other == nil || k == other { |
| 586 | return |
| 587 | } |
| 588 | other.mu.Lock() |
| 589 | inLease, inCtrl, inRetired, inBinder := other.lease, other.controller, other.retired, other.ownershipBinder |
| 590 | other.lease, other.controller, other.retired = nil, nil, nil |
| 591 | other.mu.Unlock() |
| 592 | k.mu.Lock() |
| 593 | k.releaseLocked() |
| 594 | if k.ownershipBinder == nil { |
| 595 | k.ownershipBinder = inBinder |
| 596 | } |
| 597 | k.lease, k.controller, k.retired = inLease, inCtrl, inRetired |
| 598 | if inCtrl != nil { |
| 599 | k.bindTransferredController(inCtrl) |
| 600 | } |
| 601 | k.mu.Unlock() |
| 602 | } |
| 603 | |
| 604 | func (k *SessionLeaseKeeper) releaseLocked() { |
| 605 | if k.lease != nil { |
| 606 | k.lease.Release() |
| 607 | k.lease = nil |
| 608 | } |
| 609 | k.unbindControllerLocked() |
| 610 | } |
| 611 | |
| 612 | func (k *SessionLeaseKeeper) unbindControllerLocked() { |
| 613 | if k.controller != nil { |
| 614 | k.controller.SetOnSessionTransition(nil) |
| 615 | k.controller.SetOnSessionRecovered(nil) |
| 616 | _ = k.controller.BindSessionWriteAuthority(nil) |
| 617 | k.controller = nil |
| 618 | } |
| 619 | } |
| 620 | |
| 621 | // SessionLeaseCloseHint is the universal way out of a lease refusal, appended |
| 622 | // by surfaces that have no copy escape hatch (in-TUI switches, serve, ACP). |
| 623 | const SessionLeaseCloseHint = "close the other Reasonix window or process first" |
| 624 | |
| 625 | // SessionInUseMessage renders a lease-acquisition failure as the shared |
| 626 | // operator-facing "who is holding this" line used by the CLI, serve, and ACP. |
| 627 | // It names the holder from the lease info when available and degrades to a |
| 628 | // generic line otherwise. The session file path is deliberately omitted — the |
| 629 | // caller already knows which session it asked for. |
| 630 | func SessionInUseMessage(err error) string { |
| 631 | const fallback = "this session is in use by another Reasonix window or process" |
| 632 | var leaseErr *agent.SessionLeaseError |
| 633 | if !errors.As(err, &leaseErr) || leaseErr == nil || leaseErr.Info == nil || leaseErr.Info.PID <= 0 { |
| 634 | return fallback |
| 635 | } |
| 636 | info := leaseErr.Info |
| 637 | var b strings.Builder |
| 638 | fmt.Fprintf(&b, "this session is in use by another Reasonix process (pid %d", info.PID) |
| 639 | if host := strings.TrimSpace(info.Hostname); host != "" { |
| 640 | b.WriteString(" on " + host) |
| 641 | } |
| 642 | if !info.AcquiredAt.IsZero() { |
| 643 | b.WriteString(", since " + info.AcquiredAt.Local().Format("15:04")) |
| 644 | } |
| 645 | b.WriteString(")") |
| 646 | return b.String() |
| 647 | } |
| 648 |