Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 3 additions & 3 deletions packages/command-registry/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from '@agent-device/selectors';
import { resolveWaitBudgetMs } from './wait-positionals.ts';
import {
COMPOSITE_TIMEOUT_POLICIES,
DEFAULT_TIMEOUT_POLICY,
FOLD_TIMEOUT_POLICY,
INSTALL_REQUEST_TIMEOUT_MS,
Expand Down Expand Up @@ -859,8 +860,7 @@ export const RAW_COMMAND_DESCRIPTORS = [
skipSessionlessProviderDevice: isShardedTestRequest,
saveScriptFlagOwner: true,
},
// Replay durations are script-dependent; --timeout bounds the envelope.
timeoutPolicy: { ...DEFAULT_TIMEOUT_POLICY, budget: { source: 'flag' } },
timeoutPolicy: COMPOSITE_TIMEOUT_POLICIES.replay,
batchable: false,
// Every native and Maestro action re-enters the daemon request pipeline under the delegated
// command descriptor. Maestro viewport reads do the same through `runtime gesture-viewport`,
Expand Down Expand Up @@ -1081,7 +1081,7 @@ export const RAW_COMMAND_DESCRIPTORS = [
frameworkTier: 'extended',
recordsSessionAction: false,
daemon: { route: 'session', refFrameEffect: 'delegated', appLease: 'allowed' },
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
timeoutPolicy: COMPOSITE_TIMEOUT_POLICIES.batch,
batchable: false,
// Wave 6 residue: every step runs as its own daemon request under its own descriptor, which
// is what `refFrameEffect: 'delegated'` already says. `batch` itself reaches no device.
Expand Down
27 changes: 27 additions & 0 deletions packages/command-registry/src/timeout-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,20 @@ export const DEFAULT_TIMEOUT_POLICY: CommandTimeoutPolicy = {
onTimeout: 'reset-daemon',
};

/** Composite work gets 60 seconds; its client envelope keeps 30 seconds for the result. */
const COMPOSITE_TIMEOUT_POLICY: CommandTimeoutPolicy = {
...DEFAULT_TIMEOUT_POLICY,
daemonDeadlineMs: 60_000,
};

export const COMPOSITE_TIMEOUT_POLICIES = {
batch: COMPOSITE_TIMEOUT_POLICY,
replay: {
...COMPOSITE_TIMEOUT_POLICY,
budget: { source: 'flag', envelope: 'margin' },
},
} satisfies Record<'batch' | 'replay', CommandTimeoutPolicy>;

/**
* Covers the fold route's worst-case ledger (display inventory, fold-helper preparation, HID
* dispatch, hinge settle reads, final display inventory) plus the daemon-result margin. Proven by
Expand Down Expand Up @@ -77,6 +91,8 @@ export function resolveCommandRequestTimeoutMs(
input: RequestTimeoutInput,
): number | undefined {
if (policy.envelopeMs === 'unbounded') return undefined;
const daemonBudgetMs = resolveCommandDaemonTimeoutMs(policy, input);
if (daemonBudgetMs !== undefined) return daemonBudgetMs + REQUEST_TIMEOUT_BUDGET_MARGIN_MS;
const boundedPolicy: BoundedTimeoutPolicy = { ...policy, envelopeMs: policy.envelopeMs };
return (
resolvePositionalBudgetTimeoutMs(boundedPolicy, input.positionals ?? []) ??
Expand All @@ -85,6 +101,17 @@ export function resolveCommandRequestTimeoutMs(
);
}

/** Resolves an enforced daemon request deadline, when the command declares one. */
export function resolveCommandDaemonTimeoutMs(
policy: CommandTimeoutPolicy,
input: RequestTimeoutInput,
): number | undefined {
if (policy.daemonDeadlineMs === undefined) return undefined;
return policy.budget.source === 'flag' && typeof input.flags?.timeoutMs === 'number'
? input.flags.timeoutMs
: policy.daemonDeadlineMs;
}

function resolvePositionalBudgetTimeoutMs(
policy: BoundedTimeoutPolicy,
positionals: string[],
Expand Down
5 changes: 3 additions & 2 deletions packages/command-registry/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,7 @@ export type DaemonCommandTraits = Omit<DaemonCommandDescriptor, 'command'>;
* - `'none'` — the command has no user-supplied budget; the request
* envelope is exactly `envelopeMs`.
* - `'flag'` — the `--timeout` flag (`flags.timeoutMs`). By default it
* REPLACES the envelope (replay semantics: --timeout
* bounds the request). With `envelope: 'widen'` it only
* REPLACES the envelope. With `envelope: 'widen'` it only
* ever EXTENDS the envelope to envelopeMs + budget +
* margin (interaction --settle semantics, #1101: the
* flag bounds a post-action wait, so the request must
Expand Down Expand Up @@ -73,6 +72,8 @@ export type CommandTimeoutBudget =
*/
export type CommandTimeoutPolicy = {
budget: CommandTimeoutBudget;
/** Default daemon wall-clock budget. Its envelope is the effective budget plus the result margin. */
daemonDeadlineMs?: number;
envelopeMs: number | 'unbounded';
onTimeout: 'preserve-daemon' | 'reset-daemon';
};
Expand Down
6 changes: 6 additions & 0 deletions packages/daemon/src/core/daemon-command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import {
commandDescriptors,
resolveCommandRecordingEffect,
resolveCommandDeviceClaimPolicy,
resolveCommandTimeoutPolicy,
} from '@agent-device/command-registry/registry';
import { resolveCommandDaemonTimeoutMs } from '@agent-device/command-registry/timeout-policy';
import type { RefFrameEffect } from '@agent-device/contracts/replay';
import type { DaemonRequest } from './daemon-request.ts';

Expand All @@ -31,6 +33,10 @@ export const DAEMON_COMMAND_DESCRIPTORS: readonly DaemonCommandDescriptor[] =

const DAEMON_COMMAND_REGISTRY = buildDaemonCommandRegistry(DAEMON_COMMAND_DESCRIPTORS);

export function resolveDaemonRequestTimeoutMs(req: DaemonRequest): number | undefined {
return resolveCommandDaemonTimeoutMs(resolveCommandTimeoutPolicy(req.command), req);
}

export function getDaemonCommandRoute(command: string): DaemonCommandRoute {
return getDaemonCommandDescriptor(command)?.route ?? 'generic';
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,50 @@
import { expect, test } from 'vitest';
import { expect, test, vi } from 'vitest';
import { createRequestExecutionLocks } from '../request-execution-locks.ts';

test.each(['initial', 'retained'] as const)(
'cancellation ends a queued %s lock without running device work',
async (kind) => {
const locks = new Map<string, Promise<unknown>>();
const controller = new AbortController();
let release!: () => void;
let entered!: () => void;
const started = new Promise<void>((resolve) => {
entered = resolve;
});
const owner = createRequestExecutionLocks({ locks, initialKeys: ['device:SIM-001'] }).run(
async () => {
entered();
await new Promise<void>((resolve) => {
release = resolve;
});
},
);
await started;
const queuedLocks = createRequestExecutionLocks({
locks,
initialKeys: kind === 'initial' ? ['device:SIM-001'] : ['session:queued'],
scope: { signal: controller.signal },
});
const deviceWork = vi.fn();
const queued = queuedLocks.run(async () => {
if (kind === 'retained') await queuedLocks.retainDevice('SIM-001');
deviceWork();
});
await new Promise<void>((resolve) => setImmediate(resolve));
const reason = new Error('deadline');
const refused = expect(queued).rejects.toBe(reason);
controller.abort(reason);
await refused;
release();
await owner;
await createRequestExecutionLocks({ locks, initialKeys: ['device:SIM-001'] }).run(
async () => {},
);
expect(deviceWork).not.toHaveBeenCalled();
expect(locks.size).toBe(0);
},
);

test('a dynamically retained device lock lasts until the owning request completes', async () => {
const locks = new Map<string, Promise<unknown>>();
const replayLocks = createRequestExecutionLocks({
Expand Down
30 changes: 21 additions & 9 deletions packages/daemon/src/server/request-execution-locks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ export type RequestExecutionLocks = {
export function createRequestExecutionLocks(params: {
locks: Map<string, Promise<unknown>>;
initialKeys: RequestExecutionLockKey[];
scope?: Readonly<{ signal: AbortSignal }>;
}): RequestExecutionLocks {
const { locks, initialKeys } = params;
const { locks, initialKeys, scope } = params;
const initialKeySet = new Set(initialKeys);
const retainedLocks = new Map<RequestExecutionLockKey, RetainedExecutionLock>();
let running = false;
Expand All @@ -40,7 +41,7 @@ export function createRequestExecutionLocks(params: {
}
running = true;
try {
return await withRequestExecutionLockKeys(locks, initialKeys, task);
return await withRequestExecutionLockKeys(locks, initialKeys, task, scope?.signal);
} finally {
try {
await releaseRetainedLocks();
Expand All @@ -58,7 +59,7 @@ export function createRequestExecutionLocks(params: {

let retained = retainedLocks.get(key);
if (!retained) {
retained = retainExecutionLock(locks, key);
retained = retainExecutionLock(locks, key, scope?.signal);
retainedLocks.set(key, retained);
}
await retained.acquired;
Expand All @@ -70,31 +71,42 @@ export async function withRequestExecutionLockKeys<T>(
locks: Map<string, Promise<unknown>>,
keys: readonly RequestExecutionLockKey[],
task: () => Promise<T>,
signal?: AbortSignal,
): Promise<T> {
const [key, ...remainingKeys] = keys;
if (!key) return await task();
return await withKeyedLock(
locks,
key,
async () => await withRequestExecutionLockKeys(locks, remainingKeys, task),
async () => await withRequestExecutionLockKeys(locks, remainingKeys, task, signal),
signal,
);
}

function retainExecutionLock(
locks: Map<string, Promise<unknown>>,
key: RequestExecutionLockKey,
signal?: AbortSignal,
): RetainedExecutionLock {
let markAcquired: () => void = () => {};
let rejectAcquired: (error: unknown) => void = () => {};
let release: () => void = () => {};
const acquired = new Promise<void>((resolve) => {
const acquired = new Promise<void>((resolve, reject) => {
markAcquired = resolve;
rejectAcquired = reject;
});
const released = new Promise<void>((resolve) => {
release = resolve;
});
const completion = withKeyedLock(locks, key, async () => {
markAcquired();
await released;
});
const completion = withKeyedLock(
locks,
key,
async () => {
markAcquired();
await released;
},
signal,
);
void completion.catch((error) => rejectAcquired(error));
return { acquired, completion, release };
}
1 change: 1 addition & 0 deletions packages/daemon/src/server/request-execution-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ export async function createRequestExecutionScope(params: {
const requestExecutionLocks = createRequestExecutionLocks({
locks: executionLocks,
initialKeys: lockPlan.keys,
scope: params.platformRequestScope,
});
const dispatchLedger = requestDispatchLedger(scopedReq);
const { claimAdmission, runtimeBindings } = createRequestDeviceAccess({
Expand Down
31 changes: 26 additions & 5 deletions packages/daemon/src/server/request-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,11 @@ import {
type RequestExecutionScope,
} from './request-execution-scope.ts';
import { unsupportedSaveScriptFlagResponse } from './request-save-script-policy.ts';
import { canRunReplayScopedAction } from '../core/daemon-command-registry.ts';
import {
canRunReplayScopedAction,
resolveDaemonRequestTimeoutMs,
} from '../core/daemon-command-registry.ts';
import { resolveRequestTrackingId, withRequestDeadline } from '@agent-device/host-kit/request';
import { isWebSession } from '../sessions/web-session-names.ts';
import {
inferFillText,
Expand Down Expand Up @@ -185,6 +189,12 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn {
: deps.deviceInventoryGateways;

async function handleRequest(req: DaemonRequest): Promise<DaemonResponse> {
if (resolveDaemonRequestTimeoutMs(req) !== undefined) {
req = {
...req,
meta: { ...req.meta, requestId: resolveRequestTrackingId(req.meta?.requestId) },
};
}
return await reportEndedLeases(req, handleRequestOnLedger);
}

Expand All @@ -200,10 +210,21 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn {
logPath,
},
async () => {
const response = await withCommandHost(
{ host, requestEnv: requestCommandEnv(req) },
async () => await runRequestWithinScope(req),
);
const run = async () =>
await withCommandHost(
{ host, requestEnv: requestCommandEnv(req) },
async () => await runRequestWithinScope(req),
);
let response: DaemonResponse;
try {
const timeoutMs = resolveDaemonRequestTimeoutMs(req);
response =
timeoutMs === undefined
? await run()
: await withRequestDeadline(req.meta!.requestId!, timeoutMs, run);
} catch (error) {
response = finalizeThrownRequestError(error);
}
if (!response.ok) {
// ADR 0012 decision 6, R7 (C5a): a command that finds no session but
// hits a live repair tombstone gets `REPAIR_SESSION_EXPIRED` with
Expand Down
Loading
Loading