| 1 | package control |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "sort" |
| 8 | "sync" |
| 9 | |
| 10 | "reasonix/internal/agent" |
| 11 | "reasonix/internal/event" |
| 12 | ) |
| 13 | |
| 14 | type PendingPromptOwner struct { |
| 15 | mu sync.Mutex |
| 16 | pending map[string]PendingPrompt |
| 17 | resolved map[string]PromptResolution |
| 18 | next uint64 |
| 19 | revision uint64 |
| 20 | } |
| 21 | |
| 22 | type PendingPromptState string |
| 23 | |
| 24 | const ( |
| 25 | PromptPending PendingPromptState = "pending" |
| 26 | PromptResolving PendingPromptState = "resolving" |
| 27 | ) |
| 28 | |
| 29 | type PromptTerminalState string |
| 30 | |
| 31 | const ( |
| 32 | PromptAnswered PromptTerminalState = "answered" |
| 33 | PromptRejected PromptTerminalState = "rejected" |
| 34 | PromptCancelled PromptTerminalState = "cancelled" |
| 35 | PromptUnavailable PromptTerminalState = "unavailable" |
| 36 | ) |
| 37 | |
| 38 | type PromptResolution struct { |
| 39 | Identity PromptIdentity |
| 40 | State PromptTerminalState |
| 41 | AnswerDigest string |
| 42 | } |
| 43 | |
| 44 | type PendingPrompt struct { |
| 45 | Identity PromptIdentity |
| 46 | State PendingPromptState |
| 47 | Order uint64 |
| 48 | // AnswerDigest is filled only after the exact resolver wins the one-shot |
| 49 | // transition. It lets an identical retry succeed idempotently while a |
| 50 | // conflicting late answer is rejected. |
| 51 | AnswerDigest string |
| 52 | Done chan struct{} |
| 53 | Resolve func(PromptAnswer) error |
| 54 | Cancel func() error |
| 55 | } |
| 56 | |
| 57 | func (o *PendingPromptOwner) RegisterPrompt(prompt PendingPrompt) error { |
| 58 | identity := prompt.Identity |
| 59 | if identity.PromptID == "" { |
| 60 | return ErrPromptNotPending |
| 61 | } |
| 62 | o.mu.Lock() |
| 63 | defer o.mu.Unlock() |
| 64 | if o.pending == nil { |
| 65 | o.pending = make(map[string]PendingPrompt) |
| 66 | } |
| 67 | if o.resolved == nil { |
| 68 | o.resolved = make(map[string]PromptResolution) |
| 69 | } |
| 70 | if _, exists := o.resolved[identity.PromptID]; exists { |
| 71 | return fmt.Errorf("prompt %q was already terminal", identity.PromptID) |
| 72 | } |
| 73 | if _, exists := o.pending[identity.PromptID]; exists { |
| 74 | return fmt.Errorf("prompt %q already registered", identity.PromptID) |
| 75 | } |
| 76 | if prompt.State == "" { |
| 77 | prompt.State = PromptPending |
| 78 | } |
| 79 | if prompt.Done == nil { |
| 80 | prompt.Done = make(chan struct{}) |
| 81 | } |
| 82 | o.next++ |
| 83 | prompt.Order = o.next |
| 84 | o.pending[identity.PromptID] = prompt |
| 85 | o.revision++ |
| 86 | return nil |
| 87 | } |
| 88 | |
| 89 | func (o *PendingPromptOwner) Register(identity PromptIdentity) error { |
| 90 | return o.RegisterPrompt(PendingPrompt{Identity: identity, State: PromptPending}) |
| 91 | } |
| 92 | func (o *PendingPromptOwner) Identity(id string) (PromptIdentity, bool) { |
| 93 | o.mu.Lock() |
| 94 | defer o.mu.Unlock() |
| 95 | v, ok := o.pending[id] |
| 96 | return v.Identity, ok |
| 97 | } |
| 98 | func (o *PendingPromptOwner) Prompt(id string) (PendingPrompt, bool) { |
| 99 | o.mu.Lock() |
| 100 | defer o.mu.Unlock() |
| 101 | p, ok := o.pending[id] |
| 102 | return p, ok |
| 103 | } |
| 104 | func (o *PendingPromptOwner) Remove(id string) { |
| 105 | o.mu.Lock() |
| 106 | if prompt, ok := o.pending[id]; ok { |
| 107 | delete(o.pending, id) |
| 108 | close(prompt.Done) |
| 109 | o.revision++ |
| 110 | } |
| 111 | o.mu.Unlock() |
| 112 | } |
| 113 | func (o *PendingPromptOwner) RemoveKind(kind PromptKind) { |
| 114 | o.mu.Lock() |
| 115 | defer o.mu.Unlock() |
| 116 | changed := false |
| 117 | for id, prompt := range o.pending { |
| 118 | if prompt.Identity.Kind == kind { |
| 119 | delete(o.pending, id) |
| 120 | close(prompt.Done) |
| 121 | changed = true |
| 122 | } |
| 123 | } |
| 124 | if changed { |
| 125 | o.revision++ |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | func (o *PendingPromptOwner) MarkKindTerminal(kind PromptKind, state PromptTerminalState) { |
| 130 | o.mu.Lock() |
| 131 | defer o.mu.Unlock() |
| 132 | if o.resolved == nil { |
| 133 | o.resolved = make(map[string]PromptResolution) |
| 134 | } |
| 135 | changed := false |
| 136 | for id, prompt := range o.pending { |
| 137 | if prompt.Identity.Kind != kind { |
| 138 | continue |
| 139 | } |
| 140 | delete(o.pending, id) |
| 141 | close(prompt.Done) |
| 142 | changed = true |
| 143 | if _, exists := o.resolved[id]; !exists { |
| 144 | o.resolved[id] = PromptResolution{Identity: prompt.Identity, State: state} |
| 145 | } |
| 146 | } |
| 147 | if changed { |
| 148 | o.revision++ |
| 149 | } |
| 150 | } |
| 151 | func (o *PendingPromptOwner) MarkResolved(identity PromptIdentity) { |
| 152 | o.MarkTerminal(identity, PromptAnswered) |
| 153 | } |
| 154 | func (o *PendingPromptOwner) MarkTerminal(identity PromptIdentity, state PromptTerminalState) { |
| 155 | o.mu.Lock() |
| 156 | defer o.mu.Unlock() |
| 157 | prompt, pending := o.pending[identity.PromptID] |
| 158 | if pending { |
| 159 | identity = normalizePromptIdentity(identity, prompt.Identity) |
| 160 | } |
| 161 | delete(o.pending, identity.PromptID) |
| 162 | if pending { |
| 163 | close(prompt.Done) |
| 164 | } |
| 165 | if o.resolved == nil { |
| 166 | o.resolved = make(map[string]PromptResolution) |
| 167 | } |
| 168 | if _, exists := o.resolved[identity.PromptID]; !exists { |
| 169 | o.resolved[identity.PromptID] = PromptResolution{Identity: identity, State: state, AnswerDigest: prompt.AnswerDigest} |
| 170 | pending = true |
| 171 | } |
| 172 | if pending { |
| 173 | o.revision++ |
| 174 | } |
| 175 | } |
| 176 | func (o *PendingPromptOwner) BeginResolve(identity PromptIdentity) error { |
| 177 | o.mu.Lock() |
| 178 | defer o.mu.Unlock() |
| 179 | p, ok := o.pending[identity.PromptID] |
| 180 | if !ok { |
| 181 | if _, resolved := o.resolved[identity.PromptID]; resolved { |
| 182 | return ErrPromptAlreadyResolved |
| 183 | } |
| 184 | return ErrPromptNotPending |
| 185 | } |
| 186 | identity = normalizePromptIdentity(identity, p.Identity) |
| 187 | if p.Identity != identity { |
| 188 | return ErrPromptStaleTurn |
| 189 | } |
| 190 | if p.State == PromptResolving { |
| 191 | return ErrPromptAlreadyResolved |
| 192 | } |
| 193 | p.State = PromptResolving |
| 194 | if p.Done == nil { |
| 195 | p.Done = make(chan struct{}) |
| 196 | } |
| 197 | o.pending[identity.PromptID] = p |
| 198 | o.revision++ |
| 199 | return nil |
| 200 | } |
| 201 | |
| 202 | // BindRouting fills routing fields that were not available when a prompt was |
| 203 | // queued behind another prompt. It never rewrites a captured identity: once a |
| 204 | // turn or runtime epoch is known, a later event must use that same value. |
| 205 | func (o *PendingPromptOwner) BindRouting(id, turnID, runtimeEpoch string) (PromptIdentity, bool) { |
| 206 | o.mu.Lock() |
| 207 | defer o.mu.Unlock() |
| 208 | prompt, ok := o.pending[id] |
| 209 | if !ok { |
| 210 | return PromptIdentity{}, false |
| 211 | } |
| 212 | changed := false |
| 213 | if prompt.Identity.TurnID == "" && turnID != "" { |
| 214 | prompt.Identity.TurnID = turnID |
| 215 | changed = true |
| 216 | } |
| 217 | if prompt.Identity.RuntimeEpoch == "" && runtimeEpoch != "" { |
| 218 | prompt.Identity.RuntimeEpoch = runtimeEpoch |
| 219 | changed = true |
| 220 | } |
| 221 | o.pending[id] = prompt |
| 222 | if changed { |
| 223 | o.revision++ |
| 224 | } |
| 225 | return prompt.Identity, true |
| 226 | } |
| 227 | func (o *PendingPromptOwner) Resolve(identity PromptIdentity, answer PromptAnswer) error { |
| 228 | digest := promptAnswerDigest(answer) |
| 229 | wantState := promptAnswerTerminal(identity, answer) |
| 230 | for { |
| 231 | o.mu.Lock() |
| 232 | if resolution, ok := o.resolved[identity.PromptID]; ok { |
| 233 | identity = normalizePromptIdentity(identity, resolution.Identity) |
| 234 | o.mu.Unlock() |
| 235 | if resolution.Identity == identity && resolution.State == wantState && resolution.AnswerDigest == digest { |
| 236 | return nil |
| 237 | } |
| 238 | return ErrPromptAlreadyResolved |
| 239 | } |
| 240 | prompt, ok := o.pending[identity.PromptID] |
| 241 | if !ok { |
| 242 | o.mu.Unlock() |
| 243 | return ErrPromptNotPending |
| 244 | } |
| 245 | identity = normalizePromptIdentity(identity, prompt.Identity) |
| 246 | if prompt.Identity != identity { |
| 247 | o.mu.Unlock() |
| 248 | return ErrPromptStaleTurn |
| 249 | } |
| 250 | if prompt.State == PromptResolving { |
| 251 | if prompt.AnswerDigest != digest { |
| 252 | o.mu.Unlock() |
| 253 | return ErrPromptAlreadyResolved |
| 254 | } |
| 255 | done := prompt.Done |
| 256 | o.mu.Unlock() |
| 257 | <-done |
| 258 | continue |
| 259 | } |
| 260 | prompt.State = PromptResolving |
| 261 | prompt.AnswerDigest = digest |
| 262 | if prompt.Done == nil { |
| 263 | prompt.Done = make(chan struct{}) |
| 264 | } |
| 265 | o.pending[identity.PromptID] = prompt |
| 266 | o.revision++ |
| 267 | o.mu.Unlock() |
| 268 | |
| 269 | if prompt.Resolve == nil { |
| 270 | // A published request without a live answerer is terminal. Leaving it |
| 271 | // pending would recreate the permanent-wait failure this registry owns. |
| 272 | o.MarkTerminal(identity, PromptUnavailable) |
| 273 | return ErrPromptUnavailable |
| 274 | } |
| 275 | if err := prompt.Resolve(answer); err != nil { |
| 276 | // The typed answerer failed after this registry awarded it the |
| 277 | // one-shot transition. It is no longer safe to advertise the request |
| 278 | // as answerable: terminalize it and detach typed cleanup so neither a |
| 279 | // dead connection nor a throwing adapter can create an infinite wait. |
| 280 | o.MarkTerminal(identity, PromptUnavailable) |
| 281 | if prompt.Cancel != nil { |
| 282 | go func(cancel func() error) { _ = cancel() }(prompt.Cancel) |
| 283 | } |
| 284 | return errors.Join(ErrPromptUnavailable, err) |
| 285 | } |
| 286 | o.MarkTerminal(identity, wantState) |
| 287 | resolution, ok := o.Resolution(identity.PromptID) |
| 288 | if !ok || resolution.State != wantState || resolution.AnswerDigest != digest { |
| 289 | return ErrPromptAlreadyResolved |
| 290 | } |
| 291 | return nil |
| 292 | } |
| 293 | } |
| 294 | |
| 295 | // normalizePromptIdentity preserves compatibility with transports that were |
| 296 | // shipped before toolCallId became part of the shared interaction snapshot. |
| 297 | // The owner remains authoritative: an omitted field adopts the captured value, |
| 298 | // while a conflicting non-empty value still fails the exact identity check. |
| 299 | func normalizePromptIdentity(candidate, owned PromptIdentity) PromptIdentity { |
| 300 | if candidate.ToolCallID == "" { |
| 301 | candidate.ToolCallID = owned.ToolCallID |
| 302 | } |
| 303 | return candidate |
| 304 | } |
| 305 | |
| 306 | func promptAnswerDigest(answer PromptAnswer) string { |
| 307 | b, _ := json.Marshal(answer) |
| 308 | return string(b) |
| 309 | } |
| 310 | |
| 311 | func promptAnswerTerminal(identity PromptIdentity, answer PromptAnswer) PromptTerminalState { |
| 312 | if identity.Kind == PromptMCP && answer.Action == "cancel" { |
| 313 | return PromptCancelled |
| 314 | } |
| 315 | if (identity.Kind == PromptApproval && !answer.Allow) || |
| 316 | (identity.Kind == PromptMCP && answer.Action == "decline") || |
| 317 | (identity.Kind == PromptPlan && answer.Action != "start") || |
| 318 | (identity.Kind == PromptRecovery && answer.Action != string(agent.RecoveryActionContinue)) { |
| 319 | return PromptRejected |
| 320 | } |
| 321 | return PromptAnswered |
| 322 | } |
| 323 | |
| 324 | func (o *PendingPromptOwner) MarkIDTerminal(id string, state PromptTerminalState) { |
| 325 | if prompt, ok := o.Prompt(id); ok { |
| 326 | o.MarkTerminal(prompt.Identity, state) |
| 327 | } |
| 328 | } |
| 329 | func (o *PendingPromptOwner) WasResolved(id string) bool { |
| 330 | o.mu.Lock() |
| 331 | defer o.mu.Unlock() |
| 332 | _, ok := o.resolved[id] |
| 333 | return ok |
| 334 | } |
| 335 | func (o *PendingPromptOwner) Resolution(id string) (PromptResolution, bool) { |
| 336 | o.mu.Lock() |
| 337 | defer o.mu.Unlock() |
| 338 | resolution, ok := o.resolved[id] |
| 339 | return resolution, ok |
| 340 | } |
| 341 | func (o *PendingPromptOwner) Clear() { |
| 342 | o.mu.Lock() |
| 343 | for _, prompt := range o.pending { |
| 344 | close(prompt.Done) |
| 345 | } |
| 346 | o.pending = make(map[string]PendingPrompt) |
| 347 | o.resolved = make(map[string]PromptResolution) |
| 348 | o.revision++ |
| 349 | o.mu.Unlock() |
| 350 | } |
| 351 | func (o *PendingPromptOwner) CancelAll() { |
| 352 | o.cancelMatching(func(PromptIdentity) bool { return true }) |
| 353 | } |
| 354 | |
| 355 | // CancelTurn cannot close prompts registered by a successor while an older |
| 356 | // Stop request was waiting on its asynchronous publication lane. |
| 357 | func (o *PendingPromptOwner) CancelTurn(turnID string) { |
| 358 | o.cancelMatching(func(identity PromptIdentity) bool { return identity.TurnID == turnID }) |
| 359 | } |
| 360 | |
| 361 | func (o *PendingPromptOwner) cancelMatching(matches func(PromptIdentity) bool) { |
| 362 | o.mu.Lock() |
| 363 | cancels := make([]func() error, 0, len(o.pending)) |
| 364 | identities := make([]PromptIdentity, 0, len(o.pending)) |
| 365 | prompts := make([]PendingPrompt, 0, len(o.pending)) |
| 366 | for _, prompt := range o.pending { |
| 367 | if !matches(prompt.Identity) { |
| 368 | continue |
| 369 | } |
| 370 | prompts = append(prompts, prompt) |
| 371 | identities = append(identities, prompt.Identity) |
| 372 | if prompt.Cancel != nil { |
| 373 | cancels = append(cancels, prompt.Cancel) |
| 374 | } |
| 375 | } |
| 376 | for _, identity := range identities { |
| 377 | delete(o.pending, identity.PromptID) |
| 378 | } |
| 379 | if o.resolved == nil { |
| 380 | o.resolved = make(map[string]PromptResolution) |
| 381 | } |
| 382 | for _, identity := range identities { |
| 383 | if _, exists := o.resolved[identity.PromptID]; !exists { |
| 384 | o.resolved[identity.PromptID] = PromptResolution{Identity: identity, State: PromptCancelled} |
| 385 | } |
| 386 | } |
| 387 | for _, prompt := range prompts { |
| 388 | close(prompt.Done) |
| 389 | } |
| 390 | if len(identities) > 0 { |
| 391 | o.revision++ |
| 392 | } |
| 393 | o.mu.Unlock() |
| 394 | for _, cancel := range cancels { |
| 395 | // A connector or legacy adapter may provide a cancellation callback that |
| 396 | // blocks. Registry state is already terminal, so cleanup runs detached and |
| 397 | // can never delay the session's Stop path. |
| 398 | go func(cancel func() error) { _ = cancel() }(cancel) |
| 399 | } |
| 400 | } |
| 401 | func (o *PendingPromptOwner) Identities() []PromptIdentity { |
| 402 | identities, _ := o.IdentitiesRevision() |
| 403 | return identities |
| 404 | } |
| 405 | |
| 406 | // IdentitiesRevision returns one registry projection boundary. Runtime-state |
| 407 | // assembly verifies the revision after sampling its other owners so a prompt |
| 408 | // transition cannot be published with an older todo/turn snapshot. |
| 409 | func (o *PendingPromptOwner) IdentitiesRevision() ([]PromptIdentity, uint64) { |
| 410 | o.mu.Lock() |
| 411 | defer o.mu.Unlock() |
| 412 | type orderedIdentity struct { |
| 413 | identity PromptIdentity |
| 414 | order uint64 |
| 415 | } |
| 416 | ordered := make([]orderedIdentity, 0, len(o.pending)) |
| 417 | for _, prompt := range o.pending { |
| 418 | ordered = append(ordered, orderedIdentity{identity: prompt.Identity, order: prompt.Order}) |
| 419 | } |
| 420 | sort.Slice(ordered, func(i, j int) bool { return ordered[i].order < ordered[j].order }) |
| 421 | out := make([]PromptIdentity, len(ordered)) |
| 422 | for i := range ordered { |
| 423 | out[i] = ordered[i].identity |
| 424 | } |
| 425 | return out, o.revision |
| 426 | } |
| 427 | |
| 428 | func (o *PendingPromptOwner) Revision() uint64 { |
| 429 | o.mu.Lock() |
| 430 | defer o.mu.Unlock() |
| 431 | return o.revision |
| 432 | } |
| 433 | |
| 434 | // PromptKind identifies the interactive surface that owns a pending decision. |
| 435 | type PromptKind string |
| 436 | |
| 437 | const ( |
| 438 | PromptAsk PromptKind = "ask" |
| 439 | PromptApproval PromptKind = "approval" |
| 440 | PromptPlan PromptKind = "plan" |
| 441 | PromptRecovery PromptKind = "recovery" |
| 442 | PromptMCP PromptKind = "mcp" |
| 443 | ) |
| 444 | |
| 445 | // PromptIdentity is the immutable identity captured when a decision card is |
| 446 | // emitted. Turn and runtime fences prevent a delayed UI action crossing a |
| 447 | // controller replacement. |
| 448 | type PromptIdentity struct { |
| 449 | PromptID string |
| 450 | ToolCallID string |
| 451 | TurnID string |
| 452 | RuntimeEpoch string |
| 453 | Kind PromptKind |
| 454 | } |
| 455 | |
| 456 | func (c *Controller) promptIdentitySnapshot() (string, string) { |
| 457 | turnID, _, _, _ := c.turnEventRuntimeStatus() |
| 458 | c.promptEpochMu.RLock() |
| 459 | epoch := c.promptRuntimeEpoch |
| 460 | c.promptEpochMu.RUnlock() |
| 461 | return turnID, epoch |
| 462 | } |
| 463 | |
| 464 | func (c *Controller) registerOwnedPrompt(id string, kind PromptKind) { |
| 465 | turn, epoch := c.promptIdentitySnapshot() |
| 466 | identity := PromptIdentity{PromptID: id, ToolCallID: id, TurnID: turn, RuntimeEpoch: epoch, Kind: kind} |
| 467 | resolve := func(answer PromptAnswer) error { |
| 468 | switch kind { |
| 469 | case PromptAsk: |
| 470 | return c.answerQuestionCheckedLocked(id, answer.Questions) |
| 471 | case PromptApproval: |
| 472 | return c.resolveApprovalLocked(id, answer.Allow, scopeFromApprove(answer.Allow, answer.Session, answer.Persist)) |
| 473 | case PromptPlan: |
| 474 | return c.resolvePlanDecisionWithFeedbackLocked(id, PlanDecisionAction(answer.Action), answer.Feedback) |
| 475 | case PromptRecovery: |
| 476 | return c.resolveRecoveryLocked(id, agent.RecoveryAction(answer.Action), answer.Feedback) |
| 477 | case PromptMCP: |
| 478 | return c.answerMCPInteractionCheckedLocked(id, answer.Action, answer.Content) |
| 479 | default: |
| 480 | return ErrPromptNotPending |
| 481 | } |
| 482 | } |
| 483 | cancel := func() error { |
| 484 | switch kind { |
| 485 | case PromptAsk: |
| 486 | c.approval.cancelAsk(id) |
| 487 | case PromptApproval, PromptPlan, PromptRecovery: |
| 488 | c.approval.cancel(id) |
| 489 | case PromptMCP: |
| 490 | c.approval.cancelMCPInteraction(id) |
| 491 | } |
| 492 | return nil |
| 493 | } |
| 494 | _ = c.promptOwner.RegisterPrompt(PendingPrompt{Identity: identity, State: PromptPending, Resolve: resolve, Cancel: cancel}) |
| 495 | } |
| 496 | |
| 497 | // bindOwnedPromptRouting completes an identity immediately before its request |
| 498 | // event is emitted. This closes the startup window where the prompt is queued |
| 499 | // before the turn ledger has published its active turn or runtime epoch. |
| 500 | func (c *Controller) bindOwnedPromptRouting(id, turnID, runtimeEpoch string) PromptIdentity { |
| 501 | if c == nil { |
| 502 | return PromptIdentity{} |
| 503 | } |
| 504 | identity, _ := c.promptOwner.BindRouting(id, turnID, runtimeEpoch) |
| 505 | return identity |
| 506 | } |
| 507 | |
| 508 | func (c *Controller) PendingPromptIdentities() []PromptIdentity { return c.promptOwner.Identities() } |
| 509 | |
| 510 | func (c *Controller) cancelOwnedPrompt(id string) { |
| 511 | c.cancelOwnedPromptLocked(id) |
| 512 | } |
| 513 | |
| 514 | func (c *Controller) cancelOwnedPromptLocked(id string) { |
| 515 | if prompt, ok := c.promptOwner.Prompt(id); ok && prompt.Cancel != nil { |
| 516 | _ = prompt.Cancel() |
| 517 | } else { |
| 518 | c.approval.cancel(id) |
| 519 | c.approval.cancelAsk(id) |
| 520 | c.approval.cancelMCPInteraction(id) |
| 521 | } |
| 522 | c.promptOwner.MarkIDTerminal(id, PromptCancelled) |
| 523 | } |
| 524 | |
| 525 | var ( |
| 526 | ErrPromptStaleTurn = errors.New("prompt belongs to a stale turn") |
| 527 | ErrPromptStaleRuntime = errors.New("prompt belongs to a stale runtime") |
| 528 | ErrPromptAlreadyResolved = errors.New("prompt is already resolved") |
| 529 | ErrPromptNotPending = errors.New("prompt is not pending") |
| 530 | ErrPromptUnavailable = errors.New("prompt answerer is unavailable") |
| 531 | ) |
| 532 | |
| 533 | // PromptAnswer is the transport-neutral union used by exact prompt resolve. |
| 534 | type PromptAnswer struct { |
| 535 | Questions []event.AskAnswer |
| 536 | Allow bool |
| 537 | Session bool |
| 538 | Persist bool |
| 539 | Action string |
| 540 | Feedback string |
| 541 | Content map[string]any |
| 542 | Generation uint64 |
| 543 | PermissionRevision uint64 |
| 544 | } |
| 545 | |
| 546 | // ResolvePromptExact is the single controller-owned decision boundary. The |
| 547 | // specialized resolvers retain their validation and durable receipts. The |
| 548 | // owner claims the one-shot transition before invoking an answerer, without |
| 549 | // holding its registry lock or any controller-wide lock. |
| 550 | func (c *Controller) ResolvePromptExact(identity PromptIdentity, answer PromptAnswer) error { |
| 551 | defer c.refreshRuntimeState(event.Event{}) |
| 552 | if c == nil { |
| 553 | return ErrPromptNotPending |
| 554 | } |
| 555 | if identity.PromptID == "" || identity.TurnID == "" { |
| 556 | return ErrPromptNotPending |
| 557 | } |
| 558 | c.mu.Lock() |
| 559 | closed := c.closed |
| 560 | c.mu.Unlock() |
| 561 | if closed { |
| 562 | return ErrPromptNotPending |
| 563 | } |
| 564 | c.promptEpochMu.RLock() |
| 565 | epoch := c.promptRuntimeEpoch |
| 566 | c.promptEpochMu.RUnlock() |
| 567 | if epoch != "" && (identity.RuntimeEpoch == "" || identity.RuntimeEpoch != epoch) { |
| 568 | return ErrPromptStaleRuntime |
| 569 | } |
| 570 | turnID, _, _, _ := c.turnEventRuntimeStatus() |
| 571 | if turnID != identity.TurnID { |
| 572 | return ErrPromptStaleTurn |
| 573 | } |
| 574 | owned, ok := c.promptOwner.Identity(identity.PromptID) |
| 575 | if !ok { |
| 576 | // Let the owner compare the terminal state and answer digest. An |
| 577 | // identical transport retry is idempotent; a different late answer is |
| 578 | // rejected as a conflict. |
| 579 | return c.promptOwner.Resolve(identity, answer) |
| 580 | } |
| 581 | identity = normalizePromptIdentity(identity, owned) |
| 582 | if owned.TurnID != identity.TurnID || owned.Kind != identity.Kind { |
| 583 | return ErrPromptStaleTurn |
| 584 | } |
| 585 | if owned.RuntimeEpoch != identity.RuntimeEpoch { |
| 586 | return ErrPromptStaleRuntime |
| 587 | } |
| 588 | if identity.Kind == PromptApproval { |
| 589 | if answer.Generation != 0 && answer.Generation != c.runtimeGeneration { |
| 590 | return ErrPromptStaleRuntime |
| 591 | } |
| 592 | if answer.PermissionRevision != 0 && answer.PermissionRevision != c.permissionRevision.Load() { |
| 593 | return ErrPromptStaleRuntime |
| 594 | } |
| 595 | } |
| 596 | return c.promptOwner.Resolve(identity, answer) |
| 597 | } |
| 598 |