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: 5 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,11 @@ Read the declaration rather than maintaining a prose copy:
- common command input fields, and which surface may write an input key (model, operator, retired):
`src/commands/common-input-fields.ts` and `src/commands/input-audience.ts`

Shared selector parsing and matching belongs in `@agent-device/selectors`; request cancellation
and progress in `@agent-device/host-kit/request`; cross-layer contracts in `packages/contracts/src`;
CLI flags in `src/commands/cli-grammar`; cross-surface schema composition in `src/commands/schema`.
Shared selector parsing and matching belongs in `@agent-device/selectors`; daemon-side request
cancellation and progress in `@agent-device/host-kit/request`, and the caller's per-call abort guard
beside the transports it serves in `src/daemon-client/daemon-client-transport.ts`; cross-layer
contracts in `packages/contracts/src`; CLI flags in `src/commands/cli-grammar`; cross-surface schema
composition in `src/commands/schema`.

Resolve registry completeness failures at the missing declaration. Diagnose other gate failures
at their reported invariant; do not suppress them or add an allowlist to get a pass. Build interaction
Expand Down
24 changes: 23 additions & 1 deletion packages/contracts/src/client-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ import type {

export type AgentDeviceDaemonTransportContext = {
authToken?: string;
/**
* Cancels this one in-flight request. A built-in transport that sees an abort destroys the
* request's connection, which makes the daemon mark the request canceled; the promise rejects
* with the typed canceled-request error. A custom transport that ignores the signal keeps its
* own cancellation contract, and the client still rejects the caller's promise on abort.
*/
signal?: AbortSignal;
};

export type AgentDeviceDaemonTransport = (
Expand Down Expand Up @@ -88,7 +95,22 @@ export type AgentDeviceRequestOverrides = Pick<
| 'iosXctestrunFile'
| 'iosXctestDerivedDataPath'
| 'iosXctestEnvDir'
>;
> & {
/**
* Cancels this one call. Already aborted: the call rejects without sending anything
Comment thread
thymikee marked this conversation as resolved.
* (`details.dispatched: 'no'`). Aborted in flight: the request's connection closes, the daemon
* marks the request canceled, and the promise rejects with the typed canceled-request error
* (`details.reason: 'request_canceled'`). An abort is never a timeout: no runner sweep, no
* daemon reset.
*
* The guarantee covers the daemon request, and the built-in transports enforce it; a custom
* transport receives the signal on its context and may implement cancellation differently. Two
* phases run outside it: a response-artifact download started after the response begins is not
* canceled, and a canceled one-shot replay still runs the existing cleanup that may tear down a
* daemon this client started.
*/
signal?: AbortSignal;
};

export type AgentDeviceIdentifiers = {
session?: string;
Expand Down
5 changes: 5 additions & 0 deletions packages/contracts/src/request-envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,9 @@ export type InternalRequestOptions = AgentDeviceClientConfig &
leaseTtlMs?: number;
provider?: string;
providerSessionId?: string;
/**
* Cancels this one call in flight; never crosses the wire. The client hands it to the transport
* context and rejects its own promise on abort even when a custom transport ignores it.
*/
signal?: AbortSignal;
};
103 changes: 103 additions & 0 deletions src/__tests__/client-abort-signal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* #3178: every client call accepts `signal?: AbortSignal`.
*
* These pin the client's own half of the contract against a scripted transport: an already-aborted
* call never reaches the transport (`details.dispatched: 'no'`), the signal rides the transport
* context for a transport that honors it, and a custom transport that ignores the signal still
* loses the race — the caller's promise rejects with the typed canceled-request error
* (`details.dispatched: 'unknown'`). The transport-into-daemon half is covered by
* `src/daemon-client/__tests__/daemon-client-abort.test.ts`.
*/
import assert from 'node:assert/strict';
import { test } from 'vitest';
import { isRequestCanceledError } from '@agent-device/kernel/errors';
import { createAgentDeviceClient } from '../agent-device-client.ts';
import type { AgentDeviceDaemonTransportContext } from '@agent-device/contracts/client';
import type { DaemonRequest, DaemonResponse } from '@agent-device/kernel/contracts';
import { createTransport } from './client-transport-fixture.ts';

function canceledWith(error: unknown, dispatched: 'no' | 'unknown'): boolean {
return (
isRequestCanceledError(error) &&
(error as { details?: Record<string, unknown> }).details?.dispatched === dispatched
);
}

test('a client call with an already-aborted signal never reaches the transport', async () => {
const { config, calls, transport } = createTransport(() => ({ ok: true, data: {} }));
const client = createAgentDeviceClient(config, { transport });
const controller = new AbortController();
controller.abort();
await assert.rejects(
client.interactions.press({ ref: '@e12', signal: controller.signal }),
(error: unknown) => canceledWith(error, 'no'),
);
assert.deepEqual(calls, []);
});

test('the signal rides the transport context so a built-in-style transport can close the request', async () => {
const contexts: Array<AgentDeviceDaemonTransportContext | undefined> = [];
const client = createAgentDeviceClient(
{},
{
transport: async (_req, context) => {
contexts.push(context);
return { ok: true, data: {} } satisfies DaemonResponse;
},
},
);
const controller = new AbortController();
await client.command.wait({ durationMs: 1, signal: controller.signal });
assert.equal(contexts.length, 1);
assert.equal(contexts[0]?.signal, controller.signal);
});

test('aborting a call whose transport ignores the signal still rejects the caller with the typed canceled error', async () => {
let lingering: ReturnType<typeof setTimeout> | undefined;
const client = createAgentDeviceClient(
{},
{
transport: (req) =>
new Promise<DaemonResponse>((resolve) => {
// A custom transport that never inspects the context signal: the guard must still settle
// the caller's promise when the abort fires. The late resolve is cleared once the caller
// has been rejected, so the worker keeps no timer alive for its own promise.
lingering = setTimeout(
() => resolve({ ok: true, data: { ignored: req.command } }),
10_000,
);
lingering.unref?.();
}),
},
);
const controller = new AbortController();
const call = client.interactions.press({ ref: '@e12', signal: controller.signal });
setTimeout(() => controller.abort(), 10);
await assert.rejects(call, (error: unknown) => canceledWith(error, 'unknown'));
if (lingering) clearTimeout(lingering);
});

test('a signal on one call does not cancel another', async () => {
const client = createAgentDeviceClient(
{},
{
transport: async (req: Omit<DaemonRequest, 'token'>) => {
if (req.command === 'wait') {
await new Promise((resolve) => setTimeout(resolve, 50));
}
return { ok: true, data: {} };
},
},
);
const canceled = new AbortController();
const doomed = client.command.wait({ durationMs: 5000, signal: canceled.signal });
const survivor = client.command.wait({ durationMs: 1 });
// Deferred so the doomed call is genuinely in flight when the abort fires: a synchronous abort
// would land before `execute` installs the guard and reject through the pre-abort `no` path that
// the first test already covers. The guard answers for a transport that ignores the signal, so
// this exercises the in-flight `unknown` rejection while the survivor runs untouched.
await new Promise((resolve) => setTimeout(resolve, 10));
canceled.abort();
Comment thread
thymikee marked this conversation as resolved.
await assert.rejects(doomed, (error: unknown) => canceledWith(error, 'unknown'));
assert.deepEqual(await survivor, {});
});
23 changes: 23 additions & 0 deletions src/__tests__/test-utils/loopback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,29 @@ function closeHttpConnections(server: LoopbackServer): void {
maybeHttpServer.closeIdleConnections?.();
}

/**
* `net.Server.close()` waits for every accepted connection to finish, and a request handler that
* never answers keeps its connection open forever — `http.Server` has `closeAllConnections()` and
* `net.Server` has no equivalent. Track the accepted sockets so a test whose assertion already
* failed can destroy them instead of hanging in teardown: a regression in cancellation wiring must
* surface as the failed assertion, not as the test timeout swallowing it.
*/
export function trackLoopbackSockets(server: net.Server): () => void {
const sockets = new Set<net.Socket>();
server.on('connection', (socket) => {
sockets.add(socket);
socket.on('close', () => {
sockets.delete(socket);
});
});
return () => {
for (const socket of sockets) {
socket.destroy();
}
sockets.clear();
};
}

export function waitForHttpOk(url: string, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
return new Promise((resolve, reject) => {
Expand Down
19 changes: 17 additions & 2 deletions src/agent-device-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
type SessionRuntimeHints,
} from '@agent-device/kernel/contracts';
import { AppError, throwDaemonError } from '@agent-device/kernel/errors';
import { createRequestId } from '@agent-device/host-kit/diagnostics';
import {
buildMeta,
normalizeDeployResult,
Expand Down Expand Up @@ -74,6 +75,7 @@ import { isRecord, readSnapshotKeyboardBandFact } from '@agent-device/kernel/rec
import { readResponseWarnings } from '@agent-device/kernel/success-text';
import { createLeaseClient } from './client/lease-client.ts';
import { normalizeScreenshotCaptureResult } from './client/screenshot-result.ts';
import { createRequestGuard } from './daemon-client/daemon-client-transport.ts';

export function createAgentDeviceClient(
config: AgentDeviceClientConfig = {},
Expand All @@ -95,16 +97,29 @@ export function createAgentDeviceClient(
input?: Record<string, unknown>,
): Promise<Record<string, unknown>> => {
const merged = mergeClientOptions(config, options);
// The id is generated before the guard so a canceled call names itself in `request.meta`, and
// the daemon's diagnostics for the request the transport goes on to cancel carry the same id
// the caller's rejection carries.
const requestId = merged.requestId ?? createRequestId();
const cancellation = createRequestGuard({ signal: merged.signal, requestId });
cancellation.refuseIfAborted();
const request = {
session: resolveSessionName(merged.session),
command,
positionals,
...(input ? { input } : {}),
flags: buildRequestFlags(merged, metadataFlags),
runtime: merged.runtime,
meta: buildMeta(merged),
meta: { ...buildMeta(merged), requestId },
};
const response = await transport(request, { authToken: merged.daemonAuthToken });
// `signal` rides the transport context (it is a live object, never wire data), and the guard
// answers for a custom transport that ignores it: the caller's promise settles on abort either
// way. The built-in transport closes the request's connection, which is what makes the daemon
// mark the request canceled.
const response = await cancellation.guard(
async () =>
await transport(request, { authToken: merged.daemonAuthToken, signal: merged.signal }),
);
if (!response.ok) {
throwDaemonError(response.error);
}
Expand Down
Loading
Loading