@agentsdock/server 1.0.10-beta.4 → 1.0.10

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/README.md CHANGED
@@ -20,6 +20,12 @@ the same prerequisites as the source installer: a trusted `uv`, Linux with a
20
20
  working user systemd session or Apple silicon macOS with a GUI launchd session,
21
21
  and the desired provider CLIs. It runs as the current user without `sudo`.
22
22
 
23
+ Installation and recovery exclude temporary npm lifecycle `.bin` paths, npm
24
+ `_npx` cache paths and npm node-gyp shims from the inherited PATH. Stable absolute
25
+ user/provider/Node locations remain ordered; empty and relative entries are
26
+ excluded. This lexical filter is not executable or symlink authorization and
27
+ does not repair the persisted PATH of an existing installation.
28
+
23
29
  The npm entrypoint only permits a fresh default installation. It refuses an
24
30
  existing managed installation, legacy service, or custom installation roots;
25
31
  changing directories does not create a second service. Use AgentsDock's managed
package/npm/cli.cjs CHANGED
@@ -23,6 +23,27 @@ No installation hooks run when this npm package is installed.
23
23
  `
24
24
  const ROOT_SELECTORS = ['AGENTS_SERVER_INSTALL_DIR', 'AGENTS_SERVER_CONFIG_DIR', 'AGENTS_SERVER_STATE_DIR', 'AGENTSDOCK_STATE_DIR', 'ZENITHBOT_AGENT_DIR', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME']
25
25
 
26
+ function persistentPath(value) {
27
+ if (value === undefined) return undefined
28
+ // npm adds package/ancestor .bin directories and node-gyp shims to lifecycle
29
+ // PATH; npx additionally exposes its disposable cache. These must not become
30
+ // dependencies of a long-lived native service. Keep stable absolute user,
31
+ // provider and Node paths in order; do not trust npm env as an "original PATH".
32
+ // This is a lexical persistence filter, not executable/symlink authorization.
33
+ const entries = new Set()
34
+ for (const entry of String(value).split(path.delimiter)) {
35
+ if (!path.isAbsolute(entry) || /[\x00-\x1f\x7f]/.test(entry)) continue
36
+ const normalized = path.normalize(entry).replace(/\/$/, '') || '/'
37
+ if (/\/node_modules\/\.bin$/.test(normalized) ||
38
+ /\/_npx\/[^/]+(?:\/|$)/.test(normalized) ||
39
+ /\/node_modules\/(?:npm\/bin|@npmcli\/run-script\/lib)\/node-gyp-bin$/.test(normalized)) continue
40
+ // Do not rewrite retained paths across symlink/.. boundaries: lexical
41
+ // normalization is only for recognizing npm entries, not resolving paths.
42
+ entries.add(entry)
43
+ }
44
+ return entries.size ? [...entries].join(path.delimiter) : '/usr/bin:/bin:/usr/sbin:/sbin'
45
+ }
46
+
26
47
  function parse(argv) {
27
48
  const [command = 'help', ...args] = argv
28
49
  if (['help', '--help', '-h', '--version'].includes(command)) {
@@ -236,6 +257,7 @@ async function run(argv, overrides = {}) {
236
257
  const payload = path.join(context.packageRoot, 'server')
237
258
  const payloadVersion = readRegular(path.join(payload, 'VERSION'), 200).toString('utf8').trim()
238
259
  if (payloadVersion !== metadata.version) throw new Error('Package and server payload versions do not match.')
260
+ if (Object.hasOwn(context.env, 'PATH')) context.env = { ...context.env, PATH: persistentPath(context.env.PATH) }
239
261
  if (command === 'recover') {
240
262
  const recovery = recoveryContext(context)
241
263
  if (!recovery) { context.print('No unfinished default server migration was found. No changes were made.'); return 0 }
@@ -290,4 +312,4 @@ async function run(argv, overrides = {}) {
290
312
  }
291
313
 
292
314
  if (require.main === module) run(process.argv.slice(2)).then(code => { process.exitCode = code }).catch(error => { process.stderr.write(`agentsdock-server: ${error.message}\n`); process.exitCode = 1 })
293
- module.exports = { parse, readRegular, ensureFreshInstall, recoveryContext, validateOrigin, run }
315
+ module.exports = { parse, readRegular, ensureFreshInstall, recoveryContext, validateOrigin, persistentPath, run }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentsdock/server",
3
- "version": "1.0.10-beta.4",
3
+ "version": "1.0.10",
4
4
  "description": "AgentsDock's self-hosted Python server and explicit installation CLI",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
package/server/VERSION CHANGED
@@ -1 +1 @@
1
- 1.0.10-beta.4
1
+ 1.0.10
@@ -67519,8 +67519,8 @@ async def run_opencode_process(
67519
67519
  - The workspace is explicitly passed as ``--dir`` as well as cwd.
67520
67520
  - There is no init event. Every event already carries the sessionID, so
67521
67521
  the session is considered started at the first event of any kind.
67522
- - The turn ends at a ``step_finish`` whose reason is not ``tool-calls``;
67523
- there is no separate terminal result event.
67522
+ - An explicit completion ``step_finish`` ends the turn; ``tool-calls``
67523
+ continues it and invalid/unknown finishes fail closed.
67524
67524
  - Usage is reported per step and must be accumulated, not overwritten.
67525
67525
  """
67526
67526
  from opencode_agent_client import (
@@ -68429,10 +68429,24 @@ async def run_opencode_process(
68429
68429
  opencode_usage,
68430
68430
  normalized.get("usage") or {},
68431
68431
  )
68432
- # A turn is a sequence of steps. "tool-calls" means another
68433
- # step follows; anything else ends the turn, and there is no
68434
- # separate terminal result event to wait for.
68435
- if str(normalized.get("reason") or "") != "tool-calls":
68432
+ # Only explicit completion reasons end a turn. In particular,
68433
+ # HTML-200/empty streams can yield "unknown" while the CLI
68434
+ # immediately requests another step; waiting for exit lets it
68435
+ # loop and hides the invalid response behind a cleanup error.
68436
+ finish_reason = str(normalized.get("reason") or "")
68437
+ if finish_reason not in {"tool-calls", "stop", "length", "content-filter"}:
68438
+ stream_error = (
68439
+ "OpenCode did not receive a valid completion from the "
68440
+ "model API. Check the endpoint URL, selected protocol "
68441
+ "and streaming support. The run was stopped to prevent "
68442
+ "repeated requests."
68443
+ )
68444
+ await terminate_process_tree(
68445
+ proc,
68446
+ grace=OPENCODE_GUARD_TEARDOWN_GRACE_SECONDS,
68447
+ )
68448
+ break
68449
+ if finish_reason != "tool-calls":
68436
68450
  await flush_opencode_reasoning()
68437
68451
  terminal_event_seen = True
68438
68452
  try:
@@ -814,6 +814,17 @@ def safe_model_label(label, model):
814
814
  return label.strip() if isinstance(label, str) and label.strip() and len(label) <= 160 and all(ord(c) >= 32 and ord(c) != 127 for c in label) else model
815
815
 
816
816
 
817
+ def endpoint_api_base_url(base: str, protocol: str) -> str:
818
+ """Resolve the API prefix, retaining the gateway's path and saved identity.
819
+
820
+ Anthropic settings accept either a root or its /v1 API prefix. AI SDK
821
+ adapters append /messages, unlike Claude Code which appends /v1/messages.
822
+ OpenAI-compatible settings already specify their complete API prefix.
823
+ """
824
+ base = base.rstrip("/")
825
+ return base + "/v1" if protocol == "anthropic" and not base.endswith("/v1") else base
826
+
827
+
817
828
  def endpoint_model_catalog(selected: dict, *, protocol="responses") -> dict:
818
829
  """Read only this endpoint's inventory. Never use CLI models or infer a default.
819
830
 
@@ -823,8 +834,7 @@ def endpoint_model_catalog(selected: dict, *, protocol="responses") -> dict:
823
834
  base = selected["base_url"].rstrip("/")
824
835
  parsed = urlsplit(base)
825
836
  router = parsed.scheme == "https" and parsed.netloc in {"openrouter.ai", "eu.openrouter.ai"} and parsed.path in {"/api", "/api/v1"}
826
- target = (base.removesuffix("/v1") + "/v1/models/user") if router else base + (
827
- "/v1/models" if protocol == "anthropic" and not base.endswith("/v1") else "/models")
837
+ target = (base.removesuffix("/v1") + "/v1/models/user") if router else endpoint_api_base_url(base, protocol) + "/models"
828
838
  headers = {"Accept": "application/json", "anthropic-version": "2023-06-01"}
829
839
  header = selected.get("auth_header", "bearer")
830
840
  headers["x-api-key" if header == "x-api-key" else "Authorization"] = selected["api_key"] if header == "x-api-key" else "Bearer " + selected["api_key"]
@@ -249,7 +249,7 @@ def require_retired_legacy_intent(layout: files.ExecutionLayout, previous: Any,
249
249
 
250
250
  root = layout.install_root
251
251
  def no_journal():
252
- for name in (".activation-transaction", ".execution-transaction", ".execution-uninstall.json"):
252
+ for name in (".activation-transaction", ".execution-transaction", ".execution-uninstall.json", ".execution-lifecycle.json"):
253
253
  if (root / name).exists() or (root / name).is_symlink():
254
254
  raise RuntimeError("a lifecycle journal still owns the previous recovery intent")
255
255
  no_journal()
@@ -186,6 +186,10 @@ class ExecutionControl:
186
186
  if length not in {None, 0}:
187
187
  raise ExecutionControlError(400, "Status requests do not accept a body")
188
188
  result = await self.status_callback()
189
+ # This running worker understands execution-lifecycle startup
190
+ # holds. New CLIs must not stop an older retained worker and
191
+ # discover only after restart that its queue was not held.
192
+ result = {**result, "native_lifecycle_protocol": 1}
189
193
  else:
190
194
  content_types = _header_values(headers, b"content-type")
191
195
  if (len(content_types) != 1 or content_types[0].lower()
@@ -329,6 +329,36 @@ def layout_manifest(layout: ExecutionLayout) -> dict[str, Any]:
329
329
  "retained_releases": sorted({str(layout.worker_release), str(layout.gateway_release)})}
330
330
 
331
331
 
332
+ def installed_layout(root: Path) -> ExecutionLayout:
333
+ """Validate authoritative bindings without discarding retained rollback history."""
334
+ root = _path(root)
335
+ data, _mode = _read_file(root / LAYOUT_NAME, private=True)
336
+ value = json.loads(data)
337
+ if not isinstance(value, dict):
338
+ raise ValueError("installed execution layout is invalid")
339
+ layout = ExecutionLayout.from_dict(value.get("layout"))
340
+ expected = layout_manifest(layout)
341
+ retained = value.get("retained_releases")
342
+ if (layout.install_root != root or set(value) != set(expected)
343
+ or {key: item for key, item in value.items() if key != "retained_releases"}
344
+ != {key: item for key, item in expected.items() if key != "retained_releases"}
345
+ or not isinstance(retained, list) or not all(isinstance(item, str) for item in retained)
346
+ or len(retained) != len(set(retained))
347
+ or not set(expected["retained_releases"]).issubset(retained)):
348
+ raise ValueError("installed execution layout does not match its runtime")
349
+ # Old monolith releases may have no execution_service.py. Retention is not
350
+ # activation authority, but every advisory entry must be an owned, real
351
+ # version directory inside this installation (never an arbitrary path).
352
+ for item in retained:
353
+ path = _path(item)
354
+ if path.parent != root / "releases" or path.name.startswith("."):
355
+ raise ValueError("retained release is outside the installation")
356
+ _owned_directory(path)
357
+ version, _mode = _read_file(path / "VERSION")
358
+ _text(version.decode().strip())
359
+ return layout
360
+
361
+
332
362
  def prepare_runtime(layout: ExecutionLayout) -> None:
333
363
  """Create private IPC state; never rotate an existing control token."""
334
364
  from execution_transport import ensure_execution_secret
@@ -399,6 +429,10 @@ def stage(layout: ExecutionLayout, *, scope: str, prior_services: dict[str, Any]
399
429
  legacy = layout.install_root / ".activation-transaction"
400
430
  if legacy.exists() or legacy.is_symlink():
401
431
  raise RuntimeError("legacy activation recovery must finish before execution activation")
432
+ for name in (".execution-lifecycle.json", ".execution-uninstall.json"):
433
+ marker = layout.install_root / name
434
+ if marker.exists() or marker.is_symlink():
435
+ raise RuntimeError("native lifecycle ownership must finish before execution activation")
402
436
  if layout.transaction_dir.exists() or layout.transaction_dir.is_symlink():
403
437
  raise FileExistsError("an execution activation is already pending")
404
438
  prior_layout = _snapshot(layout.manifest_path)
@@ -674,6 +708,19 @@ def pending_worker_operation(root: Path, runtime_root: Path) -> str | None:
674
708
  journal = root / TRANSACTION_NAME
675
709
  outer = root / ".activation-transaction"
676
710
  uninstall = root / ".execution-uninstall.json"
711
+ lifecycle = root / ".execution-lifecycle.json"
712
+ if lifecycle.exists() or lifecycle.is_symlink():
713
+ if any(path.exists() or path.is_symlink() for path in (outer, journal)):
714
+ raise RuntimeError("activation and lifecycle both claim execution ownership")
715
+ from execution_manage import pending_lifecycle_operation
716
+ operation = pending_lifecycle_operation(root, runtime_root)
717
+ if uninstall.exists() or uninstall.is_symlink():
718
+ # An interrupted owned-stop -> uninstall adoption may retain both
719
+ # journals, but they must describe the very same sealed operation.
720
+ from execution_uninstall import pending_uninstall_operation
721
+ if pending_uninstall_operation(root, runtime_root) != operation:
722
+ raise RuntimeError("uninstall and lifecycle ownership conflict")
723
+ return operation
677
724
  if uninstall.exists() or uninstall.is_symlink():
678
725
  if outer.exists() or outer.is_symlink() or journal.exists() or journal.is_symlink():
679
726
  raise RuntimeError("activation and uninstall both claim execution ownership")
@@ -714,13 +761,7 @@ def active_worker_release(root: Path) -> Path | None:
714
761
  data, _mode = _read_file(root / LAYOUT_NAME, private=True)
715
762
  except FileNotFoundError:
716
763
  return None
717
- value = json.loads(data)
718
- if not isinstance(value, dict) or value.get("format") != FORMAT:
719
- raise ValueError("installed execution layout is invalid")
720
- layout = ExecutionLayout.from_dict(value.get("layout"))
721
- if layout.install_root != root or value != layout_manifest(layout):
722
- raise ValueError("installed execution layout no longer matches its runtime")
723
- return layout.worker_release
764
+ return installed_layout(root).worker_release
724
765
 
725
766
 
726
767
  def main(argv: list[str] | None = None) -> None:
@@ -12,7 +12,9 @@ from __future__ import annotations
12
12
 
13
13
  import argparse
14
14
  import base64
15
+ from contextlib import ExitStack
15
16
  import errno
17
+ import hashlib
16
18
  import json
17
19
  import os
18
20
  from pathlib import Path
@@ -25,6 +27,7 @@ import sys
25
27
  import time
26
28
  from typing import Any, Callable
27
29
  import urllib.parse
30
+ import uuid
28
31
 
29
32
  import execution_install as files
30
33
  from execution_transport import _read_secret
@@ -216,6 +219,35 @@ class NativeServices:
216
219
  raise RuntimeError("native service is transitioning; retry after it settles")
217
220
  return states
218
221
 
222
+ def validate_registered_bindings(self) -> None:
223
+ """Reject foreign or stale manager-loaded jobs without exposing output."""
224
+ for role in ("worker", "gateway"):
225
+ path = str(self.layout.service_path(role))
226
+ if self.layout.platform == "Linux":
227
+ result = self._command(["systemctl", "--user", "show", self._unit(role), "--no-pager",
228
+ "--property=FragmentPath,DropInPaths,NeedDaemonReload"])
229
+ values = {}
230
+ for line in result.stdout.splitlines():
231
+ key, separator, value = line.partition("=")
232
+ if not separator or key in values:
233
+ raise RuntimeError("systemd returned ambiguous registered bindings")
234
+ values[key] = value
235
+ if values != {"FragmentPath": path, "DropInPaths": "", "NeedDaemonReload": "no"}:
236
+ raise RuntimeError("native unit registration is foreign, overridden, or stale")
237
+ else:
238
+ result = self._command(["/bin/launchctl", "print", self._target(role)], allow_failure=True)
239
+ if result.returncode:
240
+ if "Could not find service" in result.stderr:
241
+ continue # bootout removed the job; exact plist is checked separately.
242
+ raise RuntimeError("launchd registration ownership is unknown")
243
+ paths = re.findall(r"(?m)^\s*path = (.+)$", result.stdout)
244
+ programs = re.findall(r"(?m)^\s*program = (.+)$", result.stdout)
245
+ arguments = re.findall(r"(?ms)^\s*arguments = \{\n(.*?)^\s*\}", result.stdout)
246
+ expected = files.service_arguments(self.layout, role)
247
+ if (paths != [path] or programs != [expected[0]] or len(arguments) != 1
248
+ or [line.strip() for line in arguments[0].splitlines() if line.strip()] != expected):
249
+ raise RuntimeError("native launchd registration does not match the installed service")
250
+
219
251
  def stop(self, role: str) -> None:
220
252
  state = self._observe(role)
221
253
  if state["state"] in {"absent", "stopped"}:
@@ -426,6 +458,278 @@ class WorkerControl:
426
458
  "maintenance_held": worker.get("maintenance_held")}
427
459
 
428
460
 
461
+ LIFECYCLE_NAME = ".execution-lifecycle.json"
462
+
463
+
464
+ def read_lifecycle_intent(layout: files.ExecutionLayout) -> dict | None:
465
+ """Read owned stop/restart evidence; never repair or normalize user state."""
466
+ from execution_preparation import _check_binding
467
+ try:
468
+ data, _mode = files._read_file(layout.install_root / LIFECYCLE_NAME, private=True)
469
+ except FileNotFoundError:
470
+ return None
471
+ value = json.loads(data)
472
+ expected = {"format", "operation_id", "layout_sha256", "identities", "server_identity",
473
+ "worker_pid", "gateway_pid", "worker_instance_id", "lease_id", "phase", "started"}
474
+ if (not isinstance(value, dict) or set(value) != expected or type(value["format"]) is not int
475
+ or value["format"] != 1 or value["phase"] not in {"prepared", "sealed", "stopped", "starting", "releasing"}
476
+ or not isinstance(value["operation_id"], str)
477
+ or str(uuid.UUID(value["operation_id"])) != value["operation_id"]
478
+ or any(type(value[key]) is not int or value[key] <= 0 for key in ("worker_pid", "gateway_pid"))
479
+ or any(not isinstance(value[key], str) or re.fullmatch(r"[A-Za-z0-9_-]{1,128}", value[key]) is None
480
+ for key in ("server_identity", "worker_instance_id"))
481
+ or (value["lease_id"] is not None and (not isinstance(value["lease_id"], str)
482
+ or str(uuid.UUID(value["lease_id"])) != value["lease_id"]))):
483
+ raise ValueError("native lifecycle intent is invalid")
484
+ started = value["started"]
485
+ if started is not None and (not isinstance(started, dict) or set(started) != {"pid", "instance_id"}
486
+ or type(started["pid"]) is not int or started["pid"] <= 0
487
+ or not isinstance(started["instance_id"], str)
488
+ or re.fullmatch(r"[A-Za-z0-9_-]{1,128}", started["instance_id"]) is None):
489
+ raise ValueError("native lifecycle startup identity is invalid")
490
+ if (value["phase"] == "releasing" and started is None
491
+ or value["phase"] not in {"starting", "releasing"} and started is not None
492
+ or value["phase"] != "prepared" and value["lease_id"] is None):
493
+ raise ValueError("native lifecycle phase lacks its admission evidence")
494
+ paths = {"root": layout.install_root, "worker": layout.worker_release,
495
+ "gateway": layout.gateway_release, "config": layout.config_root, "state": layout.state_root}
496
+ if not isinstance(value["identities"], dict) or set(value["identities"]) != set(paths):
497
+ raise ValueError("native lifecycle directory identities are invalid")
498
+ for name, path in paths.items():
499
+ _check_binding(path, value["identities"][name])
500
+ manifest, _mode = files._read_file(layout.manifest_path, private=True)
501
+ if hashlib.sha256(manifest).hexdigest() != value["layout_sha256"]:
502
+ raise RuntimeError("installed layout changed after lifecycle admission")
503
+ return value
504
+
505
+
506
+ def pending_lifecycle_operation(root: Path, runtime_root: Path) -> str:
507
+ layout = files.installed_layout(root)
508
+ intent = read_lifecycle_intent(layout)
509
+ if intent is None or files._path(runtime_root) != layout.worker_release:
510
+ raise RuntimeError("worker is outside the retained lifecycle operation")
511
+ marker = root / ".execution-uninstall.json"
512
+ if marker.exists() or marker.is_symlink():
513
+ from execution_uninstall import _read_intent
514
+ removal = _read_intent(root, layout)
515
+ if (intent["phase"] != "stopped" or removal is None
516
+ or removal["operation_id"] != intent["operation_id"]
517
+ or removal["server_identity"] != intent["server_identity"]):
518
+ raise RuntimeError("uninstall and lifecycle ownership conflict")
519
+ return intent["operation_id"]
520
+
521
+
522
+ def retire_lifecycle_intent(layout: files.ExecutionLayout, intent: dict) -> None:
523
+ if read_lifecycle_intent(layout) != intent:
524
+ raise RuntimeError("native lifecycle intent changed before retirement")
525
+ (layout.install_root / LIFECYCLE_NAME).unlink()
526
+ files._fsync_directory(layout.install_root)
527
+
528
+
529
+ class LifecycleController:
530
+ """Idle-only control of an installed pair, not an installer or force repair.
531
+
532
+ Failures retain an exact operation journal and any sealed admission hold.
533
+ Retry may finish that operation; an unjournaled broken/stopped installation
534
+ or a changed worker epoch is never guessed safe. Explicit start releases
535
+ startup admission only after both components have authenticated health.
536
+ """
537
+
538
+ def __init__(self, layout: files.ExecutionLayout, *, services: NativeServices | None = None,
539
+ control: WorkerControl | None = None, health_timeout: float = 30.0):
540
+ self.layout = layout
541
+ self.services = services or NativeServices(layout)
542
+ self.control = control or WorkerControl(services=self.services)
543
+ self.health_timeout = health_timeout
544
+
545
+ def _save(self, intent: dict, **changes) -> dict:
546
+ if read_lifecycle_intent(self.layout) != intent:
547
+ raise RuntimeError("native lifecycle intent changed")
548
+ updated = {**intent, **changes}
549
+ files._atomic_write(self.layout.install_root / LIFECYCLE_NAME, files._json_bytes(updated))
550
+ return updated
551
+
552
+ def _unchanged(self, originals: dict) -> None:
553
+ from execution_uninstall import _service_files, _no_pending_update
554
+ from update_handoff import _no_activation
555
+ _no_activation(self.layout.install_root)
556
+ marker = self.layout.install_root / ".execution-uninstall.json"
557
+ if marker.exists() or marker.is_symlink():
558
+ raise RuntimeError("uninstall owns this native lifecycle")
559
+ _no_pending_update(self.layout)
560
+ if _service_files(self.layout) != originals:
561
+ raise RuntimeError("native service bindings changed during lifecycle operation")
562
+ self.services.validate_registered_bindings()
563
+
564
+ def _new_intent(self, prior: dict) -> dict:
565
+ from execution_preparation import _binding
566
+ receipt = self.control.receipt(self.layout, self.services)
567
+ record, status = self.control.status(self.layout)
568
+ if type(status.get("native_lifecycle_protocol")) is not int or status["native_lifecycle_protocol"] != 1:
569
+ raise RuntimeError("installed worker does not support safe paired lifecycle commands; update the installed server through its signed managed update first")
570
+ if (receipt.get("worker_pid") != prior["worker"].get("pid")
571
+ or receipt.get("gateway_pid") != prior["gateway"].get("pid")
572
+ or receipt.get("worker_release") != str(self.layout.worker_release)
573
+ or record.get("pid") != receipt.get("worker_pid")
574
+ or record.get("instance_id") != receipt.get("worker_instance_id")
575
+ or status.get("idle") is not True or status.get("lease") is not None):
576
+ raise RuntimeError("execution is busy, changed, or another operation owns admission; no services stopped")
577
+ manifest, _mode = files._read_file(self.layout.manifest_path, private=True)
578
+ intent = {"format": 1, "operation_id": str(uuid.uuid4()), "phase": "prepared", "lease_id": None,
579
+ "started": None, "server_identity": receipt.get("server_identity"),
580
+ "worker_pid": receipt["worker_pid"], "gateway_pid": receipt["gateway_pid"],
581
+ "worker_instance_id": receipt["worker_instance_id"],
582
+ "layout_sha256": hashlib.sha256(manifest).hexdigest(),
583
+ "identities": {name: _binding(path) for name, path in {
584
+ "root": self.layout.install_root, "worker": self.layout.worker_release,
585
+ "gateway": self.layout.gateway_release, "config": self.layout.config_root,
586
+ "state": self.layout.state_root}.items()}}
587
+ if not isinstance(intent["server_identity"], str) or re.fullmatch(r"[A-Za-z0-9_-]{1,128}", intent["server_identity"]) is None:
588
+ raise RuntimeError("authenticated server identity is unavailable")
589
+ if self.services.snapshot() != prior or read_lifecycle_intent(self.layout) is not None:
590
+ raise RuntimeError("native ownership changed before lifecycle admission")
591
+ files._atomic_write(self.layout.install_root / LIFECYCLE_NAME, files._json_bytes(intent))
592
+ return intent
593
+
594
+ def _stop(self, intent: dict, originals: dict) -> dict:
595
+ from execution_uninstall import _owned_lease, _no_provider_children
596
+ if intent["phase"] in {"starting", "releasing"}:
597
+ raise RuntimeError("an owned start must finish before another stop")
598
+ self._unchanged(originals)
599
+ prior = self.services.snapshot()
600
+ if any(prior[role].get("state") not in {"running", "stopped", "absent"} for role in ("worker", "gateway")):
601
+ raise RuntimeError("native lifecycle state is uncertain")
602
+ if prior["worker"].get("state") == "running":
603
+ record, status = self.control.status(self.layout)
604
+ if (record.get("pid") != intent["worker_pid"] or prior["worker"].get("pid") != intent["worker_pid"]
605
+ or record.get("instance_id") != intent["worker_instance_id"]
606
+ or record.get("release_root") != str(self.layout.worker_release)):
607
+ raise RuntimeError("worker epoch changed after lifecycle admission")
608
+ if intent["lease_id"] is not None and (status.get("lease") or {}).get("lease_id") != intent["lease_id"]:
609
+ raise RuntimeError("owned lifecycle admission changed")
610
+ try:
611
+ sealed = self.control.seal_for_stop(self.layout, intent["operation_id"], intent["worker_pid"])
612
+ except (OSError, ValueError, RuntimeError) as error:
613
+ raise RuntimeError("lifecycle admission was not confirmed; its owned retry evidence was retained and no service was stopped by this attempt; retry the same command when work is idle (a changed worker requires ownership recovery)") from error
614
+ if sealed["record"] != record:
615
+ raise RuntimeError("worker changed while lifecycle admission was sealed")
616
+ intent = self._save(intent, phase="sealed", lease_id=sealed["lease"]["lease_id"])
617
+ self._unchanged(originals)
618
+ if self.services.snapshot() != prior:
619
+ raise RuntimeError("native jobs changed after lifecycle admission")
620
+ for role in ("gateway", "worker"):
621
+ current = self.services.snapshot()[role]
622
+ if current.get("state") in {"stopped", "absent"}:
623
+ self.services.set_enabled(role, False)
624
+ continue
625
+ if current.get("state") != "running" or current.get("pid") != intent[role + "_pid"]:
626
+ raise RuntimeError("native process changed before its authorized stop")
627
+ if role == "worker":
628
+ now_record, now_status = self.control.status(self.layout)
629
+ if (now_record != sealed["record"] or now_status.get("lease") != sealed["lease"]
630
+ or now_status.get("idle") is not True):
631
+ raise RuntimeError("worker admission changed before its authorized stop")
632
+ self._unchanged(originals)
633
+ self.services.set_enabled(role, False)
634
+ self.services.stop(role)
635
+ elif intent["lease_id"] is None or prior["gateway"].get("state") == "running":
636
+ raise RuntimeError("stopped worker lacks an owned completed shutdown")
637
+ else:
638
+ for role in ("gateway", "worker"):
639
+ self.services.set_enabled(role, False)
640
+ if any(self.services.snapshot()[role].get("state") not in {"stopped", "absent"} for role in ("worker", "gateway")):
641
+ raise RuntimeError("both native services must stop before lifecycle completion")
642
+ with _owned_lease(self.layout.state_root / "admin/state-owner.lock"):
643
+ _no_provider_children(self.layout.state_root)
644
+ self._unchanged(originals)
645
+ return self._save(intent, phase="stopped")
646
+
647
+ def _wait_worker(self, intent: dict) -> dict:
648
+ deadline = time.monotonic() + self.health_timeout
649
+ while True:
650
+ try:
651
+ record, status = self.control.status(self.layout)
652
+ native = self.services.snapshot()["worker"]
653
+ if (native.get("state") != "running" or native.get("pid") != record.get("pid")
654
+ or record.get("release_root") != str(self.layout.worker_release)):
655
+ raise RuntimeError("native worker does not own startup admission")
656
+ started = {"pid": record["pid"], "instance_id": record["instance_id"]}
657
+ if intent["started"] is not None and started != intent["started"]:
658
+ raise RuntimeError("worker epoch changed during lifecycle startup")
659
+ if intent["phase"] != "releasing" or status.get("lease") is not None:
660
+ self.control.require_startup_hold(self.layout, intent["operation_id"])
661
+ return started
662
+ except (OSError, ValueError, RuntimeError) as error:
663
+ if time.monotonic() >= deadline:
664
+ raise RuntimeError("owned native worker did not reach held startup; retry start after recovery") from error
665
+ time.sleep(0.1)
666
+
667
+ def _start(self, intent: dict, originals: dict) -> dict:
668
+ from execution_uninstall import _owned_lease, _no_provider_children
669
+ if intent["phase"] == "stopped":
670
+ with _owned_lease(self.layout.state_root / "admin/state-owner.lock"):
671
+ _no_provider_children(self.layout.state_root)
672
+ self._unchanged(originals)
673
+ if any(self.services.snapshot()[role].get("state") not in {"stopped", "absent"} for role in ("worker", "gateway")):
674
+ raise RuntimeError("stopped native jobs changed before authorized start")
675
+ intent = self._save(intent, phase="starting")
676
+ if intent["phase"] not in {"starting", "releasing"}:
677
+ raise RuntimeError("native shutdown must complete before starting")
678
+ self._unchanged(originals)
679
+ native = self.services.snapshot()
680
+ if native["worker"].get("state") in {"stopped", "absent"}:
681
+ if intent["started"] is not None or native["gateway"].get("state") == "running":
682
+ raise RuntimeError("owned startup process disappeared; automatic replacement is refused")
683
+ self.services.start("worker")
684
+ started = self._wait_worker(intent)
685
+ if intent["started"] is None:
686
+ intent = self._save(intent, started=started)
687
+ self._unchanged(originals)
688
+ gateway = self.services.snapshot()["gateway"]
689
+ if gateway.get("state") in {"stopped", "absent"}:
690
+ self.services.start("gateway")
691
+ receipt = ActivationController(self.layout, services=self.services, control=self.control,
692
+ health_timeout=self.health_timeout)._wait_receipt(self.layout)
693
+ if (receipt.get("server_identity") != intent["server_identity"]
694
+ or receipt.get("worker_pid") != intent["started"]["pid"]
695
+ or receipt.get("worker_instance_id") != intent["started"]["instance_id"]):
696
+ raise RuntimeError("authenticated lifecycle identity changed")
697
+ self._wait_worker(intent)
698
+ self._unchanged(originals)
699
+ intent = self._save(intent, phase="releasing")
700
+ self.control.release(self.layout, intent["operation_id"])
701
+ retire_lifecycle_intent(self.layout, intent)
702
+ return {"ok": True, "status": "running", "receipt": receipt}
703
+
704
+ def run(self, action: str) -> dict:
705
+ if action not in {"start", "stop", "restart"}:
706
+ raise ValueError("unknown native lifecycle action")
707
+ from execution_uninstall import _service_files, _hold_preparation_leases
708
+ from execution_recovery import require_retired_owners
709
+ with InstallationLock(self.layout.install_root), ExitStack() as leases:
710
+ if files.installed_layout(self.layout.install_root) != self.layout:
711
+ raise RuntimeError("native lifecycle layout changed")
712
+ originals = _service_files(self.layout)
713
+ self._unchanged(originals)
714
+ require_retired_owners(self.layout.install_root)
715
+ _hold_preparation_leases(self.layout.install_root, leases)
716
+ intent = read_lifecycle_intent(self.layout)
717
+ if intent is None:
718
+ prior = self.services.snapshot()
719
+ if any(prior[role].get("state") != "running" for role in ("worker", "gateway")):
720
+ raise RuntimeError("unjournaled stopped or broken services require ownership recovery")
721
+ if action == "start":
722
+ return {"ok": True, "status": "running", "receipt": self.control.receipt(self.layout, self.services)}
723
+ intent = self._new_intent(prior)
724
+ if intent["phase"] not in {"starting", "releasing"}:
725
+ intent = self._stop(intent, originals)
726
+ if action == "stop":
727
+ if intent["phase"] != "stopped":
728
+ raise RuntimeError("retry start to finish the pending owned startup")
729
+ return {"ok": True, "status": "stopped"}
730
+ return self._start(intent, originals)
731
+
732
+
429
733
  class ActivationController:
430
734
  def __init__(self, layout: files.ExecutionLayout, *, services: NativeServices | None = None,
431
735
  control: WorkerControl | None = None, health_timeout: float = 30.0) -> None:
@@ -107,7 +107,7 @@ def _source_inventory(source: Path) -> dict[str, str]:
107
107
 
108
108
 
109
109
  def _conflicts(root: Path) -> None:
110
- for name in (".execution-transaction", ".execution-uninstall.json"):
110
+ for name in (".execution-transaction", ".execution-uninstall.json", ".execution-lifecycle.json"):
111
111
  if (root / name).exists() or (root / name).is_symlink():
112
112
  raise RuntimeError("another lifecycle operation owns this installation")
113
113
 
@@ -205,6 +205,7 @@ def uninstall(root: Path, *, config_root: Path, state_root: Path, home: Path,
205
205
  native = services or NativeServices(layout)
206
206
  control = control or WorkerControl()
207
207
  originals = _service_files(layout)
208
+ native.validate_registered_bindings()
208
209
  _no_pending_update(layout)
209
210
  _hold_preparation_leases(root, leases)
210
211
  _removable_tree(root)
@@ -216,6 +217,24 @@ def uninstall(root: Path, *, config_root: Path, state_root: Path, home: Path,
216
217
  if intent is not None and intent["purge_state"] != purge_state:
217
218
  raise RuntimeError("retry must preserve the existing uninstall data-removal choice")
218
219
  prior = native.snapshot()
220
+ from execution_manage import read_lifecycle_intent, retire_lifecycle_intent
221
+ lifecycle = read_lifecycle_intent(layout)
222
+ if lifecycle is not None:
223
+ # Adopt only a proven completed owned stop. Never start a worker
224
+ # merely to remove it: that could run saved scheduled/queued work.
225
+ if (lifecycle["phase"] != "stopped"
226
+ or any(prior[role].get("state") not in {"stopped", "absent"} for role in ("worker", "gateway"))):
227
+ raise RuntimeError("finish the owned lifecycle stop before uninstalling")
228
+ native.validate_registered_bindings()
229
+ with _owned_lease(state_root / "admin/state-owner.lock"):
230
+ _no_provider_children(state_root)
231
+ if intent is None:
232
+ intent = _write_intent(root, layout, lifecycle["operation_id"], purge_state,
233
+ lifecycle["server_identity"])
234
+ elif (intent["operation_id"] != lifecycle["operation_id"]
235
+ or intent["server_identity"] != lifecycle["server_identity"]):
236
+ raise RuntimeError("uninstall and lifecycle journal ownership conflict")
237
+ retire_lifecycle_intent(layout, lifecycle)
219
238
  # An interrupted graceful stop can precede the stopped-phase write.
220
239
  # The exact intent, empty child registry and exclusive state lease
221
240
  # below supply the missing proof; an unjournaled stopped install fails.
@@ -6,7 +6,6 @@ activation remains incomplete even after it has published the new files.
6
6
  """
7
7
  from __future__ import annotations
8
8
 
9
- import json
10
9
  from pathlib import Path
11
10
  from typing import Any
12
11
 
@@ -22,17 +21,11 @@ def current_components(
22
21
  """Read-only proof, invoked off the server event loop for an equal-version ensure."""
23
22
  root = files._path(root)
24
23
  files._owned_directory(root)
25
- for name in (".activation-transaction", ".execution-transaction"):
24
+ for name in (".activation-transaction", ".execution-transaction", ".execution-lifecycle.json", ".execution-uninstall.json"):
26
25
  journal = root / name
27
26
  if journal.exists() or journal.is_symlink():
28
27
  return False
29
- data, _mode = files._read_file(root / files.LAYOUT_NAME, private=True)
30
- document = json.loads(data)
31
- if not isinstance(document, dict) or document.get("format") != files.FORMAT:
32
- raise ValueError("installed execution layout is invalid")
33
- layout = files.ExecutionLayout.from_dict(document.get("layout"))
34
- if layout.install_root != root or document != files.layout_manifest(layout):
35
- raise ValueError("installed execution layout does not match its runtime")
28
+ layout = files.installed_layout(root)
36
29
  receipt: dict[str, Any] = WorkerControl().receipt(layout, NativeServices(layout))
37
30
  if (receipt.get("server_identity") != expected_server_identity
38
31
  or receipt.get("worker_instance_id") != expected_worker_instance):
@@ -45,5 +38,5 @@ def current_components(
45
38
  # Do not claim completion over the operation that just acquired ownership.
46
39
  return complete and not any(
47
40
  (root / name).exists() or (root / name).is_symlink()
48
- for name in (".activation-transaction", ".execution-transaction")
41
+ for name in (".activation-transaction", ".execution-transaction", ".execution-lifecycle.json", ".execution-uninstall.json")
49
42
  )
@@ -156,6 +156,11 @@ def opencode_error_message(error: Any) -> str:
156
156
  if not message:
157
157
  message = str(error.get("message") or "")
158
158
  name = str(error.get("name") or "")
159
+ status = data.get("statusCode") if isinstance(data, dict) else None
160
+ if type(status) is int and 100 <= status <= 599:
161
+ # Preserve useful HTTP diagnostics without exposing request headers,
162
+ # gateway response bodies or URLs that may contain credentials.
163
+ name = f"{name} (HTTP {status})" if name else f"HTTP {status}"
159
164
  if message and name:
160
165
  return f"{name}: {message}"
161
166
  return message or name
@@ -22,9 +22,12 @@ import httpx
22
22
  from fastapi import APIRouter, HTTPException, Request
23
23
  from fastapi.responses import JSONResponse
24
24
 
25
- from codex_provider import validate_selection, validate_model, endpoint_model_catalog
25
+ from codex_provider import validate_selection, validate_model, endpoint_model_catalog, endpoint_api_base_url
26
26
 
27
27
  MAX_BODY_BYTES = 16 * 1024
28
+ # Reasoning can consume output tokens before the short visible check answer.
29
+ # Keep this explicit probe bounded and single-shot, including for such models.
30
+ MODEL_CHECK_MAX_OUTPUT_TOKENS = 256
28
31
  PROTOCOLS = {"claude": {"anthropic"}, "opencode": {"anthropic", "chat_completions", "responses"}, "cursor": {"cursor"}}
29
32
  RESULTS = {"verified", "authentication_failed", "rate_limited", "unsupported", "connection_failed", "invalid_response", "model_required", "cli_update_required"}
30
33
 
@@ -277,7 +280,7 @@ class ConnectionStore:
277
280
  model = require_model(value)
278
281
  config = json.loads(env.get("OPENCODE_CONFIG_CONTENT") or "{}")
279
282
  adapter = {"anthropic": "@ai-sdk/anthropic", "chat_completions": "@ai-sdk/openai-compatible", "responses": "@ai-sdk/openai"}[value["protocol"]]
280
- options = {"baseURL": value["base_url"], "apiKey": "{env:AGENTSDOCK_CUSTOM_API_KEY}"}
283
+ options = {"baseURL": endpoint_api_base_url(value["base_url"], value["protocol"]), "apiKey": "{env:AGENTSDOCK_CUSTOM_API_KEY}"}
281
284
  if value["protocol"] == "anthropic" and value["auth_header"] == "bearer":
282
285
  options["headers"] = {"Authorization": "Bearer {env:AGENTSDOCK_CUSTOM_API_KEY}", "x-api-key": ""}
283
286
  config.setdefault("provider", {})["agentsdock_custom"] = {"npm": adapter, "name": "Custom endpoint", "options": options, "models": {model: {"name": model}}}
@@ -361,8 +364,7 @@ async def probe_credentials(selected: dict) -> str:
361
364
  headers["Authorization"] = "Bearer " + selected["api_key"]
362
365
  url = urlsplit(base)
363
366
  router = url.scheme == "https" and url.netloc == "openrouter.ai" and url.path in {"/api", "/api/v1"}
364
- target = "https://openrouter.ai/api/v1/key" if router else base + (
365
- "/v1/models" if protocol == "anthropic" and not base.endswith("/v1") else "/models")
367
+ target = "https://openrouter.ai/api/v1/key" if router else endpoint_api_base_url(base, protocol) + "/models"
366
368
  async def send():
367
369
  async with httpx.AsyncClient(timeout=10, follow_redirects=False, trust_env=False) as client:
368
370
  async with client.stream("GET", target, headers=headers) as response:
@@ -403,18 +405,18 @@ async def probe(selected: dict) -> str:
403
405
  headers["x-api-key"] = selected["api_key"]
404
406
  else:
405
407
  headers["Authorization"] = "Bearer " + selected["api_key"]
406
- base = selected["base_url"]
408
+ base = endpoint_api_base_url(selected["base_url"], protocol)
407
409
  prompt = "Connection check. Reply with OK only."
408
410
  if protocol == "anthropic":
409
- suffix = "/messages" if base.endswith("/v1") else "/v1/messages"
411
+ suffix = "/messages"
410
412
  headers["anthropic-version"] = "2023-06-01"
411
- body = {"model": selected["model"], "max_tokens": 32, "messages": [{"role": "user", "content": prompt}]}
413
+ body = {"model": selected["model"], "max_tokens": MODEL_CHECK_MAX_OUTPUT_TOKENS, "messages": [{"role": "user", "content": prompt}]}
412
414
  elif protocol == "responses":
413
415
  suffix = "/responses"
414
- body = {"model": selected["model"], "max_output_tokens": 64, "input": prompt, "store": False}
416
+ body = {"model": selected["model"], "max_output_tokens": MODEL_CHECK_MAX_OUTPUT_TOKENS, "input": prompt, "store": False}
415
417
  else:
416
418
  suffix = "/chat/completions"
417
- body = {"model": selected["model"], "max_tokens": 32, "messages": [{"role": "user", "content": prompt}]}
419
+ body = {"model": selected["model"], "max_tokens": MODEL_CHECK_MAX_OUTPUT_TOKENS, "messages": [{"role": "user", "content": prompt}]}
418
420
  async def send():
419
421
  async with httpx.AsyncClient(timeout=15, follow_redirects=False, trust_env=False) as client:
420
422
  async with client.stream("POST", base + suffix, headers=headers, json=body) as response:
@@ -600,7 +602,11 @@ def create_router(*, authorize, store: ConnectionStore, check=probe, account=nat
600
602
  current = store.read(backend)
601
603
  if current["revision"] != revision(payload["expected_revision"]):
602
604
  raise HTTPException(409, "Endpoint settings changed. Refresh and try again.")
603
- if current.get("last_result") != "verified": raise HTTPException(409, "Check this API connection first.")
605
+ if not current.get("configured"):
606
+ raise HTTPException(409, "Connect a custom API first.")
607
+ # A saved, unverified connection can choose its first model.
608
+ # This write does not verify it or make it usable by chats;
609
+ # the explicit /check operation must still succeed.
604
610
  value = {key: current[key] for key in ("base_url", "api_key", "protocol", "auth_header")}
605
611
  saved = store.write(backend, current["revision"], {**value, "model": model, "expected_revision": current["revision"]}, current["last_result"], checked_at=current["checked_at"])
606
612
  cached = store.catalog_cache.get((backend, current["revision"]))
@@ -651,7 +657,11 @@ def create_router(*, authorize, store: ConnectionStore, check=probe, account=nat
651
657
  result = "cli_update_required" if discovered.get("cli_update_required") else "verified" if discovered["discovery_status"] == "ready" and discovered["models"] else "connection_failed"
652
658
  else:
653
659
  result = await check(selected)
654
- if request.method == "PUT" and result != "verified":
660
+ # Public catalogs cannot authenticate a key. Keep a private,
661
+ # explicitly unverified setup so the client can list models
662
+ # before choosing one for a subsequent explicit check.
663
+ pending_model = result == "model_required" and not selected.get("model") and backend != "cursor"
664
+ if request.method == "PUT" and result != "verified" and not pending_model:
655
665
  return reply({"ok": False, "status": result})
656
666
  configuration = await asyncio.to_thread(store.write, backend, expected, selected, result)
657
667
  if backend == "cursor" and result == "verified":
@@ -138,6 +138,26 @@ def read_config(instance: Instance) -> dict[str, str]:
138
138
  return result
139
139
 
140
140
 
141
+ def split_layout(instance: Instance, platform: str = sys.platform):
142
+ """Default-only split detection; invalid evidence never falls back to legacy."""
143
+ if instance.name != "default":
144
+ return None
145
+ marker = instance.runtime / "execution-layout.json"
146
+ if not marker.exists() and not marker.is_symlink():
147
+ return None
148
+ import execution_install as files
149
+ from execution_uninstall import _service_files
150
+ layout = files.installed_layout(instance.runtime)
151
+ expected_platform = "Darwin" if platform == "darwin" else "Linux" if platform.startswith("linux") else None
152
+ if ((layout.install_root, layout.config_root, layout.state_root, layout.home, layout.platform)
153
+ != (instance.runtime, instance.config, instance.state, instance.home, expected_platform)):
154
+ raise ValueError("Default execution roots/platform do not match this instance.")
155
+ for role in ("worker", "gateway"):
156
+ check_path(layout.service_path(role), instance.home)
157
+ _service_files(layout)
158
+ return layout
159
+
160
+
141
161
  def validate_binding(instance: Instance, platform: str = sys.platform) -> None:
142
162
  for path in (instance.runtime, instance.config, instance.state, instance.logs, instance.service_file(platform)):
143
163
  check_path(path, instance.home)
@@ -145,6 +165,8 @@ def validate_binding(instance: Instance, platform: str = sys.platform) -> None:
145
165
  for key, value in instance.environment().items():
146
166
  if key in env and env[key] != value:
147
167
  raise ValueError(f"{instance.name}: {key} does not match this instance; refusing service changes.")
168
+ if split_layout(instance, platform) is not None:
169
+ return
148
170
  service = instance.service_file(platform)
149
171
  if service.exists():
150
172
  data = read_regular(service)
@@ -370,6 +392,22 @@ def run(command: list[str], **kwargs):
370
392
 
371
393
 
372
394
  def service_status(instance: Instance, platform: str = sys.platform) -> str:
395
+ try:
396
+ layout = split_layout(instance, platform)
397
+ if layout is not None:
398
+ from execution_manage import NativeServices, read_lifecycle_intent
399
+ native = NativeServices(layout)
400
+ native.validate_registered_bindings()
401
+ snapshot = native.snapshot()
402
+ states = {item["state"] for item in snapshot.values()}
403
+ intent = read_lifecycle_intent(layout)
404
+ if states <= {"absent", "stopped"}:
405
+ return "stopped"
406
+ if states == {"running"}:
407
+ return "pending" if intent is not None else "running"
408
+ return "partial"
409
+ except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired):
410
+ return "unknown"
373
411
  command = ["launchctl", "print", f"gui/{os.getuid()}/{launchd_label(instance.name)}"] if platform == "darwin" else ["systemctl", "--user", "show", service_name(instance.name) + ".service", "--property=ActiveState", "--value"]
374
412
  try:
375
413
  result = subprocess.run(command, capture_output=True, text=True, timeout=3, check=False)
@@ -863,6 +901,11 @@ def release_uninstalled_name(instance: Instance, registry: Registry) -> None:
863
901
 
864
902
  def control(instance: Instance, action: str, platform: str = sys.platform):
865
903
  validate_binding(instance, platform)
904
+ layout = split_layout(instance, platform)
905
+ if layout is not None:
906
+ from execution_manage import LifecycleController
907
+ LifecycleController(layout).run(action)
908
+ return
866
909
  if not instance.service_file(platform).exists():
867
910
  raise ValueError(f"{instance.name}: no installed service.")
868
911
  if platform == "darwin":
@@ -870,18 +913,22 @@ def control(instance: Instance, action: str, platform: str = sys.platform):
870
913
  status = service_status(instance, platform)
871
914
  if status == "unknown":
872
915
  raise ValueError("Cannot establish service state; no service changes made.")
873
- if action == "stop":
874
- if status != "stopped":
875
- run(["launchctl", "bootout", target])
876
- elif status == "stopped":
877
- run(["launchctl", "bootstrap", f"gui/{os.getuid()}", str(instance.service_file(platform))])
878
- elif action == "restart":
916
+ if action in {"stop", "restart"} and status != "stopped":
879
917
  run(["launchctl", "bootout", target])
918
+ # bootout may return before launchd has removed the job. Returning
919
+ # early lets a following start see the old job as running and
920
+ # silently do nothing. Stop and restart need the same unload gate.
880
921
  deadline = time.monotonic() + 180
881
- while service_status(instance, platform) != "stopped":
922
+ while True:
923
+ status = service_status(instance, platform)
924
+ if status == "stopped":
925
+ break
926
+ if status == "unknown":
927
+ raise ValueError("Cannot confirm service stopped; no replacement process was started.")
882
928
  if time.monotonic() >= deadline:
883
929
  raise ValueError("Service did not stop; refusing to start a second process.")
884
930
  time.sleep(0.1)
931
+ if action != "stop" and status == "stopped":
885
932
  run(["launchctl", "bootstrap", f"gui/{os.getuid()}", str(instance.service_file(platform))])
886
933
  else:
887
934
  run(["systemctl", "--user", action, service_name(instance.name) + ".service"])
@@ -1080,13 +1127,13 @@ def main(argv: list[str] | None = None) -> int:
1080
1127
  control(item, args.command)
1081
1128
  if args.command != "remove":
1082
1129
  print(f"{item.name}: {args.command} completed", flush=True)
1083
- except (OSError, ValueError, KeyError, subprocess.CalledProcessError) as exc:
1130
+ except (OSError, ValueError, RuntimeError, KeyError, subprocess.CalledProcessError) as exc:
1084
1131
  print(f"{item.name}: failed ({exc})", file=sys.stderr)
1085
1132
  failures += 1
1086
1133
  if args.command == "remove" and not failures:
1087
1134
  print("\n" + terminal_color("Successful!", "32"), flush=True)
1088
1135
  return int(bool(failures))
1089
- except (OSError, ValueError, KeyError) as exc:
1136
+ except (OSError, ValueError, RuntimeError, KeyError) as exc:
1090
1137
  print(f"Instance manager: {exc}", file=sys.stderr)
1091
1138
  return 1
1092
1139
 
@@ -30,9 +30,11 @@ def _no_activation(root: Path) -> None:
30
30
 
31
31
  def _no_handoff_recovery_overlap(root: Path) -> None:
32
32
  _no_activation(root)
33
- marker = root / ".execution-uninstall.json"
34
- if marker.exists() or marker.is_symlink():
35
- raise RuntimeError("uninstall already owns execution recovery")
33
+ for name in (".execution-uninstall.json", ".execution-lifecycle.json"):
34
+ marker = root / name
35
+ if marker.exists() or marker.is_symlink():
36
+ owner = "uninstall" if name == ".execution-uninstall.json" else "native lifecycle"
37
+ raise RuntimeError(owner + " already owns execution recovery")
36
38
 
37
39
 
38
40
  def _read_handoff(root: Path, path: Path) -> dict[str, Any]:
@@ -68,14 +70,7 @@ def _read_handoff(root: Path, path: Path) -> dict[str, Any]:
68
70
 
69
71
 
70
72
  def _installed_layout(root: Path) -> files.ExecutionLayout:
71
- data, _mode = files._read_file(root / files.LAYOUT_NAME, private=True)
72
- value = json.loads(data)
73
- if not isinstance(value, dict):
74
- raise ValueError("installed execution layout is invalid")
75
- layout = files.ExecutionLayout.from_dict(value.get("layout"))
76
- if layout.install_root != root or value != files.layout_manifest(layout):
77
- raise ValueError("installed execution layout changed")
78
- return layout
73
+ return files.installed_layout(root)
79
74
 
80
75
 
81
76
  def _matching_status(handoff: dict, layout: files.ExecutionLayout, record: dict,