Skip to content

feat(macos): make each native macOS app its own device - #3323

Merged
thymikee merged 21 commits into
callstack:mainfrom
janicduplessis:feat/macos-per-app-claims
Oct 21, 2026
Merged

thymikee merged 21 commits into
callstack:mainfrom
janicduplessis:feat/macos-per-app-claims

Conversation

@janicduplessis

@janicduplessis janicduplessis commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Summary

With AGENT_DEVICE_MACOS_APP_BACKEND=native, each app opened on the app surface is its own device (<mac>:app:<bundle id>). The per-device lock, claim, session check, and open --wait apply unchanged, so agents drive different apps of one Mac at once. A host and its app devices overlap; two app devices do not.

  • An app device opens in the background. It opens and closes only its own app, sends a bare link to that app, keeps the app surface, and refuses clipboard/settings (unsupported-device-scope).
  • A macos-app lease binds its app device and takes its claim (ADR 0007 amended).
  • XCTest, desktop, menubar, and frontmost-app keep the whole Mac. Bundle ids match without case.

Replaces the first design (an app scope on the Mac); credits @janicduplessis. Closes #3322; implements #3338. ADR 0035 is "macOS App Devices".

export AGENT_DEVICE_MACOS_APP_BACKEND=native
agent-device open TextEdit --platform macos --session one
agent-device open Calculator --platform macos --session two

Validation

Head 7519086c1f4b83b1214ed30ff100bd05e75e9714 (on main 6ac6611).

  • check:affected --run --head <head>: passed, run-gzpZB4, exactCommit: true, 11,813 related tests.
  • Live at this head (raw output), native backend: parallel app sessions in one daemon; cross-daemon conflicts both ways; clipboard refused; frontmost unchanged; kill -9 recovery and cleanup passed; policy (Mac or one app) held; close <other app> refused with the session intact.
  • Same-Mac locks never wait mid-request, including batch steps. Lease tenants see no foreign owner metadata.

🤖 Generated with Claude Code

@thymikee

thymikee commented Oct 8, 2026

Copy link
Copy Markdown
Member

The app-claim design is sound, but c269880 has three defects and no live run at this head, and Coverage fails on the eager-closure budgets.

findSessionHoldingDevice in open-device-contention-wait.ts skips every session whose claim key is not the device key, whatever this open targets. The new-session conflict check uses a different predicate (holdsAnotherAppClaim with this open's app claim key). So when a session holds com.example.one, both open --surface desktop --wait 30000 and open com.example.one --wait 30000 skip the wait and fail at once with DEVICE_IN_USE. Before this PR they waited for the holder to close. The test 'a session holding one app of the Mac is not waited for' uses positionals: [], which is a whole-Mac open, and it asserts this behavior. ADR 0034 rule 7 says only a session holding another app is skipped. The rule: the wait and the new-session conflict check must name the same set of conflicting sessions. Please resolve the open's app claim key before the wait, or pass the conflict predicate into beginOpenDeviceWait, and use one predicate in both places. Then rewrite the test so a whole-Mac opener and a same-app opener wait, and a different-app opener does not.

In session-open-execution.ts, the leaseBackend !== 'macos-app' filter now also applies when an app claim key is set. A lease session takes no claim (ADR 0007). So a local native open com.example.one finds no conflict beside a macos-app lease session on the same app, and it succeeds. Before this PR, findByDevice refused it. The reverse order still conflicts, so the result is asymmetric, and two sessions in one daemon can drive the same app's windows. This looks like it breaks the same-bundle rule from #3322 and the "lease unchanged" claim. I did not trace lease admission end to end, so please confirm. The rule: no two sessions in one daemon drive the same bundle id, whether a claim or a lease scopes them. Treat a lease session as holding its leased bundle in the same predicate as above, and add a test for both orders.

In device-claims.ts, the app path calls resolveExistingClaim on the device key only to decide. For this daemon's own abandoned whole-Mac claim (left by rollbackNewSessionClaim at session-open-execution.ts:731), it returns 'available' and logs a supersede diagnostic. But nothing overwrites or removes the device-key file, and tookOver is dropped. Before this PR, the next open overwrote it. After one failed desktop open, other daemons likely get DEVICE_IN_USE for unrelated apps until this daemon takes the whole Mac again or restarts. I did not reproduce this, and I did not check how a foreign live abandoned claim is classified. The rule: once an app acquisition succeeds, no device-key record may remain unless a live whole-device owner holds it. On 'available' from an abandoned own claim, please unlink the device-key file under the held device lock. Test it by abandoning the whole-Mac claim, opening an app, and checking that a foreign app acquisition succeeds.

This PR changes device-facing paths: open -g in apps.ts, the host-wide capture lock, and claim arbitration across daemons. The only live run used an earlier revision. After the fixes, please run at the new head with AGENT_DEVICE_MACOS_APP_BACKEND=native, two daemons with separate AGENT_DEVICE_STATE_DIR, and a shared claims dir. The run should show: (1) A open <bundleA> and B open <bundleB> both succeed, and device status lists both app claims; (2) lsappinfo front is unchanged after each open; (3) concurrent screenshots from A and B both succeed; (4) a third daemon's open <bundleA> and open --surface desktop both fail with DEVICE_IN_USE or DEVICE_CLAIM_LIVE_OWNER; (5) after kill -9 of A, the desktop open succeeds and A's app claim file is gone; (6) in one daemon, open --surface desktop --wait 30000 beside an app session waits and reports waitedMs. I ran no tests locally. I judged from the diff that the new claim and runtime tests would fail on the old code.

Coverage fails scripts/__tests__/eager-closure-budgets.test.ts for platform-apple: app-lifecycle-facade is 121 against 118, app-resolution-facade is 68 against 60, and macos-facade is 28 against 24. This is likely from this diff, which adds two static imports: apps.ts to os/macos/app-backend.ts to contracts/facades/session.ts (apps.ts:12), and helper.ts to host-kit/file to internal/process-lock.ts. I did not check for conflicts.

Could the open -g decision be made where the surface is known? The daemon open path already knows it is an app-surface open with an app claim, so it could pass background through the openIosApp and openMacOsApp options. That removes the apps.ts to app-backend.ts edge. It also stops open <app> --surface frontmost-app from silently not raising the app. Could the claim scope (app or whole device) also live on DeviceClaimSessionOwnership, with one predicate for the wait, the new-session conflict check and reopen? That replaces three key-string comparisons and closes the first two findings. Could withMacOsScreenCaptureLock load host-kit/file with a dynamic import and take the lock root from one owner? Nothing has to change first except the ownership type gaining app, and ADR 0034 rules 5 and 7 should then describe the unified predicate.

Not blocking: the capture-lock root in helper.ts nearly copies resolveDeviceClaimRoot, and the "is app-scoped" test is recomputed three times from key-string inequality, so you can move the resolver to host-kit and carry app on the ownership, or leave it.

Before merge, the claim-scope fixes need to land, the two eager imports need to move off the platform-apple facades so Coverage passes, and the two-daemon run needs to pass at the new head.

@janicduplessis

Copy link
Copy Markdown
Contributor Author

Thanks, all addressed in 0586e88..88b9ead.

  1. One predicate, sessionConflictsWithOpen: a session holds its claimed app, its leased bundle, or the whole device, and two sessions conflict unless each holds one app and the apps differ. DeviceClaimSessionOwnership (and the session's claim) now carries app, and the wait, the new-session check and reopen all use it; the key-string comparisons are gone. The open's bundle id comes from resolveOpenTarget on the bound runtime, which only exists under the locks, so instead of resolving before the wait: the wait first waits only for whole-device sessions, the open reports the conflict it finds under the lock, and the wait drops that refusal and waits again with the scope known. The test now shows whole-Mac and same-app openers waiting and a different-app opener not; a spent budget reports waitedMs.
  2. A macos-app lease session counts as holding its leased bundle in the same predicate, with a runtime test for both orders. Side effect: two leases on the same bundle (including different pins of one bundle) now conflict; I amended ADR 0007 to say so. Tell me if you'd rather keep lease-vs-lease as before.
  3. When an app acquisition finds the device key available, it removes the device-key file under the held device lock once the app claim is written, and keeps the reboot tookOver. The test abandons the whole-Mac claim, opens an app, and a foreign app acquisition succeeds.
  4. background now goes through OpenApplicationInput, the interactor options, openIosApp and openMacOsApp, set by the daemon only for a session that holds one app; apps.ts no longer imports app-backend.ts, and open <app> --surface frontmost-app raises the app again. withMacOsScreenCaptureLock imports host-kit/file lazily. The eager-closure budgets pass. I left the lock-root resolver where it is.
  5. ADR 0034 rules 5 and 7 describe the predicate and the wait; ADR 0031 and the commands doc match.

Live run at 88b9ead on macOS 27 (native backend, two daemons with their own state dirs, shared claims dir, Calculator and Clock): both app opens succeeded and device status listed both app claims; lsappinfo front was unchanged after each open; concurrent screenshots both succeeded; a third daemon's open com.apple.calculator and open --surface desktop both got DEVICE_IN_USE / DEVICE_CLAIM_LIVE_OWNER; after kill -9 of A, the desktop open succeeded and A's app claim file was gone; open --surface desktop --wait 30000 beside an app session waited and refused with waitedMs: 30001, and succeeded about 4 s in when the app session closed. One note: a background-launched Clock answered open before its first window existed, so a screenshot right after the open got window-not-found; it succeeded about 3 s later. Local: pnpm check:affected --run green (4739 tests), eager-closure 772/772.

@thymikee

thymikee commented Oct 9, 2026

Copy link
Copy Markdown
Member

This is ready at 88b9ead. The open-wait fix and the abandoned-claim test from the earlier review (c269880) are in, and the eager-closure failure is gone.

Not blocking, and you can take or leave these: (1) when a macos-app lease open waits, admission copies the request's internal object, so the wait's recorded spend in open-device-contention-wait.ts can be lost on the next attempt (the copy is made in lease-lifecycle.ts:118). The spend should be readable from whatever request object the conflict check reads, however often admission copies it; a lease case in the runtime wait test would show it. (2) The wait tests at open-device-contention-wait.test.ts use an inline stand-in, so deleting the reportOpenDeviceConflict call at session-open-execution.ts:420 would keep every unit test green; one handleSessionCommands test with waitMs, where a whole-Mac open waits beside an app session and then succeeds once it retires, would cover it. (3) The JSDoc of the deleted findByDevice is still stacked above listRefs in session-store.ts, and .fallowrc.json and scripts/layering/session-state.ts still name findByDevice.

Could the locked open task return a typed outcome, such as a deferral carrying the opener app, that runWhenDeviceIsUnheld reads, instead of a mutable reportOpenDeviceConflict callback on the request? Nothing would then ride on the request object, so the copy hazard in note 1 could not occur. This is optional, since the current design works for non-lease opens.

The live run at 88b9ead is as you reported it. I did not see a transcript, and I ran no tests myself.

Coverage fails one check, test-file-size-ratchet, because src/daemon/device/__tests__/device-claims.test.ts is now 1005 lines, over the 1000-line tripwire. This PR alone grows that file, so the failure is related. Please split it along the source module it mirrors, for example the app-claim cases into their own file. That is the next thing to fix before merge. I know of no conflicts.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 9, 2026
@janicduplessis
janicduplessis marked this pull request as ready for review October 9, 2026 02:36
Copilot AI balanced review requested due to automatic review settings October 9, 2026 02:37

Copilot AI left a comment

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.

🟡 Changes recommended

Cross-daemon leases and same-daemon transient claims can bypass the intended app-versus-host exclusion.

2 open findings
What changed in this PR

Adds per-app claims for native macOS sessions, enabling concurrent automation of different applications while preserving whole-device exclusion.

Changes:

  • Adds app-scoped claim records, conflict handling, waiting, and coverage.
  • Launches scoped apps in the background and serializes ScreenCaptureKit captures.
  • Documents the design through ADR 0034 and command guidance.
File Description
website/​docs/​docs/​commands.md Documents per-app concurrency and exclusions.
src/​daemon/​session-store.ts Removes single-session device lookup.
src/​daemon/​session-state.ts Adds app claim metadata.
src/​daemon/​session-lifecycle/​internal/​session-open.ts Enforces app scope on reopen.
src/​daemon/​session-lifecycle/​internal/​session-open-execution.ts Resolves and acquires app-scoped claims.
src/​daemon/​session-lifecycle/​internal/​__tests__/​session-open-execution-runtime.test.ts Tests scoped opens and lease conflicts.
src/​daemon/​open-device-contention-wait.ts Makes waiting app-scope aware.
src/​daemon/​handlers/​__tests__/​session-device-claims.test.ts Adds lease device-key fixture data.
src/​daemon/​device/​device-claims.ts Implements app claim arbitration.
src/​daemon/​device/​device-claim-record.ts Extends persisted claim schema.
src/​daemon/​device/​device-claim-paths.ts Defines canonical app claim keys.
src/​daemon/​device/​__tests__/​device-claims.test.ts Tests claim coexistence and cleanup.
src/​daemon/​device/​__tests__/​device-claim-record.test.ts Tests app claim decoding.
src/​daemon/​daemon-request.ts Adds open-conflict reporting state.
src/​daemon/​__tests__/​session-store-lifetime.test.ts Updates removed lookup coverage.
src/​daemon/​__tests__/​request-execution-scope-open-device-wait.test.ts Updates device lookup in fixtures.
src/​daemon/​__tests__/​open-device-contention-wait.test.ts Tests app-aware waiting.
src/​daemon/​__tests__/​application-lifecycle-runtime-fixture.ts Carries background-open context.
packages/​platform-apple/​src/​os/​macos/​host-provider.ts Adds open -g support.
packages/​platform-apple/​src/​os/​macos/​helper.ts Serializes screen captures globally.
packages/​platform-apple/​src/​os/​macos/​helper.test.ts Tests capture serialization.
packages/​platform-apple/​src/​os/​macos/​apps.ts Propagates background-open options.
packages/​platform-apple/​src/​os/​macos/​apps.test.ts Verifies macOS open arguments.
packages/​platform-apple/​src/​lifecycle.ts Forwards background launch behavior.
packages/​platform-apple/​src/​interactor.ts Passes background options to launch.
packages/​platform-apple/​src/​core/​tool-provider-types.ts Extends host-provider interfaces.
packages/​platform-apple/​src/​core/​app-launch.ts Extends Apple launch options.
packages/​contracts/​src/​interactor-types.ts Adds the interactor background option.
packages/​contracts/​src/​application-lifecycle-runtime.ts Adds runtime background semantics.
packages/​contracts/​src/​application-lifecycle-interaction.ts Propagates background lifecycle input.
docs/​adr/​README.md Registers ADR 0034.
docs/​adr/​0034-macos-per-app-claims.md Records the claim architecture.
docs/​adr/​0031-macos-native-app-backend.md Links native behavior to ADR 0034.
docs/​adr/​0007-remote-device-leases.md Documents lease/app-claim interaction.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/daemon/device/device-claims.ts Outdated
Comment thread src/daemon/session-lifecycle/internal/session-open-execution.ts Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment •

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.

All reported issues were addressed across 34 files

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

Comment thread src/daemon/device/device-claims.ts Outdated
Comment thread src/daemon/device/device-claims.ts Outdated
Comment thread docs/adr/0007-remote-device-leases.md Outdated
Comment thread packages/daemon/src/sessions/session-lifecycle/internal/session-open.ts Outdated
Comment thread docs/adr/0031-macos-native-app-backend.md Outdated
Comment thread docs/adr/0034-macos-per-app-claims.md Outdated
Comment thread packages/daemon/src/sessions/__tests__/open-device-contention-wait.test.ts Outdated
Comment thread packages/daemon/src/sessions/__tests__/open-device-contention-wait.test.ts Outdated
Comment thread src/daemon/device/__tests__/device-claims.test.ts Outdated
Comment thread packages/platform-apple/src/os/macos/helper.ts Outdated
@janicduplessis

Copy link
Copy Markdown
Contributor Author

Thanks. Pushed four follow-ups on top of 88b9ead.

  • device-claims.test.ts is split: the app-claim cases are now in device-claims-app.test.ts, which brings it to 792 lines and test-file-size-ratchet passes.
  • Wait spend: beginOpenDeviceWait now creates the openDeviceWait object once and updates it in place, so any copy of internal that lease admission makes reads the same spend. The runtime wait test has a case that copies the request on each attempt; it fails without the fix. I did not do the typed deferral. The open task returns a DaemonResponse through runAdmitted, runLocked and the open handler, so a typed outcome would touch all of those layers. With the spend shared by reference, the copy hazard is gone without that.
  • A new handleSessionCommands test has a whole-Mac open with waitMs waiting beside an app session and succeeding once it retires. It fails if the reportOpenDeviceConflict call is removed.
  • Removed the stale findByDevice JSDoc and its mentions in .fallowrc.json and scripts/layering.

pnpm check:affected --run is green at this head (4741 related tests), and eager-closure and the size ratchet pass. I did not repeat the two-daemon live run, since these commits change no device-facing path.

Copilot AI balanced review requested due to automatic review settings October 9, 2026 02:52

@cubic-dev-ai cubic-dev-ai Bot left a comment •

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.

All reported issues were addressed across 10 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

Comment thread packages/daemon/src/sessions/__tests__/open-device-contention-wait.test.ts Outdated

Copilot AI left a comment

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.

🔵 Needs a closer look

Core exclusion guarantees still have unresolved cross-daemon arbitration and app-scope escape defects.

2 open findings
Previously missed (1)

In code that hasn't changed since last review

Medium severity Bind launch URLs to the claimed app bundle

packages/​platform-apple/​src/​lifecycle.ts:268

An app-scoped open can still escape its claim through --launch-url. openLaunchPlan leaves macOS launch URLs as a follow-up, and this call invokes openTarget(url); background only adds -g, so the URL may launch its registered browser or another app rather than the claimed bundle. For background/per-app opens, bind the follow-up URL to input.appBundleId (equivalent to open -g -b <bundle> <url>) or reject this option.

🧠 Review effort: Balanced

@thymikee

thymikee commented Oct 9, 2026

Copy link
Copy Markdown
Member

The code in fd97751 looks good, and the reconciler change is now fixed, so I have no code findings left. The earlier review at 88b9ead was clean, and this delta only touches the open-wait spend path, the layering scanner entry and the test split. All 16 checks pass at fd97751, so there is no failure to attribute, and there are no conflicts.

I did not run the tests. I checked the spend regression by reading the old recordOpenWaitSpend and tracing the attempt and copy order by hand. I did not reproduce two-daemon behavior live and I rely on your run at 88b9ead, which this delta's device paths do not change. I also did not read prepareOpenCommandDetails for macOS side effects beyond the expireRefFrame call at session-open.ts:233.

Not blocking: the new device-claims-app.test.ts copies the acquireDeviceClaim wrapper, useClaimsRoot, the afterEach cleanup and the mac DeviceInfo fixture from device-claims.test.ts:38-70, and a shared fixtures module would remove that. Take it or leave it.

The open inline threads from the earlier round still apply in part. These still hold at P1/P2 and need a fix or an answer before merge: #3323 (comment) (the "covered" wording), #3323 (comment) (lease scope), #3323 (comment) (ADR 0007 wording), #3323 (comment) (refs expire on refusal), #3323 (comment) (ADR 0031 rule 7), #3323 (comment) (which daemon), #3323 (comment) (one-version caveat), #3323 (comment) (wait is local), and #3323 (comment) (lock ignores signal). Five lower-priority threads also still apply. These do not apply and you can resolve them: #3323 (comment) (own app claims match the existing owned-claim policy), #3323 (comment) (same reason, and a foreign app claim still conflicts), #3323 (comment) (ADR 0034:79-81 already states the rule), #3323 (comment) (not waiting on a foreign daemon is the documented design), #3323 (comment) (the runtime test now runs the real conflict check), #3323 (comment) (the copy order mirrors runAdmitted). These are fixed at this head: #3323 (comment) and #3323 (comment).

No code change is needed before merge. Please fix or answer the doc threads on ADR 0007:190-194, ADR 0031:39 and the cross-daemon caveats at commands.md:351 before a human merges.

@thymikee

Copy link
Copy Markdown
Member

@janicduplessis the code is clean at fd97751, but it now conflicts with main. Could you rebase? Then it can go to merge.

@janicduplessis
janicduplessis force-pushed the feat/macos-per-app-claims branch from fd97751 to 1f7acb2 Compare October 20, 2026 17:12
Copilot AI balanced review requested due to automatic review settings October 20, 2026 17:12

Copilot AI left a comment

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.

🟡 Changes recommended

Inconclusive claim inspection can allow whole-Mac ownership alongside an active app session.

3 open issues

Review effort: Balanced

Comment thread packages/daemon-contracts/src/device-claims.ts Outdated
Copilot AI balanced review requested due to automatic review settings October 20, 2026 18:03
thymikee and others added 5 commits October 21, 2026 11:36
A native app-surface open now binds an app device, <mac id>:app:<bundle id>,
instead of an app scope on the Mac device. The per-device execution lock,
claim, conflict check, and open --wait then apply unchanged, so two app
sessions run in parallel in one daemon too. One rule relates a host to its
app devices: they overlap, and two app devices do not.

- Device selection resolves the app before any lock through a root-composed
  resolver, only for the native backend.
- The claim store keys app device claims by the app device identity; the
  record needs no app field. The host lock still orders before the app lock.
- An app device opens in the background, opens and closes only its own app,
  routes a bare link to that app, keeps the app surface, and refuses the
  clipboard and host settings through runtime facts.
- A macos-app lease binds the app device and takes its claim.
- Removes the scope predicate, the two-phase open wait, the lease/claim
  bridge, the reopen guard, and the background-open plumbing.
- Bundle ids match without regard to case.

ADR 0035 becomes "macOS App Devices"; ADR 0007 and ADR 0031 are amended.

Co-authored-by: Janic Duplessis <janicduplessis@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A replay or batch that holds an app device's execution lock and then opens
the whole Mac retained the host lock out of the host-then-app order, so it
could deadlock with a fresh open that held the host and waited for the app.
Retaining a host's lock while holding one of its app devices now fails with
UNSUPPORTED_OPERATION (reason host-after-app-device-lock) instead of waiting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
App devices made two requests on one Mac concurrent where they used to share
the Mac's execution lock. Two replays on two apps that each close their app
and open the other's, or a replay that leaves its app for the whole Mac while
a fresh open holds the Mac, could then each wait for the lock the other held.

A request that holds a lock on a Mac or one of its apps now takes another lock
on that Mac mid-request only while it is free, and is otherwise refused with
DEVICE_IN_USE (reason DEVICE_EXECUTION_LOCK_BUSY). Locks on other devices are
still waited for. This replaces the narrower host-after-app-device-lock rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 21, 2026 15:53
@janicduplessis
janicduplessis force-pushed the feat/macos-per-app-claims branch from da4026f to 4da13ff Compare October 21, 2026 15:53

Copilot AI left a comment

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.

🟡 Changes recommended

Concurrent batch transitions can bypass the same-host lock guard and deadlock app sessions.

2 open issues

Review effort: Balanced

Comment thread packages/daemon/src/server/request-binding.ts Outdated
thymikee and others added 5 commits October 21, 2026 18:08
App devices took a derived id, so a `devices.allow` entry for the Mac
refused every native app open, and an app-only entry could never be
selected because inventory hid its Mac. The policy now admits an app device
whose host it allows. An allowed app device id admits that app alone.
Inventory lists the host of an allowed app device so selection can derive
it, while binding the host itself stays denied.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…p devices

- A daemon policy app device id matches whatever case its bundle part uses.
- On a Mac the policy does not allow, app name resolution reports no
  installed apps: a failed resolution leaves the Mac selected, which
  binding refuses.
- An app device refuses audio probe, which samples the whole Mac's audio.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Policy device ids compare canonicalized on both sides, so an id that
  merely contains `:app:` still matches itself.
- On a Mac the policy does not allow, app resolution returns only an allowed
  app device, so an installed but disallowed app answers like a missing one.
- The audio refusal carries the capture fact's reason in its details.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A batch step runs as a full request and takes its own initial locks while
its batch still holds the batch session's app device lock. Two batches in
app sessions that each close their app and open the other's could then
cycle: one step took the Mac's lock and waited for the other batch's app,
while the other step waited for the Mac. Each request now records the locks
it holds for the requests nested in it, and a nested request takes a lock on
a Mac its enclosing requests already hold a lock on only while it is free,
otherwise refused with DEVICE_IN_USE (DEVICE_EXECUTION_LOCK_BUSY). Locks on
other devices are still waited for, and cancellation is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A batch step whose lock plan resolved no device and that reopens the batch
session's app retained a lock its batch already held. The same-Mac rule saw
it as busy. A lock an enclosing request holds is now re-entered, as initial
locks already were.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 21, 2026 16:25
@thymikee

Copy link
Copy Markdown
Member

@thymikee The branch is current: your rebase onto main is 4da13ff, and its tree is identical to my earlier merge commit bc89dac. On top of it, as a fast-forward, I added three daemon-policy commits (f84f914, bb21a39, 0ff13a5) and two nested-batch lock commits (783cfe2, 409b8d5).

At the final head 409b8d5:

  • check:affected --run --head passed: run-mMgeBS, 11,810 related tests.
  • The live native run, including daemon-policy cases, passed.
  • No review threads are open.

Evidence from earlier runs is attributed to their own SHAs in the threads.

Copilot AI left a comment

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.

🔵 Needs a closer look

Cross-daemon ownership and nested locking need human review, with unresolved privacy, session-lifecycle, and replay-concurrency issues.

1 open issue
2 previously missed issues

In code that hasn't changed since last review

Medium severity Limit host lock scope to open admission and publication

packages/​daemon/​src/​server/​request-binding.ts:65

Limit the host lock to first-open admission and publication. This branch also handles fresh replays, and request-execution-scope.ts:365–368 holds the initial locks until the entire replay finishes. After a replay opens app A, a fresh open or replay for unrelated app B therefore waits on the Mac's lock throughout A's remaining actions. Long replays serialize agents whose app devices do not overlap. Separate the short host admission lock from the replay-lifetime session/app locks, preserving the host/app conflict checks and nested-lock safeguards. Add coverage showing that B can open while A continues its replay.

Medium severity Validate close targets before tearing down sessions

packages/​platform-apple/​src/​os/​macos/​apps.ts:123

Validate the app-device close target before session teardown. After opening TextEdit, an ordinary close Calculator --session <textedit-session> reaches this refusal inside runSessionCloseTeardown, after resource cleanup has begun. runCloseTeardownAndRelease then retains the claim because platformCloseError is set, but still retires the session (session-close.ts:347–363). The invalid request therefore removes the usable session and leaves a live claim blocking other sessions. Check the target at close admission, keep this platform guard, and add a close-handler regression asserting that a rejected target leaves the session and its resources intact.

2 resolved since last review

Review effort: Balanced

… bad close targets

- A `macos-app` lease tenant sees a busy app as a generic DEVICE_IN_USE with
  its typed reason, not the other owner's session, workspace, state
  directory, or host recovery command, and a reboot takeover warning does
  not name the previous owner.
- Only a fresh `open` of an app device takes its host's execution lock. A
  replay holds its locks for its whole run, so it locks only its app device;
  its opens still run under the first-open lock keyed by the host.
- `close <app>` in an app device session resolves its target through the
  platform before any teardown (a new close plan use), so another app is
  refused while the session and its claim stay intact, and any name of the
  session's own app still closes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings October 21, 2026 16:55
@thymikee

Copy link
Copy Markdown
Member

Copilot review 5602953660 raised two issues outside review threads. Both are fixed in 7519086, along with the lease-privacy thread.

Host lock scope. Only a fresh open of an app device takes the Mac's execution lock, so open --wait still checks the Mac's sessions under it. A replay holds its locks for its whole run, so it now locks only its app device. Its opens still run under the first-open lock keyed by the host. The replay lock-plan test asserts this, and it fails on 409b8d5. One known limit remains: a fresh batch --platform macos with no app target still selects the whole Mac, so it holds that lock for its run. ADR 0035 rule 3 states this.

Close target before teardown. close <app> in an app-device session now resolves its target through the platform before any teardown, using a new close plan use, resolveCloseTargetRuntimeUse. Another app is refused with app-device-scope while the session and its claim stay intact, and any name of the session's own app still closes it. Tests:

  • session-device-claims.test.ts covers the handler: the session and its claim stay intact.
  • apps.test.ts covers the platform: an alias is accepted and another app is refused.
  • The live run at 7519086 showed close Calculator refused with the session and its claim kept, and close TextEdit by name succeeding after open com.apple.TextEdit.

Validation at 7519086: check:affected --run --head passed (run-gzpZB4, 11,813 related tests). The live native run passed: apps ran in parallel, conflicts held across daemons in both orders, clipboard refused, frontmost app unchanged, kill -9 recovery worked, the policy cases held, the close cases held, and no claims were left.

@thymikee

Copy link
Copy Markdown
Member

The code at 7519086 looks correct to me, and the lock fixes from the earlier review at aefe920 (#3323 (comment)) now hold: a second same-Mac lock taken mid-request is refused instead of waiting, crossed app A/B replays are refused instead of deadlocking, and the macOS app lease no longer leaks the previous owner. All 14 checks pass at 7519086, and there are no conflicts. What is still missing is raw output from the live native macOS run at 7519086: it is only described in a maintainer comment, so please attach the output that shows two app devices on one Mac each opening, running and closing without disturbing the other. Not blocking, and you can take or leave these: the lock rule in request-execution-locks.ts now keeps a second registry of held locks (the enclosingRequestLocks store, the heldLocks set and the beforeAcquire hook) beside the kernel keyed-lock, and it needs two re-entrance special cases; the close-refusal test in session-device-claims.test.ts could build its error by calling resolveMacOsAppDeviceOpenTarget with a stub host, so the handler sees the production error. Could the question "which locks does this async context hold, including retained ones" live only in packages/kernel/src/keyed-lock.ts, with a hold primitive that registers a retained frame in the caller's context and a held-keys query, so the same-host rule becomes one check and those special cases go away? The app-device identity can stay as it is. The five open inline threads from the earlier round all apply to code that is now fixed, so please resolve them: #3323 (comment), #3323 (comment), #3323 (comment), #3323 (comment) and #3323 (comment). Before merge, please attach the live run output and decide whether to accept the lock registry or move it into keyed-lock. Then rebase against #3867, which edits the same lines in apps.ts and macos-app-lease.ts; whichever PR lands second must resolve that conflict.

@thymikee

Copy link
Copy Markdown
Member

Raw output of the live native macOS run at exact head 7519086c1f4b83b1214ed30ff100bd05e75e9714, as the reviewer asked in #issuecomment-6322558994. This is the output of the run reported in #issuecomment-6321624952. Nothing was rerun to attach it.

  • When: 2026-10-21. The four scripts ran back to back and finished at 16:55:21, 16:55:23, 16:55:27 and 16:55:30 UTC (file modification times).
  • Where: one host Mac on macOS 27.0, AGENT_DEVICE_MACOS_APP_BACKEND=native, source build from the worktree (pnpm build at that SHA, then node bin/agent-device.mjs). Every daemon used its own temporary state directory under /private/tmp, with one shared temporary AGENT_DEVICE_CLAIMS_DIR per script.
  • Redacted: the name of the Mac (<mac-name>), home and worktree paths (~, <worktree>), the unrelated app that was frontmost on the host (<unrelated-frontmost-app>), its lsappinfo ASN, and the local account name (<user>). No tokens appear in the output. Times inside ls listings are local time (UTC+2).

What the output shows

  • run.sh:
    • Two app devices in one daemon, TextEdit (opened by mixed-case bundle id) and Calculator, open; their concurrent 3 s waits both finish in 3.4 s.
    • In-session open Calculator and --surface desktop are refused with app-device-scope.
    • A second daemon gets DEVICE_IN_USE for the same app and for the desktop surface, then opens Clock beside them.
    • clipboard is refused with unsupported-device-scope.
    • Three concurrent screenshots across two daemons succeed.
    • After kill -9 of daemon B, daemon C opens Clock.
    • Cleanup closes everything and stops the daemons, leaving claims: [].
  • reverse.sh: with a whole-Mac desktop session held, app opens are refused from the same daemon and from another daemon. Cleanup leaves 0 claims.
  • policy.sh:
    • With devices.allow naming the Mac, TextEdit opens as an app device.
    • With only host-macos-local:app:com.apple.TextEdit allowed (mixed case), TextEdit opens; Calculator, an unknown app name and the desktop surface get DAEMON_POLICY_DENIED.
    • Cleanup leaves 0 claims.
  • close.sh: close Calculator in the TextEdit session is refused with app-device-scope while the session and its claim stay. close TextEdit, by name after opening by bundle id, then closes it. 0 claims are left.

Limitations

  • The frontmost check reads lsappinfo front. It shows the frontmost app staying the same; it does not inspect windows.
  • At an earlier head (da4026f), one run of two showed TextEdit frontmost after the opens. Later runs did not reproduce that, but it is not proven impossible.
  • The lease reboot-takeover warning and the crossed-batch lock refusal were not exercised live; unit and router tests cover them.
  • One host and one OS version were used.
run.sh (two app devices, two and three daemons)
ROOT=/private/tmp/ad3323-live-E9jrtS
source=7519086c1f4b83b1214ed30ff100bd05e75e9714

=== frontmost before ===
[ NULL ]  ASN:<asn>: (in front) 
    bundleID="<unrelated-frontmost-app>"
    bundle path=[ NULL ] 
    executable path=[ NULL ] 
 !cgsConnection !signalled type=[ NULL ]  flavor=[ NULL ]  Version=[ NULL ]  Arch=!!none 


=== (1) daemon A: open com.apple.TextEdit (mixed case) --session one ===
{"ok":true,"device":"<mac-name>","id":"host-macos-local:app:com.apple.textedit","appBundleId":"com.apple.TextEdit"}

=== (1) daemon A: open Calculator --session two ===
{"ok":true,"device":"<mac-name>","id":"host-macos-local:app:com.apple.calculator","appBundleId":"com.apple.calculator"}

=== frontmost after app opens ===
[ NULL ]  ASN:<asn>: (in front) 
    bundleID="<unrelated-frontmost-app>"
    bundle path=[ NULL ] 
    executable path=[ NULL ] 
 !cgsConnection !signalled type=[ NULL ]  flavor=[ NULL ]  Version=[ NULL ]  Arch=!!none 


=== (1) one daemon, two app sessions: concurrent wait 3000 (parallel ~3s, serialized ~6s) ===
{"ok":true}
{"ok":true}
two done at +3326ms
one done at +3328ms
both done after 3437ms

=== (1b) in-session: open Calculator --session one (other app) ===
{"ok":false,"code":"INVALID_ARGS","reason":"app-device-scope","msg":"This session drives com.apple.TextEdit as its own device, so it cannot open or close com.apple.calculator."}

=== (1b) in-session: open --surface desktop --session one ===
{"ok":false,"code":"INVALID_ARGS","reason":"app-device-scope","msg":"This session holds com.apple.TextEdit as its own device, so it opens only the app surface. Use a new --session for --surface desktop."}

=== (2) daemon B: open TextEdit --session three (same app, other daemon) ===
{"ok":false,"code":"DEVICE_IN_USE","reason":"DEVICE_CLAIM_LIVE_OWNER","msg":"macos device host-macos-local:app:com.apple.textedit is owned by session \"one\" in workspace \"<worktree>\"."}

=== (2) daemon B: open --surface desktop --session four ===
{"ok":false,"code":"DEVICE_IN_USE","reason":"DEVICE_CLAIM_LIVE_OWNER","msg":"macos device host-macos-local:app:com.apple.calculator is owned by session \"two\" in workspace \"<worktree>\"."}

=== (2) daemon B: open Clock --session five (different app, other daemon) ===
{"ok":true,"device":"<mac-name>","id":"host-macos-local:app:com.apple.clock","appBundleId":"com.apple.clock"}

=== (3) clipboard read --session one ===
{"ok":false,"code":"UNSUPPORTED_OPERATION","reason":"unsupported-device-scope","msg":"clipboard is not supported on this device"}

=== (5) concurrent screenshots: one, two (A), five (B) ===
{"ok":true}
{"ok":true}
{"ok":true}
-rw-r--r--@ 1 <user>  wheel  358038 Oct 21 18:55 /private/tmp/ad3323-live-E9jrtS/five.png
-rw-r--r--@ 1 <user>  wheel   73621 Oct 21 18:55 /private/tmp/ad3323-live-E9jrtS/one.png
-rw-r--r--@ 1 <user>  wheel   65773 Oct 21 18:55 /private/tmp/ad3323-live-E9jrtS/two.png

=== device status (claims) ===
[{"deviceKey":"local:apple:macos:host-macos-local:app:com.apple.calculator","classification":"live"},{"deviceKey":"local:apple:macos:host-macos-local:app:com.apple.textedit","classification":"live"},{"deviceKey":"local:apple:macos:host-macos-local:app:com.apple.clock","classification":"live"}]
0148c536d0ccec0b4b1402508b46f7af46d284a9512e5c5edfda5130b0520a3d.json
255e6e0d36afb0c479a9e71966c9cd99f62b1a647b964a9207096913ec2710e8.json
65542944481c1a63c0fd68628ffbffc45c80335223f451ef3dd252b7045d3e09.json

=== frontmost at end ===
[ NULL ]  ASN:<asn>: (in front) 
    bundleID="<unrelated-frontmost-app>"
    bundle path=[ NULL ] 
    executable path=[ NULL ] 
 !cgsConnection !signalled type=[ NULL ]  flavor=[ NULL ]  Version=[ NULL ]  Arch=!!none 


=== (6) kill -9 daemon B, then daemon C opens Clock ===
daemon B pid=1131
{"ok":true,"device":"<mac-name>","id":"host-macos-local:app:com.apple.clock","appBundleId":"com.apple.clock"}

=== cleanup ===
{"ok":true}
{"ok":true}
{"ok":true}
{"ok":true}
{"ok":true}
{
  "success": true,
  "data": {
    "claims": []
  }
}

claims left:
total 0
drwxr-xr-x  2 <user>  wheel   64 Oct 21 18:55 .
drwx------  9 <user>  wheel  288 Oct 21 18:55 ..
reverse.sh (whole-Mac session first)
source=7519086c1f4b83b1214ed30ff100bd05e75e9714
== D: open --surface desktop --session desk
{"ok":true,"id":"host-macos-local"}
== D (same daemon): open TextEdit --session app
{"ok":false,"code":"DEVICE_IN_USE","msg":"Device is already in use by session \"desk\"."}
== E (other daemon): open TextEdit --session app
{"ok":false,"code":"DEVICE_IN_USE","reason":"DEVICE_CLAIM_LIVE_OWNER","msg":"macos device host-macos-local is owned by session \"desk\" in workspace \"<worktree>\"."}
== cleanup
{"ok":true}
{"ok":true}
{"ok":true}
claims left:        0
policy.sh (daemon policy)
source=7519086c1f4b83b1214ed30ff100bd05e75e9714
== policy allows the Mac: open TextEdit
{"ok":true,"id":"host-macos-local:app:com.apple.textedit"}
{"ok":true}
== policy allows only TextEdit (mixed case): open TextEdit / Calculator / desktop
{"ok":true,"id":"host-macos-local:app:com.apple.textedit"}
{"ok":false,"code":"UNAUTHORIZED","reason":"DAEMON_POLICY_DENIED","rule":"device"}
{"ok":false,"code":"UNAUTHORIZED","reason":"DAEMON_POLICY_DENIED","rule":"device"}
{"ok":false,"code":"UNAUTHORIZED","reason":"DAEMON_POLICY_DENIED","rule":"device"}
{"ok":true}
claims left:        0
close.sh (close target scope)
source=7519086c1f4b83b1214ed30ff100bd05e75e9714
== open com.apple.TextEdit --session te
{"ok":true,"id":"host-macos-local:app:com.apple.textedit"}
== close Calculator --session te (other app)
{"ok":false,"code":"INVALID_ARGS","reason":"app-device-scope"}
== session te still open, claims:        1
["te"]
== close TextEdit --session te (own app, by name)
{"ok":true}
claims left:        0
Scripts that produced this output (commands)
# ---- run.sh
#!/bin/zsh
# Live acceptance for PR #3323 (macOS app devices). Usage: run.sh <worktree>
set -u
WT=$1
ROOT=$(mktemp -d /private/tmp/ad3323-live-XXXXXX)
A=$ROOT/a; B=$ROOT/b; C=$ROOT/c; CL=$ROOT/claims
mkdir -p $A $B $C $CL
export AGENT_DEVICE_MACOS_APP_BACKEND=native
export AGENT_DEVICE_CLAIMS_DIR=$CL
AD() { node $WT/bin/agent-device.mjs "$@"; }
step() { print -- "\n=== $* ==="; }
ts() { node -e 'process.stdout.write(String(Date.now()))'; }
summ() { node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const j=JSON.parse(s);const o=j.success?{ok:true,device:j.data?.device,id:j.data?.id,appBundleId:j.data?.appBundleId}:{ok:false,code:j.error?.code,reason:j.error?.details?.reason,session:j.error?.details?.session,msg:j.error?.message};console.log(JSON.stringify(o))}catch(e){console.log("RAW:",s.slice(0,600))}})'; }
print "ROOT=$ROOT"
print "source=$(git -C $WT rev-parse HEAD)"
step "frontmost before"; lsappinfo info -only bundleid "$(lsappinfo front)"

step "(1) daemon A: open com.apple.TextEdit (mixed case) --session one"
AD open com.apple.TextEdit --platform macos --session one --state-dir $A --json | summ
step "(1) daemon A: open Calculator --session two"
AD open Calculator --platform macos --session two --state-dir $A --json | summ
step "frontmost after app opens"; lsappinfo info -only bundleid "$(lsappinfo front)"

step "(1) one daemon, two app sessions: concurrent wait 3000 (parallel ~3s, serialized ~6s)"
T0=$(ts)
( AD wait 3000 --session one --state-dir $A --json | summ; print "one done at +$(( $(ts) - T0 ))ms" ) &
( AD wait 3000 --session two --state-dir $A --json | summ; print "two done at +$(( $(ts) - T0 ))ms" ) &
wait
print "both done after $(( $(ts) - T0 ))ms"

step "(1b) in-session: open Calculator --session one (other app)"
AD open Calculator --platform macos --session one --state-dir $A --json | summ
step "(1b) in-session: open --surface desktop --session one"
AD open --platform macos --surface desktop --session one --state-dir $A --json | summ

step "(2) daemon B: open TextEdit --session three (same app, other daemon)"
AD open TextEdit --platform macos --session three --state-dir $B --json | summ
step "(2) daemon B: open --surface desktop --session four"
AD open --platform macos --surface desktop --session four --state-dir $B --json | summ
step "(2) daemon B: open Clock --session five (different app, other daemon)"
AD open Clock --platform macos --session five --state-dir $B --json | summ

step "(3) clipboard read --session one"
AD clipboard read --session one --state-dir $A --json | summ

step "(5) concurrent screenshots: one, two (A), five (B)"
sleep 3
( AD screenshot $ROOT/one.png --session one --state-dir $A --json | summ ) &
( AD screenshot $ROOT/two.png --session two --state-dir $A --json | summ ) &
( AD screenshot $ROOT/five.png --session five --state-dir $B --json | summ ) &
wait
ls -la $ROOT/*.png

step "device status (claims)"
AGENT_DEVICE_STATE_DIR=$A AD device status --json | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const j=JSON.parse(s);const c=j.data?.claims??j.data;console.log(JSON.stringify((Array.isArray(c)?c:[]).map(x=>({deviceKey:x.claim?.deviceKey??x.deviceKey,session:x.claim?.session??x.session,classification:x.classification}))))})'
ls $CL

step "frontmost at end"; lsappinfo info -only bundleid "$(lsappinfo front)"

step "(6) kill -9 daemon B, then daemon C opens Clock"
BPID=$(node -e 'const j=require(process.argv[1]);console.log(j.pid)' $B/daemon.json 2>/dev/null)
print "daemon B pid=$BPID"; kill -9 $BPID; sleep 1
AD open Clock --platform macos --session six --state-dir $C --json | summ

step "cleanup"
AD close --session one --state-dir $A --json | summ
AD close --session two --state-dir $A --json | summ
AD close --session six --state-dir $C --json | summ
AD daemon stop --state-dir $A --json | summ
AD daemon stop --state-dir $C --json | summ
AGENT_DEVICE_STATE_DIR=$A AD device status --stale --json | head -c 400; print
print "claims left:"; ls -la $CL

# ---- reverse.sh
#!/bin/zsh
set -u
WT=$1; ROOT=$(mktemp -d /private/tmp/ad3323-rev-XXXXXX); D=$ROOT/d; E=$ROOT/e; CL=$ROOT/claims; mkdir -p $D $E $CL
export AGENT_DEVICE_MACOS_APP_BACKEND=native AGENT_DEVICE_CLAIMS_DIR=$CL
AD() { node $WT/bin/agent-device.mjs "$@"; }
summ() { node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const j=JSON.parse(s);console.log(JSON.stringify(j.success?{ok:true,id:j.data?.id}:{ok:false,code:j.error?.code,reason:j.error?.details?.reason,msg:j.error?.message}))})'; }
print "source=$(git -C $WT rev-parse HEAD)"
print "== D: open --surface desktop --session desk"; AD open --platform macos --surface desktop --session desk --state-dir $D --json | summ
print "== D (same daemon): open TextEdit --session app"; AD open TextEdit --platform macos --session app --state-dir $D --json | summ
print "== E (other daemon): open TextEdit --session app"; AD open TextEdit --platform macos --session app --state-dir $E --json | summ
print "== cleanup"; AD close --session desk --state-dir $D --json | summ
AD daemon stop --state-dir $D --json | summ; AD daemon stop --state-dir $E --json | summ
print "claims left: $(ls $CL | wc -l)"

# ---- policy.sh
#!/bin/zsh
set -u
WT=$1; ROOT=$(mktemp -d /private/tmp/ad3323-pol-XXXXXX); mkdir -p $ROOT/h $ROOT/a $ROOT/c
export AGENT_DEVICE_MACOS_APP_BACKEND=native AGENT_DEVICE_CLAIMS_DIR=$ROOT/c
AD() { node $WT/bin/agent-device.mjs "$@"; }
summ() { node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const j=JSON.parse(s);console.log(JSON.stringify(j.success?{ok:true,id:j.data?.id}:{ok:false,code:j.error?.code,reason:j.error?.details?.reason,rule:j.error?.details?.rule}))})'; }
print "source=$(git -C $WT rev-parse HEAD)"
print '{"version":1,"devices":{"allow":[{"udid":"host-macos-local"}]}}' > $ROOT/host.json
print '{"version":1,"devices":{"allow":[{"udid":"host-macos-local:app:com.apple.TextEdit"}]}}' > $ROOT/app.json
print "== policy allows the Mac: open TextEdit"
AGENT_DEVICE_DAEMON_POLICY=$ROOT/host.json AD open TextEdit --platform macos --session p1 --state-dir $ROOT/h --json | summ
AGENT_DEVICE_DAEMON_POLICY=$ROOT/host.json AD close --session p1 --state-dir $ROOT/h --json | summ
print "== policy allows only TextEdit (mixed case): open TextEdit / Calculator / desktop"
AGENT_DEVICE_DAEMON_POLICY=$ROOT/app.json AD open TextEdit --platform macos --session p2 --state-dir $ROOT/a --json | summ
AGENT_DEVICE_DAEMON_POLICY=$ROOT/app.json AD open Calculator --platform macos --session p3 --state-dir $ROOT/a --json | summ
AGENT_DEVICE_DAEMON_POLICY=$ROOT/app.json AD open NoSuchAppXyz --platform macos --session p5 --state-dir $ROOT/a --json | summ
AGENT_DEVICE_DAEMON_POLICY=$ROOT/app.json AD open --platform macos --surface desktop --session p4 --state-dir $ROOT/a --json | summ
AGENT_DEVICE_DAEMON_POLICY=$ROOT/app.json AD close --session p2 --state-dir $ROOT/a --json | summ
for d in $ROOT/h $ROOT/a; do AGENT_DEVICE_DAEMON_POLICY=$ROOT/app.json AD daemon stop --state-dir $d --json >/dev/null 2>&1; done
print "claims left: $(ls $ROOT/c | wc -l)"

# ---- close.sh
#!/bin/zsh
set -u
WT=$1; ROOT=$(mktemp -d /private/tmp/ad3323-close-XXXXXX); mkdir -p $ROOT/s $ROOT/c
export AGENT_DEVICE_MACOS_APP_BACKEND=native AGENT_DEVICE_CLAIMS_DIR=$ROOT/c
AD() { node $WT/bin/agent-device.mjs "$@"; }
summ() { node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const j=JSON.parse(s);console.log(JSON.stringify(j.success?{ok:true,id:j.data?.id}:{ok:false,code:j.error?.code,reason:j.error?.details?.reason}))})'; }
print "source=$(git -C $WT rev-parse HEAD)"
print "== open com.apple.TextEdit --session te"; AD open com.apple.TextEdit --platform macos --session te --state-dir $ROOT/s --json | summ
print "== close Calculator --session te (other app)"; AD close Calculator --session te --state-dir $ROOT/s --json | summ
print "== session te still open, claims: $(ls $ROOT/c | wc -l)"; AD session list --state-dir $ROOT/s --json | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const j=JSON.parse(s);console.log(JSON.stringify((j.data?.sessions??[]).map(x=>x.name)))})'
print "== close TextEdit --session te (own app, by name)"; AD close TextEdit --session te --state-dir $ROOT/s --json | summ
AD daemon stop --state-dir $ROOT/s --json >/dev/null 2>&1
print "claims left: $(ls $ROOT/c | wc -l)"

Status at posting: the head is still 7519086. #3867 is open unmerged and edits the same lines of apps.ts and macos-app-lease.ts. This branch is not rebased onto unmerged work; whichever PR merges second resolves the overlap. The optional move of the lock registry into keyed-lock is pending a maintainer decision and does not affect this evidence.

@thymikee

Copy link
Copy Markdown
Member

Decision: lock registry. This PR keeps the request-level lock registry in request-execution-locks.ts as it is at 7519086, and moving lock ownership into keyed-lock is a follow-up, tracked in #3876.

Reasons:

  • The current code is reviewed, green, and verified live.
  • The move changes a kernel primitive that every daemon request uses, and it has at least one trap: a lock retained inside a nested scope must stay visible to later sibling scopes. That deserves its own review.

#3876 also takes the close-test suggestion: build that test's error from the production platform refusal. Nothing else is pending here. #3867 is still unmerged, so whichever PR merges second resolves the overlap in apps.ts and macos-app-lease.ts.

@thymikee

Copy link
Copy Markdown
Member

Thanks. The raw live output at 7519086 covers what was missing: two app devices on one Mac open, run and close without disturbing each other, across daemons and after a kill -9. The decision to keep the lock registry here and move it into keyed-lock in #3876 is clear, and the nested-scope trap is a good reason to review that move on its own. All review threads are resolved and there are no conflicts, so this is ready for a human review. #3867 still touches apps.ts and macos-app-lease.ts, so whichever PR merges second resolves that overlap.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 21, 2026
@thymikee
thymikee merged commit c8b944c into callstack:main Oct 21, 2026
14 checks passed
@thymikee

Copy link
Copy Markdown
Member

The keyed-lock follow-up is open as #3879 (closes #3876). It is stacked on this PR through the base branch stack/pr-3323-app-devices at 7519086. This PR needs no change for it and can merge on its own; #3879 will be retargeted to main after it does.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

macOS: let native-backend app sessions claim one app instead of the whole Mac

3 participants