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
/acpcommand interface. - Moving vendor-specific ACP adapter logic into core.
- Requiring users to manually clean state prior to upgrading.
- Having
cancelclose 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
gatewayInstanceIdto Gateway state. - Add an ACPX lease store under the ACPX state directory.
- Write a lease before spawning a generated wrapper.
- Store
leaseIdon new ACPX session records. - Keep existing PID and command fields for old records.
Phase 2: Lease-First Cleanup
- Change close cleanup to load
leaseIdfirst. - Verify live process ownership against the lease before signaling.
- Keep the current root PID and wrapper-root fallback only for legacy records.
- Mark leases
closedafter verified cleanup. - Mark leases
lostwhen 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
closedandlostleases 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
ownerSessionKeyorspawnedBy. - 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
pscommand 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.
cancelcancels the current turn without closing reusable sessions.sessions_listcan display requester-owned cross-agent ACP children under bothtreeandall.- 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.