返回 DeepSeek-Reasonix
runtimeplan.go
根目录 / internal / extension / runtimeplan.go
1 package extension
2
3 import (
4 "maps"
5 "slices"
6 "strings"
7 )
8
9 // SubgraphKind classifies which assembly subgraph a RuntimePlan touches so
10 // Rebuild can skip unaffected work (tools/prompt, interceptors, UI, MCP).
11 type SubgraphKind uint8
12
13 const (
14 // SubgraphNone means no component identity changed (full no-op).
15 SubgraphNone SubgraphKind = iota
16 // SubgraphInterceptorOnly means only interceptor contributions moved.
17 SubgraphInterceptorOnly
18 // SubgraphProviderOnly means only provider capabilities moved.
19 SubgraphProviderOnly
20 // SubgraphUIOnly means only UI capabilities moved.
21 SubgraphUIOnly
22 // SubgraphMCPOnly means only MCP-related components moved.
23 SubgraphMCPOnly
24 // SubgraphSidecar means native runtime packages changed without a single kind.
25 SubgraphSidecar
26 // SubgraphFull means mixed or host-wide changes; full rebuild is required.
27 SubgraphFull
28 )
29
30 // RuntimePlan describes the transition from one generation's graph/snapshot
31 // to the next. Builders and Rebuild consume it to activate only the affected
32 // subgraph. PrefixChanged is an observed post-build fact; graph diffing alone
33 // only classifies which work may be required.
34 type RuntimePlan struct {
35 FromGeneration uint64
36 ToGeneration uint64
37 Added []ComponentID
38 Removed []ComponentID
39 Reloaded []ComponentID
40 Unchanged []ComponentID
41 ActivateOrder []ComponentID
42 DrainOrder []ComponentID
43 // PrefixChanged reports whether the frozen provider-visible prefix actually
44 // changed between the previous and current snapshots. Boot sets it only
45 // after both snapshots exist and their CacheHash values can be compared.
46 PrefixChanged bool
47 // ProviderChanged reports whether any changed component provided a provider
48 // capability on either side of the transition. Looking at both graphs keeps
49 // provider removal visible to diagnostics.
50 ProviderChanged bool
51 // Kind is the classified subgraph of this plan (computed by DiffRuntimePlan).
52 Kind SubgraphKind
53 // Graph is the resolved target graph (may be nil for pure no-op plans).
54 Graph *DependencyGraph
55 // RestartUnchangedSidecars requests fresh native processes even when their
56 // dependency/capability identity is unchanged. It affects activation only,
57 // so IsNoOp and CacheHash remain stable.
58 RestartUnchangedSidecars bool
59 }
60
61 // IsNoOp reports whether the plan changes no components.
62 func (p *RuntimePlan) IsNoOp() bool {
63 if p == nil {
64 return true
65 }
66 return len(p.Added) == 0 && len(p.Removed) == 0 && len(p.Reloaded) == 0
67 }
68
69 // MayChangePrefix is the conservative pre-build planning signal for whether a
70 // rebuild must re-evaluate provider-visible prompt/tool prefix state. It is not
71 // a diagnostic fact; PrefixChanged is set after comparing frozen snapshots.
72 // Interceptor-only and UI-only plans do not change CacheHash by contract.
73 func (p *RuntimePlan) MayChangePrefix() bool {
74 if p == nil || p.IsNoOp() {
75 return false
76 }
77 switch p.Kind {
78 case SubgraphInterceptorOnly, SubgraphUIOnly:
79 return false
80 default:
81 return true
82 }
83 }
84
85 // AffectsSidecars reports whether any native runtime package must start/drain.
86 func (p *RuntimePlan) AffectsSidecars() bool {
87 if p == nil {
88 return false
89 }
90 return p.RestartUnchangedSidecars || !p.IsNoOp()
91 }
92
93 // AffectsInterceptors reports whether the interceptor chain must rebuild.
94 func (p *RuntimePlan) AffectsInterceptors() bool {
95 if p == nil || p.IsNoOp() {
96 return false
97 }
98 return p.Kind == SubgraphInterceptorOnly || p.Kind == SubgraphFull || p.Kind == SubgraphSidecar
99 }
100
101 // AffectsUI reports whether the extension UI hub must rebind.
102 func (p *RuntimePlan) AffectsUI() bool {
103 if p == nil || p.IsNoOp() {
104 return false
105 }
106 return p.Kind == SubgraphUIOnly || p.Kind == SubgraphFull || p.Kind == SubgraphSidecar
107 }
108
109 // AffectsProviders reports whether extension-hosted providers must re-merge.
110 func (p *RuntimePlan) AffectsProviders() bool {
111 if p == nil || p.IsNoOp() {
112 return false
113 }
114 return p.ProviderChanged || p.Kind == SubgraphProviderOnly || p.Kind == SubgraphFull || p.Kind == SubgraphSidecar
115 }
116
117 // DiffRuntimePlan compares two graphs and produces a deterministic plan.
118 // from may be nil (cold start). PrefixChanged remains false here because graph
119 // identity cannot prove provider-visible byte changes; boot observes it after
120 // the next RuntimeSnapshot has been frozen.
121 func DiffRuntimePlan(from, to *DependencyGraph, fromGen, toGen uint64) *RuntimePlan {
122 plan := &RuntimePlan{
123 FromGeneration: fromGen,
124 ToGeneration: toGen,
125 Graph: to,
126 }
127 if to == nil {
128 return plan
129 }
130 fromIDs := map[ComponentID]ComponentDescriptor{}
131 if from != nil {
132 maps.Copy(fromIDs, from.Components)
133 }
134 toIDs := to.Components
135
136 var added, removed, reloaded, unchanged []ComponentID
137 for id, neo := range toIDs {
138 old, ok := fromIDs[id]
139 if !ok {
140 added = append(added, id)
141 continue
142 }
143 if componentIdentityChanged(old, neo) || epochsChanged(from, to, id) {
144 reloaded = append(reloaded, id)
145 continue
146 }
147 unchanged = append(unchanged, id)
148 }
149 for id := range fromIDs {
150 if _, ok := toIDs[id]; !ok {
151 removed = append(removed, id)
152 }
153 }
154 sortIDs(added)
155 sortIDs(removed)
156 sortIDs(reloaded)
157 sortIDs(unchanged)
158 plan.Added = added
159 plan.Removed = removed
160 plan.Reloaded = reloaded
161 plan.Unchanged = unchanged
162 plan.ActivateOrder = to.ActivateOrder()
163 // Drain only removed + reloaded, in reverse dependency order of the old graph.
164 drainSet := map[ComponentID]bool{}
165 for _, id := range removed {
166 drainSet[id] = true
167 }
168 for _, id := range reloaded {
169 drainSet[id] = true
170 }
171 if from != nil {
172 for _, id := range from.DrainOrder() {
173 if drainSet[id] {
174 plan.DrainOrder = append(plan.DrainOrder, id)
175 }
176 }
177 } else {
178 plan.DrainOrder = append(plan.DrainOrder, removed...)
179 plan.DrainOrder = append(plan.DrainOrder, reloaded...)
180 }
181 plan.Kind = classifySubgraph(plan, from, to)
182 plan.ProviderChanged = changedComponentsProvideKind(plan, from, to, "provider")
183 return plan
184 }
185
186 // classifySubgraph inspects changed components' provides/intercepts to pick
187 // the narrowest rebuild subgraph.
188 func classifySubgraph(plan *RuntimePlan, from, to *DependencyGraph) SubgraphKind {
189 if plan == nil || plan.IsNoOp() {
190 return SubgraphNone
191 }
192 changed := append(append(append([]ComponentID{}, plan.Added...), plan.Removed...), plan.Reloaded...)
193 var hasInterceptor, hasProvider, hasUI, hasMCP, hasOther bool
194 mcpSchemaChanged := false
195 for _, id := range changed {
196 oldDesc, oldOK := graphComponent(from, id)
197 newDesc, newOK := graphComponent(to, id)
198 mcpSchemaChanged = mcpSchemaChanged || mcpCapabilitySchemaChanged(oldDesc, oldOK, newDesc, newOK)
199 componentClassified := false
200 for _, desc := range []ComponentDescriptor{oldDesc, newDesc} {
201 if len(desc.Intercepts) > 0 || len(desc.Replaces) > 0 {
202 hasInterceptor = true
203 componentClassified = true
204 }
205 for _, cap := range desc.Provides {
206 switch strings.ToLower(cap.Key.Kind) {
207 case "provider":
208 hasProvider = true
209 componentClassified = true
210 case "ui", "uiaction":
211 hasUI = true
212 componentClassified = true
213 case "mcp", "mcpserver":
214 hasMCP = true
215 componentClassified = true
216 case "interceptors", "strategies":
217 hasInterceptor = true
218 componentClassified = true
219 default:
220 if cap.Key.Kind != "" {
221 hasOther = true
222 componentClassified = true
223 }
224 }
225 }
226 }
227 // Plugin components with empty provides still count as sidecar.
228 if strings.HasPrefix(string(id), "plugin/") && !componentClassified {
229 hasOther = true
230 }
231 }
232 // An MCP backend roll may stay narrow only while its declared schema shape is
233 // unchanged. Added/removed/renamed schemas need a full snapshot rebuild so
234 // provider-visible tool bytes cannot remain stale.
235 if mcpSchemaChanged {
236 return SubgraphFull
237 }
238 kinds := 0
239 if hasInterceptor {
240 kinds++
241 }
242 if hasProvider {
243 kinds++
244 }
245 if hasUI {
246 kinds++
247 }
248 if hasMCP {
249 kinds++
250 }
251 if hasOther {
252 kinds++
253 }
254 if kinds > 1 {
255 if hasOther {
256 return SubgraphFull
257 }
258 return SubgraphSidecar
259 }
260 switch {
261 case hasInterceptor:
262 return SubgraphInterceptorOnly
263 case hasProvider:
264 return SubgraphProviderOnly
265 case hasUI:
266 return SubgraphUIOnly
267 case hasMCP:
268 return SubgraphMCPOnly
269 default:
270 return SubgraphFull
271 }
272 }
273
274 func graphComponent(graph *DependencyGraph, id ComponentID) (ComponentDescriptor, bool) {
275 if graph == nil {
276 return ComponentDescriptor{}, false
277 }
278 desc, ok := graph.Components[id]
279 return desc, ok
280 }
281
282 func changedComponentsProvideKind(plan *RuntimePlan, from, to *DependencyGraph, kind string) bool {
283 if plan == nil || plan.IsNoOp() {
284 return false
285 }
286 changed := append(append(append([]ComponentID{}, plan.Added...), plan.Removed...), plan.Reloaded...)
287 for _, id := range changed {
288 oldDesc, _ := graphComponent(from, id)
289 newDesc, _ := graphComponent(to, id)
290 for _, desc := range []ComponentDescriptor{oldDesc, newDesc} {
291 for _, capability := range desc.Provides {
292 if strings.EqualFold(strings.TrimSpace(capability.Key.Kind), kind) {
293 return true
294 }
295 }
296 }
297 }
298 return false
299 }
300
301 func mcpCapabilitySchemaChanged(oldDesc ComponentDescriptor, oldOK bool, newDesc ComponentDescriptor, newOK bool) bool {
302 oldShape := mcpCapabilityShape(oldDesc, oldOK)
303 newShape := mcpCapabilityShape(newDesc, newOK)
304 if len(oldShape) != len(newShape) {
305 return len(oldShape) > 0 || len(newShape) > 0
306 }
307 for key, oldHash := range oldShape {
308 if newHash, ok := newShape[key]; !ok || newHash != oldHash {
309 return true
310 }
311 }
312 return false
313 }
314
315 func mcpCapabilityShape(desc ComponentDescriptor, ok bool) map[string]string {
316 shape := map[string]string{}
317 if !ok {
318 return shape
319 }
320 for _, capability := range desc.Provides {
321 switch strings.ToLower(strings.TrimSpace(capability.Key.Kind)) {
322 case "mcp", "mcpserver":
323 shape[capability.Key.String()] = strings.TrimSpace(capability.SchemaHash)
324 }
325 }
326 return shape
327 }
328
329 func componentIdentityChanged(a, b ComponentDescriptor) bool {
330 if a.Priority != b.Priority || a.Optional != b.Optional {
331 return true
332 }
333 if a.Source.key() != b.Source.key() || a.Source.Version != b.Source.Version {
334 return true
335 }
336 if !slices.Equal(a.Intercepts, b.Intercepts) || !slices.Equal(a.Replaces, b.Replaces) {
337 return true
338 }
339 if len(a.Provides) != len(b.Provides) || len(a.Requires) != len(b.Requires) {
340 return true
341 }
342 for i := range a.Provides {
343 if a.Provides[i].CanonicalHash() != b.Provides[i].CanonicalHash() {
344 return true
345 }
346 }
347 for i := range a.Requires {
348 if a.Requires[i].Key != b.Requires[i].Key ||
349 a.Requires[i].Version != b.Requires[i].Version ||
350 a.Requires[i].VersionRange != b.Requires[i].VersionRange ||
351 a.Requires[i].SchemaHash != b.Requires[i].SchemaHash ||
352 a.Requires[i].Optional != b.Requires[i].Optional {
353 return true
354 }
355 }
356 return false
357 }
358
359 func epochsChanged(from, to *DependencyGraph, id ComponentID) bool {
360 if from == nil || to == nil {
361 return false
362 }
363 c := to.Components[id]
364 for _, req := range c.Requires {
365 e1, ok1 := from.EpochFor(id, req)
366 e2, ok2 := to.EpochFor(id, req)
367 if ok1 != ok2 || e1.String() != e2.String() {
368 return true
369 }
370 }
371 return false
372 }
373
374 func sortIDs(ids []ComponentID) {
375 slices.Sort(ids)
376 }
377
377 lines GO