ACP Lifecycle Refactor: Making Session and Process Ownership Explicit

This page details the migration plan to make ACP session and ACPX process ownership explicit, reducing edge cases from inferred ownership. It is intended for developers maintaining or extending the ACP lifecycle.

Read this when

  • Refactoring ACP session lifecycle or ACPX process cleanup
  • Debugging ACPX orphan processes, PID reuse, or multi-gateway cleanup safety
  • Changing sessions_list visibility for spawned ACP or subagent sessions
  • Designing ownership metadata for background tasks, ACP sessions, or process leases

ACP lifecycle currently functions, but too much of it is reconstructed after the fact. Process cleanup infers ownership by cross-referencing PIDs, command strings, wrapper paths, and the active process table. Session visibility infers ownership from session-key strings combined with secondary sessions.list({ spawnedBy }) lookups. While this approach allows targeted fixes, it also creates numerous edge cases that are easy to overlook: PID reuse, quoted commands, adapter grandchildren, multi-gateway state roots, cancel versus close, and tree versus all visibility each become separate locations where the same ownership logic must be rediscovered.

This refactor promotes ownership to a first-class concept. The objective is not to create a new ACP product interface; it is to establish a safer internal contract governing existing ACP and ACPX behavior.

Goals

  • Cleanup never signals a process unless current live evidence matches an OpenClaw-owned lease.
  • cancel, close, and startup reaping each carry distinct lifecycle intentions.
  • sessions_list, sessions_history, sessions_send, and status checks all rely on the same requester-owned session model.
  • Multi-gateway installations cannot reap each other's ACPX wrappers.
  • Old ACPX session records remain functional during migration.
  • The runtime stays plugin-owned; core does not learn ACPX package specifics.

Non-goals

  • Replacing ACPX or altering the public /acp command interface.
  • Moving vendor-specific ACP adapter logic into core.
  • Requiring users to manually clean state prior to upgrading.
  • Having cancel close reusable ACP sessions.

Target Model

Gateway Instance Identity

Each Gateway process should be assigned a stable runtime instance id:

type GatewayInstanceId = string;

This id can be generated at Gateway startup and persisted in state for the duration of that installation. It is not a security credential; it serves as an ownership discriminator that prevents confusing one Gateway's ACP processes with those of another Gateway.

ACP Session Ownership

Every spawned ACP session should carry normalized ownership metadata:

type AcpSessionOwner = {
  sessionKey: string;
  spawnedBy?: string;
  parentSessionKey?: string;
  ownerSessionKey: string;
  agentId: string;
  backend: "acpx";
  gatewayInstanceId: GatewayInstanceId;
  createdAt: number;
};

The Gateway should include these fields on session rows where they are known. Visibility filtering should operate as a pure check against row metadata:

canSeeSessionRow({
  row,
  requesterSessionKey,
  visibility,
  a2aPolicy,
});

This eliminates hidden secondary sessions.list({ spawnedBy }) calls from visibility checks. A spawned cross-agent ACP child is requester-owned because the row declares it, not because a second query happens to locate it.

ACPX Process Leases

Every generated wrapper launch should create a lease record:

type AcpxProcessLease = {
  leaseId: string;
  gatewayInstanceId: GatewayInstanceId;
  sessionKey: string;
  wrapperRoot: string;
  wrapperPath: string;
  rootPid: number;
  processGroupId?: number;
  commandHash: string;
  startedAt: number;
  state: "open" | "closing" | "closed" | "lost";
};

The wrapper process receives the lease id and gateway instance id as portable arguments:

--openclaw-acpx-lease-id ... --openclaw-gateway-instance-id ...

When the platform permits, verification should favor live process metadata that cannot be confused by command quoting:

  • root PID still exists
  • live wrapper path is under wrapperRoot
  • process group matches the lease when available
  • arguments contain the expected lease id
  • command hash or executable path matches the lease

If the live process cannot be verified, cleanup fails closed.

Lifecycle Controller

Introduce one ACPX lifecycle controller that owns process leases and cleanup policy:

interface AcpxLifecycleController {
  ensureSession(input: AcpRuntimeEnsureInput): Promise<AcpRuntimeHandle>;
  cancelTurn(handle: AcpRuntimeHandle): Promise<void>;
  closeSession(input: {
    handle: AcpRuntimeHandle;
    discardPersistentState?: boolean;
    reason?: string;
  }): Promise<void>;
  reapStartupOrphans(): Promise<void>;
  verifyOwnedTree(lease: AcpxProcessLease): Promise<OwnedProcessTree | null>;
}

cancelTurn requests trigger cancellation only. It must not reap reusable wrapper or adapter processes.

closeSession is permitted to reap, but only after loading the session record, loading the lease, and verifying the live process tree still belongs to that lease.

reapStartupOrphans starts from open leases in state. It may use the process table to locate descendants, but it should not first scan arbitrary ACP-looking commands and then decide they are probably ours.

Wrapper Contract

Generated wrappers should remain small. They should:

  • start the adapter in a process group where supported
  • forward normal termination signals to the process group
  • detect parent death
  • on parent death, send SIGTERM, then keep the wrapper alive until the SIGKILL fallback runs
  • report root PID and process group id back to the lifecycle controller when that is available

Wrappers should not decide session policy. They only enforce local process-tree cleanup for their own adapter group.

Session Visibility Contract

Visibility should use normalized row ownership:

type SessionVisibilityInput = {
  requesterSessionKey: string;
  row: {
    key: string;
    agentId: string;
    ownerSessionKey?: string;
    spawnedBy?: string;
    parentSessionKey?: string;
  };
  visibility: "self" | "tree" | "agent" | "all";
  a2aPolicy: AgentToAgentPolicy;
};

Rules:

  • self: only the requester session.
  • tree: requester session plus rows owned by or spawned from the requester.
  • all: all same-agent rows, a2a-allowed cross-agent rows, and requester-owned spawned cross-agent rows even when general a2a is disabled.
  • agent: same agent only, unless an explicit owner relationship says the row belongs to the requester.

This makes tree and all monotonic: all must not hide an owned child that tree would show.

Migration Plan

Phase 1: Add Identity And Leases

  • Add gatewayInstanceId to Gateway state.
  • Add an ACPX lease store under the ACPX state directory.
  • Write a lease before spawning a generated wrapper.
  • Store leaseId on new ACPX session records.
  • Keep existing PID and command fields for old records.

Phase 2: Lease-First Cleanup

  • Change close cleanup to load leaseId first.
  • Verify live process ownership against the lease before signaling.
  • Keep the current root PID and wrapper-root fallback only for legacy records.
  • Mark leases closed after verified cleanup.
  • Mark leases lost when the process is gone before cleanup.

Phase 3: Lease-First Startup Reaping

  • Startup reaping scans open leases.
  • For each lease, verify the root process and collect descendants.
  • Reap verified trees children-first.
  • Expire old closed and lost leases with a bounded retention window.
  • Keep command-marker scanning only as a temporary legacy fallback, guarded by wrapper root and Gateway instance where possible.

Phase 4: Session Ownership Rows

  • Add ownership metadata to Gateway session rows.
  • Teach ACPX, subagent, background-task, and session-store writers to populate ownerSessionKey or spawnedBy.
  • Convert session visibility checks to use row metadata.
  • Remove visibility-time secondary sessions.list({ spawnedBy }) lookups.

Phase 5: Remove Legacy Heuristics

After one release window:

  • stop relying on stored root command strings for non-legacy ACPX cleanup
  • remove command-marker startup scans
  • remove visibility fallback list lookups
  • keep defensive fail-closed behavior for missing or unverifiable leases

Tests

Add two table-driven suites.

Process lifecycle simulator:

  • PID reused by unrelated process
  • PID reused by another Gateway's wrapper root
  • stored wrapper command is shell-quoted, live ps command is not
  • adapter child exits, grandchild remains in the process group
  • parent death SIGTERM fallback reaches SIGKILL
  • process listing unavailable
  • stale lease with missing process
  • startup orphan with wrapper, adapter child, and grandchild

Session visibility matrix:

  • self, tree, agent, all
  • a2a toggled on and off
  • row for the same agent
  • row for different agents
  • cross-agent ACP row spawned by and owned by the requester
  • sandboxed requester capped at tree
  • list, history, send, and status actions

A key invariant to remember: a spawned child owned by the requester appears in any location where the configured visibility covers the requester session tree, and all is at least as capable as tree.

Compatibility Notes

Older session records might lack leaseId. In that case, the legacy fail-closed cleanup path should be used:

  • a live root process must be present
  • when a generated wrapper is expected, wrapper-root ownership is required
  • for non-wrapper roots, command agreement must be verified
  • never trigger signals based solely on stale stored PID metadata

If a legacy record cannot be verified, do not touch it. The startup lease cleanup and the next release window should eventually phase out this fallback.

Success Criteria

  • Closing an outdated or stale ACPX session cannot terminate a process belonging to another Gateway.
  • When a parent process dies, no stubborn adapter grandchildren remain running.
  • cancel cancels the current turn without closing reusable sessions.
  • sessions_list can display requester-owned cross-agent ACP children under both tree and all.
  • Startup cleanup relies on leases, not broad command-string scans.
  • The focused process and visibility matrix tests cover every edge case that previously needed individual review fixes.