Files
kandev/apps/backend/internal/agent/runtime/lifecycle/execution_metadata.go
T
2026-08-09 23:54:43 +01:00

96 lines
2.9 KiB
Go

package lifecycle
// AgentExecution.metadata is written and read from unrelated goroutines: the
// launch path caches resolved profile env and MCP artifacts on it, the prompt
// path stores model overrides, and the stop path (RemoveExecution ->
// cleanupPassthroughMCPConfig) deletes the passthrough keys. Those overlap in
// practice — a scheduled stop can land while a relaunch is still writing — and
// an unsynchronized Go map crashes the process with "concurrent map writes"
// rather than corrupting quietly.
//
// The field is therefore unexported and every access goes through the helpers
// below, all of which hold metadataMu. Never hand the live map to a caller or
// store it on another struct: use MetadataSnapshot, which returns a copy.
func (e *AgentExecution) setMetadataValue(key string, value interface{}) {
if e == nil {
return
}
e.metadataMu.Lock()
defer e.metadataMu.Unlock()
if e.metadata == nil {
e.metadata = make(map[string]interface{})
}
e.metadata[key] = value
}
// deleteMetadataValues removes keys from the execution's metadata. Deleting an
// absent key is a no-op, so callers need not check first.
func (e *AgentExecution) deleteMetadataValues(keys ...string) {
if e == nil {
return
}
e.metadataMu.Lock()
defer e.metadataMu.Unlock()
if e.metadata == nil {
return
}
for _, key := range keys {
delete(e.metadata, key)
}
}
func (e *AgentExecution) metadataValue(key string) (interface{}, bool) {
if e == nil {
return nil, false
}
e.metadataMu.RLock()
defer e.metadataMu.RUnlock()
value, ok := e.metadata[key]
return value, ok
}
// metadataString returns the value at key when it is a string, and "" otherwise.
func (e *AgentExecution) metadataString(key string) string {
value, _ := e.metadataValue(key)
s, _ := value.(string)
return s
}
// metadataBool returns the value at key when it is a bool, and false otherwise.
func (e *AgentExecution) metadataBool(key string) bool {
value, _ := e.metadataValue(key)
b, _ := value.(bool)
return b
}
// metadataInt returns the value at key coerced to an int, and 0 otherwise. It
// accepts the numeric shapes metadata arrives in after a JSON round-trip.
func (e *AgentExecution) metadataInt(key string) int {
value, ok := e.metadataValue(key)
if !ok {
return 0
}
return metadataInt(map[string]interface{}{key: value}, key)
}
// MetadataSnapshot returns a shallow copy of the execution's metadata. This is
// what every caller that needs the whole map must use — passing the live map to
// an executor backend, an MCP resolver, or a persistence shape leaves it being
// read after this function returns, outside the lock.
func (e *AgentExecution) MetadataSnapshot() map[string]interface{} {
if e == nil {
return nil
}
e.metadataMu.RLock()
defer e.metadataMu.RUnlock()
if len(e.metadata) == 0 {
return nil
}
snapshot := make(map[string]interface{}, len(e.metadata))
for key, value := range e.metadata {
snapshot[key] = value
}
return snapshot
}