Skip to content

Commit a2a5859

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/limrun-scoped-token-attach-c41aff
* commit 'd396f3b509d9ed7cddaf170351ea6cf34e02ac16': fix(provider-webdriver): harden BrowserStack app references and endpoints (#3169) fix(android): back off a timed-out snapshot helper session and bound content re-captures (#3160) fix: centralize confirmed daemon retirement (#3126) fix: bind daemon registration writes to the acquired owner (#3125) fix: return confirmed daemon termination outcomes (#3124) refactor(move): share daemon registration and shutdown report modules (#3123) fix: preserve process lock exclusion across publication and reclaim (#3122) fix(cli): refuse a non-URL install-from-source source up front (#3166) fix(daemon): start a lease's TTL when its allocation completes (#3165) fix(android): fail doctor when adb is the Windows binary on a POSIX host (#3157) 0.21.20 0.21.19 # Conflicts: # src/daemon/server/daemon-runtime-metadata-ownership.test.ts
2 parents a86348e + d396f3b commit a2a5859

96 files changed

Lines changed: 4211 additions & 1578 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.

‎CONTRIBUTING.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ pnpm test-app:typecheck
3737
`pnpm build` compiles the TypeScript CLI and library. If a running development daemon must pick up
3838
that build, use `pnpm rebuild:cli`; it builds and then stops the worktree-scoped daemon.
3939

40+
`pnpm clean:daemon` retains state when it cannot confirm the recorded daemon exited. Restore
41+
process inspection or stop the verified owner before retrying. Its `--prune-dev` option considers
42+
dev state directories whose newest observed modification is at least 14 days old. It retires
43+
confirmed abandoned registrations and keeps session artifacts and state directories for inspection.
44+
4045
Build only the Apple runner target you changed:
4146

4247
```bash

‎docs/adr/0007-remote-device-leases.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,16 @@ one minute, while a cloud WebDriver connection profile asks for ten. A single co
6767
longer than its own lease is therefore ordinary on the default and only reachable through a profile
6868
on the longer one.
6969

70+
## Provider allocation
71+
72+
Allocation is admitted work too. The registry records a lease before a hosted provider creates the
73+
session behind it, and creating that session can take longer than the lease's inactivity TTL, so a
74+
lease timed from its record was already expired, or nearly so, when its client first received it, and
75+
the paid session it pointed at was orphaned. The allocation therefore holds a work pass while the
76+
provider allocates: the lease cannot expire underneath it, and a successful allocation still wanted by
77+
its requester starts the inactivity TTL from the moment allocation completed. A requester that hung up
78+
preserves nothing, and its allocation is released as before.
79+
7080
## Client-side work that precedes admission
7181

7282
Protecting admitted work covers nothing that happens before a request is admitted. Installing an
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Process lock exclusion
2+
3+
## Status
4+
5+
Accepted for the hardened host-kit protocol. Mixed access with the legacy host-kit protocol
6+
is unsupported.
7+
8+
## Rules at a glance
9+
10+
- Publication, reclaim and release hold the same non-expiring mutation guard.
11+
- The guard covers only filesystem compare-and-mutate steps. Owner liveness is judged before
12+
the guard is taken; under it, a reclaim only confirms that the record it judged is unchanged.
13+
- Release waits a bounded time for a held guard. A guard still held after that bound leaves the
14+
release unverified, as an unprovable owner record or a directory it cannot remove also does.
15+
- A claim identifies an acquisition; a PID alone cannot authorize release.
16+
- Unknown owners and abandoned guards remain retained. Age never proves abandonment.
17+
- Before upgrading, stop every legacy process using the shared state or cache paths. Keep
18+
legacy versions from returning while hardened users operate those paths. Use a single
19+
deployed version or separate environments; deleting a lock is not an upgrade procedure.
20+
- Manual guard recovery requires external confirmation that every user of its paths stopped.
21+
22+
The implementation and its executable invariants live in
23+
[`process-lock.ts`](../../packages/host-kit/src/internal/process-lock.ts) and its sibling tests.
24+
25+
## Why the guard does not expire
26+
27+
A process can pause after creating a directory or verifying a dead claim. Expiring its guard
28+
lets another process enter, then the first process resumes and overwrites or deletes the
29+
replacement. Retaining an uncertain guard trades automatic crash recovery for exclusion.
30+
31+
The guard is an exclusive file at the existing guard path. Legacy age-based `rmdir` cannot
32+
remove it, so a fresh legacy reclaimer cannot steal a hardened publisher's guard. This does
33+
not revoke a legacy reclaimer admitted before cutover: another legacy process may already
34+
have stolen its directory guard. A real paused-child experiment reproduced both protocols
35+
returning acquired after that older reclaimer resumed. Quiescence must cover all legacy
36+
users, and continued mixed access remains unsupported.
37+
38+
A different lock path would split exclusion. A version check, an owner re-read or a new
39+
guard cannot constrain legacy code after its final check. The support boundary therefore
40+
requires deployment control; host-kit does not claim to detect or evict every legacy user.
41+
42+
Daemon registration has a separate cutover boundary: keep the same `daemon.lock` path and
43+
refuse legacy files rather than automatically reclaiming them. Its startup tests must cover
44+
an already-running older daemon and concurrent old/new startup. This does not expand the
45+
host-kit mixed-protocol support contract.

‎docs/adr/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
| [0027 Descriptor Root Size vs Eager-Closure Budget (Proposed)](0027-descriptor-root-vs-eager-closure-budget.md) | splitting `packages/command-registry/src/registry.ts`, the ADR-0019 eager-closure module-count budget, and why a byte-neutral hub split is currently unshippable |
3232
| [0028 Capability-Family Cell Vocabulary — One Runtime Source (Proposed)](0028-capability-family-cell-vocabulary.md) | adding a capability operation family, `UnavailablePlatformRuntimeFacts` / `UNAVAILABLE_CELLS`, `INTERACTOR_OPERATIONS`, and why an eight-package fan-out recurs |
3333
| [0029 Daemon Policy](0029-daemon-policy.md) | `AGENT_DEVICE_DAEMON_POLICY`, confining a daemon's commands, devices, or device shutdown, and where operator rules are enforced for batch/replay steps |
34+
| [0030 Process Lock Exclusion](0030-process-lock-exclusion.md) | process-lock publication/reclaim/release, retained mutation guards, and the single-protocol upgrade boundary |
3435

3536
ADRs record *why*; the registries and gates they describe are the living source of truth — when
3637
prose and a registry disagree, the registry wins and the ADR needs a follow-up.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "agent-device",
3-
"version": "0.21.18",
3+
"version": "0.21.20",
44
"description": "Mobile app automation and verification for AI coding agents. CLI, MCP server, and typed Node.js API for iOS, Android, HarmonyOS, TV, web, macOS, and Linux.",
55
"mcpName": "io.github.callstack/agent-device",
66
"license": "MIT",

‎packages/capture-kit/src/recording/swift-cache.test.ts‎

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,16 @@ test('extra source paths participate in the cache key and reach the compiler', a
9898
expect(compiledPaths).toContainEqual(['swiftc', sourcePath, changedSupportPath]);
9999
});
100100

101-
test('stale cache locks are removed before compiling', async () => {
101+
test('cache locks with a proven dead owner are recovered before compiling', async () => {
102102
const { sourcePath, executablePath, lockDir } = await createBlockedCacheEntry();
103-
const staleTime = new Date(Date.now() - 1_000);
104-
fs.utimesSync(lockDir, staleTime, staleTime);
103+
fs.writeFileSync(
104+
path.join(lockDir, 'owner.json'),
105+
JSON.stringify({
106+
pid: 999_999_999,
107+
startTime: null,
108+
acquiredAtMs: Date.now(),
109+
}),
110+
);
105111

106112
await expect(
107113
compileSwiftSourceFile({
@@ -115,10 +121,10 @@ test('stale cache locks are removed before compiling', async () => {
115121
expect(mockRunCmd).toHaveBeenCalledTimes(1);
116122
});
117123

118-
test('cache lock timeout reports the lock path', async () => {
124+
test('an ownerless cache lock is retained with verified-recovery guidance regardless of age', async () => {
119125
const { sourcePath, lockDir } = await createBlockedCacheEntry();
120-
const futureTime = new Date(Date.now() + 60_000);
121-
fs.utimesSync(lockDir, futureTime, futureTime);
126+
const old = new Date(Date.now() - 60_000);
127+
fs.utimesSync(lockDir, old, old);
122128

123129
await expect(
124130
compileSwiftSourceFile({
@@ -132,11 +138,12 @@ test('cache lock timeout reports the lock path', async () => {
132138
details: {
133139
lockDir,
134140
timeoutMs: 1,
135-
hint: expect.stringContaining(`remove "${lockDir}"`),
141+
hint: expect.stringContaining('confirming all users of this state directory have stopped'),
136142
},
137143
});
138144

139145
expect(mockRunCmd).not.toHaveBeenCalled();
146+
expect(fs.existsSync(lockDir)).toBe(true);
140147
});
141148

142149
test('compileSwiftSourceText resolves a cache name with a long interior dash run in sub-second time', async () => {
@@ -228,15 +235,18 @@ async function expectConcurrentCacheReuse(compile: () => Promise<string>): Promi
228235

229236
const firstCompile = compile();
230237
await compileStarted;
231-
const originalMkdirSync = fs.mkdirSync;
238+
const originalReadFileSync = fs.readFileSync;
232239
const lockAttempted = new Promise<void>((resolve) => {
233-
const mkdirSpy = vi.spyOn(fs, 'mkdirSync').mockImplementation((dirPath, options) => {
234-
if (typeof dirPath === 'string' && dirPath.endsWith('.lock')) {
240+
const readSpy = vi.spyOn(fs, 'readFileSync').mockImplementation(((
241+
filePath: fs.PathOrFileDescriptor,
242+
options?: Parameters<typeof fs.readFileSync>[1],
243+
) => {
244+
if (typeof filePath === 'string' && filePath.endsWith(`.lock${path.sep}owner.json`)) {
235245
resolve();
236-
mkdirSpy.mockRestore();
246+
readSpy.mockRestore();
237247
}
238-
return originalMkdirSync(dirPath, options);
239-
});
248+
return originalReadFileSync(filePath, options);
249+
}) as typeof fs.readFileSync);
240250
});
241251
const secondCompile = compile();
242252

‎packages/capture-kit/src/recording/swift-cache.ts‎

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -204,19 +204,14 @@ async function acquireSwiftCacheLock(
204204
},
205205
timeoutMs,
206206
pollMs: LOCK_RETRY_DELAY_MS,
207-
ownerGraceMs: timeoutMs,
208207
description: `Swift cache lock: ${lockDir} (${timeoutMs}ms)`,
209208
});
210209
} catch (error) {
211-
if (
212-
error instanceof AppError &&
213-
error.message.startsWith('Timed out waiting for Swift cache lock:')
214-
) {
210+
if (error instanceof AppError && error.details?.reason === 'process_lock_timeout') {
215211
throw new AppError('COMMAND_FAILED', error.message, {
216212
...error.details,
217213
lockDir,
218214
timeoutMs,
219-
hint: `Another agent-device process may still be compiling this Swift helper. Retry shortly; if no agent-device process is active, remove "${lockDir}" and retry.`,
220215
});
221216
}
222217
throw error;

‎packages/contracts/src/app-deployment-runtime.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ export type AppDeploymentSource =
1313
export type MaterializedAppSource = Readonly<{
1414
archivePath?: string;
1515
installablePath: string;
16+
/**
17+
* The single file a hosted provider uploads for this build, named by the materializer that knows
18+
* its format. Absent when the installable is already that file or no such file exists.
19+
*/
20+
uploadPath?: string;
1621
bundleId?: string;
1722
packageName?: string;
1823
appName?: string;

‎packages/host-kit/src/file.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,13 @@ export {
1414
export { expandUserHomePath, resolveUserPath } from './internal/path-resolution.ts';
1515
export {
1616
acquireProcessLock,
17+
acquireProcessLockAcquisition,
18+
tryAcquireProcessLock,
19+
inspectProcessLock,
1720
withProcessLock,
21+
type ProcessLockAcquisition,
22+
type ProcessLockAttempt,
23+
type ProcessLockInspection,
1824
type ProcessLockOwner,
1925
type ProcessLockRelease,
2026
} from './internal/process-lock.ts';
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import fs from 'node:fs';
2+
3+
// Guard acquisition from c237027737bf4737e324adae2146ba63891152a3, before protocol hardening.
4+
export function holdLegacyReclaimMutex(mutexPath: string, abandonedAfterMs: number): boolean {
5+
for (let attempt = 0; attempt < 2; attempt += 1) {
6+
try {
7+
fs.mkdirSync(mutexPath);
8+
return true;
9+
} catch (error) {
10+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
11+
if (!clearAbandonedReclaimMutex(mutexPath, abandonedAfterMs)) return false;
12+
}
13+
}
14+
return false;
15+
}
16+
17+
function clearAbandonedReclaimMutex(mutexPath: string, abandonedAfterMs: number): boolean {
18+
let stats: fs.Stats;
19+
try {
20+
stats = fs.statSync(mutexPath);
21+
} catch {
22+
return true;
23+
}
24+
if (Date.now() - stats.mtimeMs < abandonedAfterMs) return false;
25+
try {
26+
fs.rmdirSync(mutexPath);
27+
} catch {}
28+
return true;
29+
}

0 commit comments

Comments
 (0)