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
8 changes: 4 additions & 4 deletions crates/agentos-sidecar/src/acp_extension.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,10 @@ const OPENCODE_DEFAULT_CONTEXT_PATHS: [&str; 11] = [
"OPENCODE.md",
"OPENCODE.local.md",
];
const AGENTOS_SYSTEM_PROMPT: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../packages/core/fixtures/AGENTOS_SYSTEM_PROMPT.md"
));
// Embedded next to this source so `cargo publish` packages it (an out-of-crate
// `include_str!` path breaks the isolated package-verify build). The TypeScript
// side reads the same file from this location for its sanity check.
const AGENTOS_SYSTEM_PROMPT: &str = include_str!("AGENTOS_SYSTEM_PROMPT.md");
/// Hard ceiling on the `stdout_buffer` retained on an `AcpSessionRecord` between
/// requests. The buffer only ever holds the partial trailing line not yet parsed
/// into a complete JSON-RPC message, so this also bounds the per-session record
Expand Down
3 changes: 1 addition & 2 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist",
"fixtures"
"dist"
],
"exports": {
".": {
Expand Down
171 changes: 169 additions & 2 deletions packages/core/src/agent-os.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5527,11 +5527,121 @@ interface AgentOsSidecarState {
* are cheap incremental tenants of one process rather than one-process-each.
*/
nativeProcess?: Promise<SharedSidecarNativeProcess>;
/**
* The shared sidecar's child process + stdio, cached for synchronous
* ref/unref. Unref'd when no VM leases are active so a one-shot host process
* can exit after `dispose()`; re-ref'd while leases are live.
*/
sharedChild?: SidecarEventLoopHandle;
/**
* Number of live "holds" on the shared sidecar's event-loop reference. A hold
* is taken for the WHOLE create→use→dispose lifetime of every VM lease (not
* just while it sits in `activeLeases`), so a VM that is still mid-creation
* still counts. The child + stdio are ref'd while this is >0 and unref'd at 0.
* A counter (not a boolean) so concurrent create/dispose cannot clobber each
* other — Node ref/unref is not itself counted.
*/
eventLoopHolds?: number;
}

const sidecarStates = new WeakMap<AgentOsSidecar, AgentOsSidecarState>();
const sharedSidecars = new Map<string, AgentOsSidecar>();

interface RefCountableHandle {
ref?(): unknown;
unref?(): unknown;
}

interface SidecarEventLoopHandle extends RefCountableHandle {
stdin?: RefCountableHandle | null;
stdout?: RefCountableHandle | null;
stderr?: RefCountableHandle | null;
kill?(signal?: string | number): unknown;
}

let sidecarProcessExitHookInstalled = false;

/**
* Install a one-time, synchronous `process.on("exit")` hook that SIGKILLs any
* pooled shared sidecar child. Once a one-shot host process is allowed to exit
* (its sidecar handles are unref'd at 0 leases), this reaps the sidecar
* immediately instead of waiting for its stdin-EOF grace window — no orphan, no
* delay. We deliberately do NOT install SIGINT/SIGTERM handlers: a library
* should not hijack the host's signal handling. SIGINT still reaches the sidecar
* via the process group, and SIGTERM-driven exit still closes its stdin.
*/
function ensureSidecarProcessExitCleanup(): void {
if (sidecarProcessExitHookInstalled) return;
sidecarProcessExitHookInstalled = true;
process.on("exit", () => {
for (const sidecar of sharedSidecars.values()) {
try {
sidecarStates.get(sidecar)?.sharedChild?.kill?.("SIGKILL");
} catch {
// best-effort reap; the process is exiting regardless
}
}
});
}

function sidecarChildHandle(client: unknown): SidecarEventLoopHandle | undefined {
// SidecarProcess -> StdioSidecarProtocolClient.child (the spawned ChildProcess).
const protocolClient = (
client as { protocolClient?: { child?: SidecarEventLoopHandle } } | undefined
)?.protocolClient;
return protocolClient?.child ?? undefined;
}

/**
* Apply the current hold state to the shared sidecar's child + stdio: ref them
* while ≥1 hold is live so in-flight VM work keeps the host process alive; unref
* them at 0 so a one-shot script exits on its own after `dispose()`. The sidecar
* process itself stays running (reusable) and self-exits on stdin EOF when the
* host finally goes away. Best-effort: never let ref/unref break VM lifecycle.
*/
function applySharedSidecarHold(state: AgentOsSidecarState): void {
const child = state.sharedChild;
if (!child) return;
const hold = (state.eventLoopHolds ?? 0) > 0;
for (const handle of [child, child.stdin, child.stdout, child.stderr]) {
if (!handle) continue;
try {
if (hold) handle.ref?.();
else handle.unref?.();
} catch {
// ref/unref is an optimization, not correctness-critical
}
}
}

/**
* Take a hold for the entire create→use→dispose lifetime of one VM lease. Taken
* BEFORE VM creation starts (not when the lease lands in `activeLeases`) so a VM
* that is still mid-creation keeps the sidecar ref'd and a concurrent dispose
* cannot unref it out from under the in-flight create.
*/
function acquireSharedSidecarHold(state: AgentOsSidecarState): void {
state.eventLoopHolds = (state.eventLoopHolds ?? 0) + 1;
if (state.eventLoopHolds === 1) applySharedSidecarHold(state);
}

/** Release a hold taken by {@link acquireSharedSidecarHold}; unref at 0. */
function releaseSharedSidecarHold(state: AgentOsSidecarState): void {
const current = state.eventLoopHolds ?? 0;
if (current <= 0) {
// The `holdReleased` guard makes each lease release exactly once, so this
// should be unreachable. Warn rather than silently floor, per the repo's
// no-silent-masking rule, so an accounting bug surfaces instead of hiding.
state.eventLoopHolds = 0;
console.warn(
"[agentos] shared sidecar event-loop hold released more than acquired",
);
return;
}
state.eventLoopHolds = current - 1;
if (state.eventLoopHolds === 0) applySharedSidecarHold(state);
}

/**
* Spawn-once accessor for a sidecar handle's shared native process. Concurrent
* callers await the same promise, so one `AgentOsSidecar` maps to exactly one
Expand All @@ -5542,15 +5652,47 @@ function ensureSharedSidecarNativeProcess(
): Promise<SharedSidecarNativeProcess> {
const state = getSidecarState(sidecar);
if (!state.nativeProcess) {
ensureSidecarProcessExitCleanup();
state.nativeProcess = (async () => {
const client = SidecarProcess.spawn({
cwd: REPO_ROOT,
command: ensureNativeSidecarBinary(),
args: [],
frameTimeoutMs: NATIVE_SIDECAR_FRAME_TIMEOUT_MS,
});
const session = await client.authenticateAndOpenSession();
return { client, session };
// Track the child immediately — BEFORE the handshake await — so a
// failed `authenticateAndOpenSession()` can still reap it (otherwise
// the spawned child is untracked, unreapable, and pins the loop).
state.sharedChild = sidecarChildHandle(client);
if (!state.sharedChild) {
// We reached into @secure-exec/core internals to get the child for
// idle-unref. If that shape ever changes this returns undefined and
// the optimization silently stops working (one-shot scripts would
// hang again). Make it loud rather than a silent regression.
console.warn(
"[agentos] could not resolve the shared sidecar child handle; " +
"standalone scripts may not exit cleanly after dispose(). " +
"This usually means @secure-exec/core internals changed.",
);
}
// Apply the current hold state to the just-spawned child.
applySharedSidecarHold(state);
try {
const session = await client.authenticateAndOpenSession();
return { client, session };
} catch (error) {
// Spawn/handshake failed: reap the child, drop the cached handle,
// and CLEAR the rejected promise so the next create() retries
// instead of permanently wedging on a rejected `nativeProcess`.
try {
state.sharedChild?.kill?.("SIGKILL");
} catch {
// already gone
}
state.sharedChild = undefined;
state.nativeProcess = undefined;
throw error;
}
})();
}
return state.nativeProcess;
Expand All @@ -5565,6 +5707,14 @@ async function disposeSharedSidecarNativeProcess(
return;
}
state.nativeProcess = undefined;
// The cached child is now dead; drop it (symmetric with the assignment in
// ensureSharedSidecarNativeProcess). We deliberately do NOT zero
// `eventLoopHolds` here: this runs only from `AgentOsSidecar.dispose()`, which
// has already set the handle to `disposing` (so no new lease can acquire) and
// drained `activeLeases`; the disposed handle's state is then abandoned. Force-
// zeroing a shared counter could clobber a hold on a freshly re-acquired
// process generation, so it is left to the balanced acquire/release pairs.
state.sharedChild = undefined;
try {
const { client } = await pending;
await client.dispose();
Expand Down Expand Up @@ -5700,6 +5850,18 @@ async function leaseAgentOsSidecarVm<TVmAdmin extends InProcessSidecarVmAdmin>(
},
});

// Hold the shared sidecar's event-loop ref for this lease's WHOLE lifetime —
// taken now, before VM creation, so a concurrent dispose cannot unref the
// sidecar while this create is still in flight. Released exactly once on
// dispose or on a failed create.
acquireSharedSidecarHold(state);
let holdReleased = false;
const releaseHold = () => {
if (holdReleased) return;
holdReleased = true;
releaseSharedSidecarHold(state);
};

let disposed = false;
let leaseRecord: AgentOsSidecarLeaseRecord | undefined;

Expand All @@ -5726,6 +5888,10 @@ async function leaseAgentOsSidecarVm<TVmAdmin extends InProcessSidecarVmAdmin>(
state.activeLeases.delete(leaseRecord!);
state.description.activeVmCount = state.activeLeases.size;
await client.dispose();
// Release this lease's hold; the shared sidecar is unref'd only
// once the last hold (across all in-flight + active leases) drops,
// so a one-shot host process can then exit on its own.
releaseHold();
},
};

Expand All @@ -5737,6 +5903,7 @@ async function leaseAgentOsSidecarVm<TVmAdmin extends InProcessSidecarVmAdmin>(
return lease;
} catch (error) {
await client.dispose().catch(() => {});
releaseHold();
throw error;
}
}
Expand Down
Loading
Loading