arcane-os 0.87.0 → 0.88.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.88.1
4
+
5
+ - Recover an ordinary workspace-operation lock when its recorded runner is
6
+ confirmed absent, including before the six-hour expiry. Retrying the public
7
+ operation uses the shared lock owner; existing published records stay readable.
8
+ - Preserve live or indeterminate owners and complete process-probe errors.
9
+ Recheck cancellation, nonce, PID and owner presence after asynchronous recovery
10
+ observation so an observed replacement remains owned by its actual writer.
11
+ The existing cooperative read/remove protocol is not an atomic cross-process
12
+ compare-and-delete.
13
+
14
+ ## 0.88.0
15
+
16
+ - Add optional `signal` and `startLanguageModel` controls to `AI.setAI`,
17
+ `configureProviders`, `transitionAI`, and `transitionProviders`, preserving
18
+ their existing arguments, return values, and eager default. Explicitly
19
+ deferred selections stay unloaded through later readiness notifications;
20
+ an explicit runtime load or startup can activate them.
21
+ - Keep cancellation and supersession with the owning selection. Accepted
22
+ cleanup settles before a cancelled transition can configure or preload a
23
+ replacement, and stale continuations cannot alter a newer selection.
24
+ Completed activation is independent of a later caller abort.
25
+ - Preserve truthful busy, loaded `unloading` state while an accepted request
26
+ finishes cancellation; new inference remains unavailable during cleanup.
27
+ - Preserve keyboard focus in the shared theme switcher during asynchronous
28
+ selection. Pending choices remain focusable and expose `aria-disabled` while
29
+ repeated activation is ignored; completion never overrides an intentional
30
+ focus move. Unavailable Skin choices remain natively disabled.
31
+
3
32
  ## 0.87.0
4
33
 
5
34
  - Add `createDocumentAcquisitionService` through
package/README.md CHANGED
@@ -25,7 +25,7 @@ selected SDK projection or an explicit live SDK source mount. Both profiles
25
25
  share the theme, packaging, event, cancellation,
26
26
  and browser run contracts while retaining their selected application layout.
27
27
 
28
- This checkout defines the `0.87.0` SDK contract. Development applications declare
28
+ This checkout defines the `0.88.1` SDK contract. Development applications declare
29
29
  `arcane-os` as `latest` and retain their lockfile. Exact SDK pins begin when Roshi
30
30
  designates a production build. Follow [Consumer updates](docs/consumer-updates.md)
31
31
  to refresh and compare the installed SDK and lockfile with the published channel;
@@ -71,6 +71,7 @@ alone does not make bare module names resolve in a browser.
71
71
  | Expose application-owned tools and static resources through Node STDIO | [MCP STDIO server](mcp-stdio.md) |
72
72
  | Run application-selected llama.cpp, Ollama or ONNX through Core | [Local AI through Core](local-ai.md) |
73
73
  | Observe existing renderer and Core model state, progress and failures | [Existing model observation](model-observation.md) |
74
+ | Acquire complete HTTP(S) documents and redirect responses through an application-selected native Core service | [Native document acquisition](document-acquisition.md) |
74
75
  | Generate complete local PNG images with a retained native model | [Local image generation](local-image-generation.md) |
75
76
  | Select FLUX.2 Klein 4B and its three complementary model resources | [FLUX.2 Klein 4B through Core](local-image-flux.md) |
76
77
  | Select the complete SDXL Base 1.0 checkpoint for generation and whole-image editing | [SDXL Base 1.0 through Core](local-image-sdxl.md) |
@@ -405,6 +405,16 @@ not package the app or produce `dist` output. With PWA enabled, the server
405
405
  generates the installation and offline manifests directly; see
406
406
  [PWA development and versioning](pwa.md#development-and-hosting).
407
407
 
408
+ Ordinary operation acquisition can recover a workspace-operation lock whose
409
+ recorded process is confirmed absent, including before its six-hour expiry.
410
+ Retry the intended command through its usual public entrypoint; no separate
411
+ unlock command or caller-created lease is needed. A live or indeterminate owner
412
+ remains `ARCANE_WORKSPACE_BUSY`, with an indeterminate process-probe error
413
+ retained as the cause. Recovery rechecks cancellation, nonce, PID and owner
414
+ presence after asynchronous observation, preserving a replacement detected
415
+ there. This cooperative read-then-remove protocol does not provide an
416
+ atomic compare-and-delete across competing processes.
417
+
408
418
  The startup URL remains the selected app entry. At `/`, the server serves an
409
419
  existing authored host-root `index.html` and suppresses the app-entry redirect
410
420
  and PWA alias. A root outside the selected app mapping is served unchanged only
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "sdkVersion": "0.86.1",
3
+ "sdkVersion": "0.87.0",
4
4
  "environment": {
5
5
  "runtime": "Node.js for Node entrypoints; browser for browser-only entrypoints",
6
6
  "minimumVersion": "22.23.2 for Node entrypoints",
@@ -267,6 +267,23 @@ owners; they are separate from this runtime service.
267
267
 
268
268
  ### Selected Ollama model readiness
269
269
 
270
+ High-level `AI.setAI()` and `AI.transitionAI()` accept optional
271
+ `{signal,startLanguageModel}` after their six existing preference arguments;
272
+ `configureProviders()` and `transitionProviders()` accept it as their second
273
+ argument. The default remains eager. `startLanguageModel:false` registers the
274
+ built-in adapter and commits the selected provider/model without preloading it,
275
+ including when later Ollama-ready, credential, or residency callbacks arrive.
276
+ Activate that selection explicitly with `ai.providerRuntime.load('llm',{signal})`
277
+ or `ai.startProviders({startLanguageModel:true,signal})`.
278
+
279
+ Transition cancellation waits for accepted old-provider cleanup and prevents a
280
+ subsequent configuration or preload. Cancellation during the first owned preload
281
+ reaches the native request, retaining the already-committed selection and its real
282
+ runtime state. A newer selection supersedes older pending transition work.
283
+ Cancelled activation stays inactive through readiness callbacks; a caller abort
284
+ after successful settlement does not unload the ready model. These selection
285
+ controls do not change the ownership of separate callers joining an existing load.
286
+
270
287
  The built-in `OLLAMA` LLM provider loads its selected model through Ollama's
271
288
  empty `generate` request and separately confirms that selected model in
272
289
  `running()` (`/api/ps`). A model catalog entry, a connected bridge, and a
@@ -943,6 +943,21 @@ switches, or falls back implicitly. Generic provider-promise settlement is not
943
943
  a claim that underlying work stopped; only a provider's documented positive
944
944
  acknowledgement or destructive worker teardown can prove that stronger fact.
945
945
 
946
+ High-level built-in selection has its own activation boundary:
947
+ `AI.setAI(...sixPreferences,{signal,startLanguageModel:false})`,
948
+ `AI.transitionAI(...sixPreferences,{signal,startLanguageModel:false})`,
949
+ `AI.configureProviders(selections,{signal,startLanguageModel:false})`, and
950
+ `AI.transitionProviders(selections,{signal,startLanguageModel:false})` register
951
+ the selected adapters and keep the AI tuple and runtime routes together without
952
+ an implicit LLM load. The omitted option remains eager. Deferred or cancelled
953
+ activation remains inactive through later readiness callbacks; explicit runtime
954
+ load or startup can activate that selected route. Accepted transition cleanup
955
+ settles independently, cancellation is checked before configuring/preloading the
956
+ replacement, and the first owned load receives the caller signal. Newer accepted
957
+ selections supersede older pending work without allowing older continuations to
958
+ alter the newer selection. After settlement, later caller abort does not unload
959
+ the completed model. This does not change same-route runtime-load join ownership.
960
+
946
961
  `startAIRuntime({startLanguageModel:false,startTranscription:false})` is the
947
962
  explicit startup boundary for browser-WASM LLM and default STT activation. It
948
963
  leaves the selected LLM unloaded until the shared chat control publishes an
@@ -2084,8 +2084,12 @@ changing these attributes later does not reconfigure an existing instance.
2084
2084
  `setMode()` updates pressed state and the host's `data-mode` only. `refresh()`
2085
2085
  loads the existing ThemeManager state, updates selection and Skin availability,
2086
2086
  and resolves with the complete manager state. Clicking a choice uses the same
2087
- manager persistence/activation operation; buttons remain disabled while that
2088
- operation is pending. Presentation configuration preserves that pending state.
2087
+ manager persistence/activation operation. While it is pending, choices expose
2088
+ `aria-disabled="true"` and ignore repeated activation while remaining in the
2089
+ keyboard focus flow. Native disabling remains for an unavailable Skin choice.
2090
+ Selection does not move focus when it completes or fails, so the initiating
2091
+ button retains focus unless the user moves elsewhere. Presentation configuration
2092
+ preserves that pending state; an actual reorder retains its focus behavior above.
2089
2093
 
2090
2094
  The host inherits text color. Existing `--app-bar-border` (falling back to
2091
2095
  `--border-color`), `--app-bar-accent` (then `--secondary-color`),
@@ -257,10 +257,41 @@ provider. `transitionAI()` and `transitionProviders()` are deliberate
257
257
  cross-role transitions: each stops queued audio, unloads the current LLM, STT,
258
258
  and TTS roles, then applies the replacement configuration. `transitionAI()`
259
259
  returns aggregate runtime status; `transitionProviders()` returns the configured
260
- three-role route configuration. Selected TWiN Cloud `TWIN` LLM, `OLLAMA` LLM,
261
- and Core `LOCAL_SPEACH` STT/TTS built-in routes expose truthful capability-only
262
- readiness through internal provider/2 adapters without probing, downloading, or
263
- hiding a load. Explicit speech preference slots remain unchanged, including a
260
+ three-role route configuration.
261
+
262
+ `setAI(llmService,sttService,ttsService,model,modelTTS,modelSTT,options)` and
263
+ `transitionAI(llmService,sttService,ttsService,model,modelTTS,modelSTT,options)`
264
+ preserve their six positional preference arguments. `configureProviders(selections,options)`
265
+ and `transitionProviders(selections,options)` accept the same optional
266
+ `{signal=null,startLanguageModel=true}` controls. Omission preserves eager built-in
267
+ LLM activation. `startLanguageModel:false` still registers built-in adapters and
268
+ updates both the runtime routes and AI preference tuple, while leaving the selected
269
+ LLM unloaded. Readiness events, credential changes, and native residency observations
270
+ retain that deferred intent; an explicit `providerRuntime.load('llm',{signal})` or
271
+ `startProviders({startLanguageModel:true,signal})` can activate it. Deferral never
272
+ changes the selected model or substitutes another provider.
273
+
274
+ An already-aborted signal rejects before selection changes. During an asynchronous
275
+ transition, accepted old-provider cleanup finishes, then cancellation prevents new
276
+ configuration or preload. A newer accepted selection supersedes pending older
277
+ transition work; older completion cannot configure, load, or clean up the newer
278
+ selection. Once replacement routes are committed, cancellation reaches the first
279
+ owned load and leaves that replacement explicitly selected with its actual runtime
280
+ state. Later readiness callbacks do not restart the cancelled activation. Completed
281
+ selection/load settlement detaches its caller signal, so later abort never unloads
282
+ the ready model. `setAI()` retains its synchronous boolean and `configureProviders()`
283
+ its synchronous route result; their asynchronous readiness remains observable through
284
+ the provider runtime. Rejected synchronous configuration preserves the prior owned
285
+ selection/load. A selection accepted by a synchronous registration or configuration
286
+ listener supersedes the older continuation before it can apply preferences or load.
287
+ These controls do not change cancellation ownership for a
288
+ separate caller joining an already-pending runtime load.
289
+
290
+ Selected TWiN Cloud `TWIN` LLM, `OLLAMA` LLM,
291
+ and Core `LOCAL_SPEACH` STT/TTS built-in routes use internal provider/2 adapters.
292
+ TWiN Cloud and Core speech expose capability readiness; Ollama explicitly
293
+ preloads its selected model and confirms its residency before reporting ready.
294
+ Explicit speech preference slots remain unchanged, including a
264
295
  selected external provider that the application registers after AI startup.
265
296
  TWiN Cloud availability
266
297
  requires the selected LLM route, its model, a credential, and `fetch`; Core speech
@@ -57,6 +57,8 @@ runtime layouts.
57
57
  | `arcane-os/core/runtime` | App-neutral native Core dispatcher and service lifecycle. |
58
58
  | `arcane-os/core/model-observation` | Passive observation of an existing Core and its actual native model service owners. |
59
59
  | `arcane-os/ai/model-observation` | Attach existing renderer model owners and subscribe through an existing Core client. |
60
+ | `arcane-os/document-acquisition` | Acquire complete document and redirect bodies through the selected native Core service. |
61
+ | `arcane-os/core/document-acquisition` | Native HTTP(S) GET service, application-owned destination predicate and acquisition lifecycle. |
60
62
  | `arcane-os/core/development` | App-service composition and document-owned Core transport during source development, independently of local AI. |
61
63
  | `arcane-os/core/stdio` | Framed Node stdio transport with graceful runtime drain. |
62
64
  | `arcane-os/core/host` | Node-only reusable Core host lifecycle, app-selected native launch defaults and shared native location resolution. |
@@ -425,6 +427,8 @@ The remaining data-only subpaths are eight JSON Schemas and package metadata.
425
427
  | `createModelObservationService()` | function | `arcane-os/core/model-observation` | Existing model observation | Node with an existing Core runtime and optional existing native model services |
426
428
  | `attachModelObservation()` | function | `arcane-os/ai/model-observation` | Existing model observation | JavaScript with an existing Core client and optional existing renderer owners |
427
429
  | `subscribeModelObservation()` | function | `arcane-os/ai/model-observation` | Existing model observation | JavaScript with an existing Core client and composed observation service |
430
+ | `acquireCoreDocument()` | function | `arcane-os/document-acquisition` | Native document acquisition | Browser or native WebView with an existing Core client and selected document service |
431
+ | `createDocumentAcquisitionService()` | function | `arcane-os/core/document-acquisition` | Native document acquisition | Node Core on Windows, Linux and macOS; Android requires host adaptation |
428
432
  | `encodeCoreFrame()` | function | `arcane-os/core/stdio` | Native Core stdio transport | Node |
429
433
  | `createCoreFrameDecoder()` | function | `arcane-os/core/stdio` | Native Core stdio transport | Node |
430
434
  | `startCoreStdio()` | function | `arcane-os/core/stdio` | Native Core stdio transport | Node |
@@ -1864,6 +1868,17 @@ whole-tree replacement. The mutable result includes the installed-package
1864
1868
  location, materialized workspace runtime paths, and `workspaceLock` path and
1865
1869
  document. It does not install dependencies or merge application source.
1866
1870
 
1871
+ Ordinary acquisition automatically recovers a workspace-operation lock when
1872
+ the recorded owner process is confirmed absent, without waiting solely for its
1873
+ six-hour expiry. Retry this public operation normally; `workspaceOperationLease`
1874
+ is for active nested SDK work and cannot be reconstructed from the lock JSON.
1875
+ Live or indeterminate owners remain `ARCANE_WORKSPACE_BUSY`; an indeterminate
1876
+ process-probe error is retained as the cause. Existing published lock documents
1877
+ remain readable. Recovery rechecks cancellation, the captured nonce and PID,
1878
+ and owner presence after asynchronous observation, preserving a replacement
1879
+ detected there. This cooperative read-then-remove protocol does not provide an
1880
+ atomic compare-and-delete across competing processes.
1881
+
1867
1882
  Replacement removes files absent from the selected runtime content and restores
1868
1883
  the prior tree if commit fails. `signal` and
1869
1884
  `onEvent` remain caller-owned before commit. Once the atomic replacement starts,
@@ -7671,6 +7686,64 @@ promises may await `close()` without becoming its completion barrier. It starts
7671
7686
  no model, refresh, polling, endpoint discovery, host or reconnect. See
7672
7687
  [observation records and limits](model-observation.md#read-or-subscribe-from-an-existing-client).
7673
7688
 
7689
+ ## acquireCoreDocument()
7690
+
7691
+ ```text
7692
+ acquireCoreDocument({url,client,signal,onProgress}={})
7693
+ ```
7694
+
7695
+ Import from `arcane-os/document-acquisition`. The asynchronous helper uses the
7696
+ supplied or installed Core client to invoke `documents.acquire` with `{url}`.
7697
+ The application must compose the native document service. Missing Core reports
7698
+ `DOCUMENT_ACQUISITION_CORE_UNAVAILABLE`; a missing service retains the existing
7699
+ unavailable-method error. Browser-only previews have no browser Fetch fallback.
7700
+
7701
+ Resolves with `{requestedUrl,finalUrl,url,status,statusText,ok,headers,mediaType,
7702
+ body,complete,redirects}`. The final body and every ordered redirect body are
7703
+ complete `Blob` values. An HTTP error response retains its status and content;
7704
+ `ok:false` is a result, not a generic acquisition error. Headers and entity
7705
+ content reflect Fetch's public response, including ordinary content decoding.
7706
+
7707
+ `signal` cancels this request; there is no implicit timeout. `onProgress` receives
7708
+ request-correlated `documents.progress` records with phases, document counts
7709
+ and status fields, never body content. All accepted callback promises settle
7710
+ before the helper settles. Callback failures cancel acquisition and remain
7711
+ observable; concurrent operation and callback failures retain an `AggregateError`.
7712
+ A late callback or subscription cleanup failure retains the completed response
7713
+ in `error.documentAcquisition` with the original cause or aggregated failures.
7714
+ Every settlement removes the subscription. Local abort rejects immediately and
7715
+ does not wait for native error evidence. See
7716
+ [complete result, progress and error records](document-acquisition.md).
7717
+
7718
+ ## createDocumentAcquisitionService()
7719
+
7720
+ ```text
7721
+ createDocumentAcquisitionService({destinationPredicate,signal}={})
7722
+ ```
7723
+
7724
+ Import from `arcane-os/core/document-acquisition`. This synchronous factory
7725
+ returns the Core service definition with `documents.acquire`, direct
7726
+ `acquire({url,signal,onProgress})` and idempotent asynchronous `dispose()`.
7727
+ Select it through the application's existing native service composition. The
7728
+ ordinary service performs HTTP(S) GET acquisition without a predicate.
7729
+
7730
+ An application-selected `destinationPredicate(destinationUrl,
7731
+ {requestedUrl,previousUrl,signal})` runs locally before every actual GET,
7732
+ including the first destination and each redirect. Only `true` permits the
7733
+ request; other returns produce `DOCUMENT_DESTINATION_DECLINED`, and a thrown
7734
+ or rejected value remains the original cause. The predicate is never serialized.
7735
+ Redirects retain every response body; no automatic retry or redirect-count cap
7736
+ is imposed. The caller can cancel a repeating redirect chain.
7737
+
7738
+ Direct results and error evidence retain `Uint8Array` bodies. Acquisition errors
7739
+ keep the original cause and `documentAcquisition` response/redirect records;
7740
+ an interrupted body read retains available content marked `complete:false`.
7741
+ Only Core JSON transport encodes bodies as base64; the browser restores Blobs.
7742
+ The factory signal owns the service lifetime; each call signal owns only its
7743
+ request and leaves sibling acquisitions active. `dispose()` aborts and joins all
7744
+ active operations. Bodies are buffered completely; progress is not a body stream.
7745
+ See [native composition and lifecycle](document-acquisition.md).
7746
+
7674
7747
  ## createPortableNativeProvider()
7675
7748
 
7676
7749
  ### Overview
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcane-os",
3
- "version": "0.87.0",
3
+ "version": "0.88.1",
4
4
  "description": "Arcane OS JavaScript SDK, project-local CLI, browser runtime, and repository-portable application packager.",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -38,7 +38,7 @@
38
38
  overflow-wrap:anywhere;
39
39
  }
40
40
 
41
- button:hover:not(:disabled):not([aria-pressed="true"]) {
41
+ button:hover:not(:disabled):not([aria-disabled="true"]):not([aria-pressed="true"]) {
42
42
  background:color-mix(in srgb,currentColor 14%,transparent);
43
43
  }
44
44
 
@@ -48,7 +48,8 @@
48
48
  box-shadow:inset 0 0 0 .1rem currentColor;
49
49
  }
50
50
 
51
- button:disabled {
51
+ button:disabled,
52
+ button[aria-disabled="true"] {
52
53
  cursor:not-allowed;
53
54
  opacity:.55;
54
55
  }
@@ -88,7 +89,7 @@
88
89
  host.refresh=refresh;
89
90
  group.addEventListener('click',async function selectScheme(event){
90
91
  const button=event.target.closest('[data-scheme]');
91
- if(!button||button.disabled) return;
92
+ if(!button||button.disabled||busy) return;
92
93
  setBusy(true);
93
94
  try{
94
95
  const state=button.dataset.scheme==='custom'?await manager.activateCustom():await manager.setScheme(button.dataset.scheme);
@@ -134,10 +135,14 @@
134
135
  }
135
136
  function setBusy(value){
136
137
  busy=Boolean(value);
137
- for(const button of buttons) button.disabled=busy;
138
+ // Keep the focused choice in the keyboard flow while its operation
139
+ // settles. The click owner rejects repeat activation without moving focus.
140
+ for(const button of buttons) button.setAttribute('aria-disabled',String(busy||button.disabled));
138
141
  }
139
142
  function setCustomAvailable(value){
140
- buttons.find(function customScheme(button){return button.dataset.scheme==='custom';}).disabled=busy||!value;
143
+ const button=buttons.find(function customScheme(button){return button.dataset.scheme==='custom';});
144
+ button.disabled=!value;
145
+ button.setAttribute('aria-disabled',String(busy||button.disabled));
141
146
  }
142
147
  async function refresh(){const state=await manager.load();setMode(state.mode);setCustomAvailable(Boolean(state.theme));return state;}
143
148
 
@@ -759,6 +759,19 @@ function isAbortSignal(value){
759
759
  &&is.function(value.removeEventListener);
760
760
  }
761
761
 
762
+ function normalizeAISelectionOptions({signal=null,startLanguageModel=true}={}){
763
+ if(signal!==null&&!isAbortSignal(signal)){
764
+ throw new TypeError('AI selection signal must be an AbortSignal.');
765
+ }
766
+ if(!is.boolean(startLanguageModel)){
767
+ throw new TypeError('AI selection startLanguageModel must be a boolean.');
768
+ }
769
+ if(signal?.aborted){
770
+ throw normalizeAIRequestAbort(signal.reason);
771
+ }
772
+ return {signal,startLanguageModel};
773
+ }
774
+
762
775
  function closedRecord(value,keys,required,label){
763
776
  if(!value
764
777
  ||!is.object(value)
@@ -1173,6 +1186,8 @@ class AI {
1173
1186
  #browserSpeechTransition=Promise.resolve();
1174
1187
  #builtInLLMProviders=new Map();
1175
1188
  #builtInLLMReadiness=Promise.resolve(null);
1189
+ #providerSelectionGeneration=0;
1190
+ #providerSelectionOperation=null;
1176
1191
  #builtInSpeechProviders=new Map();
1177
1192
  #configuredSpeechProviders=new Map();
1178
1193
  #builtInSpeechReadiness=Promise.resolve(null);
@@ -2146,7 +2161,8 @@ class AI {
2146
2161
  });
2147
2162
  }
2148
2163
 
2149
- #ensureBuiltInLLMProvider(providerId){
2164
+ #ensureBuiltInLLMProvider(providerId,operation=null){
2165
+ if(operation)this.#assertProviderSelection(operation);
2150
2166
  if(providerId!=='TWIN'&&providerId!=='OLLAMA'){
2151
2167
  return false;
2152
2168
  }
@@ -2154,15 +2170,34 @@ class AI {
2154
2170
  return false;
2155
2171
  }
2156
2172
  const provider=this.#createBuiltInLLMProvider(providerId);
2157
- const unregister=this.#providerRuntime.register(provider);
2158
- this.#builtInLLMProviders.set(
2159
- providerId,
2160
- completeValue({provider,unregister})
2161
- );
2173
+ const runtime=this;
2174
+ let unregister=null;
2175
+ const record={
2176
+ provider,
2177
+ unregister:function unregisterBuiltInLLMProvider(){
2178
+ if(unregister)return unregister();
2179
+ if(!runtime.#providerRuntime.ownsProvider('llm',provider))return false;
2180
+ return runtime.#providerRuntime.unregister('llm',providerId);
2181
+ }
2182
+ };
2183
+ // Registration can publish a pending route synchronously; its owner must
2184
+ // already be visible to a selection accepted by that callback.
2185
+ this.#builtInLLMProviders.set(providerId,record);
2186
+ try{
2187
+ unregister=this.#providerRuntime.register(provider);
2188
+ }catch(error){
2189
+ if(!this.#providerRuntime.ownsProvider('llm',provider)
2190
+ &&this.#builtInLLMProviders.get(providerId)===record){
2191
+ this.#builtInLLMProviders.delete(providerId);
2192
+ }
2193
+ throw error;
2194
+ }
2195
+ if(operation)this.#assertProviderSelection(operation);
2162
2196
  return true;
2163
2197
  }
2164
2198
 
2165
- #ensureBuiltInSpeechProvider(role,providerId){
2199
+ #ensureBuiltInSpeechProvider(role,providerId,operation=null){
2200
+ if(operation)this.#assertProviderSelection(operation);
2166
2201
  if(!['stt','tts'].includes(role)||providerId!=='LOCAL_SPEACH'){
2167
2202
  return false;
2168
2203
  }
@@ -2170,33 +2205,54 @@ class AI {
2170
2205
  return false;
2171
2206
  }
2172
2207
  const provider=this.#createBuiltInSpeechProvider(role,providerId);
2173
- const unregister=this.#providerRuntime.register(provider);
2174
- this.#builtInSpeechProviders.set(
2175
- this.#builtInSpeechProviderKey(role,providerId),
2176
- completeValue({role,providerId,provider,unregister})
2177
- );
2208
+ const runtime=this;
2209
+ const key=this.#builtInSpeechProviderKey(role,providerId);
2210
+ let unregister=null;
2211
+ const record={
2212
+ role,providerId,provider,
2213
+ unregister:function unregisterBuiltInSpeechProvider(){
2214
+ if(unregister)return unregister();
2215
+ if(!runtime.#providerRuntime.ownsProvider(role,provider))return false;
2216
+ return runtime.#providerRuntime.unregister(role,providerId);
2217
+ }
2218
+ };
2219
+ this.#builtInSpeechProviders.set(key,record);
2220
+ try{
2221
+ unregister=this.#providerRuntime.register(provider);
2222
+ }catch(error){
2223
+ if(!this.#providerRuntime.ownsProvider(role,provider)
2224
+ &&this.#builtInSpeechProviders.get(key)===record){
2225
+ this.#builtInSpeechProviders.delete(key);
2226
+ }
2227
+ throw error;
2228
+ }
2229
+ if(operation)this.#assertProviderSelection(operation);
2178
2230
  return true;
2179
2231
  }
2180
2232
 
2181
- #releaseInactiveBuiltInLLMProviders(activeProviderId){
2233
+ #releaseInactiveBuiltInLLMProviders(activeProviderId,operation=null){
2182
2234
  for(const [providerId,record] of this.#builtInLLMProviders){
2235
+ if(operation)this.#assertProviderSelection(operation);
2183
2236
  if(providerId===activeProviderId){
2184
2237
  continue;
2185
2238
  }
2186
- if(record.unregister()){
2239
+ if(record.unregister()&&this.#builtInLLMProviders.get(providerId)===record){
2187
2240
  this.#builtInLLMProviders.delete(providerId);
2188
2241
  }
2189
2242
  }
2190
2243
  }
2191
2244
 
2192
- #releaseInactiveBuiltInSpeechProviders(activeProviders){
2245
+ #releaseInactiveBuiltInSpeechProviders(activeProviders,operation=null){
2193
2246
  for(const [key,record] of this.#builtInSpeechProviders){
2247
+ if(operation)this.#assertProviderSelection(operation);
2194
2248
  if(activeProviders[record.role]===record.providerId){
2195
2249
  continue;
2196
2250
  }
2197
2251
  if(record.unregister()){
2198
2252
  record.provider.dispose();
2199
- this.#builtInSpeechProviders.delete(key);
2253
+ if(this.#builtInSpeechProviders.get(key)===record){
2254
+ this.#builtInSpeechProviders.delete(key);
2255
+ }
2200
2256
  }
2201
2257
  }
2202
2258
  }
@@ -2246,7 +2302,85 @@ class AI {
2246
2302
  return this.#builtInSpeechReadiness;
2247
2303
  }
2248
2304
 
2249
- #reconcileBuiltInLLMReadiness(){
2305
+ #beginProviderSelection({signal,startLanguageModel},deferSupersession=false){
2306
+ const previous=this.#providerSelectionOperation;
2307
+ const controller=new AbortController();
2308
+ const operation={
2309
+ generation:++this.#providerSelectionGeneration,
2310
+ controller,
2311
+ startLanguageModel,
2312
+ previous:deferSupersession?previous:null,
2313
+ restorePrevious:deferSupersession,
2314
+ committed:false,
2315
+ settled:false,
2316
+ detachSignal:function detachAISelectionSignal(){
2317
+ signal?.removeEventListener('abort',forwardAbort);
2318
+ }
2319
+ };
2320
+ function forwardAbort(){
2321
+ controller.abort(signal.reason);
2322
+ }
2323
+ this.#providerSelectionOperation=operation;
2324
+ signal?.addEventListener('abort',forwardAbort,{once:true});
2325
+ if(signal?.aborted)forwardAbort();
2326
+ if(!deferSupersession)this.#supersedeProviderSelection(previous);
2327
+ return operation;
2328
+ }
2329
+
2330
+ #supersedeProviderSelection(previous){
2331
+ for(let operation=previous;operation;operation=operation.previous){
2332
+ operation.detachSignal();
2333
+ if(!operation.settled){
2334
+ operation.controller.abort(aiProviderError(
2335
+ 'AI provider selection was superseded.',
2336
+ 'ARCANE_AI_OPERATION_SUPERSEDED'
2337
+ ));
2338
+ }
2339
+ }
2340
+ }
2341
+
2342
+ #assertProviderSelection(operation,allowAborted=false){
2343
+ if(operation.generation!==this.#providerSelectionOperation?.generation){
2344
+ throw aiProviderError(
2345
+ 'AI provider selection was superseded.',
2346
+ 'ARCANE_AI_OPERATION_SUPERSEDED'
2347
+ );
2348
+ }
2349
+ if(!allowAborted&&operation.controller.signal.aborted){
2350
+ throw normalizeAIRequestAbort(operation.controller.signal.reason);
2351
+ }
2352
+ }
2353
+
2354
+ #commitProviderSelection(operation,tuple){
2355
+ // Configuration already committed its routes before notifying listeners.
2356
+ // Keep its tuple together even when that notification aborts this caller;
2357
+ // a newer owner alone prevents this continuation from applying the tuple.
2358
+ this.#assertProviderSelection(operation,true);
2359
+ this.#applyPreferenceTuple(tuple);
2360
+ operation.committed=true;
2361
+ const previous=operation.previous;
2362
+ operation.previous=null;
2363
+ this.#supersedeProviderSelection(previous);
2364
+ this.#assertProviderSelection(operation);
2365
+ }
2366
+
2367
+ #finishProviderSelection(operation){
2368
+ if(operation.restorePrevious&&!operation.committed
2369
+ &&this.#providerSelectionOperation===operation){
2370
+ this.#providerSelectionOperation=operation.previous;
2371
+ }
2372
+ operation.previous=null;
2373
+ operation.settled=true;
2374
+ operation.detachSignal();
2375
+ }
2376
+
2377
+ #reconcileBuiltInLLMReadiness(operation=this.#providerSelectionOperation){
2378
+ if(operation&&(
2379
+ operation!==this.#providerSelectionOperation
2380
+ ||!operation.committed
2381
+ )){
2382
+ return Promise.resolve(this.#providerRuntime.status('llm'));
2383
+ }
2250
2384
  const selection=this.#builtInLLMSelection(false);
2251
2385
  if(!selection){
2252
2386
  return Promise.resolve(this.#providerRuntime.status('llm'));
@@ -2259,10 +2393,14 @@ class AI {
2259
2393
  return this.#providerRuntime.unload('llm');
2260
2394
  }
2261
2395
  if(this.#builtInLLMCapability(selection.providerId)){
2262
- if(status.state==='ready'&&status.loaded===true){
2396
+ if(operation?.startLanguageModel===false
2397
+ ||operation?.controller.signal.aborted
2398
+ ||(status.state==='ready'&&status.loaded===true)){
2263
2399
  return Promise.resolve(status);
2264
2400
  }
2265
- return this.#providerRuntime.load('llm');
2401
+ return this.#providerRuntime.load('llm',{
2402
+ signal:operation?.controller.signal??null
2403
+ });
2266
2404
  }
2267
2405
  if(status.loaded===true
2268
2406
  ||status.busy===true
@@ -2591,12 +2729,15 @@ class AI {
2591
2729
  }
2592
2730
  }
2593
2731
 
2594
- async #unloadProviderRolesForTransition(){
2595
- const settlements=await Promise.allSettled([
2596
- this.#providerRuntime.unload('llm'),
2597
- this.#providerRuntime.unload('stt'),
2598
- this.#providerRuntime.unload('tts')
2599
- ]);
2732
+ async #unloadProviderRolesForTransition(operation){
2733
+ const runtime=this;
2734
+ const settlements=await Promise.allSettled(
2735
+ ['llm','stt','tts'].map(async function unloadPreviousAIProviderRole(role){
2736
+ runtime.#assertProviderSelection(operation);
2737
+ // Accepted cleanup owns completion independently of page cancellation.
2738
+ return runtime.#providerRuntime.unload(role);
2739
+ })
2740
+ );
2600
2741
  const failure=settlements.find(function findAITransitionCleanupFailure(result){
2601
2742
  return result.status==='rejected';
2602
2743
  });
@@ -2643,7 +2784,8 @@ class AI {
2643
2784
  ttsService,
2644
2785
  model,
2645
2786
  modelTTS,
2646
- modelSTT
2787
+ modelSTT,
2788
+ options
2647
2789
  ) {
2648
2790
  if (
2649
2791
  !(
@@ -2667,59 +2809,83 @@ class AI {
2667
2809
  ]);
2668
2810
  this.#assertValidProviderTuple(tuple);
2669
2811
  this.#assertSynchronousBrowserSpeechSupersession('AI.setAI');
2670
- this.#ensureBuiltInLLMProvider(tuple[0]);
2671
- this.#ensureBuiltInSpeechProvider('stt',tuple[1]);
2672
- this.#ensureBuiltInSpeechProvider('tts',tuple[2]);
2673
- this.#providerRuntime.configure(this.#routesFromPreferenceTuple(tuple));
2674
- this.#invalidateSpeechControl();
2675
- this.#applyPreferenceTuple(tuple);
2676
- this.#releaseInactiveBuiltInLLMProviders(tuple[0]);
2677
- this.#releaseInactiveBuiltInSpeechProviders({
2678
- stt:tuple[1],
2679
- tts:tuple[2]
2680
- });
2681
- this.#retainBuiltInLLMReadiness(
2682
- this.#reconcileBuiltInLLMReadiness()
2683
- );
2684
- this.#retainBuiltInSpeechReadiness(
2685
- this.#reconcileBuiltInSpeechReadiness()
2686
- );
2687
- return true;
2812
+ const normalized=normalizeAISelectionOptions(options);
2813
+ const operation=this.#beginProviderSelection(normalized,true);
2814
+ try{
2815
+ this.#assertProviderSelection(operation);
2816
+ this.#ensureBuiltInLLMProvider(tuple[0],operation);
2817
+ this.#ensureBuiltInSpeechProvider('stt',tuple[1],operation);
2818
+ this.#ensureBuiltInSpeechProvider('tts',tuple[2],operation);
2819
+ this.#providerRuntime.configure(this.#routesFromPreferenceTuple(tuple));
2820
+ this.#commitProviderSelection(operation,tuple);
2821
+ this.#invalidateSpeechControl();
2822
+ this.#assertProviderSelection(operation);
2823
+ this.#releaseInactiveBuiltInLLMProviders(tuple[0],operation);
2824
+ this.#assertProviderSelection(operation);
2825
+ this.#releaseInactiveBuiltInSpeechProviders({
2826
+ stt:tuple[1],
2827
+ tts:tuple[2]
2828
+ },operation);
2829
+ this.#assertProviderSelection(operation);
2830
+ const runtime=this;
2831
+ this.#retainBuiltInLLMReadiness(
2832
+ this.#reconcileBuiltInLLMReadiness(operation).finally(
2833
+ function settleAISelectionReadiness(){
2834
+ runtime.#finishProviderSelection(operation);
2835
+ }
2836
+ )
2837
+ );
2838
+ this.#assertProviderSelection(operation);
2839
+ this.#retainBuiltInSpeechReadiness(
2840
+ this.#reconcileBuiltInSpeechReadiness()
2841
+ );
2842
+ return true;
2843
+ }catch(error){
2844
+ this.#finishProviderSelection(operation);
2845
+ throw error;
2846
+ }
2688
2847
  }
2689
2848
 
2690
- configureProviders(selections){
2849
+ configureProviders(selections,options){
2691
2850
  const prepared=this.#providerRuntime.validateConfiguration(selections);
2692
2851
  this.#assertDeviceSpeechConfiguration(prepared);
2693
2852
  this.#assertSynchronousBrowserSpeechSupersession('AI.configureProviders');
2694
- this.#ensureBuiltInLLMProvider(
2695
- prepared.llm.default?.providerId
2696
- );
2697
- this.#ensureBuiltInSpeechProvider(
2698
- 'stt',
2699
- prepared.stt.default?.providerId
2700
- );
2701
- this.#ensureBuiltInSpeechProvider(
2702
- 'tts',
2703
- prepared.tts.default?.providerId
2704
- );
2705
- this.#assertRegisteredBuiltInRoutes(prepared);
2706
- const configured=this.#providerRuntime.configure(prepared);
2707
- this.#invalidateSpeechControl();
2708
- this.#applyPreferenceTuple(this.#tupleFromProviderRoutes(configured));
2709
- this.#releaseInactiveBuiltInLLMProviders(
2710
- configured.llm.default?.providerId
2711
- );
2712
- this.#releaseInactiveBuiltInSpeechProviders({
2713
- stt:configured.stt.default?.providerId,
2714
- tts:configured.tts.default?.providerId
2715
- });
2716
- this.#retainBuiltInLLMReadiness(
2717
- this.#reconcileBuiltInLLMReadiness()
2718
- );
2719
- this.#retainBuiltInSpeechReadiness(
2720
- this.#reconcileBuiltInSpeechReadiness()
2721
- );
2722
- return configured;
2853
+ const normalized=normalizeAISelectionOptions(options);
2854
+ const operation=this.#beginProviderSelection(normalized,true);
2855
+ try{
2856
+ this.#assertProviderSelection(operation);
2857
+ this.#ensureBuiltInLLMProvider(prepared.llm.default?.providerId,operation);
2858
+ this.#ensureBuiltInSpeechProvider('stt',prepared.stt.default?.providerId,operation);
2859
+ this.#ensureBuiltInSpeechProvider('tts',prepared.tts.default?.providerId,operation);
2860
+ this.#assertRegisteredBuiltInRoutes(prepared);
2861
+ const configured=this.#providerRuntime.configure(prepared);
2862
+ this.#commitProviderSelection(operation,this.#tupleFromProviderRoutes(configured));
2863
+ this.#invalidateSpeechControl();
2864
+ this.#assertProviderSelection(operation);
2865
+ this.#releaseInactiveBuiltInLLMProviders(configured.llm.default?.providerId,operation);
2866
+ this.#assertProviderSelection(operation);
2867
+ this.#releaseInactiveBuiltInSpeechProviders({
2868
+ stt:configured.stt.default?.providerId,
2869
+ tts:configured.tts.default?.providerId
2870
+ },operation);
2871
+ this.#assertProviderSelection(operation);
2872
+ const runtime=this;
2873
+ this.#retainBuiltInLLMReadiness(
2874
+ this.#reconcileBuiltInLLMReadiness(operation).finally(
2875
+ function settleAIProviderConfigurationReadiness(){
2876
+ runtime.#finishProviderSelection(operation);
2877
+ }
2878
+ )
2879
+ );
2880
+ this.#assertProviderSelection(operation);
2881
+ this.#retainBuiltInSpeechReadiness(
2882
+ this.#reconcileBuiltInSpeechReadiness()
2883
+ );
2884
+ return configured;
2885
+ }catch(error){
2886
+ this.#finishProviderSelection(operation);
2887
+ throw error;
2888
+ }
2723
2889
  }
2724
2890
 
2725
2891
  configureSpeechProviders(selections){
@@ -2762,7 +2928,8 @@ class AI {
2762
2928
  ttsService,
2763
2929
  model,
2764
2930
  modelTTS,
2765
- modelSTT
2931
+ modelSTT,
2932
+ options
2766
2933
  ){
2767
2934
  const tuple=this.#nextPreferenceTuple([
2768
2935
  llmService,
@@ -2773,54 +2940,70 @@ class AI {
2773
2940
  modelSTT
2774
2941
  ]);
2775
2942
  this.#assertValidProviderTuple(tuple);
2776
- await this.#supersedeBrowserSpeechForRouteChange();
2777
- this.#invalidateSpeechControl();
2778
- await this.#unloadProviderRolesForTransition();
2779
- this.#ensureBuiltInLLMProvider(tuple[0]);
2780
- this.#ensureBuiltInSpeechProvider('stt',tuple[1]);
2781
- this.#ensureBuiltInSpeechProvider('tts',tuple[2]);
2782
- this.#providerRuntime.configure(this.#routesFromPreferenceTuple(tuple));
2783
- this.#applyPreferenceTuple(tuple);
2784
- this.#releaseInactiveBuiltInLLMProviders(tuple[0]);
2785
- this.#releaseInactiveBuiltInSpeechProviders({
2786
- stt:tuple[1],
2787
- tts:tuple[2]
2788
- });
2789
- await this.#reconcileBuiltInLLMReadiness();
2790
- await this.#reconcileBuiltInSpeechReadiness();
2791
- return this.#providerRuntime.status();
2943
+ const operation=this.#beginProviderSelection(normalizeAISelectionOptions(options));
2944
+ try{
2945
+ this.#assertProviderSelection(operation);
2946
+ await this.#supersedeBrowserSpeechForRouteChange(operation);
2947
+ this.#assertProviderSelection(operation);
2948
+ this.#invalidateSpeechControl();
2949
+ await this.#unloadProviderRolesForTransition(operation);
2950
+ this.#assertProviderSelection(operation);
2951
+ this.#ensureBuiltInLLMProvider(tuple[0],operation);
2952
+ this.#ensureBuiltInSpeechProvider('stt',tuple[1],operation);
2953
+ this.#ensureBuiltInSpeechProvider('tts',tuple[2],operation);
2954
+ this.#assertProviderSelection(operation);
2955
+ this.#providerRuntime.configure(this.#routesFromPreferenceTuple(tuple));
2956
+ this.#commitProviderSelection(operation,tuple);
2957
+ this.#releaseInactiveBuiltInLLMProviders(tuple[0],operation);
2958
+ this.#assertProviderSelection(operation);
2959
+ this.#releaseInactiveBuiltInSpeechProviders({
2960
+ stt:tuple[1],
2961
+ tts:tuple[2]
2962
+ },operation);
2963
+ this.#assertProviderSelection(operation);
2964
+ await this.#reconcileBuiltInLLMReadiness(operation);
2965
+ this.#assertProviderSelection(operation);
2966
+ await this.#reconcileBuiltInSpeechReadiness();
2967
+ this.#assertProviderSelection(operation);
2968
+ return this.#providerRuntime.status();
2969
+ }finally{
2970
+ this.#finishProviderSelection(operation);
2971
+ }
2792
2972
  }
2793
2973
 
2794
- async transitionProviders(selections){
2974
+ async transitionProviders(selections,options){
2795
2975
  const prepared=this.#providerRuntime.validateConfiguration(selections);
2796
2976
  this.#assertDeviceSpeechConfiguration(prepared);
2797
- await this.#supersedeBrowserSpeechForRouteChange();
2798
- this.#ensureBuiltInLLMProvider(
2799
- prepared.llm.default?.providerId
2800
- );
2801
- this.#ensureBuiltInSpeechProvider(
2802
- 'stt',
2803
- prepared.stt.default?.providerId
2804
- );
2805
- this.#ensureBuiltInSpeechProvider(
2806
- 'tts',
2807
- prepared.tts.default?.providerId
2808
- );
2809
- this.#assertRegisteredBuiltInRoutes(prepared);
2810
- this.#invalidateSpeechControl();
2811
- await this.#unloadProviderRolesForTransition();
2812
- const configured=this.#providerRuntime.configure(prepared);
2813
- this.#applyPreferenceTuple(this.#tupleFromProviderRoutes(configured));
2814
- this.#releaseInactiveBuiltInLLMProviders(
2815
- configured.llm.default?.providerId
2816
- );
2817
- this.#releaseInactiveBuiltInSpeechProviders({
2818
- stt:configured.stt.default?.providerId,
2819
- tts:configured.tts.default?.providerId
2820
- });
2821
- await this.#reconcileBuiltInLLMReadiness();
2822
- await this.#reconcileBuiltInSpeechReadiness();
2823
- return configured;
2977
+ const operation=this.#beginProviderSelection(normalizeAISelectionOptions(options));
2978
+ try{
2979
+ this.#assertProviderSelection(operation);
2980
+ await this.#supersedeBrowserSpeechForRouteChange(operation);
2981
+ this.#assertProviderSelection(operation);
2982
+ this.#invalidateSpeechControl();
2983
+ await this.#unloadProviderRolesForTransition(operation);
2984
+ this.#assertProviderSelection(operation);
2985
+ this.#ensureBuiltInLLMProvider(prepared.llm.default?.providerId,operation);
2986
+ this.#ensureBuiltInSpeechProvider('stt',prepared.stt.default?.providerId,operation);
2987
+ this.#ensureBuiltInSpeechProvider('tts',prepared.tts.default?.providerId,operation);
2988
+ this.#assertRegisteredBuiltInRoutes(prepared);
2989
+ this.#assertProviderSelection(operation);
2990
+ const configured=this.#providerRuntime.configure(prepared);
2991
+ this.#commitProviderSelection(operation,this.#tupleFromProviderRoutes(configured));
2992
+ this.#releaseInactiveBuiltInLLMProviders(configured.llm.default?.providerId,operation);
2993
+ this.#assertProviderSelection(operation);
2994
+ this.#releaseInactiveBuiltInSpeechProviders({
2995
+ stt:configured.stt.default?.providerId,
2996
+ tts:configured.tts.default?.providerId
2997
+ },operation);
2998
+ this.#assertProviderSelection(operation);
2999
+ await this.#reconcileBuiltInLLMReadiness(operation);
3000
+ this.#assertProviderSelection(operation);
3001
+ await this.#reconcileBuiltInSpeechReadiness();
3002
+ this.#assertProviderSelection(operation);
3003
+ return configured;
3004
+ }finally{
3005
+ this.#finishProviderSelection(operation);
3006
+ }
2824
3007
  }
2825
3008
 
2826
3009
  async transitionSpeechProviders(selections){
@@ -3061,10 +3244,13 @@ class AI {
3061
3244
  );
3062
3245
  }
3063
3246
 
3064
- async #supersedeBrowserSpeechForRouteChange(){
3247
+ async #supersedeBrowserSpeechForRouteChange(operation=null){
3065
3248
  for(const role of [...this.#configuredSpeechProviders.keys()]){
3249
+ if(operation)this.#assertProviderSelection(operation);
3066
3250
  await this.configureSpeechProvider(role,null);
3251
+ if(operation)this.#assertProviderSelection(operation);
3067
3252
  }
3253
+ if(operation)this.#assertProviderSelection(operation);
3068
3254
  if(!this.#browserSpeechConfigurationRecord
3069
3255
  &&this.#browserSpeechRetiredRecords.size===0){
3070
3256
  return false;
@@ -304,8 +304,11 @@ function copyRoleRecord(role, record) {
304
304
  }
305
305
  const pendingRecovery = ['recovering', 'error'].includes(record.state)
306
306
  && record.operationId !== null;
307
- if (record.busy && !pendingRecovery && (record.state !== 'ready' || !record.loaded)) {
308
- fail('a busy role must be ready and loaded or retain its recovering/error operation.');
307
+ const pendingUnload = record.state === 'unloading'
308
+ && record.loaded && record.operationId !== null;
309
+ if (record.busy && !pendingRecovery && !pendingUnload
310
+ && (record.state !== 'ready' || !record.loaded)) {
311
+ fail('a busy role must be ready and loaded, retain its recovering/error operation, or retain its loaded unloading operation.');
309
312
  }
310
313
  if (record.state === 'error' && error === null) {
311
314
  fail('an error role state must include error details.');
@@ -98,17 +98,20 @@ async function ensureLockDirectory(workspace){
98
98
  return physicalDirectory(directory,'Workspace operation-lock directory',{create:true});
99
99
  }
100
100
 
101
- function ownerIsAlive(pid){
101
+ function ownerIsAbsent(pid){
102
102
  if(!is.safeInteger(pid)||pid<1)return false;
103
103
  try{
104
104
  process.kill(pid,0);
105
- return true;
105
+ return false;
106
106
  }catch(error){
107
- return error?.code==='EPERM';
107
+ if(error?.code==='ESRCH')return true;
108
+ const busyError=workspaceBusy('The recorded Arcane workspace operation owner could not be determined.');
109
+ busyError.cause=error;
110
+ throw busyError;
108
111
  }
109
112
  }
110
113
 
111
- function lockDocument(value,{workspaceRoot,now=Date.now()}={}){
114
+ function lockDocument(value,{workspaceRoot}={}){
112
115
  if(!value||!is.object(value)||is.array(value)
113
116
  ||value.schemaVersion!==1
114
117
  ||value.kind!=='arcane-workspace-operation-lock'
@@ -124,7 +127,7 @@ function lockDocument(value,{workspaceRoot,now=Date.now()}={}){
124
127
  }
125
128
  const expiresAt=Date.parse(value.expiresAt);
126
129
  if(!is.finite(expiresAt))return null;
127
- return {...value,expired:expiresAt<=now};
130
+ return value;
128
131
  }
129
132
 
130
133
  async function readLock(lockPath,workspaceRoot){
@@ -145,16 +148,24 @@ async function readLock(lockPath,workspaceRoot){
145
148
  return lockDocument(value,{workspaceRoot});
146
149
  }
147
150
 
148
- async function recoverExpiredLock(lockPath,workspaceRoot,onEvent){
151
+ async function recoverAbsentOwnerLock(lockPath,workspaceRoot,onEvent,signal){
152
+ throwIfAborted(signal);
149
153
  const document=await readLock(lockPath,workspaceRoot);
150
- if(!document?.expired||ownerIsAlive(document.owner.pid))return false;
154
+ throwIfAborted(signal);
155
+ if(!document||!ownerIsAbsent(document.owner.pid))return false;
151
156
  await onEvent?.({
152
157
  type:'workspace.operation.stale-recovered',
153
158
  workspaceRoot,
154
159
  lockPath,
155
- previousOwner:document.owner,
160
+ previousOwner:{...document.owner},
156
161
  previousOperation:document.operation
157
162
  });
163
+ throwIfAborted(signal);
164
+ const current=await readLock(lockPath,workspaceRoot);
165
+ throwIfAborted(signal);
166
+ // Observers may replace the record; their completion does not retain ownership.
167
+ if(current?.nonce!==document.nonce||current?.owner.pid!==document.owner.pid
168
+ ||!ownerIsAbsent(current.owner.pid))return false;
158
169
  await rm(lockPath);
159
170
  return true;
160
171
  }
@@ -254,15 +265,17 @@ async function acquire({
254
265
  lockPath=path.join(directory.canonical,LOCK_NAME);
255
266
  let recovered=false;
256
267
  for(;;){
268
+ throwIfAborted(signal);
257
269
  try{
258
270
  handle=await open(lockPath,OPEN_EXCLUSIVE,0o600);
259
271
  break;
260
272
  }catch(error){
261
273
  if(error?.code!=='EEXIST')throw error;
262
- if(recovered||!(await recoverExpiredLock(
274
+ if(recovered||!(await recoverAbsentOwnerLock(
263
275
  lockPath,
264
276
  workspace.canonical,
265
- onEvent
277
+ onEvent,
278
+ signal
266
279
  ))){
267
280
  fail(
268
281
  `Workspace is locked by another Arcane operation: ${lockPath}. `