Skip to content

Commit 3665ea0

Browse files
committed
refactor: finish the dedupe — one release path, kernel errorMessage, AWS on releaseOnFailure
Code-quality review at 7f9d148: 1. aws-device-farm.ts still carried its own copy of releaseOnFailure (the dedupe commit's script aborted before reaching it and I mis-verified). Now uses the shared helper; private copy deleted. 2. Empty 'import {} from kernel/errors' in maestro/shared.ts removed (2738700). 3. errorMessage() lives in @agent-device/kernel/errors; the two copies this PR had added (lease.ts, webdriver-utils.ts) import it. Sweeping the pre-existing copies is a follow-up. 4. lease.ts has ONE release path: releaseLease(registry, provider, lease, request, ctx) → { released (registry), provider } used by both the lease_release case (wire shape unchanged) and the gone-requester branch, which folds a throwing provider release into releaseError. 'released' now means the same thing in both; the provider verdict is a separate 'providerReleased' (warnings-free, no throw) that drives the stop-by-hand hint. -~35 lines. 5. sessionCreateTimeoutMs is Omit-ed at the WebDriverTransportOptions boundary instead of Pick-ed back out internally.
1 parent 2738700 commit 3665ea0

7 files changed

Lines changed: 82 additions & 85 deletions

File tree

‎packages/kernel/src/errors.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,11 @@ export function isRequestCanceledError(error: unknown): boolean {
162162
return error.message === REQUEST_CANCELED_MESSAGE;
163163
}
164164

165+
/** The message of whatever was thrown, for diagnostics that must not themselves throw. */
166+
export function errorMessage(error: unknown): string {
167+
return error instanceof Error ? error.message : String(error);
168+
}
169+
165170
export function asAppError(err: unknown, fallbackCode: AppErrorCode = 'UNKNOWN'): AppError {
166171
if (err instanceof AppError) return err;
167172
if (err instanceof Error) {

‎packages/provider-webdriver/src/aws-device-farm.ts‎

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { setTimeout as sleep } from 'node:timers/promises';
2424
import { AppError } from '@agent-device/kernel/errors';
2525
import type { RunHostCommand } from './dependencies.ts';
2626
import { CLOUD_WEBDRIVER_PROVIDERS } from './providers.ts';
27-
import { resolveLeaseValue, type LeaseValue } from './webdriver-utils.ts';
27+
import { releaseOnFailure, resolveLeaseValue, type LeaseValue } from './webdriver-utils.ts';
2828

2929
const AWS_DEVICE_FARM_PROVIDER = CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm;
3030
export const AWS_DEVICE_FARM_CAPABILITY_OVERRIDES = {
@@ -231,7 +231,7 @@ export function createAwsDeviceFarmPrepareSession(
231231
);
232232
}
233233
} catch (error) {
234-
await stopRemoteAccessSessionAfterFailure(options.client, remoteAccess.arn, error);
234+
await releaseOnFailure(error, () => options.client.stopRemoteAccessSession(remoteAccess.arn));
235235
throw error;
236236
}
237237
const deviceName = running.device?.name ?? options.deviceName;
@@ -327,23 +327,6 @@ function throwIfRemoteAccessSessionEnded(session: AwsDeviceFarmRemoteAccessSessi
327327
});
328328
}
329329

330-
async function stopRemoteAccessSessionAfterFailure(
331-
client: AwsDeviceFarmClient,
332-
arn: string,
333-
primaryError: unknown,
334-
): Promise<void> {
335-
try {
336-
await client.stopRemoteAccessSession(arn);
337-
} catch (cleanupError) {
338-
if (primaryError instanceof AppError) {
339-
primaryError.details = {
340-
...primaryError.details,
341-
cleanupError: cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
342-
};
343-
}
344-
}
345-
}
346-
347330
async function runAwsJson(
348331
runHostCommand: RunHostCommand,
349332
command: string,

‎packages/provider-webdriver/src/runtime-session.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type {
44
} from '@agent-device/contracts/observability';
55
import type { DeviceLease, LeaseLifecycleContext } from '@agent-device/contracts/device';
66
import { deviceFieldsFromPublicPlatform, type DeviceInfo } from '@agent-device/kernel/device';
7-
import { AppError } from '@agent-device/kernel/errors';
7+
import { AppError, errorMessage } from '@agent-device/kernel/errors';
88
import { unavailableCloudArtifactsResult } from './artifact-results.ts';
99
import {
1010
createCloudWebDriverCapabilities,
@@ -14,7 +14,7 @@ import { WebDriverClient, type WebDriverSession } from './webdriver-client.ts';
1414
import { isWebDriverRequestTimeout } from './webdriver-transport.ts';
1515
import { createWebDriverInteractor } from './webdriver-interactor.ts';
1616
import { snapshotBackendForPlatform } from './runtime-helpers.ts';
17-
import { errorMessage, releaseOnFailure } from './webdriver-utils.ts';
17+
import { releaseOnFailure } from './webdriver-utils.ts';
1818
import type {
1919
CloudWebDriverBaseSession,
2020
CloudWebDriverPlatform,

‎packages/provider-webdriver/src/webdriver-transport.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ export type WebDriverTransportOptions = {
4646
endpoint: string | URL;
4747
auth?: WebDriverAuth;
4848
headers?: Record<string, string>;
49-
requestPolicy?: WebDriverRequestPolicy;
49+
/** Session creation is the client's phase; the transport sees only per-request policy. */
50+
requestPolicy?: Omit<WebDriverRequestPolicy, 'sessionCreateTimeoutMs'>;
5051
};
5152

5253
type WebDriverResponse = {
@@ -61,7 +62,7 @@ type ResolvedWebDriverRequestOverrides = {
6162
};
6263

6364
type ResolvedWebDriverRequestPolicy = Required<
64-
Pick<WebDriverRequestPolicy, 'timeoutMs' | 'retryAttempts' | 'retryDelayMs'>
65+
NonNullable<WebDriverTransportOptions['requestPolicy']>
6566
>;
6667

6768
/** Focused HTTP/retry policy for one WebDriver endpoint; session semantics stay in WebDriverClient. */

‎packages/provider-webdriver/src/webdriver-utils.ts‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,8 @@
11
import type { DeviceLease } from '@agent-device/contracts/device';
2-
import { AppError } from '@agent-device/kernel/errors';
2+
import { AppError, errorMessage } from '@agent-device/kernel/errors';
33

44
export type LeaseValue<T> = T | ((lease: DeviceLease) => T);
55

6-
export function errorMessage(error: unknown): string {
7-
return error instanceof Error ? error.message : String(error);
8-
}
9-
106
/** Best-effort release after a failure; a failed release rides along as `cleanupError`, never masks the primary. */
117
export async function releaseOnFailure(
128
primaryError: unknown,

‎src/daemon/__tests__/request-handler-catalog.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,7 @@ test('a lease allocated for a requester that left is released, not registered',
331331
assert.ok(isRequestCanceledError(error));
332332
const details = (error as AppError).details ?? {};
333333
assert.equal(details.released, true);
334+
assert.equal(details.providerReleased, true);
334335
assert.match(String(details.providerSessionId), /^bs-/);
335336
return true;
336337
},
@@ -376,7 +377,8 @@ test('a failed provider release after cancellation is reported as unreleased wit
376377
(error: unknown) => {
377378
assert.ok(isRequestCanceledError(error));
378379
const details = (error as AppError).details ?? {};
379-
assert.equal(details.released, false);
380+
assert.equal(details.released, true, 'the daemon lease record is gone either way');
381+
assert.equal(details.providerReleased, false);
380382
assert.equal(details.providerSessionId, 'bs-live');
381383
assert.match(String(details.hint), /could NOT be confirmed released/);
382384
assert.match(String(details.hint), /bs-live/);

‎src/daemon/handlers/lease.ts‎

Lines changed: 66 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type {
99
CloudArtifactProvider,
1010
} from '@agent-device/contracts/observability';
1111
import type { DaemonRequest, DaemonResponse } from '../types.ts';
12-
import type { LeaseRegistry } from '../lease-registry.ts';
12+
import type { LeaseRegistry, ReleaseLeaseRequest } from '../lease-registry.ts';
1313
import type { SessionStore } from '../session-store.ts';
1414
import {
1515
isProxyLeaseScope,
@@ -21,7 +21,7 @@ import {
2121
leaseScopeToHeartbeatRequest,
2222
leaseScopeToReleaseRequest,
2323
} from '../../core/lease-scope.ts';
24-
import { AppError, createRequestCanceledError } from '@agent-device/kernel/errors';
24+
import { AppError, createRequestCanceledError, errorMessage } from '@agent-device/kernel/errors';
2525
import { LEASE_ALLOCATION_BUDGET_MS } from '../../core/command-descriptor/timeout-policy.ts';
2626
import { getRequestSignal, isRequestCanceled } from '../../request/cancel.ts';
2727
import { listDownloadableArtifacts } from '../artifact-tracking.ts';
@@ -75,7 +75,7 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise<Daemo
7575
deadline: Date.now() + LEASE_ALLOCATION_BUDGET_MS,
7676
});
7777
} catch (error) {
78-
releaseRegistryLease(leaseRegistry, lease);
78+
leaseRegistry.releaseLease(leaseReleaseRequestFor(lease));
7979
throw error;
8080
}
8181
if (isRequestCanceled(req.meta?.requestId)) {
@@ -101,93 +101,103 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise<Daemo
101101
}
102102
case 'lease_release': {
103103
const releaseRequest = leaseScopeToReleaseRequest(leaseScope);
104-
const lease = leaseRegistry.getLease(releaseRequest);
105-
const providerData = lease
106-
? await leaseLifecycleProvider?.release?.(lease, leaseLifecycleContext(req))
107-
: undefined;
108-
const result = leaseRegistry.releaseLease(releaseRequest);
104+
const outcome = await releaseLease(
105+
leaseRegistry,
106+
leaseLifecycleProvider,
107+
leaseRegistry.getLease(releaseRequest),
108+
releaseRequest,
109+
leaseLifecycleContext(req),
110+
);
109111
return {
110112
ok: true,
111-
data: { released: result.released, ...(providerData ? { provider: providerData } : {}) },
113+
data: {
114+
released: outcome.released,
115+
...(outcome.provider ? { provider: outcome.provider } : {}),
116+
},
112117
};
113118
}
114119
default:
115120
return null;
116121
}
117122
}
118123

119-
function releaseRegistryLease(leaseRegistry: LeaseRegistry, lease: DeviceLease): void {
120-
leaseRegistry.releaseLease(
121-
leaseScopeToReleaseRequest({
122-
leaseId: lease.leaseId,
123-
tenantId: lease.tenantId,
124-
runId: lease.runId,
125-
leaseBackend: lease.backend,
126-
leaseProvider: lease.leaseProvider,
127-
deviceKey: lease.deviceKey,
128-
clientId: lease.clientId,
129-
}),
130-
);
124+
function leaseReleaseRequestFor(lease: DeviceLease): ReleaseLeaseRequest {
125+
return leaseScopeToReleaseRequest({
126+
leaseId: lease.leaseId,
127+
tenantId: lease.tenantId,
128+
runId: lease.runId,
129+
leaseBackend: lease.backend,
130+
leaseProvider: lease.leaseProvider,
131+
deviceKey: lease.deviceKey,
132+
clientId: lease.clientId,
133+
});
134+
}
135+
136+
type LeaseReleaseOutcome = {
137+
/** The daemon's own lease record was released. */
138+
released: boolean;
139+
/** Provider release data (providerSessionId, warnings, cloudArtifacts…) when a provider held it. */
140+
provider?: Record<string, unknown>;
141+
};
142+
143+
/** THE release path: provider first (it still needs the lease record), then the registry. */
144+
async function releaseLease(
145+
leaseRegistry: LeaseRegistry,
146+
leaseLifecycleProvider: LeaseLifecycleProvider | undefined,
147+
lease: DeviceLease | undefined,
148+
request: ReleaseLeaseRequest,
149+
context?: LeaseLifecycleContext,
150+
): Promise<LeaseReleaseOutcome> {
151+
const provider = lease ? await leaseLifecycleProvider?.release?.(lease, context) : undefined;
152+
return { released: leaseRegistry.releaseLease(request).released, provider };
131153
}
132154

133155
/**
134-
* Releases a lease that finished allocating after its requester was gone, and
135-
* turns the outcome into the canceled-request error the (absent) requester
136-
* would have received. Release evidence is only claimed on a clean release: a
137-
* provider that could not delete its session (`warnings`) is reported as such,
138-
* with the identifiers an operator needs to stop it by hand.
156+
* Releases a lease that finished allocating after its requester was gone and
157+
* turns the outcome into the canceled-request error nobody is left to receive:
158+
* a throwing provider release is folded into `releaseError` rather than raised,
159+
* and the provider session counts as released only when it reported no
160+
* warnings — otherwise the error names what an operator must stop by hand.
139161
*/
140162
async function releaseAllocationForGoneRequester(
141163
lease: DeviceLease,
142164
leaseLifecycleProvider: LeaseLifecycleProvider | undefined,
143165
leaseRegistry: LeaseRegistry,
144166
): Promise<AppError> {
145-
const outcome = await releaseProviderLease(lease, leaseLifecycleProvider);
146-
releaseRegistryLease(leaseRegistry, lease);
147-
return canceledAllocationError(lease, outcome);
148-
}
149-
150-
type ProviderReleaseOutcome = {
151-
providerSessionId?: unknown;
152-
warnings: unknown[];
153-
releaseError?: string;
154-
};
155-
156-
async function releaseProviderLease(
157-
lease: DeviceLease,
158-
leaseLifecycleProvider: LeaseLifecycleProvider | undefined,
159-
): Promise<ProviderReleaseOutcome> {
167+
const request = leaseReleaseRequestFor(lease);
168+
let outcome: LeaseReleaseOutcome;
169+
let releaseError: string | undefined;
160170
try {
161-
const released = await leaseLifecycleProvider?.release?.(lease);
162-
return {
163-
providerSessionId: released?.providerSessionId,
164-
warnings: Array.isArray(released?.warnings) ? released.warnings : [],
165-
};
171+
outcome = await releaseLease(leaseRegistry, leaseLifecycleProvider, lease, request);
166172
} catch (error) {
167-
return { warnings: [], releaseError: errorMessage(error) };
173+
releaseError = errorMessage(error);
174+
outcome = { released: leaseRegistry.releaseLease(request).released };
168175
}
176+
return canceledAllocationError(lease, outcome, releaseError);
169177
}
170178

171-
function canceledAllocationError(lease: DeviceLease, outcome: ProviderReleaseOutcome): AppError {
172-
const { providerSessionId, warnings, releaseError } = outcome;
173-
const released = releaseError === undefined && warnings.length === 0;
179+
function canceledAllocationError(
180+
lease: DeviceLease,
181+
outcome: LeaseReleaseOutcome,
182+
releaseError: string | undefined,
183+
): AppError {
184+
const providerSessionId = outcome.provider?.providerSessionId;
185+
const warnings = Array.isArray(outcome.provider?.warnings) ? outcome.provider.warnings : [];
186+
const providerReleased = releaseError === undefined && warnings.length === 0;
174187
return createRequestCanceledError({
175188
leaseId: lease.leaseId,
176189
leaseProvider: lease.leaseProvider,
190+
released: outcome.released,
191+
providerReleased,
177192
providerSessionId,
178193
...(warnings.length > 0 ? { warnings } : {}),
179194
...(releaseError !== undefined ? { releaseError } : {}),
180-
released,
181-
hint: released
195+
hint: providerReleased
182196
? 'The lease request was canceled while the provider was still allocating; the session it produced was released.'
183197
: `The lease request was canceled while the provider was still allocating, and the session it produced could NOT be confirmed released — it may still be running and billing. Stop provider session ${String(providerSessionId ?? '(unknown)')} for lease ${lease.leaseId} by hand.`,
184198
});
185199
}
186200

187-
function errorMessage(error: unknown): string {
188-
return error instanceof Error ? error.message : String(error);
189-
}
190-
191201
function leaseLifecycleContext(req: DaemonRequest): LeaseLifecycleContext {
192202
return {
193203
flags: req.flags,

0 commit comments

Comments
 (0)