Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
77dde28
feat(host): capture HostEnvironment once at entry points
thymikee Oct 16, 2026
f4d4970
fix(host): resolve relative state dirs against the captured working d…
thymikee Oct 16, 2026
2ebb886
fix(host): give platform code the command host instead of process reads
thymikee Oct 16, 2026
5effcd7
fix(host): answer review of the host environment foundation
thymikee Oct 16, 2026
ae204b4
fix(host): keep the test host setup out of mutation scope and finish …
thymikee Oct 16, 2026
fb9e4e6
fix(host): bind the test command host per suite, not globally
thymikee Oct 16, 2026
30bbef5
chore(deps): declare test-kit in the HarmonyOS and Linux platform pac…
thymikee Oct 16, 2026
d522d25
fix(host): bind the test command host in the stale-provider admission…
thymikee Oct 16, 2026
756cf4e
fix(host): resolve every client read from the client's host
thymikee Oct 17, 2026
63b984b
fix(host): resolve the daemon policy path against the host working di…
thymikee Oct 17, 2026
f4616b9
style: format the daemon policy reuse test
thymikee Oct 17, 2026
2a6416d
Merge remote-tracking branch 'origin/main' into refactor/host-environ…
thymikee Oct 17, 2026
7c68072
test: merge the duplicate test-kit import in the handler chain routin…
thymikee Oct 17, 2026
3c622c3
fix(host): start a launched daemon in the launching host's working di…
thymikee Oct 17, 2026
ab5ee6e
test: keep the registration-owner test under its size ratchet
thymikee Oct 17, 2026
d1ab624
Merge remote-tracking branch 'origin/main' into refactor/host-environ…
thymikee Oct 17, 2026
665af8a
test: merge the duplicate test-kit import in the stale provider admis…
thymikee Oct 17, 2026
7eb7379
refactor(daemon): read configuration from the request host instead of…
thymikee Oct 17, 2026
225e0bd
fix(daemon): drop the unused host from install-from-source and tighte…
thymikee Oct 17, 2026
5816abe
refactor(cli): resolve one transport per run and one host per MCP too…
thymikee Oct 17, 2026
916db0b
Merge remote-tracking branch 'origin/main' into refactor/host-environ…
thymikee Oct 17, 2026
9a4e081
test: pin the one-transport CLI run and one-host MCP call; document t…
thymikee Oct 17, 2026
f744554
test: keep the failure that explains a missed materialization in the …
thymikee Oct 17, 2026
c9aa2f2
Merge remote-tracking branch 'origin/main' into refactor/host-environ…
thymikee Oct 17, 2026
326a9ef
Merge remote-tracking branch 'origin/main' into refactor/host-environ…
thymikee Oct 17, 2026
acc42ba
refactor(daemon): read the host cwd where the deploy handlers use it,…
thymikee Oct 17, 2026
db55a5d
test: give the iOS recording scenario's daemon host the PATH and Swif…
thymikee Oct 17, 2026
ede74e1
test: fail the transport test on an unexpected exit or throw and alwa…
thymikee Oct 17, 2026
e2e3dc7
test: keep the scenario harness's state dir and transport out of reac…
thymikee Oct 17, 2026
ab7aad0
Merge remote-tracking branch 'origin/main' into tmp/hostenv-merge
thymikee Oct 17, 2026
d8a9bd4
fix(daemon): abandon a transient device claim under the same env that…
thymikee Oct 17, 2026
597f793
refactor(host): read host settings from HostEnvironment in root src a…
thymikee Oct 17, 2026
870cf66
Merge remote-tracking branch 'origin/main' into refactor/host-environ…
thymikee Oct 17, 2026
9f3e58f
test: build the scenario gateway on the daemon host's env and pin the…
thymikee Oct 17, 2026
779dcab
test: say which package still reads PATH from the process in the reco…
thymikee Oct 17, 2026
cb17bb3
Merge remote-tracking branch 'origin/main' into refactor/host-environ…
thymikee Oct 17, 2026
9c1e9ec
refactor(platforms): read host settings from HostEnvironment in platf…
thymikee Oct 17, 2026
521ccb5
Merge remote-tracking branch 'origin/main' into refactor/host-environ…
thymikee Oct 17, 2026
fab82cc
test: pass the host to the iOS e2e live runner's request
thymikee Oct 17, 2026
4f6dd3e
refactor(react-native): drop the redundant outer catch around the sco…
thymikee Oct 17, 2026
595fa48
refactor(capture-kit): report an unavailable PNG worker in every scop…
thymikee Oct 18, 2026
d1f3001
refactor(capture-kit): report each PNG worker transition to unavailab…
thymikee Oct 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- The PNG worker no longer calls `process.emitWarning` when it is unavailable, and it starts with an empty environment (`env: {}`). The daemon registers a sink when it pre-warms the worker, so every transition to unavailable (spawn failure, missing entry, load error, exit) leaves exactly one `png_worker_unavailable` event, with a typed `reason` (`entry-not-found`, `spawn-failed`, `worker-error`, `worker-exited`) and its `detail`, in `daemon.log`, with or without `--debug`. Requests then continue on the in-process fallback without repeating the event.
- Behavior change: `resolveHubAppReference`, shipped through `agent-device/plugins/webdriver`, now needs `cwd` to resolve a relative app path. Without it the call fails with `INVALID_ARGS` and `details.reason: provider_app_cwd_missing`; it used to resolve against the process's working directory. Absolute paths, URLs and provider references are unaffected. The daemon fills it from the client's working directory (or its host's) for every provider lease request, so `connect` and `lease_allocate` are unchanged.
- The `daemon-not-running` hint now always names `--state-dir <dir>`, and a daemon force-retired for outlasting its drain is reported as `Replaced unreachable daemon` instead of `Replaced idle daemon`. `daemon-drain-timeout` and the post-drain force retire are documented in the client API and `help`.
- Fix `ensureDaemon()`, the CLI and MCP killing a daemon they had just asked to leave. After the daemon answers the quiesce request with `draining`, the client now waits up to 60 s for it to exit by itself and clean up its registration, so plugin disposal and lease finalization finish (on Windows a signal ends the process outright, and on POSIX a drain longer than 3 s was killed). A daemon still running at the deadline that fails its liveness probes is force-retired; one that still answers is left running and the call fails with `details.reason: 'daemon-drain-timeout'`, with a hint naming `agent-device daemon stop --state-dir <path>`.
- `@agent-device/testmu` is now a version 2 provider plugin. `import testmu from '@agent-device/testmu'` gives a factory, `testmu({ credentials: { username, accessKey } })`, that reads those credentials instead of `LT_USERNAME` and `LT_ACCESS_KEY`; without options it reads the variables as before. The package still declares the version 1 plugin entry, so `agent-device plugins add @agent-device/testmu` keeps working on hosts that predate the version 2 contract.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export type AppLogRotationConfig = Readonly<{
}>;

/** Shared lower-level file policy for daemon markers and runtime output sinks. */
export function ensureAppLogPath(outPath: string, env: NodeJS.ProcessEnv = process.env): void {
export function ensureAppLogPath(outPath: string, env: NodeJS.ProcessEnv): void {
const directory = path.dirname(outPath);
if (!fs.existsSync(directory)) fs.mkdirSync(directory, { recursive: true });
assertAppLogFileIsNotSymbolicLink(outPath);
Expand Down
11 changes: 11 additions & 0 deletions packages/capture-kit/src/host-environment.fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { captureHostEnvironment } from '@agent-device/host-kit/process';
import type { HostEnvironment } from '@agent-device/kernel/source-value';

/**
* The host a capture-kit test hands to code that takes one explicitly: a snapshot of the process
* as it is when called, so `vi.stubEnv` made earlier in the test reaches it. Test-kit depends on
* capture-kit, so capture-kit keeps this local.
*/
export function createTestHost(): HostEnvironment {
return captureHostEnvironment(process);
}
57 changes: 57 additions & 0 deletions packages/capture-kit/src/png-worker-client-unavailable.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { EventEmitter } from 'node:events';
import { expect, test, vi } from 'vitest';
import { PNG } from './png.ts';

test('a worker whose entry is missing is reported once and later registrations hear it', async () => {
vi.resetModules();
vi.doMock('./internal-entry.ts', () => ({ resolveInternalEntryModulePath: () => null }));
const { decodePngAsync, prewarmPngWorker } = await import('./png-worker-client.ts');
const png = PNG.sync.write(new PNG({ width: 1, height: 1 }));
const first = vi.fn();
const second = vi.fn();

prewarmPngWorker(first);
const decoded = await decodePngAsync(png, 'fallback');
prewarmPngWorker(second);

expect(decoded.width).toBe(1);
expect(first.mock.calls).toEqual([[{ kind: 'entry-not-found' }]]);
expect(second.mock.calls).toEqual([[{ kind: 'entry-not-found' }]]);
vi.doUnmock('./internal-entry.ts');
});

test('a worker that dies after it started is reported once, and its pending job still completes', async () => {
vi.resetModules();
class FakeWorker extends EventEmitter {
static last: FakeWorker | undefined;
constructor() {
super();
FakeWorker.last = this;
}
postMessage(): void {}
ref(): void {}
unref(): void {}
terminate(): Promise<number> {
return Promise.resolve(0);
}
}
vi.doMock('node:worker_threads', async (importOriginal) => ({
...(await importOriginal<typeof import('node:worker_threads')>()),
Worker: FakeWorker,
}));
const { decodePngAsync, prewarmPngWorker } = await import('./png-worker-client.ts');
const png = PNG.sync.write(new PNG({ width: 1, height: 1 }));
const unavailable = vi.fn();
prewarmPngWorker(unavailable);
expect(unavailable).not.toHaveBeenCalled();

const decoded = decodePngAsync(png, 'under-request');
const worker = FakeWorker.last;
if (!worker) throw new Error('the pre-warm did not start a worker');
worker.emit('error', new Error('worker crashed'));
worker.emit('exit', 1);

expect((await decoded).width).toBe(1);
expect(unavailable.mock.calls).toEqual([[{ kind: 'worker-error', detail: 'worker crashed' }]]);
vi.doUnmock('node:worker_threads');
});
67 changes: 39 additions & 28 deletions packages/capture-kit/src/png-worker-client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { Worker } from 'node:worker_threads';
import { emitDiagnostic } from '@agent-device/host-kit/diagnostics';
import { AppError, toAppErrorCode } from '@agent-device/kernel/errors';
import type { Rect } from '@agent-device/kernel/snapshot';
import { resolveInternalEntryModulePath } from './internal-entry.ts';
Expand Down Expand Up @@ -29,6 +28,12 @@ import {

const PNG_WORKER_ENTRYPOINT = 'png-worker';

/** Why the worker left service for good; `detail` carries the underlying error text. */
export type PngWorkerUnavailableReason = {
kind: 'entry-not-found' | 'spawn-failed' | 'worker-error' | 'worker-exited';
detail?: string;
};

/** Worker-infrastructure failure: the generic runner falls back to the sync path. */
class PngWorkerUnavailableError extends Error {}

Expand All @@ -38,22 +43,16 @@ type PendingJob = {
};

let worker: Worker | null = null;
let workerUnavailable = false;
let warnedWorkerUnavailable = false;
let unavailableReason: PngWorkerUnavailableReason | undefined;
let reportUnavailable: ((reason: PngWorkerUnavailableReason) => void) | undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: reportUnavailable survives terminatePngWorker(), so a later worker created after daemon shutdown can report failures into the old daemon's logPath. Clear the sink during termination before allowing the module to respawn a worker.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/capture-kit/src/png-worker-client.ts, line 47:

<comment>`reportUnavailable` survives `terminatePngWorker()`, so a later worker created after daemon shutdown can report failures into the old daemon's `logPath`. Clear the sink during termination before allowing the module to respawn a worker.</comment>

<file context>
@@ -38,23 +43,16 @@ type PendingJob = {
-let workerUnavailable = false;
-let workerUnavailableReason: string | undefined;
+let unavailableReason: PngWorkerUnavailableReason | undefined;
+let reportUnavailable: ((reason: PngWorkerUnavailableReason) => void) | undefined;
 let nextJobId = 0;
 const pendingJobs = new Map<number, PendingJob>();
</file context>

let nextJobId = 0;
const pendingJobs = new Map<number, PendingJob>();

/** Permanently degrades to the sync path and reports the reason once. */
function markWorkerUnavailable(reason: string): void {
workerUnavailable = true;
if (warnedWorkerUnavailable) return;
warnedWorkerUnavailable = true;
// Worker failures can surface outside a diagnostics scope (e.g. daemon
// startup pre-warm), so pair the scoped diagnostic with a process warning.
emitDiagnostic({ level: 'warn', phase: 'png_worker_unavailable', data: { reason } });
process.emitWarning(
`PNG worker unavailable, falling back to in-process PNG processing: ${reason}`,
);
/** Permanently degrades to the sync path; the daemon's sink hears each transition once. */
function markWorkerUnavailable(reason: PngWorkerUnavailableReason): void {
if (unavailableReason) return;
unavailableReason = reason;
reportUnavailable?.(reason);
}

function handleWorkerMessage(message: PngWorkerResponse): void {
Expand All @@ -74,14 +73,14 @@ function handleWorkerMessage(message: PngWorkerResponse): void {
}
}

function handleWorkerFailure(failed: Worker, error: Error): void {
function handleWorkerFailure(failed: Worker, reason: PngWorkerUnavailableReason): void {
if (worker !== failed) return;
// Keep the failure handling conservative: after any worker-level error the
// daemon permanently falls back to the in-process synchronous path.
markWorkerUnavailable(error.message);
markWorkerUnavailable(reason);
worker = null;
void failed.terminate().catch(() => {});
rejectPendingJobs(new PngWorkerUnavailableError(`PNG worker failed: ${error.message}`));
rejectPendingJobs(new PngWorkerUnavailableError(`PNG worker failed: ${reason.kind}`));
}

function rejectPendingJobs(error: Error): void {
Expand All @@ -102,31 +101,34 @@ function updateWorkerRef(): void {
}

function obtainWorker(): Worker | null {
if (workerUnavailable) return null;
if (unavailableReason) return null;
if (worker) return worker;
const modulePath = resolveInternalEntryModulePath(import.meta.url, PNG_WORKER_ENTRYPOINT);
if (!modulePath) {
markWorkerUnavailable('worker entry module not found next to the current module');
markWorkerUnavailable({ kind: 'entry-not-found' });
return null;
}
try {
// The worker decodes and encodes bytes it is sent; it needs none of the host's environment.
const created = new Worker(modulePath, {
env: {},
execArgv: modulePath.endsWith('.ts') ? ['--experimental-strip-types'] : [],
});
created.on('message', handleWorkerMessage);
created.on('error', (error) => {
handleWorkerFailure(created, error);
handleWorkerFailure(created, { kind: 'worker-error', detail: error.message });
});
created.on('exit', (code) => {
handleWorkerFailure(created, new Error(`PNG worker exited with code ${code}`));
handleWorkerFailure(created, { kind: 'worker-exited', detail: `exit code ${code}` });
});
created.unref();
worker = created;
return created;
} catch (error) {
markWorkerUnavailable(
`failed to spawn worker: ${error instanceof Error ? error.message : String(error)}`,
);
markWorkerUnavailable({
kind: 'spawn-failed',
detail: error instanceof Error ? error.message : String(error),
});
return null;
}
}
Expand Down Expand Up @@ -176,14 +178,23 @@ async function runPngJob<Kind extends PngWorkerJobKind>(
try {
return await runWorkerJob(job);
} catch (error) {
if (error instanceof PngWorkerUnavailableError) return await runSync();
if (error instanceof PngWorkerUnavailableError) {
return await runSync();
}
throw error;
}
}

/** Daemon startup hook: spawns the worker before the first screenshot job. */
export function prewarmPngWorker(): void {
obtainWorker();
/**
* Daemon startup hook: spawns the worker before the first screenshot job and registers the daemon's
* sink, which hears every later transition to unavailable once, inside or outside a request.
*/
export function prewarmPngWorker(
onUnavailable: (reason: PngWorkerUnavailableReason) => void,
): void {
reportUnavailable = onUnavailable;
if (unavailableReason) onUnavailable(unavailableReason);
else obtainWorker();
}

/** Stops the worker thread (used by tests and shutdown); later calls respawn it. */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import fs from 'node:fs';
import path from 'node:path';
import { expect, test, vi } from 'vitest';
import { mkdtempForTestSync } from '../../tmp-dir.fixtures.ts';

vi.mock('@agent-device/host-kit/command', async () => {
const fs = await import('node:fs');
return {
runCmd: vi.fn(async (_cmd: string, args: string[]) => {
fs.writeFileSync(args[args.indexOf('--output') + 1]!, 'processed');
return { stdout: '', stderr: '', exitCode: 0 };
}),
};
});
vi.mock('../video.ts', () => ({ waitForPlayableVideo: vi.fn(async () => {}) }));
vi.mock('../swift-cache.ts', () => ({
buildSwiftToolEnv: () => ({}),
compileSwiftSourceFile: vi.fn(async () => '/bin/true'),
resolveRecordingScriptPath: vi.fn(
(name: string, _projectRoot: string, cwd: string) => `${cwd}/${name}`,
),
}));

import { overlayRecordingTouches, startRecordingOverlayBudget } from '../overlay.ts';
import { compileSwiftSourceFile } from '../swift-cache.ts';

test('a helper script path is resolved per host working directory, not once per process', async () => {
const root = mkdtempForTestSync('agent-device-overlay-script-paths-');
const videoPath = path.join(root, 'recording.mp4');
const telemetryPath = path.join(root, 'recording.gesture-telemetry.json');
fs.writeFileSync(telemetryPath, '{"events":[]}');
const overlay = async (cwd: string) => {
fs.writeFileSync(videoPath, 'original');
await overlayRecordingTouches({
host: { env: {}, cwd },
videoPath,
telemetryPath,
deadline: startRecordingOverlayBudget(),
});
};

await overlay('/cwd/one');
await overlay('/cwd/two');
await overlay('/cwd/one');

expect(vi.mocked(compileSwiftSourceFile).mock.calls.map(([params]) => params.sourcePath)).toEqual(
[
'/cwd/one/recording-overlay.swift',
'/cwd/two/recording-overlay.swift',
'/cwd/one/recording-overlay.swift',
],
);
});
Loading
Loading