Skip to content

Commit 7b90408

Browse files
committed
Merge remote-tracking branch 'origin/refactor/host-environment' into refactor/daemon-runtime-no-process-globals
* origin/refactor/host-environment: refactor(daemon): read configuration from the request host instead of the ambient scope test: merge the duplicate test-kit import in the stale provider admission scenarios feat(cli): ensure the daemon explicitly before attaching in the CLI and MCP (#3766) feat(client): daemonLifecycle attach mode and ensureDaemon() (#3765) feat(daemon): quiesce request that retires an idle daemon atomically (#3764) fix(daemon): answer a named missing session on the session, not the device (#3701) test: route test/ and scripts/ off the daemon's testing exports (#3733) refactor(plugins): make the plugin framework provider-independent (#3722) fix(apple): name an Xcode too old for its host macOS as a runner startup failure (#3739) # Conflicts: # packages/daemon/src/server/http/daemon-runtime.ts
2 parents 1a86a19 + 7eb7379 commit 7b90408

313 files changed

Lines changed: 10478 additions & 2602 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,7 @@ jobs:
366366
with: { gate: integration-node }
367367

368368
- name: Run seeded concurrency torture lane (fast PR sweep)
369-
# #1416's nightly torture lane lives under test/integration/nightly/, out
369+
# #1416's nightly torture lane lives under packages/daemon/test/nightly/, out
370370
# of the test:integration:node glob, so this is a *deliberate* fast PR
371371
# sweep (TORTURE_RUNS default 128 seeds, ~sub-second) — not an accidental
372372
# glob inclusion. The Concurrency Torture Nightly workflow sweeps a much

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- Behavior change: the CLI and MCP now call `ensureDaemon()` explicitly once their config is resolved, then attach to the daemon it leaves. A command that finds a daemon from another version or build replaces it only if the daemon reports no request, session, repair session or live lease. A resumable upload still streaming and a host human-control hold that names no lease are not counted, so a replacement can end them. A daemon that answers nothing on any transport is retired after repeated failed liveness probes, which cannot show it idle. A busy one fails the command with `details.reason: 'daemon-busy'` instead of being replaced with its sessions, and a daemon from a release that predates quiescing fails it with `'daemon-incompatible'`; stop either with `agent-device daemon stop --state-dir <path>`. The takeover notice moves from `Replacing daemon … : <reason>` to `Replaced idle daemon …` (an incompatible daemon) or `Replaced unreachable daemon …` (one that answered nothing). A request that times out no longer retires an unresponsive daemon from the CLI or MCP; the error names `agent-device daemon stop --state-dir <path>`. A one-shot `replay` or `test` with an explicit `--state-dir` leaves the daemon it started running until its idle reap instead of stopping it. Remote `--daemon-base-url` connections and replay on its private ephemeral daemon are unchanged. Node.js clients keep `daemonLifecycle: 'managed'` until 0.22.
6+
- Add `ensureDaemon({ stateDir })` and the client config `daemonLifecycle: 'attach' | 'managed'`. An `attach` client connects to the daemon registered in the state directory and never starts, replaces or kills one, including after a request times out. It fails with `details.reason: 'daemon-not-running'` (the hint points at `ensureDaemon()`) or `'daemon-incompatible'` (both identities in `details`, the hint names `agent-device daemon stop`). `ensureDaemon()` starts a missing daemon and replaces an incompatible one that answers only through an authenticated quiesce request the daemon answers itself (a resumable upload still streaming and a human-control hold that names no lease are not counted, so a replacement can end them): it refuses with `daemon-busy` while any request is in flight or any session, repair session or live lease exists, and a daemon from a release before this one is never replaced. A registered daemon that answers nothing is retired, as is one that times out on the quiesce request and then fails repeated liveness probes; neither can be asked. A remote `daemonBaseUrl` connection never starts or retires a local daemon in either mode. `managed` stays the default and behaves as before until 0.22.
57
- Add `uninstall <app>` and `client.apps.uninstall({ app })` to remove an installed app on adb-backed Android targets, iOS simulators, and CoreDevice iOS devices. Both report `wasInstalled`. A package name or bundle id that is not installed succeeds with `wasInstalled: false`. Other targets, Appium and Limrun sessions included, refuse it before anything runs.
68
- Add `settings mobile-data on|off` for adb-backed Android targets. It switches mobile data through `svc data` and reports the `mobileData` state stored in the global `mobile_data` row; multi-SIM read-back is not covered yet. Every other target refuses it before anything runs, and `--app` is refused because the switch is device-wide.
79
- On iOS, a `fill` that runs under a degraded XCTest snapshot channel now aims at a point, including ref, selector and replay fills. When the runner identifies no text input under that point, it refuses with `NO_TEXT_INPUT_AT_POINT` (`error.details.reason: no_text_input_at_point`, `dispatched: no`) before dispatching its focus tap, and no field changes; when the input lookup itself cannot answer, it refuses with `TEXT_INPUT_PROBE_UNAVAILABLE` (`error.details.reason: text_input_probe_unavailable`). Previously such a fill cleared and typed through the app-level first responder, so it could rewrite a field the caller never named, or report a clear failure after emptying it. A ref or selector fill whose rect centre misses the real field (an outer container, a non-input node) now needs a point inside the input's own rect.

‎contracts/fixtures/dispatch-disclosure.json‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,12 @@
107107
"trigger": "a device-bound command with no active session and no device selector: requireSessionOrExplicitSelector refuses before any target resolves, carrying details.reason session_or_device_selector_required",
108108
"dispatched": "no"
109109
},
110+
{
111+
"id": "daemon.route.named-session-refusal",
112+
"producer": "daemon",
113+
"trigger": "a command naming a session this daemon does not hold and no device selector: requireNamedSessionHoldsDevice refuses with SESSION_NOT_FOUND and details.reason named_session_not_found before any device is listed or resolved",
114+
"dispatched": "no"
115+
},
110116
{
111117
"id": "daemon.route.find-type-refused-after-focus",
112118
"producer": "daemon",

‎docs/adr/0006-daemon-rpc-protocol-version.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,29 @@ the gap and its reason instead of implying coverage. See `test/wire-compat/READM
8484
Legacy remote daemons without `rpcProtocolVersion` remain reachable. This keeps the first release of
8585
the proxy compatible with older HTTP daemons, but it means absence of the marker is not proof of
8686
compatibility.
87+
88+
## Daemon capabilities and quiesce
89+
90+
A daemon on the local state directory publishes `capabilities` in `daemon.json`. This is the
91+
command-level metadata the decision above prefers to a protocol bump. A daemon that predates a
92+
capability omits it, so a client treats absence as "cannot do this" and never infers it from the
93+
package version.
94+
95+
`quiesce` is how a client replaces an incompatible daemon without destroying other clients' work.
96+
The client sends `daemon_quiesce` with the daemon token and the pid and start time recorded in
97+
`daemon.json`. The daemon checks the token and that it is that instance, refuses a request that
98+
reached it as a remote-origin one, then closes admission and counts what it holds in a single
99+
synchronous step: requests in flight, sessions, repair sessions and live leases, and whether the
100+
daemon is still starting up (`startingUp`), because before that it has no drain path. Any of them
101+
refuses with `daemon-busy` and reopens admission in that same step. Otherwise it answers
102+
`draining`, keeps admission closed, and shuts down. The request is answered before dispatch, so
103+
it joins neither the in-flight count nor the command registry.
104+
105+
The daemon accepts a quiesce only when it can prove its own start time and the request names both
106+
the pid and that start time; a daemon that cannot is never addressed. A client never signals a
107+
daemon that did not accept one, except to retire it after it answered `draining`.
108+
109+
The census counts what the contract names and nothing else. A resumable upload still streaming and
110+
a host-administration human-control hold that names no lease are outside it, the same boundary
111+
idle reap draws, so a quiesce can end them. A daemon started with an HTTP auth hook marks every
112+
HTTP request remote-origin and so refuses a quiesce over HTTP; the socket transport is unaffected.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@
208208
"test:integration:node": "node --experimental-strip-types scripts/node-test-tmpdir.ts --test --experimental-test-isolation=none test/integration/*.test.ts",
209209
"test:integration:macos-coverage": "node --experimental-strip-types scripts/node-test-tmpdir.ts --test test/integration/smoke-macos-coverage.test.ts",
210210
"test:integration": "pnpm test:integration:node && pnpm test:integration:provider",
211-
"test:concurrency-torture": "node --experimental-strip-types scripts/node-test-tmpdir.ts --test test/integration/nightly/concurrency-torture.test.ts",
211+
"test:concurrency-torture": "node --experimental-strip-types scripts/node-test-tmpdir.ts --test packages/daemon/test/nightly/concurrency-torture.test.ts",
212212
"test:replay:ios": "node --experimental-strip-types src/bin.ts test test/integration/replays/ios/simulator",
213213
"test:replay:ios-device": "node --experimental-strip-types src/bin.ts test test/integration/replays/ios/device",
214214
"test:replay:android": "node --experimental-strip-types src/bin.ts test test/integration/replays/android/emulator",

‎packages/capture-kit/src/capture-admission/screen-recording-stop-recovery.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ import type {
88
ScreenRecordingCompletion,
99
} from '@agent-device/contracts/screen-recording-runtime';
1010
import type { DurableResourceEnvelope } from '@agent-device/contracts/durable-resource-envelope';
11-
import { deviceIdentity, sameDeviceIdentity, type DeviceInfo } from '@agent-device/kernel/device';
11+
import {
12+
deviceIdentity,
13+
sameDeviceIdentity,
14+
type DeviceIdentity,
15+
type DeviceInfo,
16+
} from '@agent-device/kernel/device';
1217
import { AppError } from '@agent-device/kernel/errors';
1318
import { isRecord } from '@agent-device/kernel/record';
1419
import { emitDiagnostic } from '@agent-device/host-kit/diagnostics';
@@ -70,6 +75,21 @@ export function screenRecordingManifestIsTerminal(params: ScreenRecordingManifes
7075
return record.status === 'decoded' && record.envelope.lifecycle === 'completed';
7176
}
7277

78+
/**
79+
* The device a stop without a held session must be answered on: the one the session's durable
80+
* manifest names, when a decodable manifest exists. `undefined` means there is nothing to recover
81+
* from, and a caller that addressed a session answers on the session instead of choosing a device.
82+
* The caller still resolves this identity against the host to recover the live device fields, and
83+
* `assertManifestBelongsToRequest` still fails the request closed when the resolved device or the
84+
* session address does not match what the manifest recorded.
85+
*/
86+
export function screenRecordingManifestDevice(
87+
params: ScreenRecordingManifestParams,
88+
): DeviceIdentity | undefined {
89+
const { record } = readSessionManifest(params);
90+
return record.status === 'decoded' ? record.envelope.device : undefined;
91+
}
92+
7393
function readSessionManifest(params: ScreenRecordingManifestParams): Readonly<{
7494
resourcePath: string;
7595
record: DurableCaptureResourceRecord<'screen-recording'>;

‎packages/command-registry/src/__tests__/batch.test.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,47 @@ function batchRequest(commands: string[], responseLevel?: ResponseLevel): BatchR
5858
};
5959
}
6060

61+
// #3674: the daemon refuses a request that names a session it does not hold. A batch step is
62+
// addressed through `session:`, so only a caller who named a session may see one in `flags`:
63+
// stamping every step would hand that refusal to sessionless batches, whose steps are meant to
64+
// resolve their device from the session or the selectors.
65+
test('sessionless batch steps carry no session flag, so they stay sessionless at the daemon', async () => {
66+
const seen: Array<Record<string, unknown> | undefined> = [];
67+
const request: BatchRequest = {
68+
token: 't',
69+
command: 'batch',
70+
positionals: [],
71+
flags: { batchSteps: [{ command: 'snapshot' }] },
72+
};
73+
const response = await runBatch(request, 'cwd:1a2b3c4d5e6f7a8b:default', async (req) => {
74+
seen.push(req.flags as Record<string, unknown> | undefined);
75+
return { ok: true, data: {} };
76+
});
77+
78+
assert.equal(response.ok, true);
79+
assert.equal(seen[0]?.session, undefined);
80+
});
81+
82+
test('a batch whose caller named a session stamps that address onto every step', async () => {
83+
const seen: Array<Record<string, unknown> | undefined> = [];
84+
const request: BatchRequest = {
85+
token: 't',
86+
command: 'batch',
87+
positionals: [],
88+
session: 'e2e-ios-0',
89+
meta: { sessionExplicit: true },
90+
flags: { batchSteps: [{ command: 'snapshot' }, { command: 'get' }] },
91+
};
92+
const response = await runBatch(request, 'e2e-ios-0', async (req) => {
93+
seen.push(req.flags as Record<string, unknown> | undefined);
94+
return { ok: true, data: {} };
95+
});
96+
97+
assert.equal(response.ok, true);
98+
assert.equal(seen[0]?.session, 'e2e-ios-0');
99+
assert.equal(seen[1]?.session, 'e2e-ios-0');
100+
});
101+
61102
// #2997: `testIme` joined INHERITED_PARENT_FLAG_KEYS for the flow command's session
62103
// opens; batch shares that list, so its steps inherit the parent's opt-in the same way.
63104
// Pinning it here keeps the shared-list addition deliberate for both consumers.

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
type DaemonResponse,
77
type ResponseLevel,
88
isNonDefaultResponseLevel,
9+
namesSessionExplicitly,
910
} from '@agent-device/kernel/contracts';
1011
import { AppError, asAppError, type DaemonError } from '@agent-device/kernel/errors';
1112
import { isRecord } from '@agent-device/kernel/record';
@@ -320,7 +321,11 @@ async function runBatchStep(
320321
const isFinalStep = stepNumber === totalSteps;
321322
const stepStartedAt = Date.now();
322323
const stepFlags = buildBatchStepFlags(req.flags, step.flags);
323-
if (stepFlags.session === undefined) {
324+
// The step is already addressed through `session:` below. Stamp the flag only when the caller
325+
// genuinely named a session: an unconditional stamp would make a sessionless batch look
326+
// caller-addressed to the daemon's named-session refusal, which no caller of this surface asked
327+
// for (#3674). A step's own recorded flag always wins, explicit parent or not.
328+
if (stepFlags.session === undefined && namesSessionExplicitly(req)) {
324329
stepFlags.session = sessionName;
325330
}
326331
const response = await invoke(

‎packages/contracts/src/client-connection.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import type {
44
DaemonLockPolicy,
5+
DaemonServerMode,
6+
DaemonTransportPreference,
57
DaemonRequest,
68
DaemonResponse,
79
LeaseBackend,
@@ -15,8 +17,58 @@ import type {
1517
RemoteConnectionProfileFields,
1618
} from './remote-config-fields.ts';
1719

20+
/**
21+
* Who owns the daemon on a client's state directory.
22+
*
23+
* - `managed`: the client starts a missing daemon and replaces an incompatible one itself, and may
24+
* retire a hung one after a timeout. This is the default until 0.22.
25+
* - `attach`: the client only connects to the daemon registered in the state directory. It never
26+
* starts, replaces or kills one. A missing daemon fails with `details.reason: 'daemon-not-running'`
27+
* and an incompatible one with `'daemon-incompatible'`; `ensureDaemon()` is the explicit way to
28+
* start or replace one.
29+
*
30+
* Independent of `daemonTransport`, `daemonServerMode` and `daemonBaseUrl`: a remote connection
31+
* never starts or retires a local daemon in either mode.
32+
*/
33+
export type DaemonLifecycle = 'attach' | 'managed';
34+
35+
/** What a client can learn about a daemon without talking to it: how it identifies itself on disk. */
36+
export type DaemonIdentity = {
37+
pid: number;
38+
version?: string;
39+
/** `installed` for a published package, `checkout` for a source tree. */
40+
codeOrigin?: 'installed' | 'checkout';
41+
codeSignature?: string;
42+
processStartTime?: string;
43+
};
44+
45+
export type EnsureDaemonOptions = {
46+
/** The state directory the daemon owns; defaults to `AGENT_DEVICE_STATE_DIR`, then the user default. */
47+
stateDir?: string;
48+
daemonTransport?: DaemonTransportPreference;
49+
daemonServerMode?: DaemonServerMode;
50+
/**
51+
* Called when this call started a daemon, before `ensureDaemon` returns. `missing` means no
52+
* usable daemon was registered; `incompatible` means a daemon this client cannot use was
53+
* replaced after it agreed to leave. `previous` is the daemon that was replaced, when there was
54+
* one: a `missing` start with a `previous` replaced a registered daemon that answered nothing,
55+
* either unreachable or hung and force-retired.
56+
*/
57+
onStart?: (reason: 'missing' | 'incompatible', previous?: DaemonIdentity) => void;
58+
};
59+
60+
/** A reachable local daemon. It carries no credentials. */
61+
export type DaemonConnection = {
62+
stateDir: string;
63+
daemon: DaemonIdentity;
64+
/** True when this call started the daemon. */
65+
started: boolean;
66+
};
67+
1868
export type AgentDeviceDaemonTransportContext = {
1969
authToken?: string;
70+
/** How this request treats the local daemon; `managed` when omitted. */
71+
daemonLifecycle?: DaemonLifecycle;
2072
/**
2173
* Cancels this one in-flight request. A built-in transport that sees an abort destroys the
2274
* request's connection, which makes the daemon mark the request canceled; the promise rejects
@@ -33,6 +85,8 @@ export type AgentDeviceDaemonTransport = (
3385

3486
export type AgentDeviceClientConfig = RemoteConnectionProfileFields &
3587
CloudProviderProfileFields & {
88+
/** See `DaemonLifecycle`. */
89+
daemonLifecycle?: DaemonLifecycle;
3690
session?: string;
3791
lockPolicy?: DaemonLockPolicy;
3892
lockPlatform?: PlatformSelector;

‎packages/contracts/src/facades/client.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,11 @@ export type {
3434
AgentDeviceIdentifiers,
3535
AgentDeviceRequestOverrides,
3636
AgentDeviceSelectionOptions,
37+
DaemonConnection,
38+
DaemonIdentity,
39+
DaemonLifecycle,
3740
DeviceCommandBaseOptions,
41+
EnsureDaemonOptions,
3842
} from '../client-connection.ts';
3943
export type {
4044
AgentDeviceCapabilitiesResult,

0 commit comments

Comments
 (0)