Skip to content

RFC: SessionEntryMergePolicy."merge-plugin-metadata" for safe third-party plugin coexistence #71260

@100yenadmin

Description

@100yenadmin

Problem statement

mergeSessionEntry at src/config/sessions/types.ts:398-403 does a shallow spread:

const next = { ...existing, ...patch, sessionId, updatedAt };

When a patch contains pluginMetadata: { 'plugin-A': {...} }, this REPLACES the entire pluginMetadata field. Any other plugin's slot ('plugin-B', 'plugin-C', …) is silently dropped.

Observable in production today via the third-party Smarter-Claw plugin: when an agent reply lands and patches the entry, the agent's payload typically includes only its own pluginMetadata slice — the spread clobbers the plan-mode state Smarter-Claw wrote earlier in the same session. The plugin currently works around this with a top-level mirror surface that the same shallow spread can't clobber, but the workaround creates vocabulary split-brain (slot stores kind: \"approval\", mirror translates to kind: \"plan\") that has produced two separate live bugs already.

Why not "just lock around the read-modify-write"?

withSessionStoreLock already serializes writes per-store-path. The race isn't between concurrent writers — it's that the AGENT'S own patch (via the regular request path) doesn't carry forward the plugin's prior pluginMetadata writes, because the agent code doesn't know what plugins have stamped state. The patch is "everything I want this entry to look like" from the agent's perspective, which legitimately doesn't include opaque per-plugin state. The fix has to live at merge time.

Proposed change

Add \"merge-plugin-metadata\" to SessionEntryMergePolicy (purely additive):

// types.ts:356
export type SessionEntryMergePolicy =
  | \"touch-activity\"
  | \"preserve-activity\"
  | \"merge-plugin-metadata\"; // NEW

Implementation in mergeSessionEntryWithPolicy (additive branch):

const next = { ...existing, ...patch, sessionId, updatedAt };

// NEW: when policy === \"merge-plugin-metadata\", deep-merge pluginMetadata
// at the namespace level (one level deep). The patch's slots take
// precedence over existing slots; existing slots not in patch are preserved.
if (
  options?.policy === \"merge-plugin-metadata\" &&
  existing?.pluginMetadata &&
  isPlainObject(patch.pluginMetadata)
) {
  next.pluginMetadata = {
    ...existing.pluginMetadata,
    ...patch.pluginMetadata,
  };
}

return normalizeSessionRuntimeModelFields(next);

Call-site adoption

Existing mergeSessionEntry callers (no policy) → unchanged behavior.

Opt-in candidates that ingest plugin-stamped metadata:

  • src/gateway/sessions-patch.ts (handles sessions.patch RPC)
  • src/agents/command/session-store.ts (agent-reply persist)

One-line change per call site, preserves third-party plugin state across the request paths that produce most entry mutations.

Backward compatibility

  • Callers using existing default: unchanged
  • Callers using \"preserve-activity\": orthogonal (controls updatedAt, not pluginMetadata)
  • Slot-deletion semantics: caller could pass { \"plugin-id\": null } sentinel for explicit removal (matches Postgres JSONB merge). Recommend including in the same RFC.

Tests proposed

  • merge-plugin-metadata with empty existing → identical to default
  • … with existing slots and patch having different slot → both preserved
  • … with existing slot and patch having same slot → patch wins
  • … with patch having null value for slot → slot deleted (if option-b adopted)

What this unblocks for plugin authors

For Smarter-Claw specifically, once we migrate to this policy:

  1. Drop the top-level mirror surface (planMode, pendingInteraction, pendingQuestionApprovalId)
  2. Delete kind-translation logic (approvalplan)
  3. Single source of truth for approval ID
  4. Retire the planned file-state migration (or keep as opt-in for multi-process safety)

The pattern generalizes — any third-party plugin that writes pluginMetadata needs this safety to coexist with the agent-reply request path.

Cross-link

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions