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
2 changes: 2 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
### Added
- `gjc setup provider` now ships parameterized proxy presets: `--preset litellm` and `--preset openai-compatible-proxy` (aliases `litellm-proxy`, `openai-proxy`, `compatible-proxy`, `custom-proxy`) with a required `--base-url`, configurable `--api-key-env`, and live model discovery (#4123).
- New `modelProfile.proxyProvider` and `modelProfile.proxyMode` settings route built-in model-preset selectors through an authenticated OpenAI-compatible proxy (e.g. `xai/grok-4.3` → `litellm/xai/grok-4.3`). `fallback` preserves directly authenticated providers by default; `always` forces every proxy-routable built-in selector through the configured gateway. Routing fails closed for unconfigured or unauthenticated proxies and missing or ambiguous proxy models (#4123).
- SDK-only session hosts now publish their session ID and register their endpoint lifecycle with the broker, matching its identity and staleness fences. This restores broker/coordinator resolution for durable workflow-gate controls (`workflow.gates.list` and `workflow.gate_answer`) without relying on tmux pane input; broker unavailability leaves non-lifecycle local hosts usable and retries publication later.

## [0.12.21] - 2026-08-09

### Fixed
Expand Down
97 changes: 96 additions & 1 deletion packages/coding-agent/src/sdk/host/session-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { describe, expect, test } from "bun:test";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use a namespace import for filesystem helpers

The changed import adds more named imports from node:fs/promises, while the repository convention requires Node modules—including fs/promises—to use namespace imports. Convert this to import * as fs from "node:fs/promises" and qualify the helper calls so the new tests follow the enforced filesystem convention.

AGENTS.md reference: AGENTS.md:L132-L132

Useful? React with 👍 / 👎.

import * as os from "node:os";
import * as path from "node:path";
import type { ExtensionAPI } from "../../extensibility/extensions";
import { Broker } from "../broker/broker";
import {
createInvocationReconciliation,
createSdkSessionRuntimeExtension,
Expand Down Expand Up @@ -263,6 +264,7 @@ describe("SessionSdkSessionRuntime", () => {
} as any;
const transports: Array<{ starts: number; stops: number }> = [];
createSdkSessionRuntimeExtension(api, {
agentDir: path.join(cwd, ".gjc", "agent"),
createTransport: async ({ sessionId, stateRoot, token }) => {
const stats = { starts: 0, stops: 0 };
const failFirstStop = transports.length === 0;
Expand All @@ -281,6 +283,9 @@ describe("SessionSdkSessionRuntime", () => {
sendFrame: () => {},
start: async () => {
stats.starts += 1;
const endpoint = path.join(stateRoot, "sdk", `${sessionId}.json`);
await mkdir(path.dirname(endpoint), { recursive: true });
await writeFile(endpoint, JSON.stringify({ sessionId, token, pid: process.pid }));
return { url: `ws://127.0.0.1:${30_000 + stats.starts}` };
},
stop: async () => {
Expand Down Expand Up @@ -316,6 +321,95 @@ describe("SessionSdkSessionRuntime", () => {
await rm(cwd, { recursive: true, force: true });
}
});
test("keeps a local SDK-only host alive through broker failure and registers after recovery", async () => {
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-broker-recovery-"));
const agentDir = path.join(cwd, ".gjc", "agent");
await mkdir(path.dirname(agentDir), { recursive: true });
await writeFile(agentDir, "blocked");
const handlers = new Map<string, (event: unknown, ctx: any) => Promise<void> | void>();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Type the broker recovery harnesses

Both newly added broker tests declare their handler context as any and cast the API object with as any, bypassing checks that the mocked extension surface matches ExtensionContext and ExtensionAPI. These mocks can be expressed with the actual types, as the integration test in this same change does, and the repository contract explicitly prohibits unnecessary any.

AGENTS.md reference: AGENTS.md:L113-L113

Useful? React with 👍 / 👎.

const api = {
on(event: string, handler: (event: unknown, ctx: any) => Promise<void> | void) {
handlers.set(event, handler);
},
} as any;
const sessionId = "broker-recovery";
createSdkSessionRuntimeExtension(api, {
agentDir,
createTransport: async ({ stateRoot, token }) => ({
sessionId,
stateRoot,
token,
onFrame: () => undefined,
sendFrame: () => {},
start: async () => {
const endpoint = path.join(stateRoot, "sdk", `${sessionId}.json`);
await mkdir(path.dirname(endpoint), { recursive: true });
await writeFile(endpoint, JSON.stringify({ sessionId, token, pid: process.pid }));
return { url: "ws://127.0.0.1:1" };
},
stop: async () => {},
}),
});
const context = extensionContext(sessionId, cwd);
let broker: Broker | undefined;
try {
await handlers.get("session_start")?.({}, context);
await rm(agentDir);
await mkdir(agentDir, { recursive: true });
broker = new Broker({ agentDir });
await broker.start();
await handlers.get("turn_start")?.({}, context);
expect(await broker.handleRequest("session.get_endpoint", { sessionId, endpointGeneration: 1 })).toMatchObject(
{
ok: true,
result: { sessionId, token: expect.any(String) },
},
);
await handlers.get("session_shutdown")?.({}, context);
expect(await broker.handleRequest("session.get_endpoint", { sessionId, endpointGeneration: 1 })).toMatchObject(
{
ok: false,
error: { code: "endpoint_stale", message: "session endpoint is stale" },
},
);
} finally {
await broker?.stop();
await rm(cwd, { recursive: true, force: true });
}
});

test("rejects lifecycle-required SDK-only startup when broker registration fails", async () => {
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-broker-required-"));
const agentDir = path.join(cwd, ".gjc", "agent");
await mkdir(path.dirname(agentDir), { recursive: true });
await writeFile(agentDir, "blocked");
const handlers = new Map<string, (event: unknown, ctx: any) => Promise<void> | void>();
const api = {
on(event: string, handler: (event: unknown, ctx: any) => Promise<void> | void) {
handlers.set(event, handler);
},
} as any;
createSdkSessionRuntimeExtension(api, {
agentDir,
brokerRegistrationRequired: true,
createTransport: async ({ sessionId, stateRoot, token }) => ({
sessionId,
stateRoot,
token,
onFrame: () => undefined,
sendFrame: () => {},
start: async () => ({ url: "ws://127.0.0.1:1" }),
stop: async () => {},
}),
});
try {
await expect(
handlers.get("session_start")?.({}, extensionContext("broker-required", cwd)),
).rejects.toBeDefined();
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
});
interface PreflightHooks {
onPreflightAccepted?: () => void;
Expand Down Expand Up @@ -359,6 +453,7 @@ async function invocationHarness(
sendUserMessage: hooks.sendUserMessage ?? (async () => {}),
} as unknown as ExtensionAPI;
createSdkSessionRuntimeExtension(api, {
agentDir: cwd,
createTransport: async ({ sessionId: id, stateRoot, token }) => ({
sessionId: id,
stateRoot,
Expand Down
159 changes: 149 additions & 10 deletions packages/coding-agent/src/sdk/host/session-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { type ModelSelectorValue, normalizeModelSelectorValue } from "../../conf
import { type Settings, validateSettingPatch } from "../../config/settings";
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "../../extensibility/extensions";
import { parseThinkingLevel } from "../../thinking";
import { ensureBroker } from "../broker/ensure";
import { SessionIndex } from "../broker/session-index";
import { elevationAuthorityPath, verifyElevationCapability } from "../elevation/capability";
import {
collectAuthenticatedProfileProviders,
Expand Down Expand Up @@ -218,6 +220,10 @@ export class SessionSdkSessionRuntime {

/** Narrow extension-facing factory for the SDK-only session path. */
export interface CreateSdkSessionRuntimeOptions {
/** Authoritative broker state root for this session's endpoint lifecycle. */
agentDir: string;
/** Lifecycle-owned sessions require broker publication before they become usable. */
brokerRegistrationRequired?: boolean;
createTransport(input: {
sessionId: string;
stateRoot: string;
Expand Down Expand Up @@ -906,6 +912,52 @@ function containsSecretConfigKey(value: unknown, seen = new Set<object>()): bool
containsSecretConfigKey(nested, seen),
);
}
async function resolveSdkWorkflowGate(
ctx: ExtensionContext,
operation: "workflow.gate_answer" | "workflow.plan_approve",
id: string,
answer: unknown,
expectedSessionId: string | undefined,
idempotencyKey: string,
canResolve: () => boolean,
): Promise<unknown> {
if (!canResolve())
throw Object.assign(new Error("Workflow gate is no longer answerable."), { code: "resource_gone" });
if (expectedSessionId !== undefined && expectedSessionId !== ctx.sessionManager.getSessionId())
throw Object.assign(new Error("Workflow gate session does not match this endpoint."), { code: "resource_gone" });
if (expectedSessionId === undefined) logger.warn("workflow_control_missing_expected_session_id", { operation });
const workflowGate = ctx.workflowGate;
if (
typeof workflowGate?.resolveGate !== "function" ||
typeof workflowGate.recoverAcceptedGates !== "function" ||
typeof workflowGate.lookupCompletedResolution !== "function" ||
typeof workflowGate.prepareTerminalization !== "function" ||
typeof workflowGate.clearPreparedTerminalization !== "function"
)
throw Object.assign(new Error("Workflow gates are unavailable for this session."), { code: "resource_gone" });
const response = { gate_id: id, answer, idempotency_key: idempotencyKey };
const completed = workflowGate.lookupCompletedResolution(response);
if (completed.kind === "completed") return completed.resolution;
if (completed.kind === "accepted_incomplete") {
await workflowGate.recoverAcceptedGates();
const recovered = workflowGate.lookupCompletedResolution(response);
if (recovered.kind === "completed") return recovered.resolution;
throw Object.assign(new Error("Workflow gate resolution outcome is uncertain."), { code: "terminal_uncertain" });
}
if (!workflowGate.prepareTerminalization(id, "not_published"))
throw Object.assign(new Error("Workflow gate is no longer answerable."), { code: "resource_gone" });
try {
const resolution = await workflowGate.resolveGate(response);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Drain gate resolutions before stopping the SDK-only host

When session_shutdown or session_switch races this await, the SDK-only teardown immediately retracts the endpoint without fencing new gate controls or waiting for in-flight resolutions; SessionSdkHost also dispatches frames fire-and-forget, so stop() cannot drain them. The notification-host path explicitly marks the runtime as stopping and awaits waitForGateResolutionQuiescence() before teardown (sdk/bus/index.ts:3670-3740), but this new path has no equivalent. Consequently a gate answer can be durably accepted while its response is lost, or have its continuation fenced before advancement, leaving the client uncertain or the workflow stuck; track these resolutions and drain them before stopping the runtime.

Useful? React with 👍 / 👎.

if ((resolution as { status?: unknown }).status === "rejected") workflowGate.clearPreparedTerminalization(id);
return resolution;
} catch (error) {
const stillPending = workflowGate.listPendingGates?.().some(gate => gate.gate_id === id) === true;
if (stillPending) workflowGate.clearPreparedTerminalization(id);
else workflowGate.quarantineGate?.(id);
Comment on lines +954 to +956

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve recovery authority after accepted gate failures

When resolveGate durably records an accepted answer but then throws during terminalization or workflow advancement, listPendingGates() returns false because the record is no longer pending. This branch consequently calls quarantineGate, which releases the live continuation and converts the accepted record to quarantined, preventing the emitter's scheduled or explicit recoverAcceptedGates() from ever advancing it. A transient post-accept failure can therefore leave a planning workflow permanently stuck despite accepting the user's answer; retain continuation authority and report an uncertain terminal outcome instead of quarantining a durably accepted gate.

Useful? React with 👍 / 👎.

throw error;
}
}

function createControlSurface(
ctx: ExtensionContext,
api: ExtensionAPI,
Expand All @@ -916,6 +968,8 @@ function createControlSurface(
configOverrides?: Map<string, unknown>,
configRevision: { current: number } = { current: 0 },
elevationAuthorityToken?: string,
canResolveGate: () => boolean = () => true,
trackGateResolution: <T>(resolution: Promise<T>) => Promise<T> = async resolution => await resolution,
): ControlSurface {
const surfacePolicy =
policy ?? createSdkSurfacePolicyForContext(ctx, hasSdkWorkflowGateCapability(ctx.workflowGate));
Expand Down Expand Up @@ -1084,9 +1138,22 @@ function createControlSurface(
return await submit("prompt", undefined, options => api.sendUserMessage(text, options));
},
answerAsk: unavailable("ask.answer"),
answerGate: (_id, _response, _expectedSessionId, _idempotencyKey, _elevationRequestId) =>
unavailable("workflow.gate_answer")(),
approvePlan: (_id, _choice, _expectedSessionId, _elevationRequestId) => unavailable("workflow.plan_approve")(),
answerGate: async (id, response, expectedSessionId, idempotencyKey) =>
await trackGateResolution(
resolveSdkWorkflowGate(
ctx,
"workflow.gate_answer",
id,
response,
expectedSessionId,
idempotencyKey ?? id,
canResolveGate,
),
),
approvePlan: async (id, choice, expectedSessionId) =>
await trackGateResolution(
resolveSdkWorkflowGate(ctx, "workflow.plan_approve", id, choice, expectedSessionId, id, canResolveGate),
),
invokeSkill: async (name, args, clientRef) => {
if (!ctx.invokeSkill) return unavailable("skill.invoke")();
if (args !== undefined && typeof args !== "string")
Expand Down Expand Up @@ -1268,6 +1335,9 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
cursors: CursorRegistry;
reconciliation: InvocationReconciliation;
pending: Array<{ kind: InvocationKind; correlation: InvocationCorrelation }>;
registerBroker: () => Promise<void>;
fenceGateResolutions: () => void;
waitForGateResolutionQuiescence: () => Promise<void>;
activeInvocation?: { kind: InvocationKind; correlation: InvocationCorrelation };
disposeGate?: () => void;
}
Expand All @@ -1286,9 +1356,12 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
};
api.on("agent_start", async (_event, ctx) => await emitLifecycle("agent_start", ctx));
api.on("agent_end", async (_event, ctx) => await emitLifecycle("agent_end", ctx));
api.on("turn_start", (_event, ctx) =>
active?.runtime.emitEvent({ type: "turn_start", sessionId: ctx.sessionManager.getSessionId() }),
);
api.on("turn_start", async (_event, ctx) => {
const current = active;
if (!current) return;
await current.registerBroker();
current.runtime.emitEvent({ type: "turn_start", sessionId: ctx.sessionManager.getSessionId() });
Comment on lines +1359 to +1363

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retry optional broker registration off the turn path

When broker discovery repeatedly fails—for example, a broker child launches but never publishes discovery—every model turn now awaits registerBroker(), whose ensureBroker() attempt can run until the broker's ten-second discovery deadline. Because turn_start handlers are awaited before turn processing continues, an otherwise-local SDK session can incur that delay on every turn. Fresh evidence in this head is that the startup failure is caught as optional, but the retry was moved into this synchronous per-turn handler; retry it in the background or with backoff instead.

Useful? React with 👍 / 👎.

});
api.on("turn_end", (_event, ctx) =>
active?.runtime.emitEvent({ type: "turn_end", sessionId: ctx.sessionManager.getSessionId() }),
);
Expand Down Expand Up @@ -1321,6 +1394,20 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
// read the live generation/seq once the runtime exists (Q30 atomic
// capture, C9).
let eventWatermarkSource: () => { generation: number; seq: number } = () => ({ generation: 0, seq: 0 });
let acceptingGateResolutions = true;
const inFlightGateResolutions = new Set<Promise<unknown>>();
const trackGateResolution = <T>(resolution: Promise<T>): Promise<T> => {
const tracked = resolution.finally(() => inFlightGateResolutions.delete(tracked));
inFlightGateResolutions.add(tracked);
return tracked;
};
const waitForGateResolutionQuiescence = async (): Promise<void> => {
const settled = Promise.allSettled(inFlightGateResolutions);
const timeout = Bun.sleep(5_000).then(() => {
throw new Error("Timed out waiting for SDK workflow gate resolutions to settle.");
});
Comment on lines +1406 to +1408

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Cancel the unused gate-drain timer

When an SDK-only session has no in-flight gate work, as in a normal print-mode shutdown, Promise.allSettled resolves immediately but this Bun.sleep(5_000) continues to hold Bun's event loop open for the full five seconds. Since print mode awaits session.dispose() and then returns without forcing process.exit, default SDK-only invocations linger after producing their output; cancel or unref the timeout once quiescence wins the race.

Useful? React with 👍 / 👎.

await Promise.race([settled, timeout]);
};
const surfaceFactory = createSdkSurfaceFactory({
ctx,
id: sessionId,
Expand All @@ -1345,6 +1432,8 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
options.configOverrides,
configRevision,
elevationAuthorityToken,
() => acceptingGateResolutions,
trackGateResolution,
);
let runtime: SessionSdkSessionRuntime;
const installProviderDefinitions = (capability: string, definitions: unknown): void => {
Expand Down Expand Up @@ -1458,9 +1547,45 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
const disposeGate = ctx.workflowGate?.onGateEmitted?.(gate =>
runtime.emitEvent({ kind: "workflow_gate", payload: gate }),
);
active = { runtime, revisions, cursors, reconciliation, pending, disposeGate };
let brokerRegistered = false;
const registerBroker = async (): Promise<void> => {
if (brokerRegistered) return;
try {
await ensureBroker({ agentDir: options.agentDir });
const index = await new SessionIndex(options.agentDir).open();
const locator = { repo: path.resolve(ctx.cwd), stateRoot };
await runtime.registerWithBroker({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Clear failed optional broker registrations

When the session index opens but registration itself persistently fails—for example, because the index has a corrupt suffix—SessionSdkHost.registerWithBroker() stores this writer before awaiting its register callback. This optional catch therefore leaves the failed writer installed, and a later shutdown or switch calls its equally failing unregister, causing runtime.stop() to reject and preventing the successor runtime from starting even though broker publication was declared best-effort. Clear the installed registration after an optional failure, or only retain it once registration succeeds.

Useful? React with 👍 / 👎.

register: async input => {
const endpointMtimeMs = (await fs.stat(path.join(input.stateRoot, "sdk", `${input.sessionId}.json`)))
.mtimeMs;
await index.append({ type: "host_registered", ...input, locator, pid: process.pid, endpointMtimeMs });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Publish the host's process incarnation

When an SDK-only host's workspace is deleted or its endpoint becomes unreachable, the broker can signal it only if the registration contains the host's OS process incarnation; otherwise hasDurableProcessIdentity cannot use either the deleted workspace marker or broker-owned fallback and session.close returns close_refused. The notification-host registration already preserves this field, so include it here as well to prevent restart:sdk-broker --close-session-hosts from leaving these newly indexed hosts orphaned.

Useful? React with 👍 / 👎.

Comment on lines +1559 to +1561

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize broker registration with runtime teardown

When a turn_start broker retry overlaps session_switch or shutdown, this registration callback pauses at fs.stat() after SessionSdkHost.registerWithBroker() has installed its writer. Teardown can therefore append host_unregistered during that pause, followed by this delayed host_registered, making the stopped predecessor the latest index entry; because its PID remains alive after a switch, session.list continues exposing the stale session for the lifetime of the process. Cancel or serialize in-flight registration with teardown, or recheck runtime authority before appending.

Useful? React with 👍 / 👎.

},
unregister: async input => {
await index.append({ type: "host_unregistered", ...input, locator, pid: process.pid });
},
});
brokerRegistered = true;
} catch (error) {
if (options.brokerRegistrationRequired) throw error;
logger.warn(`sdk broker registration unavailable: ${String(error)}`);
}
};
active = {
runtime,
revisions,
cursors,
reconciliation,
pending,
registerBroker,
fenceGateResolutions: () => {
acceptingGateResolutions = false;
},
waitForGateResolutionQuiescence,
disposeGate,
};
try {
await runtime.start();
await registerBroker();
} catch (error) {
active = undefined;
disposeGate?.();
Expand All @@ -1471,7 +1596,19 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
code: errorCode(cleanupError),
error: String(cleanupError),
});
active = { runtime, revisions, cursors, reconciliation, pending, disposeGate };
active = {
runtime,
revisions,
cursors,
reconciliation,
pending,
registerBroker,
fenceGateResolutions: () => {
acceptingGateResolutions = false;
},
waitForGateResolutionQuiescence,
disposeGate,
};
throw new AggregateError([error, cleanupError], "SDK runtime startup failed and cleanup failed.");
}
cursors.close();
Expand All @@ -1481,10 +1618,12 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
};
const stopActive = async (): Promise<void> => {
const current = active;
active = undefined;
if (!current) return;
current.disposeGate?.();
current.fenceGateResolutions();
try {
await current.waitForGateResolutionQuiescence();
Comment on lines +1622 to +1624

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fence all controls before draining the stale endpoint

During session_switch or session_branch with a slow in-flight gate resolution, the session context has already rotated to the successor before this handler runs, but this call fences only additional gate answers while leaving the old broker-published transport active until the drain finishes. A client connected through the predecessor endpoint can therefore submit prompts, model changes, or other non-gate controls against the successor session for up to five seconds. Fence all inbound control dispatch before awaiting quiescence, as opposed to only gate resolution.

Useful? React with 👍 / 👎.

active = undefined;
current.disposeGate?.();
await current.runtime.stop();
} catch (error) {
logger.error("sdk runtime stop failed", { code: errorCode(error), error: String(error) });
Comment on lines 1628 to 1629

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Tear down the host after the gate-drain timeout

When a gate resolution remains in flight for more than the new five-second bound, waitForGateResolutionQuiescence() rejects before runtime.stop() is attempted, and this catch restores the still-published runtime as active. Extension event dispatch isolates handler failures, so a real session_shutdown continues disposing the agent while its WebSocket endpoint and broker registration remain live indefinitely; on a switch, the successor runtime is never started by this handler. A bounded drain timeout must still proceed through endpoint teardown rather than retaining the old host.

Useful? React with 👍 / 👎.

Expand Down
Loading
Loading