Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
05be6bf
feat(daemon): the runtime reports its shutdown instead of exiting the…
thymikee Oct 16, 2026
5789838
feat(daemon): per-runtime state for admission, resolution, artifacts,…
thymikee Oct 16, 2026
9793a87
feat(daemon): per-runtime Apple runner state and a shared PNG worker
thymikee Oct 16, 2026
0b83711
chore(daemon): drop re-exports nothing consumes yet
thymikee Oct 16, 2026
c3baaec
fix(daemon): a failed shutdown step releases the rest, and dispose re…
thymikee Oct 17, 2026
c047776
fix(daemon): assert registration release on a failed close and share …
thymikee Oct 17, 2026
22cea00
fix(apple): scope start admissions, prep builds and bad-artifact mark…
thymikee Oct 17, 2026
5c98812
refactor(move): keep daemon-runtime.ts under the size limit
thymikee Oct 17, 2026
817930f
chore: merge the host environment foundation into the runtime state b…
thymikee Oct 17, 2026
012b50d
Merge branch 'refactor/host-environment' into refactor/daemon-runtime…
thymikee Oct 17, 2026
2f29e99
test: drop the empty daemon-runtime imports left by the runtime-state…
thymikee Oct 17, 2026
5b26ca3
Merge remote-tracking branch 'origin/refactor/host-environment' into …
thymikee Oct 17, 2026
7eedaf6
test: run the Limrun public HTTP test on the runtime shutdown contract
thymikee Oct 17, 2026
1a86a19
fix(daemon): bound the runtime close and report work that outlives it
thymikee Oct 17, 2026
7b90408
Merge remote-tracking branch 'origin/refactor/host-environment' into …
thymikee Oct 17, 2026
b29df19
Merge remote-tracking branch 'origin/refactor/host-environment' into …
thymikee Oct 17, 2026
3aa6def
fix(daemon): record abandoned requests in the daemon log and print un…
thymikee Oct 17, 2026
5a20a1c
Merge remote-tracking branch 'origin/refactor/host-environment' into …
thymikee Oct 17, 2026
be191a3
refactor(daemon): report the close through one function the executabl…
thymikee Oct 17, 2026
fff0337
Merge remote-tracking branch 'origin/refactor/host-environment' into …
thymikee Oct 17, 2026
794c048
Merge remote-tracking branch 'origin/refactor/host-environment' into …
thymikee Oct 17, 2026
4ab64cc
Merge branch 'refactor/host-environment' into refactor/daemon-runtime…
thymikee Oct 17, 2026
9c64e47
refactor(android): own the snapshot-helper sessions, failed starts, r…
thymikee Oct 17, 2026
418b072
chore(layering): enumerate the Android helper runtime-state subpath b…
thymikee Oct 17, 2026
e445455
Merge remote-tracking branch 'origin/main' into refactor/daemon-runti…
thymikee Oct 17, 2026
29f3e33
refactor(daemon): split the plugin close into its budget race and its…
thymikee Oct 17, 2026
db08148
Merge commit 'db55a5dfbd' into refactor/daemon-runtime-no-process-glo…
thymikee Oct 17, 2026
b7a9d15
test: pin the close report beside the unfinished-work lines and drive…
thymikee Oct 17, 2026
631d640
Merge commit 'd8a9bd40c0' into refactor/daemon-runtime-no-process-glo…
thymikee Oct 17, 2026
2f4e815
refactor(android): keep the helper runtime state inside the adb modul…
thymikee Oct 17, 2026
8539b30
refactor(android): export the per-runtime helper state from the adb h…
thymikee Oct 17, 2026
0b2939c
Merge commit '870cf663c4' into refactor/daemon-runtime-no-process-glo…
thymikee Oct 17, 2026
2568e52
Merge remote-tracking branch 'origin/refactor/host-environment' into …
thymikee Oct 17, 2026
f8251da
Merge remote-tracking branch 'origin/refactor/host-environment' into …
thymikee Oct 17, 2026
45458ef
Merge remote-tracking branch 'origin/refactor/host-environment' into …
thymikee Oct 17, 2026
d6ba2d9
Merge remote-tracking branch 'origin/main' into refactor/daemon-runti…
thymikee Oct 17, 2026
d5682c7
Merge origin/main and adapt to the platform host migration
thymikee Oct 17, 2026
b6fd554
refactor(runtime): give the Android helper state to the root's slot s…
thymikee Oct 17, 2026
5a8f1cd
Merge remote-tracking branch 'origin/refactor/host-environment' into …
thymikee Oct 17, 2026
3ef9ace
refactor(runtime): move Apple runner state and request-cancel onto de…
thymikee Oct 17, 2026
967dcc7
refactor(daemon): keep the five per-runtime daemon states in defineRu…
thymikee Oct 17, 2026
0658db9
fix(gate): count an approved closure addition only when it is not a r…
thymikee Oct 17, 2026
66f0b27
refactor(daemon): take RuntimeStateHandle from host-kit's runtime-slo…
thymikee Oct 17, 2026
70c901d
Merge remote-tracking branch 'origin/refactor/host-environment' into …
thymikee Oct 17, 2026
d9ff517
refactor(android): define the helper runtime slots in the adb host mo…
thymikee Oct 18, 2026
d932926
chore(layering): let daemon composition import the adb host module fo…
thymikee Oct 18, 2026
029e82d
Merge remote-tracking branch 'origin/refactor/host-environment' into …
thymikee Oct 18, 2026
1c18440
test(android): drop the host stub the shell-protocol probe test no lo…
thymikee Oct 18, 2026
c58e2b0
Merge remote-tracking branch 'origin/refactor/host-environment' into …
thymikee Oct 18, 2026
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
13 changes: 8 additions & 5 deletions packages/capture-kit/src/png-worker-client-unavailable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,20 @@ 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 { decodePngAsync, retainPngWorker } = 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 releaseFirst = retainPngWorker(first);
const decoded = await decodePngAsync(png, 'fallback');
prewarmPngWorker(second);
const releaseSecond = retainPngWorker(second);

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

Expand All @@ -39,10 +41,10 @@ test('a worker that dies after it started is reported once, and its pending job
...(await importOriginal<typeof import('node:worker_threads')>()),
Worker: FakeWorker,
}));
const { decodePngAsync, prewarmPngWorker } = await import('./png-worker-client.ts');
const { decodePngAsync, retainPngWorker } = await import('./png-worker-client.ts');
const png = PNG.sync.write(new PNG({ width: 1, height: 1 }));
const unavailable = vi.fn();
prewarmPngWorker(unavailable);
const release = retainPngWorker(unavailable);
expect(unavailable).not.toHaveBeenCalled();

const decoded = decodePngAsync(png, 'under-request');
Expand All @@ -53,5 +55,6 @@ test('a worker that dies after it started is reported once, and its pending job

expect((await decoded).width).toBe(1);
expect(unavailable.mock.calls).toEqual([[{ kind: 'worker-error', detail: 'worker crashed' }]]);
await release();
vi.doUnmock('node:worker_threads');
});
17 changes: 17 additions & 0 deletions packages/capture-kit/src/png-worker-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
decodePngAsync,
decodeScreenshotImageAsync,
encodePngAsync,
retainPngWorker,
terminatePngWorker,
transcodeScreenshotToPngAsync,
} from './png-worker-client.ts';
Expand Down Expand Up @@ -225,3 +226,19 @@ test('decodeScreenshotImageAsync rejects a container it cannot read with the can
},
);
});

test('the worker serves every holder and stops only when the last one lets go', async () => {
const first = retainPngWorker(() => {});
const second = retainPngWorker(() => {});
const encoded = PNG.sync.write(buildPatternPng(5, 4, 2));

await first();
await first();
const stillServing = await runOnWorkerThread('decode after one release', () =>
decodePngAsync(encoded, 'fixture'),
);
assert.equal(stillServing.width, 5);

await second();
assert.equal(process.getActiveResourcesInfo().includes('MessagePort'), false);
});
27 changes: 20 additions & 7 deletions packages/capture-kit/src/png-worker-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,15 @@ type PendingJob = {

let worker: Worker | null = null;
let unavailableReason: PngWorkerUnavailableReason | undefined;
let reportUnavailable: ((reason: PngWorkerUnavailableReason) => void) | undefined;
const unavailableSinks = new Set<(reason: PngWorkerUnavailableReason) => void>();
let nextJobId = 0;
const pendingJobs = new Map<number, PendingJob>();

/** 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);
for (const sink of unavailableSinks) sink(reason);
}

function handleWorkerMessage(message: PngWorkerResponse): void {
Expand Down Expand Up @@ -185,16 +185,29 @@ async function runPngJob<Kind extends PngWorkerJobKind>(
}
}

let pngWorkerHolds = 0;

/**
* 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.
* Daemon startup hook: spawns the worker before the first screenshot job and holds it for the
* caller. The sink hears every transition to unavailable once, inside or outside a request, and
* hears an earlier one at once. The worker is shared by every runtime in the process, so it stops
* when the last holder lets go; the returned release is idempotent.
*/
export function prewarmPngWorker(
export function retainPngWorker(
onUnavailable: (reason: PngWorkerUnavailableReason) => void,
): void {
reportUnavailable = onUnavailable;
): () => Promise<void> {
unavailableSinks.add(onUnavailable);
if (unavailableReason) onUnavailable(unavailableReason);
else obtainWorker();
pngWorkerHolds += 1;
Comment thread
thymikee marked this conversation as resolved.
let released = false;
return async () => {
if (released) return;
released = true;
unavailableSinks.delete(onUnavailable);
pngWorkerHolds -= 1;
if (pngWorkerHolds === 0) await terminatePngWorker();
};
}

/** Stops the worker thread (used by tests and shutdown); later calls respawn it. */
Expand Down
10 changes: 6 additions & 4 deletions packages/contracts/src/application-lifecycle-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,8 +382,10 @@ export type AppleApplicationTools = Readonly<{
applyRuntimeHints(device: DeviceInfo, input: RuntimeHintsApplicationInput): Promise<void>;
clearRuntimeHints(device: DeviceInfo, input: RuntimeHintsApplicationInput): Promise<void>;
dismissCloseAlerts(device: DeviceInfo, input: CloseApplicationFinalizationInput): Promise<void>;
detachRunnerSessionsForShutdown(): Promise<void>;
finalizeRunnerSessionsForShutdown(): Promise<void>;
/** The devices whose runner was handed to the next daemon instead of stopped. */
detachRunnerSessionsForShutdown(): Promise<readonly string[]>;
/** The devices whose runner this daemon stopped. */
finalizeRunnerSessionsForShutdown(): Promise<readonly string[]>;
}>;

/** Lazy Android mechanics used by the Android package's own lifecycle binding. */
Expand Down Expand Up @@ -413,6 +415,6 @@ export type AndroidApplicationTools = Readonly<{
/** Gateway-coordinated durable phases whose concrete mechanics stay package-owned. */
export type ApplicationLifecycleResourceLifecycle = Readonly<{
recoverStartupResources(input: Readonly<{ stateDir: string }>): Promise<void>;
detachForDaemonShutdown(): Promise<void>;
finalizeDaemonShutdown(): Promise<void>;
detachForDaemonShutdown(): Promise<readonly string[]>;
finalizeDaemonShutdown(): Promise<readonly string[]>;
}>;
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ApplicationLifecycleResourceLifecycle } from '@agent-device/contracts/application-lifecycle-runtime';
import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures';
import { createOwnedProcessRecordStore } from '@agent-device/host-kit/process';
import type { ProviderDeviceRuntime } from '@agent-device/contracts/device';
import { vi } from 'vitest';
import type {
ComposeDaemonRuntimeDeps,
Expand All @@ -25,8 +26,8 @@ export function applicationLifecycleResourceFixture(
): ApplicationLifecycleResourceLifecycle {
return Object.freeze({
recoverStartupResources: vi.fn(async () => {}),
detachForDaemonShutdown: vi.fn(async () => {}),
finalizeDaemonShutdown: vi.fn(async () => {}),
detachForDaemonShutdown: vi.fn(async (): Promise<readonly string[]> => []),
finalizeDaemonShutdown: vi.fn(async (): Promise<readonly string[]> => []),
...overrides,
});
}
Expand Down Expand Up @@ -59,6 +60,7 @@ export function daemonRuntimeDepsFixture(
return async (input) => {
const resolved = typeof overrides === 'function' ? overrides(input) : overrides;
return Object.freeze({
runtimeStates: [],
ownedProcessRecords: createOwnedProcessRecordStore({
stateDir: input.baseDir,
sessionsDir: input.sessionsDir,
Expand Down Expand Up @@ -94,3 +96,48 @@ export function daemonRuntimeDepsFixture(
});
};
}

/**
* A plugin registry over provider runtimes, as the composition hands it to the daemon: the
* inventory of active plugins and the one `close` that shuts each runtime down and answers with a
* per-plugin outcome.
*/
export function pluginRegistryFixture(
runtimes: readonly Pick<ProviderDeviceRuntime, 'provider' | 'shutdown'>[],
): Pick<DaemonRuntimeDeps, 'pluginInventory' | 'shutdownProviderRuntimes'> {
return {
pluginInventory: runtimes.map((runtime) => ({
provider: runtime.provider,
source: 'builtin' as const,
state: 'active' as const,
credentials: { variables: [], byBackend: {} },
})),
shutdownProviderRuntimes: async () =>
await Promise.all(
runtimes.map(async (runtime) => {
try {
await runtime.shutdown();
return {
provider: runtime.provider,
source: 'builtin' as const,
shutdown: { status: 'ok' as const },
dispose: { status: 'ok' as const },
};
} catch (error) {
return {
provider: runtime.provider,
source: 'builtin' as const,
shutdown: {
status: 'failed' as const,
error: {
code: 'COMMAND_FAILED',
message: error instanceof Error ? error.message : String(error),
},
},
dispose: { status: 'ok' as const },
};
}
}),
),
};
}
18 changes: 15 additions & 3 deletions packages/daemon/src/core/interactor-resolution.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { defineRuntimeSlots, type RuntimeStateHandle } from '@agent-device/host-kit/runtime-slots';
import type { Interactor, RunnerContext } from '@agent-device/contracts/interactor-types';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { AppError } from '@agent-device/kernel/errors';
Expand Down Expand Up @@ -27,17 +28,28 @@ const NO_INTERACTOR_RESOLUTION: InteractorResolution = {
},
};

let installedInteractorResolution: InteractorResolution = NO_INTERACTOR_RESOLUTION;
type InteractorResolutionState = { resolution: InteractorResolution };

const interactorResolutionRuntime = defineRuntimeSlots();
const INTERACTOR_RESOLUTION_SLOT = Symbol('interactorResolution');
const interactorResolutionState = (): InteractorResolutionState =>
interactorResolutionRuntime.slot(INTERACTOR_RESOLUTION_SLOT, (): InteractorResolutionState => ({
resolution: NO_INTERACTOR_RESOLUTION,
}));

/** The resolution one daemon runtime owns; the runtime installs its composition's into it. */
export const createInteractorResolutionRuntimeState = (): RuntimeStateHandle =>
interactorResolutionRuntime.createHandle();

/**
* Root composition's installation point, beside the provider-device admission that shares the
* same request-scoped provider scope: one change names both. The interactor graph stays behind
* the module root composition imports, and the daemon never loads it itself.
*/
export function installInteractorResolution(resolution: InteractorResolution): void {
installedInteractorResolution = resolution;
interactorResolutionState().resolution = resolution;
}

export function interactorResolution(): InteractorResolution {
return installedInteractorResolution;
return interactorResolutionState().resolution;
}
18 changes: 15 additions & 3 deletions packages/daemon/src/core/provider-device-admission.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { defineRuntimeSlots, type RuntimeStateHandle } from '@agent-device/host-kit/runtime-slots';
import type { DeviceInfo } from '@agent-device/kernel/device';

/**
Expand All @@ -18,17 +19,28 @@ const NO_PROVIDER_DEVICE_ADMISSION: ProviderDeviceAdmission = {
isActive: () => false,
};

let installedProviderDeviceAdmission: ProviderDeviceAdmission = NO_PROVIDER_DEVICE_ADMISSION;
type ProviderAdmissionState = { admission: ProviderDeviceAdmission };

const providerAdmissionRuntime = defineRuntimeSlots();
const PROVIDER_ADMISSION_SLOT = Symbol('providerDeviceAdmission');
const providerAdmissionState = (): ProviderAdmissionState =>
providerAdmissionRuntime.slot(PROVIDER_ADMISSION_SLOT, (): ProviderAdmissionState => ({
admission: NO_PROVIDER_DEVICE_ADMISSION,
}));

/** The admission one daemon runtime owns; the runtime installs its composition's into it. */
export const createProviderDeviceAdmissionRuntimeState = (): RuntimeStateHandle =>
providerAdmissionRuntime.createHandle();

/**
* Root composition's installation point. The provider runtime scope is ambient per request, so
* this is the one place the daemon names where the fact comes from, and the daemon never reads
* provider runtime internals itself.
*/
export function installProviderDeviceAdmission(admission: ProviderDeviceAdmission): void {
installedProviderDeviceAdmission = admission;
providerAdmissionState().admission = admission;
}

export function isActiveProviderDevice(device: DeviceInfo): boolean {
return installedProviderDeviceAdmission.isActive(device);
return providerAdmissionState().admission.isActive(device);
}
4 changes: 2 additions & 2 deletions packages/daemon/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
export { startDaemonRuntime } from './server/http/daemon-runtime.ts';
export { daemonStartupExitCode, startDaemonRuntime } from './server/http/daemon-runtime.ts';
export { reportDaemonClose } from './server/http/server-shutdown.ts';
export type {
ComposeDaemonRuntimeDeps,
DaemonRuntimeController,
DaemonRuntimeDeps,
DaemonRuntimeDepsInput,
} from './server/http/daemon-runtime.ts';
export type { DaemonInvokeFn, DaemonRequest } from './core/daemon-request.ts';
export type { DaemonPlatformServices } from './core/platform-services.ts';
Expand Down
32 changes: 32 additions & 0 deletions packages/daemon/src/resources/artifact-tracking.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import fs from 'node:fs';
import path from 'node:path';
import { expect, test } from 'vitest';
import { mkdtempForTestSync } from '@agent-device/test-kit';
import {
createArtifactTrackingRuntimeState,
trackDownloadableArtifact,
trackUploadedArtifact,
} from './artifact-tracking.ts';

test('disposing a runtime removes the temporary files it still tracks', () => {
const root = mkdtempForTestSync('agent-device-artifact-dispose-');
const artifactPath = path.join(root, 'artifact.txt');
const uploadDir = path.join(root, 'upload');
fs.writeFileSync(artifactPath, 'artifact');
fs.mkdirSync(uploadDir);
fs.writeFileSync(path.join(uploadDir, 'payload'), 'payload');
const runtime = createArtifactTrackingRuntimeState();

runtime.run(() => {
trackDownloadableArtifact({
artifactPath,
artifactType: 'runtime-artifact',
fileName: 'artifact.txt',
});
trackUploadedArtifact({ artifactPath: path.join(uploadDir, 'payload'), tempDir: uploadDir });
});
runtime.dispose();

expect(fs.existsSync(uploadDir)).toBe(false);
expect(fs.existsSync(artifactPath)).toBe(false);
});
Loading
Loading