acpmux: lineage is a field (parent, root, depth, spawn tool call)

Merged
TE
9d ago
+896-51

What

PR 1 of the session-lineage brief: acpmux records lineage as a field, once, at session creation, from the proven caller.

  • session/new takes its parent from the session the connection proved it runs in at initialize (an agent's shell running acpmux new / acpmux run). _meta.acpmux.parent may name the caller itself or one of its lineage descendants; anything else is refused (lineage.parent_not_owned), so a session cannot claim a parent it does not own. parent: null, an ephemeral run, and an adopted chat make a root.
  • Persisted in SessionMeta.lineage {parent, root, depth, toolCallId?} (skip_serializing_if none; old metas load with none). SubsessionLink keeps its sub-session-only fields; a sub-session saved before lineage existed derives its lineage from its link (Hub::lineage_of), so old stores need no migration.
  • start_session fills toolCallId from the caller's in-flight start_session tool call (tracked from tool_call updates; a prompt match, else the only running call, else none). acpmux new sends ACPMUX_TOOL_CALL_ID when the harness exports it.
  • Summary gains parent, root, depth (lineage depth) and spawnToolCallId. spawnedBy stays as the one-level alias (now from lineage, else the legacy tag), so the app and webviews keep working unchanged. The CLI only sends the legacy spawnedBy tag when the daemon recorded no parent (older daemon, peer session).
  • Depth limits (agents.subsessions.maxDepth) apply to acpmux new as they do to start_session: one depth for every way in.
  • A caller whose session is gone gets a root, never an error. Forks and moved-in sessions are roots. Peer-forwarded session/new strips parent/spawnToolCallId (a family lives on one daemon).
  • Protocol spec and generated web types regenerated.

acpmux ls --tree (+ --json) is split into a follow-up PR to keep this one under ~800 lines.

Why

Leo: "LIKE E.G. AGENT SESSION CREATION LINEAGES... CLAUDE CODE MOSTLY JUST HAS ONE LAYER... WHAT IF WE DID MORE? OR DIFFERENT?"

Lineage is the data the sidebar nesting (later PRs) is derived from; today acpmux new only sets a best-effort one-level tag after creation.

Leo-ask: ask-19882abbc3

Tests

  • tests/session_lineage.rs: app-started session is a root; CLI child (proven caller) records parent/root/depth 1/toolCallId with no tag request; child of a child gets depth 2 and the same root; maxDepth refuses a third level for both session/new and start_session; parent: null and ephemeral make roots; spoofed parents refused (from the app and from a child naming its ancestor); a sub-session gets the same lineage; a gone caller leaves a root; lineage survives a daemon restart.
  • Unit tests: hub/lineage.rs (parent ask parsing, tool call id bounds, start_session call tracking, store round trip and old meta), client.rs (env tool call, parentless, tag only when no parent recorded).

No capture: acpmux daemon/CLI only, no UI
Agent session: cosmic-gray


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.


Summary by cubic

Records the ancestry of agent-started sessions as first-class lineage fields instead of the best-effort one-level spawnedBy tag, giving the UI real data for nesting.

  • Session summaries now expose parent, root, spawnToolCallId, and lineage-based depth; spawnedBy stays as a one-level alias limited to roots, so existing clients keep working.
  • A named parent is accepted only when it is the proven caller itself or a descendant, checked even on requests that record no lineage; other refusals carry lineage.parent_not_owned.
  • Tool call IDs come from the caller's in-flight start_session call or ACPMUX_TOOL_CALL_ID.
  • Old sessions derive lineage from their SubsessionLink; no migration needed.
  • agents.subsessions.maxDepth applies to acpmux new like it does to start_session, and forks plus moved-in sessions are roots.
  • The CLI sends the legacy tag only to daemons that recorded no lineage (an older daemon, a peer); a lineage-recording daemon's root is never tagged, and peer-forwarded requests strip lineage fields.