Skip to content

Commit 41e89ed

Browse files
thymikeeclaude
andcommitted
fix(daemon-client): disclose dispatch on every transport failure
Only the client-side timeout disclosed whether the request can have reached the device. A connection lost after the send, a reply that could not be read, or an invalid reply carried no verdict, so a daemon killed mid-tap printed "Next: Retry command." with nothing on the line to say the tap may have landed. One registry helper, resolveCommandDispatchDisclosure, now owns the verdict: "no" for a read, "unknown" for a mutation or a delegating composite (batch, replay, test), none for any other command. The daemon's request seam uses it, and the client applies it once, in sendRequest's catch, to every transport failure after the send, and to an artifact download that fails after the daemon answered; a failure before the send is "no". Socket and HTTP failures record daemonRequestSent, read and parse failures carry typed reasons, and the transport-fallback check reads the reason instead of the error message. The timeout module no longer keeps its own copy of the policy, so a timed-out `devices` no longer says to run snapshot. Eight dispatch-disclosure fixture rows drive the real transports against loopback stand-in daemons; ADR 0011 records the client side. The wire ledger acks the three client-only error-shape changes. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent b30373d commit 41e89ed

14 files changed

Lines changed: 417 additions & 83 deletions

‎contracts/fixtures/dispatch-disclosure.json‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,5 +588,53 @@
588588
"producer": "web-agent-browser",
589589
"trigger": "a coordinate fill whose click returned and whose in-page selection found no editable field the click reached: the fill is refused before any text is sent, and the failure reports unknown with details.dispatchedSteps 4",
590590
"dispatched": "unknown"
591+
},
592+
{
593+
"id": "daemon-client.socket-closed-after-send",
594+
"producer": "daemon-client",
595+
"trigger": "the daemon closed the socket after reading a press request and before replying; the request was sent, so the client discloses the press's declared mutates-app effect",
596+
"dispatched": "unknown"
597+
},
598+
{
599+
"id": "daemon-client.http-reset-after-send",
600+
"producer": "daemon-client",
601+
"trigger": "the daemon reset the HTTP connection after reading a press request and before replying",
602+
"dispatched": "unknown"
603+
},
604+
{
605+
"id": "daemon-client.invalid-response-after-send",
606+
"producer": "daemon-client",
607+
"trigger": "the daemon answered a press request over the socket with a line that is not a JSON-RPC response",
608+
"dispatched": "unknown"
609+
},
610+
{
611+
"id": "daemon-client.timeout-after-send",
612+
"producer": "daemon-client",
613+
"trigger": "the client's request window expired after it sent a press request to a remote daemon that never answered",
614+
"dispatched": "unknown"
615+
},
616+
{
617+
"id": "daemon-client.read-failure-after-send",
618+
"producer": "daemon-client",
619+
"trigger": "the daemon closed the socket after reading a snapshot request; snapshot declares observes-app, so nothing app-visible ran",
620+
"dispatched": "no"
621+
},
622+
{
623+
"id": "daemon-client.refused-before-send",
624+
"producer": "daemon-client",
625+
"trigger": "the socket connection was refused before a press request could be written",
626+
"dispatched": "no"
627+
},
628+
{
629+
"id": "daemon-client.delegated-composite-after-send",
630+
"producer": "daemon-client",
631+
"trigger": "the daemon closed the socket after reading a batch request; batch declares a delegated ref-frame effect, so its nested requests may have acted",
632+
"dispatched": "unknown"
633+
},
634+
{
635+
"id": "daemon-client.artifact-fetch-after-answer",
636+
"producer": "daemon-client",
637+
"trigger": "a remote daemon answered a press request with an artifact, then the artifact download failed; the daemon ran the request",
638+
"dispatched": "unknown"
591639
}
592640
]

‎docs/adr/0011-interaction-guarantee-contract.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,12 @@ Before the ledger holds a sent mutation, the registry's `recordingEffect` decide
205205
- For `'observes-app'`, the router sets `no` over any producer value.
206206
- A command with no declared effect passes through.
207207

208+
`resolveCommandDispatchDisclosure` in the command registry owns this verdict.
209+
It also gives a delegating composite (`batch`, `replay`, `test`, declared `refFrameEffect: 'delegated'`) `unknown`, because its nested requests act.
210+
The client applies the same verdict to its own transport failures, because the router never sees them.
211+
A failure after the request left the client gets the verdict: a timeout, a connection lost after the send, or a reply it cannot read or parse.
212+
A failure before the send is `no`.
213+
208214
For a read, `no` means that a resend repeats no app-visible action.
209215
The registry declares `record`, `trace`, and `perf` as `'observes-app'`, although their recorder and profiler controls reach the device.
210216
There are two reasons:

‎packages/command-registry/src/registry.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// The typed-flags request from contracts/, not the daemon's server-side refinement: these
22
// descriptors read `command`, `positionals` and `flags` and never touch `internal`.
33
import type { CommandFlags, DispatchedCommand } from '@agent-device/contracts/command';
4+
import type { DispatchDisclosure } from '@agent-device/kernel/errors';
45
import type { RuntimeUseStepSelector } from '@agent-device/contracts/command-platform-execution';
56
import { checkFindArgs, isReadOnlyFindAction } from '@agent-device/selectors';
67
import {
@@ -840,6 +841,22 @@ export function resolveCommandRecordingEffect(req: DispatchedCommand): Recording
840841
: descriptor.recordingEffect;
841842
}
842843

844+
/**
845+
* Whether a failed request for this command can have reached the device: a read
846+
* (`observes-app`) cannot have changed the app, and a mutation or a delegating composite (`batch`,
847+
* `replay`, `test`, whose nested requests act) may have. Any other command gets no verdict. The
848+
* daemon's request seam and the client's transport failures both read this, so they cannot disagree.
849+
*/
850+
export function resolveCommandDispatchDisclosure(
851+
req: DispatchedCommand,
852+
): DispatchDisclosure | undefined {
853+
const effect = resolveCommandRecordingEffect(req);
854+
if (effect !== undefined) return effect === 'observes-app' ? 'no' : 'unknown';
855+
const refFrame = COMMAND_DESCRIPTOR_BY_NAME.get(req.command)?.daemon?.refFrameEffect;
856+
const resolved = typeof refFrame === 'function' ? refFrame(req) : refFrame;
857+
return resolved === 'delegated' ? 'unknown' : undefined;
858+
}
859+
843860
/**
844861
* @internal The commands that declare a {@link CommandResponseDataTransform}, with it.
845862
*
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
// Every transport failure discloses whether the request can have reached the device. Each row
2+
// drives the real transport against a loopback stand-in daemon that fails at one point of the
3+
// exchange; the verdict comes from the command's declared effect once the request was sent.
4+
5+
import type { DaemonWireRequest } from '@agent-device/contracts/command';
6+
import fs from 'node:fs';
7+
import http from 'node:http';
8+
import net from 'node:net';
9+
import path from 'node:path';
10+
import assert from 'node:assert/strict';
11+
import { test } from 'vitest';
12+
import { AppError } from '@agent-device/kernel/errors';
13+
import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog';
14+
import type { DaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution';
15+
import {
16+
assertDispatchDisclosureDriversMatchRows,
17+
closeLoopbackServer,
18+
createTestHostEnvironment,
19+
DISPATCH_DISCLOSURE_TABLE_PATH,
20+
dispatchDisclosureRowsOwnedBy,
21+
listenOnLoopback,
22+
type LoopbackServer,
23+
mkdtempForTestSync,
24+
skipWhenLoopbackUnavailable,
25+
} from '@agent-device/test-kit';
26+
import { sendRequest } from '../daemon-client-transport.ts';
27+
import type { DaemonInfo } from '../daemon-client-metadata.ts';
28+
29+
const IDENTITY = { token: 'test-token', pid: process.pid, processStartTime: 'stand-in' };
30+
31+
function statePaths(): DaemonPaths {
32+
const baseDir = mkdtempForTestSync('agent-device-dispatch-disclosure-test');
33+
return {
34+
baseDir,
35+
infoPath: path.join(baseDir, 'daemon.json'),
36+
lockPath: path.join(baseDir, 'daemon.lock'),
37+
logPath: path.join(baseDir, 'daemon.log'),
38+
allocationsDir: path.join(baseDir, 'allocations'),
39+
} as DaemonPaths;
40+
}
41+
42+
function request(command: string): DaemonWireRequest {
43+
return {
44+
token: IDENTITY.token,
45+
session: 'default',
46+
command,
47+
positionals: [],
48+
flags: {},
49+
meta: { requestId: `dispatch-${command}` },
50+
};
51+
}
52+
53+
/** A socket daemon that reads one request line and then does `onRequest` with its socket. */
54+
async function socketDaemon(onRequest: (socket: net.Socket) => void): Promise<LoopbackServer> {
55+
const server = net.createServer((socket) => {
56+
socket.on('error', () => {});
57+
socket.once('data', () => onRequest(socket));
58+
});
59+
await listenOnLoopback(server);
60+
return server;
61+
}
62+
63+
function portOf(server: LoopbackServer): number {
64+
const address = server.address();
65+
assert.ok(address && typeof address === 'object');
66+
return address.port;
67+
}
68+
69+
async function overSocket(
70+
command: string,
71+
onRequest: (socket: net.Socket) => void,
72+
): Promise<unknown> {
73+
const server = await socketDaemon(onRequest);
74+
try {
75+
return await sendRequest(
76+
{ ...IDENTITY, port: portOf(server) },
77+
request(command),
78+
'socket',
79+
statePaths(),
80+
undefined,
81+
{ host: createTestHostEnvironment() },
82+
);
83+
} finally {
84+
await closeLoopbackServer(server);
85+
}
86+
}
87+
88+
async function overHttp(info: (port: number) => DaemonInfo, timeoutMs?: number): Promise<unknown> {
89+
const server: LoopbackServer = http.createServer((req) => {
90+
req.on('error', () => {});
91+
// Reads the whole request, then (without a timeout) drops the connection before replying.
92+
req.on('end', () => {
93+
if (timeoutMs === undefined) req.socket.destroy();
94+
});
95+
req.resume();
96+
});
97+
const port = await listenOnLoopback(server);
98+
try {
99+
return await sendRequest(
100+
info(port),
101+
request(PUBLIC_COMMANDS.press),
102+
'http',
103+
statePaths(),
104+
timeoutMs,
105+
{ host: createTestHostEnvironment() },
106+
);
107+
} finally {
108+
await closeLoopbackServer(server);
109+
}
110+
}
111+
112+
/** A remote daemon that answers the request with one artifact and then refuses its download. */
113+
async function artifactFetchAfterAnswer(): Promise<unknown> {
114+
const server: LoopbackServer = http.createServer((req, res) => {
115+
req.resume();
116+
req.on('end', () => {
117+
if (req.method !== 'POST') {
118+
res.statusCode = 404;
119+
res.end();
120+
return;
121+
}
122+
res.setHeader('content-type', 'application/json');
123+
res.end(
124+
JSON.stringify({
125+
jsonrpc: '2.0',
126+
id: 'dispatch-press',
127+
result: {
128+
ok: true,
129+
data: {
130+
artifacts: [{ artifactId: 'gone', field: 'path', fileName: 'screen.png' }],
131+
},
132+
},
133+
}),
134+
);
135+
});
136+
});
137+
const port = await listenOnLoopback(server);
138+
try {
139+
return await sendRequest(
140+
{ ...IDENTITY, baseUrl: `http://127.0.0.1:${port}` },
141+
request(PUBLIC_COMMANDS.press),
142+
'http',
143+
statePaths(),
144+
undefined,
145+
{ host: createTestHostEnvironment() },
146+
);
147+
} finally {
148+
await closeLoopbackServer(server);
149+
}
150+
}
151+
152+
async function refusedBeforeSend(): Promise<unknown> {
153+
const server = net.createServer();
154+
const port = await listenOnLoopback(server);
155+
await closeLoopbackServer(server);
156+
return await sendRequest(
157+
{ ...IDENTITY, port },
158+
request(PUBLIC_COMMANDS.press),
159+
'socket',
160+
statePaths(),
161+
undefined,
162+
{ host: createTestHostEnvironment() },
163+
);
164+
}
165+
166+
const DRIVERS: Record<string, () => Promise<unknown>> = {
167+
'daemon-client.socket-closed-after-send': () =>
168+
overSocket(PUBLIC_COMMANDS.press, (socket) => socket.destroy()),
169+
'daemon-client.http-reset-after-send': () =>
170+
overHttp((port) => ({ ...IDENTITY, httpPort: port })),
171+
'daemon-client.invalid-response-after-send': () =>
172+
overSocket(PUBLIC_COMMANDS.press, (socket) => socket.end('not a json-rpc response\n')),
173+
'daemon-client.timeout-after-send': () =>
174+
overHttp((port) => ({ ...IDENTITY, baseUrl: `http://127.0.0.1:${port}` }), 60),
175+
'daemon-client.read-failure-after-send': () =>
176+
overSocket(PUBLIC_COMMANDS.snapshot, (socket) => socket.destroy()),
177+
'daemon-client.refused-before-send': refusedBeforeSend,
178+
'daemon-client.delegated-composite-after-send': () =>
179+
overSocket(PUBLIC_COMMANDS.batch, (socket) => socket.destroy()),
180+
'daemon-client.artifact-fetch-after-answer': artifactFetchAfterAnswer,
181+
};
182+
183+
const ROWS = dispatchDisclosureRowsOwnedBy(
184+
import.meta.url,
185+
fs.readFileSync(DISPATCH_DISCLOSURE_TABLE_PATH, 'utf8'),
186+
);
187+
188+
test('every daemon-client dispatch-disclosure row has exactly one driver', () => {
189+
assertDispatchDisclosureDriversMatchRows(ROWS, Object.keys(DRIVERS));
190+
});
191+
192+
for (const row of ROWS) {
193+
test(`${row.id}: ${row.trigger} → dispatched ${row.dispatched}`, async (t) => {
194+
if (await skipWhenLoopbackUnavailable(t)) return;
195+
const drive = DRIVERS[row.id];
196+
assert.ok(drive, `no driver for ${row.id}`);
197+
await assert.rejects(drive(), (error: unknown) => {
198+
assert.ok(error instanceof AppError, `unexpected failure: ${String(error)}`);
199+
assert.equal(error.details?.dispatched, row.dispatched);
200+
return true;
201+
});
202+
});
203+
}
204+
205+
// The registry gives a command without a declared effect no verdict, at the daemon and here alike,
206+
// so the CLI prints no dispatch fact instead of a snapshot step for a request that has no screen.
207+
test('a sent request for a command without a declared effect gets no dispatch verdict', async (t) => {
208+
if (await skipWhenLoopbackUnavailable(t)) return;
209+
await assert.rejects(
210+
overSocket(PUBLIC_COMMANDS.devices, (socket) => socket.destroy()),
211+
(error: unknown) => {
212+
assert.ok(error instanceof AppError);
213+
// A socket closed without a reply surfaces as either post-send reason, depending on timing.
214+
assert.ok(
215+
['daemon_transport_failure', 'daemon_response_invalid'].includes(
216+
String(error.details?.reason),
217+
),
218+
);
219+
assert.equal('dispatched' in (error.details ?? {}), false);
220+
return true;
221+
},
222+
);
223+
});

‎packages/daemon-client/src/__tests__/daemon-client-lifecycle.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -626,7 +626,7 @@ test('sendToDaemon does not replay over HTTP after the socket request is written
626626

627627
assert.ok(thrown instanceof AppError);
628628
assert.equal(thrown.message, 'Failed to communicate with daemon');
629-
assert.equal(thrown.details?.daemonSocketRequestWritten, true);
629+
assert.equal(thrown.details?.daemonRequestSent, true);
630630
assert.deepEqual(socket.ports, [65_531, 65_531]);
631631
assert.equal(socket.writes.length, 1);
632632
assert.deepEqual(daemon.seenPaths, []);

‎packages/daemon-client/src/__tests__/daemon-client-timeout-route.test.ts‎

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -250,11 +250,6 @@ type RouteRow = Readonly<{
250250
connections: 1 | 2;
251251
/** Whether this row's verdict is the reset branch, which clears the daemon's metadata. */
252252
resets: boolean;
253-
/**
254-
* The request was already sent, so a command declared to mutate the app may have run on the
255-
* device; only a declared read proves it did not. The CLI prints this as the dispatch fact.
256-
*/
257-
dispatched: 'no' | 'unknown';
258253
}>;
259254

260255
// The `ios` declarations are deliberate: eligibility never keyed off the declared platform, and
@@ -269,7 +264,6 @@ const ROUTE_ROWS: readonly RouteRow[] = [
269264
afterFirst: 'answer',
270265
hintPattern: /The timed-out open request was canceled; the daemon was kept alive/,
271266
connections: 2,
272-
dispatched: 'unknown',
273267
resets: false,
274268
},
275269
{
@@ -280,7 +274,6 @@ const ROUTE_ROWS: readonly RouteRow[] = [
280274
afterFirst: 'answer',
281275
hintPattern: /the daemon was kept alive so the session can still be closed or inspected/,
282276
connections: 2,
283-
dispatched: 'unknown',
284277
resets: false,
285278
},
286279
{
@@ -291,7 +284,6 @@ const ROUTE_ROWS: readonly RouteRow[] = [
291284
afterFirst: 'refuse',
292285
hintPattern: /The daemon did not answer the liveness probe and was reset after the timeout/,
293286
connections: 2,
294-
dispatched: 'unknown',
295287
resets: true,
296288
},
297289
{
@@ -305,7 +297,6 @@ const ROUTE_ROWS: readonly RouteRow[] = [
305297
hintPattern:
306298
/The timed-out snapshot request was canceled; the daemon was kept alive.*prepare ios-runner/s,
307299
connections: 1,
308-
dispatched: 'no',
309300
resets: false,
310301
},
311302
{
@@ -322,7 +313,6 @@ const ROUTE_ROWS: readonly RouteRow[] = [
322313
hintPattern:
323314
/^The daemon may still be exporting the recording\. Run agent-device record stop --session default again/,
324315
connections: 1,
325-
dispatched: 'no',
326316
resets: false,
327317
},
328318
{
@@ -334,7 +324,6 @@ const ROUTE_ROWS: readonly RouteRow[] = [
334324
afterFirst: 'answer',
335325
hintPattern: /Verify the remote daemon URL, auth token, and remote host logs/,
336326
connections: 1,
337-
dispatched: 'unknown',
338327
resets: false,
339328
},
340329
];
@@ -379,7 +368,6 @@ for (const row of ROUTE_ROWS) {
379368
),
380369
row.hintPattern,
381370
);
382-
assert.equal(error.details?.dispatched, row.dispatched);
383371
// The hint is printed as the Next step; a failure that already wrote its log never asks for
384372
// a --debug rerun.
385373
assert.equal(/--debug/.test(String(error.details?.hint)), false);

0 commit comments

Comments
 (0)