Skip to content
Open
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
4 changes: 4 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

### Fixed

- Fixed newly spawned shared RPC hosts consuming their entire idle window in post-readiness filesystem work before `ensureHost()` returned. The authenticated readiness connection now stays attached through the endpoint lock commit and final handoff, giving the first real client a complete idle window to attach (Refs #1656).

### Removed

## [2026.9.15] - 2026-09-15
Expand Down Expand Up @@ -105,8 +107,10 @@

- Fixed the goal monitor parking on the ask-user idle-timeout setting instead of the earliest pending question deadline, so a shorter request no longer waits for a longer one; typing in an answer now extends that park without adding continuation prompts ([#1645](https://github.com/code-yeongyu/senpi/issues/1645)).


- Fixed shared RPC hosts expiring an old idle window after a short readiness connection, which could remove the Windows named pipe before the client attached (part of #1290).
- Fixed missing Bedrock, Cursor, and Devin implementations in relocated standalone binaries by registering bundled modules in both the launcher and shared-session workers ([#1656](https://github.com/code-yeongyu/senpi/issues/1656)).

- Fixed the ask-user question dialog carrying a committed own-answer into the next question: after answering a question with typed text, the next question's editor no longer shows the previous answer's text and pressing Enter again no longer submits it as the next question's own answer.
- Fixed the remaining focus traps in the ask-user question dialog: committing an own answer now lands on the next question's option list instead of leaving the editor open; Up/Down, Tab/Shift+Tab and Backspace-on-empty leave the own-answer editor (Left/Right move its cursor); the Submit tab's review rows are navigable (Up from the comment highlights the last answer, Enter on a row jumps back to that question, Left/Right move the comment cursor once it has text); Backspace on the option list clears the answer instead of opening the editor; Esc inside the own-answer editor of an async question returns to the options instead of collapsing it; and re-expanding an async question restores its draft answers and comment.

Expand Down
5 changes: 5 additions & 0 deletions packages/coding-agent/docs/rpc.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,11 @@ Environment overrides beat the file, and invalid values fall through to the next
(`transient`|`persistent`) and `SENPI_RPC_HOST_IDLE_EXIT_MS` (positive integer milliseconds).

The host exits only after the window elapses with NO attached client connections and NO active turns — continuously.
Authenticated connection and disconnection events update the idle clock immediately, including readiness probes that
fit entirely between timer ticks. Rejected authentication does not reset it. For a newly spawned host, `ensureHost()`
keeps its successful authenticated readiness connection attached through the remaining ownership work, including the
endpoint lock's commit and close, then releases it as the call returns. The caller therefore receives one complete idle
window in which to attach; slow post-probe filesystem work cannot consume that window before the handoff.
Any connection or agent turn resets the window, so a busy host never exits, and the exit itself is clean: the RPC host
receives SIGTERM first, flushes pending output, removes its socket, and the supervisor then removes `host.pid` and
`settings.json` (the stderr log stays for diagnostics). After an idle exit, the next `ensureHost()` transparently
Expand Down
40 changes: 40 additions & 0 deletions packages/coding-agent/src/modes/rpc/changes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# changes

## 2026-09-14 - Retain readiness through the ensure handoff (#1656)

### What changed

- `packages/coding-agent/src/modes/rpc/host-ensure.ts` transfers the successful authenticated readiness-probe connection to the outer ensure scope, retains it through the endpoint lock's real commit and close, and releases it in final cleanup immediately before `ensureHost()` settles.
- `packages/coding-agent/test/rpc-host-lifecycle.test.ts` wraps the real ownership-lock release, records readiness ownership before and after its commit, verifies a protocol exchange after handoff, and proves the transient host still idle-exits after that client detaches without a scheduling delay.
- `packages/coding-agent/docs/rpc.md` documents the full idle-window handoff guarantee for newly spawned hosts.

### Why

- On a loaded Windows runner, lock release and state writes after the readiness probe detached could outlast the transient host's idle window. The supervisor then removed the named pipe before `ensureHost()` returned, so the first real client received `connect ENOENT`.

### Why an extension could not handle it

- `ensureHost()` owns the readiness connection and returns before any session extension can observe or influence the client handoff.

### Expected merge conflict zones

- LOW: the spawned-host readiness result, outer endpoint-lock cleanup, and probe socket lifetime in `packages/coding-agent/src/modes/rpc/host-ensure.ts`.

## 2026-09-14 - Keep bundled workers out of supervisor entry dispatch

### What changed
Expand Down Expand Up @@ -54,6 +74,26 @@

- `packages/coding-agent/src/modes/rpc/session-worker.ts` startup imports and initialization before `parentPort` message subscription.

## 2026-09-13 - Preserve short readiness activity between supervisor idle ticks (#1656)

### What changed

- `packages/coding-agent/src/modes/rpc/host-lifecycle.ts` delegates the authenticated public proxy to `packages/coding-agent/src/modes/rpc/host-client-proxy.ts` and updates its idle decider on connection edges, not only timer ticks.
- The extracted proxy reports successful attachment and the first detachment; rejected authentication does not count as activity. Idle policy values and the readiness protocol are unchanged.
- The lifecycle suite registers real-socket, controlled-clock regressions covering reconnect before expiry, exact idle expiry, and rejected authentication.

### Why

- A readiness connection could open and close between ticks. The next tick then treated time containing that connection as continuously idle, removed the Windows named pipe, and left the attaching client with `connect ENOENT`.

### Why an extension could not handle it

- The detached lifecycle supervisor owns the public listener and idle clock before any session extension runs.

### Expected merge conflict zones

- The public proxy construction in `packages/coding-agent/src/modes/rpc/host-lifecycle.ts`, its extracted implementation in `packages/coding-agent/src/modes/rpc/host-client-proxy.ts`, and the lifecycle test registration.

## 2026-09-12 - Queued RPC input carries its source to extension `input` handlers

### What changed
Expand Down
47 changes: 47 additions & 0 deletions packages/coding-agent/src/modes/rpc/host-client-proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { createConnection, createServer, type Server, type Socket } from "node:net";
import { authenticateSocket, resolveSocketTransportAddress, sendSocketHandshake } from "./socket-transport.ts";

interface HostProxyEndpoints {
readonly publicSecret: Uint8Array | undefined;
readonly internalSocket: string;
readonly internalSecret: Uint8Array | undefined;
}

interface HostProxyActivity {
readonly clients: Set<Socket>;
readonly isShuttingDown: () => boolean;
readonly onActivity: () => void;
}

/** Authenticated public connections proxied to the private RPC host. */
export function createHostClientProxy(endpoints: HostProxyEndpoints, activity: HostProxyActivity): Server {
return createServer((client) => {
const accept = (): void => {
if (activity.isShuttingDown()) {
client.destroy();
return;
}
const internal = createConnection(
resolveSocketTransportAddress(endpoints.internalSocket, process.platform, endpoints.internalSecret),
);
if (endpoints.internalSecret) sendSocketHandshake(internal, endpoints.internalSecret);
activity.clients.add(client);
// A readiness probe can connect and disconnect entirely between ticks.
// Record both edges so the idle window measures continuous inactivity.
activity.onActivity();
const detach = (): void => {
if (activity.clients.delete(client)) activity.onActivity();
internal.destroy();
client.destroy();
};
client.pipe(internal);
internal.pipe(client);
client.once("close", detach);
client.once("error", detach);
internal.once("close", detach);
internal.once("error", detach);
};
if (endpoints.publicSecret) authenticateSocket(client, endpoints.publicSecret, accept);
else accept();
});
}
103 changes: 83 additions & 20 deletions packages/coding-agent/src/modes/rpc/host-ensure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ export interface EnsureHostOptions {
* to stall the real process-identity probe.
*/
readonly beforePidFileWrite?: () => Promise<void>;
/** Wraps the real ownership-lock release so tests can observe readiness ownership across it. */
readonly releaseOwnershipLock?: (
releaseLock: () => Promise<void>,
isReadinessRetained: () => boolean,
) => Promise<void>;
/** Overrides the process-identity probe so a test can force its failure. */
readonly readProcessStartTime?: (pid: number) => Promise<string | undefined>;
};
Expand All @@ -77,6 +82,16 @@ export interface EnsuredHost {
readonly reused: boolean;
}

interface ReadinessLease {
readonly release: () => void;
readonly isRetained: () => boolean;
}

type EnsuredHostLocked = {
readonly host: EnsuredHost;
readonly readinessLease?: ReadinessLease;
};

type ProtocolInfo = {
readonly serverVersion: string;
readonly capabilities: readonly string[];
Expand Down Expand Up @@ -137,11 +152,22 @@ export async function ensureHost(options: EnsureHostOptions): Promise<EnsuredHos
// locked". Its own guards (60s age, dead owner pid) already make it safe unlocked.
await reapOrphanedInternalHostDirs();
const release = await acquireOwnershipSafeLock(`${lockTarget}.lock`, lockOptions);
let result: EnsuredHostLocked | undefined;
try {
await options._test?.afterLockAcquired?.();
return await ensureHostLocked(paths, socket, options.agentDir ?? getAgentDir(), options.policy, options._test);
result = await ensureHostLocked(paths, socket, options.agentDir ?? getAgentDir(), options.policy, options._test);
return result.host;
} finally {
await release();
const releaseOwnershipLock =
options._test?.releaseOwnershipLock ?? ((releaseLock: () => Promise<void>) => releaseLock());
try {
await releaseOwnershipLock(release, () => result?.readinessLease?.isRetained() ?? false);
} finally {
// The outer ensure scope owns the successful readiness connection. Release
// it only after the endpoint lock has committed and closed, immediately
// before the function settles, including when lock release itself fails.
result?.readinessLease?.release();
}
}
}

Expand All @@ -151,13 +177,13 @@ async function ensureHostLocked(
agentDir: string,
policy: HostLifecyclePolicyInput | undefined,
testOptions: EnsureHostOptions["_test"],
): Promise<EnsuredHost> {
): Promise<EnsuredHostLocked> {
const pidFile = await readPidFile(paths);
const protocol = await probeProtocolInfo(socket, EXISTING_HOST_PROBE_TIMEOUT_MS);
const { protocol } = await probeProtocolInfo(socket, EXISTING_HOST_PROBE_TIMEOUT_MS);
if (isCompatible(protocol)) {
// A compatible socket is attachable even when another client surface
// started it. Only hosts we spawned are eligible for lifecycle management.
return { pid: pidFile?.pid ?? 0, socket, reused: true };
return { host: { pid: pidFile?.pid ?? 0, socket, reused: true } };
}
const probe = testOptions?.readProcessStartTime ?? readProcessStartTime;
const pidMatches = pidFile ? await matchesPidFileOrUnknown(pidFile, probe) : false;
Expand All @@ -177,7 +203,7 @@ async function startHost(
agentDir: string,
policy: HostLifecyclePolicyInput | undefined,
testOptions: EnsureHostOptions["_test"],
): Promise<EnsuredHost> {
): Promise<EnsuredHostLocked> {
// The settings file must exist before the supervisor reads it at boot, so it
// records the policy before the spawn instead of beside the pidfile.
if (process.platform === "win32") await createSocketSecret(socketSecretPath(socket));
Expand Down Expand Up @@ -273,7 +299,12 @@ async function startHost(
}
const readinessTimeoutMs = testOptions?.readinessTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS;
const result = await pollProtocolInfo(socket, readinessTimeoutMs, childExit);
if (isCompatible(result.protocol)) return { pid: pidFile.pid, socket, reused: false };
if (isCompatible(result.protocol)) {
return {
host: { pid: pidFile.pid, socket, reused: false },
readinessLease: result.readinessLease,
};
}
// Teardown runs for the diagnostic's sake, so it must never replace it: a stop
// failure here (unreadable identity, a host that outlives SIGKILL) would other-
// wise propagate instead of the readiness message and skip cleanupState below,
Expand Down Expand Up @@ -405,7 +436,16 @@ async function waitForGone(

type ChildExit = { readonly code: number | null; readonly signal: NodeJS.Signals | null };

type ProtocolPollResult = { readonly protocol?: ProtocolInfo; readonly exited?: ChildExit };
type ProtocolPollResult = {
readonly protocol?: ProtocolInfo;
readonly exited?: ChildExit;
readonly readinessLease?: ReadinessLease;
};

type ProtocolProbeResult = {
readonly protocol?: ProtocolInfo;
readonly readinessLease?: ReadinessLease;
};

async function pollProtocolInfo(
socket: string,
Expand All @@ -418,6 +458,7 @@ async function pollProtocolInfo(
const probe = probeProtocolInfo(
socket,
Math.min(SPAWNED_HOST_PROBE_TIMEOUT_MS, Math.max(1, deadline - Date.now())),
true,
);
const raced = childExit ? await Promise.race([probe, childExit]) : await probe;
if (isChildExit(raced)) {
Expand All @@ -427,44 +468,63 @@ async function pollProtocolInfo(
// had a chance to deliver an answer. A host that never answers still
// resolves through probeProtocolInfo's bounded timeout/close handling.
const info = await probe;
if (info) {
lastProtocol = info;
if (isCompatible(info)) return { protocol: info };
if (info.protocol) {
lastProtocol = info.protocol;
if (isCompatible(info.protocol)) return info;
} else {
return { protocol: lastProtocol, exited: raced };
}
} else if (raced) {
lastProtocol = raced;
if (isCompatible(raced)) return { protocol: raced };
} else if (raced.protocol) {
lastProtocol = raced.protocol;
if (isCompatible(raced.protocol)) return raced;
}
await delay(50);
}
return { protocol: lastProtocol };
}

function isChildExit(value: ProtocolInfo | ChildExit | undefined): value is ChildExit {
return !!value && "code" in value && "signal" in value;
function isChildExit(value: ProtocolProbeResult | ChildExit): value is ChildExit {
return "code" in value && "signal" in value;
}

async function probeProtocolInfo(socketPath: string, timeoutMs: number): Promise<ProtocolInfo | undefined> {
async function probeProtocolInfo(
socketPath: string,
timeoutMs: number,
retainCompatibleConnection = false,
): Promise<ProtocolProbeResult> {
let secret: Buffer | undefined;
if (process.platform === "win32") {
try {
secret = await readSocketSecret(socketSecretPath(socketPath));
} catch {
return undefined;
return {};
}
}
return new Promise((resolveProbe) => {
const socket = createConnection(resolveSocketTransportAddress(socketPath, process.platform, secret));
let buffer = "";
let settled = false;
let retained = false;
const finish = (value?: ProtocolInfo): void => {
if (settled) return;
settled = true;
clearTimeout(timeout);
if (retainCompatibleConnection && isCompatible(value)) {
retained = true;
resolveProbe({
protocol: value,
readinessLease: {
isRetained: () => retained,
release: () => {
retained = false;
socket.destroy();
},
},
});
return;
}
socket.destroy();
resolveProbe(value);
resolveProbe({ protocol: value });
};
const timeout = setTimeout(() => finish(), timeoutMs);
socket.once("connect", () => {
Expand All @@ -477,7 +537,10 @@ async function probeProtocolInfo(socketPath: string, timeoutMs: number): Promise
finish(readProtocolInfo(buffer.slice(0, newline)));
});
socket.once("error", () => finish());
socket.once("close", () => finish());
socket.once("close", () => {
retained = false;
finish();
});
// Register the error listener before sending the Windows named-pipe handshake.
// When an idle host has already removed its pipe, the handshake write can
// surface ENOENT immediately; without the listener this probe escapes instead
Expand Down
Loading