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
Original file line number Diff line number Diff line change
Expand Up @@ -1483,13 +1483,13 @@ def observed(outcome: str, reason: str | None) -> dict[str, Any]:
or native._sync is not transport
or transport._proc is not process
or thread.id != thread_id
or self._unusable != unusable
or client._initialized != initialized
or thread._codex is not thread_owner
):
return {**result, "reason_code": "observation_changed"}
if process.poll() is not None:
outcome, reason = "failed", "agent_exited"
elif self._unusable != unusable or client._initialized != initialized:
return {**result, "reason_code": "observation_changed"}
return {
**result,
"outcome": outcome,
Expand Down
7 changes: 5 additions & 2 deletions adapters/typescript/cline/src/cline-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,7 @@ class ClineSdkSessionHandle implements ClineSessionHandle {
}
const unchanged = () => !this.stopped && residentSessions(this.core) === this.sessions &&
this.sessions?.get(sessionId) === resident && resident.agent === this.agent &&
resident.agent.getAgentId() === this.agentId && !resident.agent.shutdownCalled;
resident.agent.getAgentId() === this.agentId;
try {
const sessions = residentSessions(this.core);
const current = sessions?.get(sessionId);
Expand Down Expand Up @@ -608,14 +608,17 @@ class ClineSdkSessionHandle implements ClineSessionHandle {
this.probing = false;
}
if (!unchanged()) return unobservedHealth("observation_changed");
if (resident.agent.shutdownCalled) return observation("failed", "session_closed");
if (snapshot?.sessionId !== sessionId || !["idle", "running", "pending"].includes(snapshot.status)) {
return observation("failed", "health_failed");
}
}
return unchanged() ? observation("passed", null) : unobservedHealth("observation_changed");
if (!unchanged()) return unobservedHealth("observation_changed");
return resident.agent.shutdownCalled ? observation("failed", "session_closed") : observation("passed", null);
} catch {
try {
if (!unchanged()) return unobservedHealth("observation_changed");
if (resident.agent.shutdownCalled) return observation("failed", "session_closed");
} catch { return unobservedHealth("observation_changed"); }
return unobservedHealth("probe_error");
}
Expand Down
43 changes: 43 additions & 0 deletions adapters/typescript/cline/test/health.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,46 @@ test("a replaced native host cannot turn a missing resident into confirmed closu
assert.equal((await handle.check("live")).reason_code, "observation_changed");
await handle.stop();
});

test("runtime health preserves confirmed native exit evidence without invoking or stopping", async (t) => {
const evidence = {
outcome: "failed", reason_code: "agent_exited", activity: "stopped",
source: "fabric.adapter.liveness", observed_at: new Date().toISOString(),
};
let stops = 0;
const runtime = new ClineAdapterRuntime({
create: async () => ({
id: "native-session",
check: async () => evidence,
prompt: async () => { throw new Error("health must not invoke"); },
stop: async () => { stops += 1; },
}),
});
await runtime.start(input);
t.after(() => runtime.stop());
assert.deepEqual(await runtime.check("live"), evidence);
assert.equal(stops, 0);
});

for (const rejected of [false, true]) {
test(`ready preserves the same native agent's closure when get ${rejected ? "rejects" : "resolves"}`, async (t) => {
const fixture = nativeFixture();
const reply = Promise.withResolvers();
fixture.core.get = () => reply.promise;
const handle = await new ClineSdkSessionFactory(fixture.loader).create(input);
t.after(() => handle.stop());
const checking = handle.check("ready");
fixture.agent.shutdownCalled = true;
if (rejected) reply.reject(new Error("PRIVATE native shutdown"));
else reply.resolve({ sessionId: "session", status: "idle" });
const result = await checking;
assert.equal(result.outcome, "failed");
assert.equal(result.reason_code, "session_closed");
assert.equal(result.activity, "stopped");
assert.ok(Number.isFinite(Date.parse(result.observed_at)));
assert.doesNotMatch(JSON.stringify(result), /PRIVATE/);
assert.equal(fixture.calls.send, 0);
assert.equal(fixture.sessions.get("session"), fixture.resident);
assert.equal(fixture.resident.agent, fixture.agent);
});
}
4 changes: 4 additions & 0 deletions adapters/typescript/common/test/fixtures/acp-agent.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ let effort = "medium";
let mcpCount = 0;
let ready = false;

if (process.env.TEST_PROCESS_MARKER !== undefined) {
writeFileSync(process.env.TEST_PROCESS_MARKER, String(process.pid));
}

if (process.env.TEST_DESCENDANT_MARKER !== undefined) {
const descendant = spawn(process.execPath, ["-e", `
process.on("SIGTERM", () => {});
Expand Down
5 changes: 4 additions & 1 deletion adapters/typescript/droid/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,10 @@ settings, and unknown built-in tool IDs fail instead of being ignored.
`Fabric.check()` supports `live`. The adapter sends a fresh native
`DroidSession.listTools()` request. A successful SDK response establishes that
the selected Droid subprocess responds; a retained SDK handle is insufficient.
A failed request returns unknown health and does not close the session.
A failed request returns unknown health unless the SDK reports `ProcessExitError`,
which confirms that the owned subprocess exited and returns `failed/agent_exited`.
Later checks preserve that terminal observation and its original `observed_at`.
Checks do not close the session.

`active` and `ready` are unsupported. The pinned SDK does not expose a qualified
read-only probe for prompt acceptance or native readiness. A busy invocation
Expand Down
30 changes: 24 additions & 6 deletions adapters/typescript/droid/src/droid-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ interface DroidSdkSession {

interface DroidSdkModule {
createSession(options: Record<string, unknown>): Promise<DroidSdkSession>;
ProcessExitError?: typeof import("@factory/droid-sdk/node").ProcessExitError;
}

interface EnvironmentLease {
Expand Down Expand Up @@ -688,8 +689,13 @@ class DroidSdkSessionHandle implements DroidSessionHandle {
private readonly runtimeRoot?: string;
private stopped = false;
private healthPending = false;
private processExit?: AdapterCheckResult;

constructor(session: DroidSdkSession, runtimeRoot?: string) {
constructor(
session: DroidSdkSession,
runtimeRoot?: string,
private readonly processExitError?: DroidSdkModule["ProcessExitError"],
) {
this.session = session;
this.runtimeRoot = runtimeRoot;
}
Expand All @@ -706,6 +712,7 @@ class DroidSdkSessionHandle implements DroidSessionHandle {
...unobservedHealth("unsupported_level"),
outcome: "unsupported",
};
if (this.processExit !== undefined) return { ...this.processExit };
// The SDK has no cancellation option for listTools. Retain at most one pending request across caller timeouts.
if (this.healthPending) return unobservedHealth("probe_timeout");
this.healthPending = true;
Expand All @@ -720,10 +727,19 @@ class DroidSdkSessionHandle implements DroidSessionHandle {
source: "fabric.adapter.liveness",
observed_at: new Date().toISOString(),
};
} catch {
return unobservedHealth(
this.stopped ? "observation_changed" : "probe_error",
);
} catch (error) {
if (this.stopped) return unobservedHealth("observation_changed");
if (this.processExitError !== undefined && error instanceof this.processExitError) {
this.processExit = {
outcome: "failed",
reason_code: "agent_exited",
activity: "stopped",
source: "fabric.adapter.liveness",
observed_at: new Date().toISOString(),
};
return { ...this.processExit };
}
return unobservedHealth("probe_error");
} finally {
this.healthPending = false;
}
Expand Down Expand Up @@ -820,10 +836,12 @@ export class DroidSdkSessionFactory implements DroidSessionFactory {
};

let session: DroidSdkSession | undefined;
let processExitError: DroidSdkModule["ProcessExitError"];
try {
const environment = leaseEnvironment(sessionEnvironment, apiKeyEnv, apiKey, this.parentEnvironment);
try {
const sdk = await this.loader();
processExitError = sdk.ProcessExitError;
session = await sdk.createSession({
cwd: workspace,
modelId: selected.model,
Expand All @@ -846,7 +864,7 @@ export class DroidSdkSessionFactory implements DroidSessionFactory {
await applyToolPolicy(session, input.config, mcpServers);
await validateMcpTools(session, mcpServers, this.mcpStartupTimeoutMs);
await validateSkills(session, runtimeProfile.entrypoints);
return new DroidSdkSessionHandle(session, runtimeProfile.root);
return new DroidSdkSessionHandle(session, runtimeProfile.root, processExitError);
} catch (error) {
await session?.close().catch(() => undefined);
if (runtimeProfile.root !== undefined) {
Expand Down
45 changes: 45 additions & 0 deletions adapters/typescript/droid/test/droid-sdk.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { tmpdir } from "node:os";
import { basename, dirname, join } from "node:path";
import test from "node:test";

import { ProcessExitError } from "@factory/droid-sdk/node";

import { DroidSdkSessionFactory } from "../dist/droid-sdk.js";

function input(overrides = {}) {
Expand Down Expand Up @@ -547,3 +549,46 @@ test("native health failures remain unknown and never close a session", async ()
assert.equal((await handle.prompt("one")).status, "completed");
await handle.stop();
});

test("health retains the SDK's confirmed process exit without repeating requests", async (t) => {
const sdk = fakeSdk();
const loader = async () => ({ ...await sdk.loader(), ProcessExitError });
const handle = await new DroidSdkSessionFactory(loader).create(input());
t.after(() => handle.stop());
let calls = 0;
sdk.session.listTools = async () => {
calls += 1;
throw new ProcessExitError("PRIVATE-EXIT-DETAILS", { exitCode: 7 });
};
for (let attempt = 0; attempt < 2; attempt += 1) {
const result = await handle.check("live");
assert.equal(result.outcome, "failed");
assert.equal(result.reason_code, "agent_exited");
assert.equal(result.activity, "stopped");
assert.equal(result.source, "fabric.adapter.liveness");
assert.ok(Number.isFinite(Date.parse(result.observed_at)));
assert.doesNotMatch(JSON.stringify(result), /PRIVATE/);
}
assert.equal(calls, 1);
assert.equal(sdk.calls.close, 0);
assert.deepEqual(sdk.calls.stream, []);
});

test("health leaves transport errors and same-named errors inconclusive", async (t) => {
const sdk = fakeSdk();
const loader = async () => ({ ...await sdk.loader(), ProcessExitError });
const handle = await new DroidSdkSessionFactory(loader).create(input());
t.after(() => handle.stop());
const misleading = new Error("PRIVATE process exited");
misleading.name = "ProcessExitError";
for (const error of [new Error("PRIVATE connection lost"), misleading]) {
sdk.session.listTools = async () => { throw error; };
const result = await handle.check("live");
assert.equal(result.outcome, "unknown");
assert.equal(result.reason_code, "probe_error");
assert.equal(result.observed_at, null);
assert.doesNotMatch(JSON.stringify(result), /PRIVATE/);
}
assert.equal(sdk.calls.close, 0);
assert.deepEqual(sdk.calls.stream, []);
});
20 changes: 20 additions & 0 deletions adapters/typescript/droid/test/health.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,23 @@ test("a restart invalidates a check even when a factory reuses a session object"
assert.equal((await checking).reason_code, "observation_changed");
await runtime.stop();
});

test("runtime health preserves confirmed native exit evidence without invoking or stopping", async (t) => {
const evidence = {
outcome: "failed", reason_code: "agent_exited", activity: "stopped",
source: "fabric.adapter.liveness", observed_at: new Date().toISOString(),
};
let stops = 0;
const runtime = new DroidAdapterRuntime({
create: async () => ({
id: "native-session",
check: async () => evidence,
prompt: async () => { throw new Error("health must not invoke"); },
stop: async () => { stops += 1; },
}),
});
await runtime.start(input);
t.after(() => runtime.stop());
assert.deepEqual(await runtime.check("live"), evidence);
assert.equal(stops, 0);
});
36 changes: 20 additions & 16 deletions adapters/typescript/kilo/src/kilo-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,14 +445,14 @@ class KiloSdkSessionHandle implements KiloSessionHandle {
outcome: "failed",
activity: "stopped",
};
if (!this.server.isAlive())
return {
outcome: "failed",
reason_code: "agent_exited",
activity: "stopped",
source: "fabric.adapter.liveness",
observed_at: new Date().toISOString(),
};
const exited = (): AdapterCheckResult => ({
outcome: "failed",
reason_code: "agent_exited",
activity: "stopped",
source: "fabric.adapter.liveness",
observed_at: new Date().toISOString(),
});
if (!this.server.isAlive()) return exited();
if (level === "live")
return {
outcome: "passed",
Expand All @@ -476,8 +476,16 @@ class KiloSdkSessionHandle implements KiloSessionHandle {
options,
)
: await this.client.global.health(options);
if (this.stopped || !this.server.isAlive())
if (this.stopped) return unobservedHealth("observation_changed");
if (
level === "active" &&
isRecord(response.data) &&
typeof response.data.id === "string" &&
response.data.id.length > 0 &&
response.data.id !== this.id
)
return unobservedHealth("observation_changed");
if (!this.server.isAlive()) return exited();
if (!isRecord(response.data)) return unobservedHealth("probe_error");
if (level === "active" && response.data.id !== this.id)
return unobservedHealth("observation_changed");
Expand Down Expand Up @@ -506,13 +514,9 @@ class KiloSdkSessionHandle implements KiloSessionHandle {
observed_at: new Date().toISOString(),
};
} catch {
return unobservedHealth(
this.stopped || !this.server.isAlive()
? "observation_changed"
: signal.aborted
? "probe_timeout"
: "probe_error",
);
if (this.stopped) return unobservedHealth("observation_changed");
if (!this.server.isAlive()) return exited();
return unobservedHealth(signal.aborted ? "probe_timeout" : "probe_error");
} finally {
this.healthPending = false;
}
Expand Down
Loading
Loading