返回 DeepSeek-Reasonix
runtime.go
根目录 / internal / boot / runtime.go
1 package boot
2
3 import (
4 "context"
5 "fmt"
6 "strings"
7 "sync/atomic"
8
9 "reasonix/internal/command"
10 "reasonix/internal/control"
11 "reasonix/internal/extension"
12 "reasonix/internal/extension/dispatch"
13 "reasonix/internal/extension/protocol"
14 "reasonix/internal/extension/sidecar"
15 "reasonix/internal/extension/uihub"
16 "reasonix/internal/hook"
17 "reasonix/internal/plugin"
18 "reasonix/internal/provider"
19 "reasonix/internal/skill"
20 "reasonix/internal/skill/skillwatch"
21 "reasonix/internal/tool"
22 )
23
24 // BuildResult is the full product of one runtime build: the ready-to-drive
25 // controller plus the extension kernel's frozen view of the same assembly.
26 // Snapshot is nil when kernel assembly failed — boot behavior is never
27 // allowed to depend on it (see build). Runtime is the snapshot's bound
28 // closable set; it holds the extension sidecar Manager when any v2 runtime
29 // package is installed, and its Close is chained into the controller's
30 // cleanup so sidecars die with their controller generation.
31 type BuildResult struct {
32 Controller *control.Controller
33 Snapshot *extension.RuntimeSnapshot
34 Runtime *extension.RuntimeSet
35 // Owner is the session-lineage lifecycle owner. Independent builds receive
36 // independent owners; RebuildFrom reuses the previous owner so only that
37 // lineage's old generation drains.
38 Owner *extension.RuntimeOwner
39 // Extensions is the started extension sidecar Manager (nil when no v2
40 // runtime package is installed). Its lifecycle belongs to Runtime; the
41 // field exists so later stages (and tests) can reach the live clients.
42 Extensions *sidecar.Manager
43 // Dispatcher is the frozen interceptor dispatcher for this runtime
44 // generation (nil when no sidecar started, or when snapshot assembly
45 // degraded). It is immutable and safe for concurrent turns; the
46 // controller receives it through SetExtensions right after assembly.
47 Dispatcher *dispatch.Dispatcher
48 // ExtensionUI is the host extension UI hub for this runtime generation
49 // (stage 8a; nil when no sidecar started). It is bound to the build's
50 // session ID and generation; the controller receives it through
51 // SetExtensionUI right after assembly, and a Rebuild creates a fresh hub
52 // on the new generation.
53 ExtensionUI *uihub.Hub
54 // ProviderResolver is the build's effective provider resolver: the
55 // caller-owned broker when Options.ProviderResolver is set, the local
56 // config-backed resolver otherwise, merged with any extension-hosted
57 // sidecar providers (stage 7). Plugin-namespaced refs route to the owning
58 // sidecar; every other ref resolves through the base resolver.
59 ProviderResolver provider.Resolver
60 // BaseProviderResolver is the pre-sidecar catalog used to re-merge after
61 // a narrow rebuild replaces the Manager (must not re-merge already-merged).
62 BaseProviderResolver provider.Resolver
63 // Plan is the RuntimePlan for this generation (from the previous graph
64 // when Rebuild supplies one; cold start when nil previous).
65 Plan *extension.RuntimePlan
66 // Status is the diagnostic component status snapshot for doctor/UI.
67 Status *extension.RuntimeStatus
68 // Lifecycle tracks component state transitions for this generation.
69 Lifecycle *extension.LifecycleRegistry
70 // Assembly is retained so a subsequent RebuildFrom can skip rediscovery
71 // when the RuntimePlan is no-op or interceptor/UI-only.
72 Assembly *ReusedAssembly
73 // ReusedController is true when a true subgraph rebuild kept the previous
74 // controller pointer (no control.New / BuildRuntime). Callers must not
75 // Close the "old" controller when it is the same pointer as Controller.
76 ReusedController bool
77 // SkillWatchService exposes content-free resource counters to doctor/UI.
78 SkillWatchService *skillwatch.Service
79 }
80
81 // runtimeGeneration is the process-wide build generation counter. The first
82 // build gets generation 1 so 0 can mean "no snapshot" on a RuntimeSet built
83 // outside the kernel pipeline.
84 var runtimeGeneration atomic.Uint64
85
86 // nextRuntimeGeneration returns the next build generation. Generations pair
87 // with RuntimeSet.CloseIfGeneration so stale cleanup can never close a newer
88 // runtime's resources.
89 func nextRuntimeGeneration() uint64 { return runtimeGeneration.Add(1) }
90
91 // BuildRuntime runs the full boot assembly and returns the controller
92 // together with the extension kernel's frozen snapshot of the exact resources
93 // the build wired — tools, skills, commands, hooks, MCP servers, providers,
94 // and the composed system prompt. The snapshot is assembled from the in-hand
95 // objects the build itself produced (discovery never re-runs), so it cannot
96 // drift from what the controller actually uses, and it never makes an
97 // otherwise-successful build fail: an assembly error degrades to a nil
98 // Snapshot with a logged warning.
99 func BuildRuntime(ctx context.Context, opts Options) (*BuildResult, error) {
100 return build(ctx, opts)
101 }
102
103 // Build loads config, resolves the model(s), and returns a Controller wrapping a
104 // single Agent, or a two-model Coordinator when agent.planner_model is set. The
105 // returned controller owns plugin subprocesses; call Close (via Controller.Close)
106 // to release them.
107 //
108 // Build is the compatibility wrapper over BuildRuntime: frontends keep their
109 // existing signature. The runtime set is NOT closed here — it is chained into
110 // the controller's cleanup (the way LSP cleanup is chained), so extension
111 // sidecars live exactly as long as their controller.
112 func Build(ctx context.Context, opts Options) (*control.Controller, error) {
113 res, err := BuildRuntime(ctx, opts)
114 if err != nil {
115 return nil, err
116 }
117 return res.Controller, nil
118 }
119
120 // legacyAssembly carries the already-assembled runtime resources the kernel
121 // snapshot is built from. Every field is the exact object the rest of the
122 // build wired into the controller — the snapshot never re-derives anything.
123 type legacyAssembly struct {
124 systemPrompt string
125 registry *tool.Registry
126 skills []skill.Skill
127 commands []command.Command
128 hooks []hook.ResolvedHook
129 mcpSpecs []plugin.Spec
130 providers []provider.Descriptor
131 }
132
133 // extensionBoot carries the extension sidecar launch inputs into snapshot
134 // assembly: the session the sidecars serve, where non-fatal warnings go, and
135 // (stage 8a) the UI hub the sidecars' host/ui/* calls bind to.
136 type extensionBoot struct {
137 session protocol.SessionContext
138 onWarning func(string)
139 ui *uihub.Hub
140 // skipPromptStrategy skips system_prompt.build strategy when the RuntimePlan
141 // is a no-op (or does not affect cache), preserving the previous prompt.
142 skipPromptStrategy bool
143 // previousDispatcher reuses an interceptor chain when the plan is no-op.
144 previousDispatcher *dispatch.Dispatcher
145 }
146
147 func (p extensionBoot) warn(msg string) {
148 if p.onWarning != nil {
149 p.onWarning(msg)
150 }
151 }
152
153 // startExtensionPackages is the sidecar launch seam (tests override it).
154 // previous+plan adopt Unchanged packages without respawn.
155 var startExtensionPackages = sidecar.StartPackagesWithPlan
156
157 // preflightExtensionRuntimes starts enabled runtime packages before model
158 // resolution. Required failures are fatal; optional failures warn. With
159 // prev+plan, only Added/Reloaded start and Unchanged are adopted.
160 func preflightExtensionRuntimes(ctx context.Context, home string, ext extensionBoot, prev *sidecar.Manager, plan *extension.RuntimePlan) (*sidecar.Manager, error) {
161 if strings.TrimSpace(home) == "" {
162 return nil, nil
163 }
164 packages, loadWarnings := sidecar.LoadRuntimePackages(home)
165 if len(packages) == 0 {
166 // No v2 runtime packages: surface the installed-state warnings exactly
167 // as the in-assembly StartPackages call did, and take the untouched
168 // pre-sidecar path (no processes, no Manager).
169 for _, warning := range loadWarnings {
170 ext.warn(warning)
171 }
172 return nil, nil
173 }
174 // The stage-8a UI hub installs as each sidecar's host/ui/* handler; a nil
175 // hub keeps the "ui not available" default. Guard against the typed-nil
176 // trap: a nil *uihub.Hub must stay a nil UIHandler.
177 var ui sidecar.UIHandler
178 if ext.ui != nil {
179 ui = ext.ui
180 }
181 mgr, warnings, err := startExtensionPackages(ctx, home, ext.session, ui, prev, plan)
182 for _, warning := range warnings {
183 ext.warn(warning)
184 }
185 if err != nil {
186 // A required runtime failed: the Manager already shut down whatever
187 // started. The call site treats *RequiredStartError as fatal.
188 return nil, err
189 }
190 if len(mgr.Clients()) == 0 {
191 // Every package was optional and failed: retire the empty Manager and
192 // take the pre-sidecar path with warnings already surfaced.
193 _ = mgr.Close()
194 return nil, nil
195 }
196 return mgr, nil
197 }
198
199 // assembleLegacySnapshot freezes one boot's resources into an extension
200 // kernel snapshot. ConflictCollect is deliberate: the legacy sources already
201 // resolved their clashes inside their own discovery passes, and any residual
202 // dispute must land on the snapshot's Diagnostics without changing whether
203 // the session boots.
204 //
205 // Extension sidecars (stage 5b): mgr is the Manager preflightExtensionRuntimes
206 // started before model resolution; this function takes OVER its ownership —
207 // on every error path it closes mgr, and on success the Manager is registered
208 // into the RuntimeSet at activation, so it dies with its controller
209 // generation. Starting before the builder freezes is load-bearing: the
210 // handshake's declared providers and UI actions can only enter the catalog
211 // from a live handshake, and required-runtime failures must fail the build,
212 // which the activator seam (post-freeze) could only do after the catalog
213 // already settled.
214 //
215 // Dispatch wiring (stage 6b1): with live sidecars the boot also runs the
216 // system_prompt.build strategy BEFORE the freeze, so the frozen snapshot's
217 // SystemPrompt and CacheHash cover the final (possibly replaced) prompt —
218 // the hash honestly attributes the prompt the session was built with rather
219 // than a pre-strategy draft. A strategy failure fails the build because the
220 // slot owner is required-class by definition. After the freeze the boot
221 // builds the generation's Dispatcher from the snapshot's frozen chain and
222 // replacements, and broadcasts the system_prompt.build event with the final
223 // prompt to every observer. The replaced prompt lands in the snapshot; the
224 // build's tail (stage 6b2) swaps the live executor session to the same final
225 // prompt when it differs from the host-composed one the session was built
226 // with, so snapshot and session describe the same session before any turn.
227 // With a nil Manager the path is byte-identical to the pre-sidecar one: no
228 // processes, no contributions, no dispatcher, an empty runtime set.
229 func assembleLegacySnapshot(ctx context.Context, in legacyAssembly, generation uint64, ext extensionBoot, mgr *sidecar.Manager) (*extension.RuntimeSnapshot, *extension.RuntimeSet, *dispatch.Dispatcher, error) {
230 legacy := legacyContributions(in)
231 b := extension.NewBuilder().
232 WithGeneration(generation).
233 WithConflictPolicy(extension.ConflictCollect).
234 AddContributor(extension.ContributorFunc{
235 ContributorName: "boot-legacy",
236 Fn: func(context.Context) ([]extension.Contribution, error) {
237 return legacy, nil
238 },
239 })
240
241 prompt := in.systemPrompt
242 var dispatcher *dispatch.Dispatcher
243 // postFreeze runs after a successful b.Build when sidecars are live: it
244 // builds the generation's dispatcher from the snapshot's own frozen chain
245 // and replacements, then broadcasts system_prompt.build with the final
246 // prompt so observers see exactly what froze.
247 var postFreeze func(snap *extension.RuntimeSnapshot)
248 if mgr != nil && len(mgr.Clients()) > 0 {
249 managed := mgr
250 bindExtensionUI(ext.ui, managed, ext.warn)
251 sidecarContribs := managed.Contributions()
252 b.AddContributor(extension.ContributorFunc{
253 ContributorName: "boot-extension-runtimes",
254 Fn: func(context.Context) ([]extension.Contribution, error) {
255 return sidecarContribs, nil
256 },
257 })
258 b.WithActivator(func(actx context.Context, snap *extension.RuntimeSnapshot) (*extension.RuntimeSet, error) {
259 rs := extension.NewRuntimeSet(snap.Generation())
260 // Track the sidecar manager as a cancelable generation effect so
261 // mid-activation failure and drain share one EffectScope owner.
262 if err := rs.Track(extension.Effect{
263 ID: "sidecar-manager",
264 Owner: "boot",
265 Component: "extension-runtimes",
266 Class: extension.Cancelable,
267 Dispose: func(ctx context.Context) error {
268 _ = ctx
269 return managed.Close()
270 },
271 }); err != nil {
272 _ = managed.Close()
273 return nil, err
274 }
275 // UI hub binding is a reversible generation effect (rebind is free).
276 if err := extension.TrackUIHub(rs.Scope(), snap.Generation()); err != nil {
277 _ = rs.Close()
278 return nil, err
279 }
280 // Per-client MCP/process handles stay under the manager dispose above;
281 // track an event-subscription style teardown for each live client so
282 // EffectScope inventory matches the live process set for doctor.
283 for _, client := range managed.Clients() {
284 pluginID := client.PluginID()
285 _ = extension.TrackEventSubscription(rs.Scope(), "sidecar:"+pluginID, func() error {
286 // Manager.Close already tears down clients; this is inventory.
287 return nil
288 })
289 }
290 _ = actx
291 return rs, nil
292 })
293
294 // Resolve replacement-slot claims over the exact contribution set
295 // the builder will see, mirroring the kernel's claim pass, so a
296 // conflict fails BEFORE any strategy runs and the winning claims
297 // drive the pre-freeze strategy below.
298 claims, err := resolveReplacementClaims(legacy, sidecarContribs)
299 if err != nil {
300 _ = managed.Close()
301 return nil, nil, nil, err
302 }
303 required := requiredRuntimeSet(managed)
304 clients := sidecarClientResolver(managed)
305 dispatchOpts := dispatch.Options{Warn: ext.warn}
306 // system_prompt.build strategy: the slot's owner rules on the
307 // composed prompt before the snapshot freezes. Skipped on no-op plans
308 // so CacheHash stays stable across rebuilds.
309 if !ext.skipPromptStrategy {
310 if _, owned := claims[extension.SlotSystemPrompt]; owned {
311 strategyDispatcher := dispatch.New(nil, claims, clients, required, dispatchOpts)
312 payload := dispatch.SystemPromptPayload{Prompt: prompt, WorkspaceRoot: ext.session.WorkspaceRoot}
313 if err := strategyDispatcher.RunStrategy(ctx, extension.SlotSystemPrompt, extension.PointSystemPromptBuild, &payload); err != nil {
314 _ = managed.Close()
315 return nil, nil, nil, err
316 }
317 prompt = payload.Prompt
318 }
319 }
320
321 postFreeze = func(snap *extension.RuntimeSnapshot) {
322 if ext.previousDispatcher != nil && ext.skipPromptStrategy {
323 dispatcher = ext.previousDispatcher
324 } else {
325 dispatcher = dispatch.New(snap.InterceptorChain(), snap.Replacements(), clients, required, dispatchOpts)
326 }
327 dispatcher.Event(extension.PointSystemPromptBuild, dispatch.SystemPromptPayload{
328 Prompt: prompt, WorkspaceRoot: ext.session.WorkspaceRoot,
329 })
330 }
331 } else if mgr != nil {
332 // Defensive: preflight already retires client-less managers, but a
333 // caller-owned Manager must never leak through assembly either way.
334 _ = mgr.Close()
335 mgr = nil
336 }
337
338 b.WithSystemPrompt(prompt)
339 snap, runtimeSet, err := b.Build(ctx)
340 if err != nil {
341 if mgr != nil {
342 _ = mgr.Close()
343 }
344 return nil, nil, nil, err
345 }
346 if postFreeze != nil {
347 postFreeze(snap)
348 }
349 return snap, runtimeSet, dispatcher, nil
350 }
351
352 // resolveReplacementClaims replays the kernel's slot-claim pass (see
353 // resolveContributions) over the contribution set before the builder freezes:
354 // claims come from every contribution's SlotClaimer payload, winners and
355 // losers alike, and a second claimant is a *SlotConflictError.
356 func resolveReplacementClaims(groups ...[]extension.Contribution) (map[extension.Slot]extension.ContributionSource, error) {
357 claims := extension.NewReplaceClaims()
358 for _, group := range groups {
359 for _, ct := range group {
360 claimer, ok := ct.Payload.(extension.SlotClaimer)
361 if !ok {
362 continue
363 }
364 for _, slot := range claimer.ReplacementSlots() {
365 if err := claims.Claim(slot, ct.Source); err != nil {
366 return nil, err
367 }
368 }
369 }
370 }
371 return claims.Claims(), nil
372 }
373
374 // requiredRuntimeSet marks every started sidecar whose manifest declared the
375 // runtime required:true, the dispatcher's required-class input.
376 func requiredRuntimeSet(mgr *sidecar.Manager) map[string]bool {
377 clients := mgr.Clients()
378 out := make(map[string]bool, len(clients))
379 for _, client := range clients {
380 out[client.PluginID()] = client.Required()
381 }
382 return out
383 }
384
385 // sidecarClientResolver adapts the Manager to the dispatcher's client lookup.
386 // The dispatcher requires an untyped nil for missing clients; Manager.Client
387 // returns a typed *sidecar.Client.
388 func sidecarClientResolver(mgr *sidecar.Manager) func(pluginID string) dispatch.Client {
389 return func(pluginID string) dispatch.Client {
390 if client := mgr.Client(pluginID); client != nil {
391 return client
392 }
393 return nil
394 }
395 }
396
397 // bindExtensionUI finishes the stage-8a hub binding once sidecars are live:
398 // the client resolver routes later /<plugin>:<action> invocations and form
399 // submissions, and each client's handshake-declared UI actions enter the
400 // registry. An invalid declaration degrades to a warning (the plugin's other
401 // contributions are unaffected) rather than failing the build.
402 func bindExtensionUI(hub *uihub.Hub, mgr *sidecar.Manager, warn func(string)) {
403 if hub == nil || mgr == nil {
404 return
405 }
406 hub.SetResolver(func(pluginID string) uihub.ActionClient {
407 if client := mgr.Client(pluginID); client != nil {
408 return client
409 }
410 return nil
411 })
412 for _, client := range mgr.Clients() {
413 actions := client.Handshake().UIActions
414 if len(actions) == 0 {
415 continue
416 }
417 if err := hub.RegisterActions(client.PluginID(), actions); err != nil {
418 warn(fmt.Sprintf("%s: extension UI actions not registered: %v", client.PluginID(), err))
419 }
420 }
421 }
422
423 // legacyContributions maps the assembled runtime resources to kernel
424 // contributions, reusing the extension package's per-kind mappers so scope
425 // attribution stays in one place. Contributions whose IDs predate or violate
426 // the kernel's ID contract are skipped: one pathological legacy name must not
427 // take down the whole snapshot, and the live runtime is untouched either way.
428 func legacyContributions(in legacyAssembly) []extension.Contribution {
429 specByName := map[string]plugin.Spec{}
430 for _, spec := range in.mcpSpecs {
431 specByName[mcpNormalizedServerName(spec.Name)] = spec
432 }
433 var out []extension.Contribution
434 for _, entry := range in.registry.ContractEntries() {
435 if !extension.ValidToolID(entry.Name) {
436 // Legacy MCP name normalization preserves uppercase characters,
437 // which predates the kernel's lowercase tool-ID contract. Such a
438 // tool keeps working in the live registry; it is skipped here
439 // rather than failing the whole snapshot.
440 continue
441 }
442 out = append(out, extension.Contribution{
443 Kind: extension.KindTool,
444 ID: entry.Name,
445 Source: legacyToolSource(entry.Name, specByName),
446 Payload: entry,
447 })
448 }
449 for _, sk := range in.skills {
450 if !kernelID(sk.SlashName()) {
451 continue
452 }
453 out = append(out, extension.SkillContribution(sk))
454 }
455 for _, cmd := range in.commands {
456 if !kernelID(cmd.Name) {
457 continue
458 }
459 out = append(out, extension.CommandContribution(cmd))
460 }
461 perEvent := map[hook.Event]int{}
462 for _, h := range in.hooks {
463 seq := perEvent[h.Event]
464 perEvent[h.Event]++
465 out = append(out, extension.HookContribution(h, seq))
466 }
467 for _, spec := range in.mcpSpecs {
468 if !kernelID(spec.Name) {
469 // A server name outside the kernel ID contract cannot be a
470 // catalog entry; the server's tools still attribute through
471 // specByName above.
472 continue
473 }
474 out = append(out, extension.MCPServerContribution(spec))
475 }
476 for _, desc := range in.providers {
477 if !extension.IsProviderRef(desc.Ref) {
478 // The kernel keys providers on <name>/<model> refs; legacy
479 // catalogs can carry a bare provider name or a model that itself
480 // contains a slash. Those entries stay resolvable through the
481 // ordinary provider path but cannot be catalogued in a v2
482 // snapshot.
483 continue
484 }
485 out = append(out, extension.ProviderContribution(desc))
486 }
487 return out
488 }
489
490 // kernelID mirrors the generic ID hygiene the kernel validates for every
491 // kind (non-empty, no whitespace). The kernel's parse step trims first, so
492 // only interior whitespace or emptiness disqualifies.
493 func kernelID(id string) bool {
494 id = strings.TrimSpace(id)
495 return id != "" && !strings.ContainsAny(id, " \t\n")
496 }
497
498 // legacyToolSource attributes a registry tool to its origin. MCP-backed tools
499 // (the mcp__<server>__<tool> namespace) belong to the server's spec: a
500 // package-owned server attributes to the plugin package, otherwise to the
501 // server itself, both at the plugin tier. Everything else — and any MCP tool
502 // whose server can no longer be matched to a spec — stays at the builtin
503 // tier: the registry is the compile-time default surface, and an
504 // unattributable entry must not masquerade as a higher one. Tool IDs are
505 // unique inside one registry, so in stage 3a the scope is provenance only and
506 // never decides a shadow race.
507 func legacyToolSource(name string, specByName map[string]plugin.Spec) extension.ContributionSource {
508 if server, _, ok := tool.SplitMCPName(name); ok {
509 if spec, found := specByName[server]; found {
510 pluginID := strings.TrimSpace(spec.Package)
511 if pluginID == "" {
512 pluginID = spec.Name
513 }
514 origin := strings.TrimSpace(spec.ConfigSource)
515 if origin == "" {
516 origin = "mcp"
517 }
518 return extension.ContributionSource{Scope: extension.ScopePlugin, PluginID: pluginID, Origin: origin}
519 }
520 }
521 return extension.ContributionSource{Scope: extension.ScopeBuiltin, Origin: "builtin"}
522 }
523
524 // mcpNormalizedServerName returns the server name as it appears inside
525 // mcp__<server>__<tool> registry names, i.e. after the plugin package's name
526 // normalization (invalid characters replaced, collision hash appended).
527 func mcpNormalizedServerName(name string) string {
528 return strings.TrimSuffix(strings.TrimPrefix(plugin.ToolPrefix(name), tool.MCPNamePrefix), "__")
529 }
530
531 // enabledMCPSpecs returns the deduplicated set of MCP server specs a build
532 // enabled: the configured eager/background tiers plus host-session extras.
533 // Names are deduplicated so the kernel catalog holds one entry per server.
534 func enabledMCPSpecs(configSpecs, extraSpecs []plugin.Spec) []plugin.Spec {
535 var out []plugin.Spec
536 seen := map[string]bool{}
537 add := func(spec plugin.Spec) {
538 name := strings.TrimSpace(spec.Name)
539 if name == "" || seen[name] {
540 return
541 }
542 seen[name] = true
543 out = append(out, spec)
544 }
545 for _, spec := range configSpecs {
546 add(spec)
547 }
548 for _, spec := range extraSpecs {
549 add(spec)
550 }
551 return out
552 }
553
554 // gateExtensionUIRequest serves a sidecar's blocking host/ui/request once the
555 // session controller exists. A sidecar may legally ask right after
556 // extension/initialized — while the build is still assembling the controller —
557 // so the preflight hub cannot answer immediately. Rather than failing the
558 // prompt (which would deadlock an extension waiting on its own startup
559 // request), the gate waits for the controller to become ready, for the build
560 // to fail, or for the request context to cancel. serve is only invoked after
561 // load reports a controller.
562 func gateExtensionUIRequest(reqCtx context.Context, load func() *control.Controller, ready <-chan struct{}, failed <-chan struct{}, serve func(*control.Controller) (map[string]any, bool, error)) (map[string]any, bool, error) {
563 if c := load(); c != nil {
564 return serve(c)
565 }
566 select {
567 case <-ready:
568 c := load()
569 if c == nil {
570 return nil, false, fmt.Errorf("extension UI request: controller readiness signalled without a controller")
571 }
572 return serve(c)
573 case <-failed:
574 return nil, false, fmt.Errorf("extension UI request arrived but the session build failed")
575 case <-reqCtx.Done():
576 return nil, false, reqCtx.Err()
577 }
578 }
579
579 lines GO