Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .pylon/features.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,21 @@ decisions:
- Prime upstream exposes an equivalent public-root token, exact caller environment reuse, generation-scoped post-attach proof, and structured bounded cleanup outcomes.
- Pylon can remove the fork token without weakening native multi-instance isolation or its pre-create ACP fallback.

recoverable-owned-session-adoption:
area: runtime-reliability
state: shipped
owner: shared
decision: retain
pylon_refs:
- https://github.com/pylon-code/prime-agent/issues/27
upstream_refs:
- https://github.com/PrimeIntellect-ai/prime-agent/tree/c718bf3c30fd8da206ed551837cbb54f7ad15948
fork_change: recoverable-owned-session-adoption-v1
upstream_support: Prime upstream does not expose capability-gated same-supervisor adoption of an existing caller-owned worker with private rotating authority, prepare/commit proof, authoritative snapshot fencing, ordered event reconciliation, and in-place MCP ownership transfer.
revisit_when:
- Prime upstream exposes an equivalent frozen public SDK token, detached-daemon capability, non-enumerating failure contract, same-generation rotating authority, staged proof, and explicit durability confirmation.
- Pylon can remove the fork token without weakening caller-owned worker continuity, event ordering, credential privacy, or owner isolation.

deterministic-pylon-release-artifacts:
area: distribution
state: candidate
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Added recoverable adoption for caller-owned daemon sessions. ([#27](https://github.com/pylon-code/prime-agent/issues/27))
8 changes: 8 additions & 0 deletions packages/coding-agent/docs/daemon.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ Headless and ephemeral clients use the same worker runtime as interactive client

The full launch environment remains in supervisor memory and is not written to the worker descriptor. Direct SDK calls to print and RPC modes remain in-process so embedders can pass non-serializable extension factories.

### Recoverable ownership adoption

A full-access client-owned worker can opt into `daemon_recoverable_owned_session_adoption_v1`. Recovery remains limited to the same detached supervisor process and generation. The supervisor keeps worker authority and the HMAC secret in memory, while a separate `OwnedSessionRecoveryStore` persists only opaque keyed verifiers with atomic `0600` writes in a `0700` directory. Recovery handles never enter worker descriptors or the generic command journal.

Availability requires schema revision 30; the frozen SDK adoption and caller-environment tokens; hello offers for adoption, caller-environment cleanup, and authoritative cleanup; negotiated `event_sequence`, `correlated_prompt_lifecycle_v1`, `client_owned_sessions`, and `caller_owned_session_environment_cleanup_v1`; and exact attached adoption and cleanup proofs. The attached worker replay status must be `complete`. Partial or unavailable replay fails before ownership changes.

Prepare freezes ownership mutation, permanently retires the original create-request replay authority, obtains an authoritative worker snapshot, and buffers bounded sequenced frames. Commit validates the snapshot proof and caller cursor, transfers both the worker owner and ACP MCP owner in place, attaches the claimant, then publishes only contiguous post-snapshot frames. Exact repeated cursor/frame bytes after reconnect are deduplicated; a conflicting duplicate fails closed, and traffic for other sessions on a shared client is discarded from the staging lane. A disconnect before commit rolls ownership back, but never revives create replay. Confirmation removes the prior-handle retry window only after the caller persists the rotated handle and exact proof. Retention is bounded for no-lifecycle, active, terminal, prepared, and unconfirmed-final states.

## Session Ownership and Leases

Every persisted session is protected by a process-safe lease keyed by canonical JSONL path.
Expand Down
12 changes: 12 additions & 0 deletions packages/coding-agent/docs/sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,18 @@ The contract proof contains only the feature/status, protocol identity, schema r

Use `disposeOwnedSession({ timeoutMs })` when cleanup must be observable. Concurrent calls join one operation, and `timeoutMs` is one strict total deadline for reconnection, authoritative queries, completion, side-question aborts, and unsupported-peer finalization. Its fixed statuses are `completed`, `already_completed`, `replacement_settled`, `owner_mismatch`, `uncertain`, `transport_failure`, and `unsupported`; `uncertain` also reports whether the last authoritative state was `active` or `stopping`. `replacement_settled` means an authenticated supervisor with a different generation answered the read-only cleanup query with `settled` for the connection's previously proved opaque route. Cleanup never sends completion on a replacement or pending route without a current internal attach proof. Each strict cleanup request is transport-bound and is never replayed after reconnect. Public attachment proof remains absent throughout disposal. Results never return raw errors or environment identity. `dispose()` remains the legacy best-effort `Promise<void>` API.

### Recoverable caller-owned daemon sessions

Native detached-daemon hosts can opt into same-supervisor recovery with `recoverable_owned_session_adoption_v1`. The complete pre-create gate requires schema revision 30, the frozen package-root SDK tokens `recoverable_owned_session_adoption_v1` and `caller_owned_session_environment_cleanup_v1`, and connected hello offers for `daemon_recoverable_owned_session_adoption_v1`, `caller_owned_session_environment_cleanup_v1`, and `authoritative_owned_session_cleanup_v1`. If any proof is absent, select ACP before creating a native worker. This is a POSIX detached-daemon contract; it does not provide resident promotion, cross-host recovery, or recovery after supervisor process replacement.

Prepare must then echo the exact requested attachment capabilities `event_sequence`, `correlated_prompt_lifecycle_v1`, `client_owned_sessions`, and `caller_owned_session_environment_cleanup_v1`. The attached snapshot must prove complete replay, the exact adoption proof, and the current caller-owned cleanup contract through `getOwnedSessionContractProof()`. Treat a missing capability, partial or unavailable replay, mismatched adoption proof, or missing cleanup proof as unavailable before ownership commit. A hello offer alone is not attachment proof.

Call `createRecoverableOwnedSession()` with a fresh request ID that encodes at least 128 bits, one caller-captured launch environment, the recovery config, correlation ID, and MCP owner ID. Durably store the returned 256-bit `recoveryHandle`, `supervisorGeneration`, active session ID, session ID, authoritative event cursor, correlation ID, MCP owner ID, recovery config, and launch environment. Treat the handle as a bearer secret. The daemon persists only opaque keyed verifiers and keeps the authority needed to recover the existing worker private to the current supervisor process.

After the exact owner transport disconnects, call `adoptRecoverableOwnedSession()` on a new daemon client with that complete authority tuple, the last durable cursor, a new MCP owner ID, and a stable request ID. The SDK performs prepare and commit internally. It does not create or attach to a replacement worker as fallback. It installs an authoritative snapshot before it releases strictly ordered post-snapshot events, and the daemon retags existing MCP servers in place. A live old owner, wrong or stale authority, supervisor replacement, conflicting retry, proof mismatch, race, expiry, or unsupported peer all fail with `Recoverable owned session adoption is unavailable`.

The result contains the staged connection, the exact post-adoption proof, and a rotated handle. Durably replace the old authority tuple with that exact result before calling `confirmRecoverableOwnedSessionAdoption()`. Use the same request ID for a retry whose response may have been lost. Prepare, commit, and confirmation retries converge on the same rotated receipt. Confirmation closes the old-handle retry window; it does not replace durable caller storage.

`DaemonClient.request()` keeps legacy reconnect replay by default. Pass `{ recoverAcrossReconnect: false }` only when one request must fail on transport close instead of crossing to a new daemon transport.

`DaemonClient.close()` is terminal owner disposal. `isClosed` becomes true, later `connect()` calls reject, and a live `DaemonAgentConnection` emits one terminal close. Normal and update recovery stop before any later restart, connect, attach, or restored-session query; already-running recovery callbacks are not cancellable but their results are discarded.
Expand Down
5 changes: 3 additions & 2 deletions packages/coding-agent/src/cli-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import {
maybeRunOwnedSessionWorkerFrontend,
} from "./cli/owned-session-worker.js";
import { APP_NAME } from "./config.js";
import type { DaemonWorkerBootstrapEnvironment } from "./modes/daemon/daemon-worker-protocol.js";

export async function runCli(): Promise<void> {
export async function runCli(daemonWorkerBootstrap?: DaemonWorkerBootstrapEnvironment): Promise<void> {
try {
enableCompileCache?.();
} catch {
Expand Down Expand Up @@ -38,7 +39,7 @@ export async function runCli(): Promise<void> {
setGlobalDispatcher(new EnvHttpProxyAgent({ bodyTimeout: 0, headersTimeout: 0 }));

try {
await main(process.argv.slice(2));
await main(process.argv.slice(2), { daemonWorkerBootstrap });
} finally {
closeOwnedSessionWorkerOwnerWatch();
}
Expand Down
16 changes: 15 additions & 1 deletion packages/coding-agent/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,20 @@ const supported = assertNodeVersion({
});

if (supported) {
const workerProtocol = await import("./modes/daemon/daemon-worker-protocol.js");
const authenticatedWorkerLaunch =
workerProtocol.isDaemonWorkerProcess() &&
process.env[workerProtocol.DAEMON_WORKER_TOKEN_ENV] !== undefined &&
process.env[workerProtocol.DAEMON_WORKER_STARTUP_GATE_FD_ENV] !== undefined;
let daemonWorkerBootstrap: ReturnType<typeof workerProtocol.readDaemonWorkerBootstrapEnvironment> | undefined;
try {
if (authenticatedWorkerLaunch) {
workerProtocol.waitForDaemonWorkerStartupGate();
daemonWorkerBootstrap = workerProtocol.readDaemonWorkerBootstrapEnvironment();
}
} finally {
workerProtocol.sanitizeDaemonWorkerBootstrapEnvironment(process.env);
}
const { runCli } = await import("./cli-main.js");
await runCli();
await runCli(daemonWorkerBootstrap);
}
15 changes: 2 additions & 13 deletions packages/coding-agent/src/cli/daemon-launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,7 @@ import { DAEMON_PROTOCOL_VERSION, DAEMON_SCHEMA_ID } from "../modes/daemon/daemo
import { getDaemonRuntimeIdentity } from "../modes/daemon/daemon-runtime-identity.js";
import { isSessionSummaryBusy, type SessionSummary } from "../modes/daemon/daemon-session-list.js";
import { defaultDaemonSocketPath, normalizeSocketPath } from "../modes/daemon/daemon-socket.js";
import {
DAEMON_WORKER_ACTIVE_SESSION_ID_ENV,
DAEMON_WORKER_RECOVERY_JOURNAL_ENV,
DAEMON_WORKER_ROLE_ENV,
DAEMON_WORKER_SUPERVISOR_SOCKET_ENV,
DAEMON_WORKER_TOKEN_ENV,
} from "../modes/daemon/daemon-worker-protocol.js";
import { sanitizeDaemonWorkerBootstrapEnvironment } from "../modes/daemon/daemon-worker-protocol.js";
import { isHelpCommandRequest, PUBLIC_COMMAND_NAMES, REMOVED_COMMAND_NAMES } from "./command-registry.js";
import { createCliSubprocessEnv, formatCurrentCliCommand } from "./subprocess-launch.js";

Expand Down Expand Up @@ -357,12 +351,7 @@ async function ensureDaemonRunning(socketPath: string, spawnCwd?: string): Promi
// a CLI running inside a daemon worker (e.g. a test spawned by the Prime
// Agent daemon) would launch the supervisor in worker mode, which listens
// on the socket but never sends the daemon_hello handshake.
const env = createCliSubprocessEnv();
delete env[DAEMON_WORKER_ROLE_ENV];
delete env[DAEMON_WORKER_TOKEN_ENV];
delete env[DAEMON_WORKER_ACTIVE_SESSION_ID_ENV];
delete env[DAEMON_WORKER_RECOVERY_JOURNAL_ENV];
delete env[DAEMON_WORKER_SUPERVISOR_SOCKET_ENV];
const env = sanitizeDaemonWorkerBootstrapEnvironment(createCliSubprocessEnv());
delete env[ORPHAN_PROCESS_JOURNAL_ENV];
delete env[SESSION_LEASES_ENABLED_ENV];
delete env[SESSION_LEASE_OWNER_ID_ENV];
Expand Down
12 changes: 2 additions & 10 deletions packages/coding-agent/src/cli/daemon-update-restart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,7 @@ import { getProcessStartId, SESSION_LEASE_OWNER_ID_ENV, SESSION_LEASES_ENABLED_E
import { defaultDaemonSocketDir, defaultDaemonSocketPath, normalizeSocketPath } from "../modes/daemon/daemon-socket.js";
import {
DAEMON_WORKER_ACTIVE_SESSION_ID_ENV,
DAEMON_WORKER_RECOVERY_JOURNAL_ENV,
DAEMON_WORKER_ROLE_ENV,
DAEMON_WORKER_SUPERVISOR_SOCKET_ENV,
DAEMON_WORKER_TOKEN_ENV,
sanitizeDaemonWorkerBootstrapEnvironment,
} from "../modes/daemon/daemon-worker-protocol.js";
import { createCliSubprocessLaunchSpec } from "./subprocess-launch.js";

Expand Down Expand Up @@ -513,14 +510,9 @@ function createStatusPath(agentDir: string, socketPath: string, requestId: strin
}

function coordinatorEnvironment(agentDir: string): NodeJS.ProcessEnv {
const environment = { ...process.env };
const environment = sanitizeDaemonWorkerBootstrapEnvironment({ ...process.env });
environment[ENV_AGENT_DIR] = agentDir;
delete environment[SELF_UPDATE_INTERACTIVE_CHILD_ENV];
delete environment[DAEMON_WORKER_ROLE_ENV];
delete environment[DAEMON_WORKER_TOKEN_ENV];
delete environment[DAEMON_WORKER_ACTIVE_SESSION_ID_ENV];
delete environment[DAEMON_WORKER_RECOVERY_JOURNAL_ENV];
delete environment[DAEMON_WORKER_SUPERVISOR_SOCKET_ENV];
delete environment[ORPHAN_PROCESS_JOURNAL_ENV];
delete environment[SESSION_LEASES_ENABLED_ENV];
delete environment[SESSION_LEASE_OWNER_ID_ENV];
Expand Down
99 changes: 98 additions & 1 deletion packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ import {
import type { HostRequestHandlers, KernelSentAgentMessage } from "./kernel/index.js";
import { type RestoreResult, snapshotPathIn } from "./kernel/state-snapshot.js";
import type { AcpMcpServerConfig } from "./mcp/acp-mcp-types.js";
import type { McpManager } from "./mcp/mcp-manager.js";
import type { AcpMcpOwnerTransferProof, McpManager } from "./mcp/mcp-manager.js";
import {
type BashExecutionMessage,
type CompactionOutcome,
Expand Down Expand Up @@ -1060,6 +1060,14 @@ function attributeChildUsage(parentUsage: Usage, childUsage: Usage): void {
parentUsage.totalTokens = parentContextTokens;
}

const MISSING_MCP_OWNER_TRANSFER_RETENTION_MS = 5 * 60_000;
const MISSING_MCP_OWNER_TRANSFER_RECEIPT_LIMIT = 256;

interface MissingMcpOwnerTransferReceipt {
proof: AcpMcpOwnerTransferProof;
expiresAt: number;
}

export class AgentSession {
readonly agent: Agent;
readonly sessionManager: SessionManager;
Expand Down Expand Up @@ -1172,6 +1180,7 @@ export class AgentSession {
private _agentMessageController?: AgentSessionMessageController;
private _agentObserveController?: AgentObserveController;
private _mcpManager?: McpManager;
private readonly _missingMcpOwnerTransfers = new Map<string, MissingMcpOwnerTransferReceipt>();
private _baseToolsOverride?: Record<string, AgentTool>;
private _sessionStartEvent: SessionStartEvent;
private _extensionUIContext?: ExtensionUIContext;
Expand Down Expand Up @@ -1389,6 +1398,94 @@ export class AgentSession {
this.agent.state.systemPrompt = this._baseSystemPrompt;
}

transferAcpMcpServersOwner(previousOwnerId: string, nextOwnerId: string): void {
this._mcpManager?.transferAcpServersOwner(previousOwnerId, nextOwnerId);
}

transferAcpMcpServersOwnerTransaction(
transactionId: string,
previousOwnerId: string,
nextOwnerId: string,
): AcpMcpOwnerTransferProof {
if (!this._mcpManager) {
const existing = this.getMissingMcpOwnerTransfer(transactionId);
if (existing) {
if (existing.previousOwnerId !== previousOwnerId || existing.nextOwnerId !== nextOwnerId) {
throw new Error("ACP MCP owner transfer transaction tuple changed");
}
return { ...existing };
}
const proof = { transactionId, previousOwnerId, nextOwnerId, changed: false, state: "transferred" as const };
this.rememberMissingMcpOwnerTransfer(proof);
return { ...proof };
}
return this._mcpManager.transferAcpServersOwnerTransaction(transactionId, previousOwnerId, nextOwnerId);
}

queryAcpMcpServersOwnerTransaction(
transactionId: string,
previousOwnerId: string,
nextOwnerId: string,
): AcpMcpOwnerTransferProof {
if (!this._mcpManager) {
const proof = this.getMissingMcpOwnerTransfer(transactionId);
if (!proof || proof.previousOwnerId !== previousOwnerId || proof.nextOwnerId !== nextOwnerId) {
throw new Error("ACP MCP owner transfer transaction is unknown");
}
return { ...proof };
}
return this._mcpManager.queryAcpServersOwnerTransaction(transactionId, previousOwnerId, nextOwnerId);
}

rollbackAcpMcpServersOwnerTransaction(
transactionId: string,
previousOwnerId: string,
nextOwnerId: string,
): AcpMcpOwnerTransferProof {
if (!this._mcpManager) {
const proof = this.queryAcpMcpServersOwnerTransaction(transactionId, previousOwnerId, nextOwnerId);
const rolledBack = { ...proof, state: "rolled_back" as const };
this.rememberMissingMcpOwnerTransfer(rolledBack);
return { ...rolledBack };
}
return this._mcpManager.rollbackAcpServersOwnerTransaction(transactionId, previousOwnerId, nextOwnerId);
}

retireAcpMcpServersOwnerTransaction(transactionId: string, previousOwnerId: string, nextOwnerId: string): void {
if (this._mcpManager) {
this._mcpManager.retireAcpServersOwnerTransaction(transactionId, previousOwnerId, nextOwnerId);
return;
}
const proof = this.getMissingMcpOwnerTransfer(transactionId);
if (proof && (proof.previousOwnerId !== previousOwnerId || proof.nextOwnerId !== nextOwnerId)) {
throw new Error("ACP MCP owner transfer transaction tuple changed");
}
this._missingMcpOwnerTransfers.delete(transactionId);
}

private getMissingMcpOwnerTransfer(transactionId: string): AcpMcpOwnerTransferProof | undefined {
const receipt = this._missingMcpOwnerTransfers.get(transactionId);
if (!receipt) return undefined;
if (receipt.expiresAt <= Date.now()) {
this._missingMcpOwnerTransfers.delete(transactionId);
return undefined;
}
return receipt.proof;
}

private rememberMissingMcpOwnerTransfer(proof: AcpMcpOwnerTransferProof): void {
this._missingMcpOwnerTransfers.delete(proof.transactionId);
this._missingMcpOwnerTransfers.set(proof.transactionId, {
proof,
expiresAt: Date.now() + MISSING_MCP_OWNER_TRANSFER_RETENTION_MS,
});
while (this._missingMcpOwnerTransfers.size > MISSING_MCP_OWNER_TRANSFER_RECEIPT_LIMIT) {
const oldest = this._missingMcpOwnerTransfers.keys().next().value;
if (oldest === undefined) break;
this._missingMcpOwnerTransfers.delete(oldest);
}
}

replaceAcpMcpServers(servers: readonly AcpMcpServerConfig[], ownerId: string): void {
if (this.isStreaming) throw new Error("Cannot replace ACP MCP servers while the agent is running");
if (!this._mcpManager) {
Expand Down
Loading
Loading