Compare commits
2
Commits
99bde4febb
...
82567fd248
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82567fd248 | ||
|
|
d00ada67a5 |
@@ -18,6 +18,7 @@ from host_agent.execution import create_execution_factories
|
||||
from host_agent.heartbeat import HeartbeatSynchronizer
|
||||
from host_agent.history import ConsoleHistoryStore
|
||||
from host_agent.identity import HostIdentityStore
|
||||
from host_agent.instance_lock import InstanceLock
|
||||
from host_agent.lease import ActiveAssignmentRunner
|
||||
from host_agent.local_account import LocalAccountStore
|
||||
from host_agent.policy_cache import HostPolicyCacheStore
|
||||
@@ -40,6 +41,7 @@ class HostAgentApplication:
|
||||
console_server: uvicorn.Server | None = None
|
||||
console_enrollment_client: HostAgentEnrollmentClient | None = None
|
||||
dependency_supervisor: DependencySupervisor | None = None
|
||||
instance_lock: InstanceLock | None = None
|
||||
|
||||
def run(self) -> None:
|
||||
asyncio.run(self.run_async())
|
||||
@@ -109,6 +111,8 @@ class HostAgentApplication:
|
||||
if self.console_enrollment_client is not None:
|
||||
self.console_enrollment_client.close()
|
||||
await self.client.aclose()
|
||||
if self.instance_lock is not None:
|
||||
self.instance_lock.release()
|
||||
|
||||
async def _claim_until_stopped(
|
||||
self,
|
||||
@@ -151,114 +155,125 @@ def create_application(
|
||||
enrollment_client: HostAgentEnrollmentClient | None = None,
|
||||
) -> HostAgentApplication:
|
||||
startup_config = config or load_host_agent_config()
|
||||
config_store = device_config_store or DeviceConfigStore()
|
||||
resolved_identity_store = identity_store or HostIdentityStore(
|
||||
startup_config.identity_path
|
||||
)
|
||||
owned_enrollment_client = enrollment_client is None
|
||||
bootstrap_client = enrollment_client or HostAgentEnrollmentClient(startup_config)
|
||||
instance_lock = InstanceLock(startup_config.identity_path.parent)
|
||||
instance_lock.acquire()
|
||||
try:
|
||||
resolved_config = resolve_host_identity(
|
||||
startup_config,
|
||||
identity_store=resolved_identity_store,
|
||||
client=bootstrap_client,
|
||||
config_store = device_config_store or DeviceConfigStore()
|
||||
resolved_identity_store = identity_store or HostIdentityStore(
|
||||
startup_config.identity_path
|
||||
)
|
||||
bootstrap_client.config = resolved_config
|
||||
resolved_manager = manager or _configured_device_manager(
|
||||
config_store,
|
||||
config=resolved_config,
|
||||
enrollment_client=bootstrap_client,
|
||||
owned_enrollment_client = enrollment_client is None
|
||||
bootstrap_client = enrollment_client or HostAgentEnrollmentClient(
|
||||
startup_config
|
||||
)
|
||||
finally:
|
||||
if owned_enrollment_client:
|
||||
bootstrap_client.close()
|
||||
client = HostAgentClient(resolved_config)
|
||||
try:
|
||||
resolved_config = resolve_host_identity(
|
||||
startup_config,
|
||||
identity_store=resolved_identity_store,
|
||||
client=bootstrap_client,
|
||||
)
|
||||
bootstrap_client.config = resolved_config
|
||||
resolved_manager = manager or _configured_device_manager(
|
||||
config_store,
|
||||
config=resolved_config,
|
||||
enrollment_client=bootstrap_client,
|
||||
)
|
||||
finally:
|
||||
if owned_enrollment_client:
|
||||
bootstrap_client.close()
|
||||
client = HostAgentClient(resolved_config)
|
||||
|
||||
history_store = ConsoleHistoryStore(
|
||||
resolved_config.identity_path.parent / "host_console_history.sqlite3",
|
||||
limit=resolved_config.console_history_limit,
|
||||
)
|
||||
status_tracker = AgentStatusTracker()
|
||||
console_enrollment_client: HostAgentEnrollmentClient | None = None
|
||||
if resolved_config.enrollment_managed:
|
||||
console_enrollment_client = HostAgentEnrollmentClient(resolved_config)
|
||||
metadata_store = TaskMetadataStore(db_path=resolved_config.task_progress_db_path)
|
||||
timeline = Timeline(ArtifactStore(root=resolved_config.task_artifact_dir))
|
||||
executor = AssignmentExecutor(
|
||||
create_execution_factories(
|
||||
resolved_manager,
|
||||
history_store = ConsoleHistoryStore(
|
||||
resolved_config.identity_path.parent / "host_console_history.sqlite3",
|
||||
limit=resolved_config.console_history_limit,
|
||||
)
|
||||
status_tracker = AgentStatusTracker()
|
||||
console_enrollment_client: HostAgentEnrollmentClient | None = None
|
||||
if resolved_config.enrollment_managed:
|
||||
console_enrollment_client = HostAgentEnrollmentClient(resolved_config)
|
||||
metadata_store = TaskMetadataStore(
|
||||
db_path=resolved_config.task_progress_db_path
|
||||
)
|
||||
timeline = Timeline(ArtifactStore(root=resolved_config.task_artifact_dir))
|
||||
executor = AssignmentExecutor(
|
||||
create_execution_factories(
|
||||
resolved_manager,
|
||||
metadata_store=metadata_store,
|
||||
timeline=timeline,
|
||||
host_agent_config=resolved_config,
|
||||
)
|
||||
)
|
||||
console_app = create_console_app(
|
||||
config=resolved_config,
|
||||
manager=resolved_manager,
|
||||
config_store=config_store,
|
||||
local_account_store=LocalAccountStore(resolved_config.local_account_path),
|
||||
identity_store=resolved_identity_store,
|
||||
history_store=history_store,
|
||||
status_tracker=status_tracker,
|
||||
session_manager=SessionManager(
|
||||
ttl_seconds=resolved_config.console_session_ttl_seconds
|
||||
),
|
||||
enrollment_client=console_enrollment_client,
|
||||
metadata_store=metadata_store,
|
||||
timeline=timeline,
|
||||
host_agent_config=resolved_config,
|
||||
executor=executor,
|
||||
)
|
||||
)
|
||||
console_app = create_console_app(
|
||||
config=resolved_config,
|
||||
manager=resolved_manager,
|
||||
config_store=config_store,
|
||||
local_account_store=LocalAccountStore(resolved_config.local_account_path),
|
||||
identity_store=resolved_identity_store,
|
||||
history_store=history_store,
|
||||
status_tracker=status_tracker,
|
||||
session_manager=SessionManager(
|
||||
ttl_seconds=resolved_config.console_session_ttl_seconds
|
||||
),
|
||||
enrollment_client=console_enrollment_client,
|
||||
metadata_store=metadata_store,
|
||||
timeline=timeline,
|
||||
executor=executor,
|
||||
)
|
||||
console_server = _EmbeddedConsoleServer(
|
||||
uvicorn.Config(
|
||||
console_app,
|
||||
host=resolved_config.console_bind_host,
|
||||
port=resolved_config.console_port,
|
||||
log_level="warning",
|
||||
console_server = _EmbeddedConsoleServer(
|
||||
uvicorn.Config(
|
||||
console_app,
|
||||
host=resolved_config.console_bind_host,
|
||||
port=resolved_config.console_port,
|
||||
log_level="warning",
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
heartbeat = HeartbeatSynchronizer(
|
||||
resolved_manager,
|
||||
client,
|
||||
resolved_config,
|
||||
status_tracker=status_tracker,
|
||||
on_sync=lambda device_count: history_store.record_heartbeat(
|
||||
device_count=device_count
|
||||
),
|
||||
policy_cache=HostPolicyCacheStore(
|
||||
resolved_config.identity_path.parent / "host_governance_policy.json"
|
||||
),
|
||||
on_policy_sync=lambda revision: history_store.record_policy_sync(
|
||||
revision=revision
|
||||
),
|
||||
)
|
||||
active_runner = ActiveAssignmentRunner(client, executor)
|
||||
processor = AssignmentProcessor(
|
||||
client,
|
||||
active_runner,
|
||||
status_tracker=status_tracker,
|
||||
on_result=lambda assignment, result: _on_assignment_finished(
|
||||
history_store,
|
||||
metadata_store,
|
||||
timeline,
|
||||
heartbeat = HeartbeatSynchronizer(
|
||||
resolved_manager,
|
||||
client,
|
||||
resolved_config,
|
||||
assignment,
|
||||
result,
|
||||
),
|
||||
)
|
||||
dependency_supervisor: DependencySupervisor | None = None
|
||||
if resolved_config.dependency_supervisor_enabled:
|
||||
dependency_supervisor = DependencySupervisor.from_host_agent_config(
|
||||
resolved_config
|
||||
status_tracker=status_tracker,
|
||||
on_sync=lambda device_count: history_store.record_heartbeat(
|
||||
device_count=device_count
|
||||
),
|
||||
policy_cache=HostPolicyCacheStore(
|
||||
resolved_config.identity_path.parent / "host_governance_policy.json"
|
||||
),
|
||||
on_policy_sync=lambda revision: history_store.record_policy_sync(
|
||||
revision=revision
|
||||
),
|
||||
)
|
||||
return HostAgentApplication(
|
||||
client=client,
|
||||
heartbeat=heartbeat,
|
||||
processor=processor,
|
||||
console_server=console_server,
|
||||
console_enrollment_client=console_enrollment_client,
|
||||
dependency_supervisor=dependency_supervisor,
|
||||
)
|
||||
active_runner = ActiveAssignmentRunner(client, executor)
|
||||
processor = AssignmentProcessor(
|
||||
client,
|
||||
active_runner,
|
||||
status_tracker=status_tracker,
|
||||
on_result=lambda assignment, result: _on_assignment_finished(
|
||||
history_store,
|
||||
metadata_store,
|
||||
timeline,
|
||||
resolved_config,
|
||||
assignment,
|
||||
result,
|
||||
),
|
||||
)
|
||||
dependency_supervisor: DependencySupervisor | None = None
|
||||
if resolved_config.dependency_supervisor_enabled:
|
||||
dependency_supervisor = DependencySupervisor.from_host_agent_config(
|
||||
resolved_config
|
||||
)
|
||||
return HostAgentApplication(
|
||||
client=client,
|
||||
heartbeat=heartbeat,
|
||||
processor=processor,
|
||||
console_server=console_server,
|
||||
console_enrollment_client=console_enrollment_client,
|
||||
dependency_supervisor=dependency_supervisor,
|
||||
instance_lock=instance_lock,
|
||||
)
|
||||
except BaseException:
|
||||
instance_lock.release()
|
||||
raise
|
||||
|
||||
|
||||
def _on_assignment_finished(
|
||||
|
||||
@@ -8,6 +8,7 @@ from dataclasses import replace
|
||||
|
||||
from host_agent.app import create_application
|
||||
from host_agent.config import load_host_agent_config
|
||||
from host_agent.instance_lock import InstanceAlreadyRunningError
|
||||
from host_agent.local_account import LocalAccountStore
|
||||
|
||||
|
||||
@@ -30,16 +31,22 @@ def main(argv: Sequence[str] | None = None) -> None:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
create_application(config=config).run()
|
||||
try:
|
||||
create_application(config=config).run()
|
||||
except InstanceAlreadyRunningError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
|
||||
def _run_setup() -> None:
|
||||
config = load_host_agent_config()
|
||||
store = LocalAccountStore(config.local_account_path)
|
||||
if store.load() is not None:
|
||||
confirm = input(
|
||||
"A local account already exists. Overwrite it? [y/N] "
|
||||
).strip().lower()
|
||||
confirm = (
|
||||
input("A local account already exists. Overwrite it? [y/N] ")
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
if confirm != "y":
|
||||
print("Setup cancelled; existing account left unchanged.")
|
||||
return
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Per-installation exclusive-execution lock for the Host Agent process.
|
||||
|
||||
Backed by ``filelock.FileLock`` (OS-level advisory file locking) so that
|
||||
the lock is released automatically when the holding process exits for
|
||||
any reason — clean shutdown, unhandled exception, or ``SIGKILL`` — without
|
||||
requiring any stale-lock cleanup step. See
|
||||
``openspec/changes/host-agent-single-instance-lock/design.md`` for the
|
||||
rationale behind choosing ``filelock`` over hand-rolled ``fcntl``/``msvcrt``
|
||||
branching.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from filelock import FileLock, Timeout
|
||||
|
||||
_LOCK_FILENAME = "host_agent.lock"
|
||||
|
||||
|
||||
class InstanceAlreadyRunningError(RuntimeError):
|
||||
"""Raised when another live process already holds the instance lock."""
|
||||
|
||||
def __init__(self, lock_path: Path) -> None:
|
||||
self.lock_path = lock_path
|
||||
super().__init__(
|
||||
"another Host Agent instance is already running for this "
|
||||
f"identity state directory (lock held at {lock_path}); "
|
||||
"stop the other process before starting a new one"
|
||||
)
|
||||
|
||||
|
||||
class InstanceLock:
|
||||
"""Exclusive, non-blocking lock scoped to a Host Agent state directory.
|
||||
|
||||
The state directory (typically ``identity_path.parent``) is created on
|
||||
construction to match the colocated-file convention already used for
|
||||
``host_console_history.sqlite3`` and ``host_governance_policy.json``.
|
||||
"""
|
||||
|
||||
def __init__(self, state_dir: Path) -> None:
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._lock_path = state_dir / _LOCK_FILENAME
|
||||
self._lock = FileLock(str(self._lock_path), timeout=0)
|
||||
self._acquired = False
|
||||
|
||||
@property
|
||||
def lock_path(self) -> Path:
|
||||
return self._lock_path
|
||||
|
||||
def acquire(self) -> None:
|
||||
try:
|
||||
self._lock.acquire()
|
||||
except Timeout as exc:
|
||||
raise InstanceAlreadyRunningError(self._lock_path) from exc
|
||||
self._acquired = True
|
||||
|
||||
def release(self) -> None:
|
||||
if not self._acquired:
|
||||
return
|
||||
try:
|
||||
self._lock.release()
|
||||
finally:
|
||||
self._acquired = False
|
||||
|
||||
def __enter__(self) -> InstanceLock:
|
||||
self.acquire()
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc_info: object) -> None:
|
||||
self.release()
|
||||
@@ -7,6 +7,7 @@ dependencies = [
|
||||
"device-agent-runtime==0.1.0",
|
||||
"device-cloud-platform==0.1.0",
|
||||
"fastapi>=0.115.0",
|
||||
"filelock>=3.0",
|
||||
"httpx>=0.27.0",
|
||||
"jinja2>=3.1",
|
||||
"uvicorn[standard]>=0.30.0",
|
||||
|
||||
@@ -6,6 +6,7 @@ from contextlib import suppress
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from cloud.internal_api.models import (
|
||||
AssignmentModel,
|
||||
@@ -16,6 +17,7 @@ from device.manager import DeviceManager
|
||||
from host_agent.app import HostAgentApplication, create_application
|
||||
from host_agent.config import HostAgentConfig
|
||||
from host_agent.identity import HostIdentityStore
|
||||
from host_agent.instance_lock import InstanceAlreadyRunningError
|
||||
from storage.device_config import DeviceConfigStore
|
||||
|
||||
|
||||
@@ -576,3 +578,123 @@ def test_run_async_starts_supervisor_before_first_heartbeat_connect() -> None:
|
||||
assert events.index("supervisor-stop") < events.index("closed")
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_second_create_application_against_held_lock_raises_before_enrollment(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
identity_path = tmp_path / "host_identity.json"
|
||||
config = HostAgentConfig(
|
||||
control_plane_url="https://control.example",
|
||||
host_id="host-a",
|
||||
token="secret",
|
||||
identity_path=identity_path,
|
||||
)
|
||||
|
||||
class TrackingEnrollmentClient:
|
||||
def __init__(self) -> None:
|
||||
self.config = config
|
||||
self.calls: list[str] = []
|
||||
|
||||
def enroll_host(self, **payload):
|
||||
self.calls.append("host")
|
||||
return HostEnrollmentResponse(host_id="host-a")
|
||||
|
||||
def enroll_device(self, **payload):
|
||||
self.calls.append("device")
|
||||
return DeviceEnrollmentResponse(device_id="device-a")
|
||||
|
||||
def close(self) -> None:
|
||||
return None
|
||||
|
||||
first_client = TrackingEnrollmentClient()
|
||||
first_app = create_application(
|
||||
config=config,
|
||||
identity_store=HostIdentityStore(identity_path),
|
||||
enrollment_client=first_client, # type: ignore[arg-type]
|
||||
)
|
||||
try:
|
||||
second_client = TrackingEnrollmentClient()
|
||||
with pytest.raises(InstanceAlreadyRunningError) as info:
|
||||
create_application(
|
||||
config=config,
|
||||
identity_store=HostIdentityStore(identity_path),
|
||||
enrollment_client=second_client, # type: ignore[arg-type]
|
||||
)
|
||||
assert info.value.lock_path == identity_path.parent / "host_agent.lock"
|
||||
assert second_client.calls == []
|
||||
finally:
|
||||
asyncio.run(first_app.client.aclose())
|
||||
|
||||
|
||||
def test_create_application_with_independent_identity_paths_coexist(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
config_a = HostAgentConfig(
|
||||
control_plane_url="https://control.example",
|
||||
host_id="host-a",
|
||||
token="secret",
|
||||
identity_path=tmp_path / "identity-a" / "host_identity.json",
|
||||
)
|
||||
config_b = HostAgentConfig(
|
||||
control_plane_url="https://control.example",
|
||||
host_id="host-b",
|
||||
token="secret",
|
||||
identity_path=tmp_path / "identity-b" / "host_identity.json",
|
||||
)
|
||||
|
||||
app_a = create_application(config=config_a, manager=DeviceManager())
|
||||
try:
|
||||
app_b = create_application(config=config_b, manager=DeviceManager())
|
||||
asyncio.run(app_b.client.aclose())
|
||||
finally:
|
||||
asyncio.run(app_a.client.aclose())
|
||||
|
||||
|
||||
def test_lock_released_after_run_async_allows_restart(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
identity_path = tmp_path / "host_identity.json"
|
||||
config = HostAgentConfig(
|
||||
control_plane_url="https://control.example",
|
||||
host_id="host-a",
|
||||
token="secret",
|
||||
identity_path=identity_path,
|
||||
)
|
||||
|
||||
first_app = create_application(config=config, manager=DeviceManager())
|
||||
|
||||
class StoppedClient:
|
||||
async def claim(self):
|
||||
raise AssertionError("polling must not start")
|
||||
|
||||
async def aclose(self):
|
||||
return None
|
||||
|
||||
class FastHeartbeat:
|
||||
async def run(self, stop):
|
||||
await stop.wait()
|
||||
|
||||
async def sync_once(self):
|
||||
return None
|
||||
|
||||
class IdleProcessor:
|
||||
async def process(self, assignment):
|
||||
raise AssertionError("no assignment expected")
|
||||
|
||||
def request_stop(self):
|
||||
return None
|
||||
|
||||
first_app.client = StoppedClient() # type: ignore[assignment]
|
||||
first_app.heartbeat = FastHeartbeat() # type: ignore[assignment]
|
||||
first_app.processor = IdleProcessor() # type: ignore[assignment]
|
||||
|
||||
stop = asyncio.Event()
|
||||
stop.set()
|
||||
asyncio.run(first_app.run_async(stop))
|
||||
|
||||
# Lock must be free now; a fresh create_application against the same
|
||||
# identity_path must succeed (simulates a clean restart).
|
||||
second_app = create_application(config=config, manager=DeviceManager())
|
||||
asyncio.run(second_app.client.aclose())
|
||||
|
||||
@@ -5,6 +5,7 @@ import sys
|
||||
import pytest
|
||||
|
||||
from host_agent import cli
|
||||
from host_agent.instance_lock import InstanceAlreadyRunningError
|
||||
from host_agent.local_account import LocalAccountStore
|
||||
|
||||
|
||||
@@ -60,7 +61,9 @@ def test_existing_account_fast_path_skips_prompting(monkeypatch, tmp_path) -> No
|
||||
assert captured["config"].display_name == "operator"
|
||||
|
||||
|
||||
def test_interactive_first_run_prompts_and_creates_account(monkeypatch, tmp_path) -> None:
|
||||
def test_interactive_first_run_prompts_and_creates_account(
|
||||
monkeypatch, tmp_path
|
||||
) -> None:
|
||||
_set_env(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
|
||||
inputs = iter(["operator"])
|
||||
@@ -120,3 +123,26 @@ def test_setup_subcommand_refuses_overwrite_without_confirmation(
|
||||
cli.main(["setup"])
|
||||
|
||||
assert store.load() == original
|
||||
|
||||
|
||||
def test_duplicate_instance_exits_with_clear_error(
|
||||
monkeypatch, tmp_path, capsys
|
||||
) -> None:
|
||||
_set_env(monkeypatch, tmp_path)
|
||||
LocalAccountStore(tmp_path / "account.json").create(
|
||||
"operator", "correct horse battery staple"
|
||||
)
|
||||
lock_path = tmp_path / "identity_dir" / "host_agent.lock"
|
||||
|
||||
def raising_create_application(*, config=None, **kwargs):
|
||||
raise InstanceAlreadyRunningError(lock_path)
|
||||
|
||||
monkeypatch.setattr(cli, "create_application", raising_create_application)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
cli.main([])
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
err = capsys.readouterr().err
|
||||
assert "another Host Agent instance" in err
|
||||
assert str(lock_path) in err
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from host_agent.instance_lock import (
|
||||
InstanceAlreadyRunningError,
|
||||
InstanceLock,
|
||||
)
|
||||
|
||||
|
||||
def test_acquire_succeeds_when_free(tmp_path) -> None:
|
||||
lock = InstanceLock(tmp_path)
|
||||
lock.acquire()
|
||||
assert lock.lock_path == tmp_path / "host_agent.lock"
|
||||
lock.release()
|
||||
|
||||
|
||||
def test_acquire_raises_when_already_held(tmp_path) -> None:
|
||||
first = InstanceLock(tmp_path)
|
||||
first.acquire()
|
||||
try:
|
||||
second = InstanceLock(tmp_path)
|
||||
with pytest.raises(InstanceAlreadyRunningError) as info:
|
||||
second.acquire()
|
||||
assert info.value.lock_path == tmp_path / "host_agent.lock"
|
||||
assert "another Host Agent instance" in str(info.value)
|
||||
finally:
|
||||
first.release()
|
||||
|
||||
|
||||
def test_release_then_reacquire_succeeds(tmp_path) -> None:
|
||||
lock = InstanceLock(tmp_path)
|
||||
lock.acquire()
|
||||
lock.release()
|
||||
# Same handle can reacquire after release.
|
||||
lock.acquire()
|
||||
lock.release()
|
||||
# A fresh handle against the same path also succeeds.
|
||||
other = InstanceLock(tmp_path)
|
||||
other.acquire()
|
||||
other.release()
|
||||
|
||||
|
||||
def test_release_is_idempotent_and_safe_before_acquire(tmp_path) -> None:
|
||||
lock = InstanceLock(tmp_path)
|
||||
# Releasing before acquire must be a no-op, not raise.
|
||||
lock.release()
|
||||
lock.acquire()
|
||||
lock.release()
|
||||
lock.release()
|
||||
|
||||
|
||||
def test_independent_paths_never_contend(tmp_path) -> None:
|
||||
dir_a = tmp_path / "identity-a"
|
||||
dir_b = tmp_path / "identity-b"
|
||||
lock_a = InstanceLock(dir_a)
|
||||
lock_a.acquire()
|
||||
try:
|
||||
lock_b = InstanceLock(dir_b)
|
||||
lock_b.acquire()
|
||||
lock_b.release()
|
||||
finally:
|
||||
lock_a.release()
|
||||
|
||||
|
||||
def test_context_manager_releases_on_exit(tmp_path) -> None:
|
||||
with InstanceLock(tmp_path):
|
||||
second = InstanceLock(tmp_path)
|
||||
with pytest.raises(InstanceAlreadyRunningError):
|
||||
second.acquire()
|
||||
# After the context exits, a fresh handle can acquire.
|
||||
InstanceLock(tmp_path).acquire()
|
||||
|
||||
|
||||
def test_state_directory_is_created_if_missing(tmp_path) -> None:
|
||||
nested = tmp_path / "nested" / "state"
|
||||
lock = InstanceLock(nested)
|
||||
assert nested.exists()
|
||||
lock.acquire()
|
||||
lock.release()
|
||||
@@ -404,6 +404,24 @@ Host Agent 会在本机回环地址提供 Console。必须保留并保护 `tasks
|
||||
直接注册意味着任何能访问该云端地址的设备都能自行注册成为 Host,没有审批环节,
|
||||
也没有限流保护;这一取舍依赖网络边界(防火墙/反向代理)而非应用层限制。
|
||||
|
||||
### 重复实例保护
|
||||
|
||||
Host Agent 启动时会先尝试获取一个独占的本地文件锁(位于
|
||||
`tasks/host_agent.lock`,与 `host_identity.json` 同目录),确保同一个 identity
|
||||
状态目录下同一时刻只有一个 Host Agent 进程在运行。这是为了避免两个进程用同一个
|
||||
`host_id` 同时心跳和 claim,导致任务派发落到从未注册过该设备的进程上(典型的
|
||||
`DeviceNotFoundError` 事故场景)。
|
||||
|
||||
如果启动时锁已被另一个仍在运行的进程持有,Host Agent 会立即以非零退出码退出,
|
||||
stderr 打印 `error: another Host Agent instance is already running ...`,**不会**
|
||||
联系云端、不会触发任何 enrollment。处理方式:
|
||||
|
||||
1. 用 `lsof tasks/host_agent.lock`(macOS)/ 任务管理器(Windows)或
|
||||
`pgrep -fa device-host-agent` 找到仍在运行的旧进程。
|
||||
2. 确认旧进程应被停止后,再 `kill` 它(或等它的 graceful shutdown 完成)。
|
||||
3. 重新启动 Host Agent。崩溃/被 `kill -9` 的旧进程退出时 OS 会自动释放文件锁,
|
||||
不需要手动删除 `host_agent.lock`。
|
||||
|
||||
### 使用本地 Web Console
|
||||
|
||||
Host Agent 启动时会同时启动本地 Web Console,用于在这台 Mac 上直接查看和管理
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-14
|
||||
@@ -0,0 +1,3 @@
|
||||
# host-agent-single-instance-lock
|
||||
|
||||
Prevent duplicate host-agent processes under one host identity from racing on assignment claims
|
||||
@@ -0,0 +1,67 @@
|
||||
## Context
|
||||
|
||||
- `identity_path` (`host_agent/config.py:22`, default `Path("tasks/host_identity.json")`, overridable via `HOST_AGENT_IDENTITY_PATH`) is persisted through `HostIdentityStore` (`host_agent/identity.py`). Its *parent directory* is already the de facto per-installation state directory: `create_application()` derives `host_console_history.sqlite3` (`app.py:178`) and `host_governance_policy.json` (`app.py:229`) from `resolved_config.identity_path.parent`.
|
||||
- `create_application()` (`app.py:145-172`) is fully synchronous and front-loads every side-effecting startup call: `resolve_host_identity()` (may enroll a new host with the control plane) then `_configured_device_manager()` (enrolls every locally configured device). Both are network calls against the same control plane the claim/heartbeat loops later use.
|
||||
- A repo-wide search confirms no pidfile, lockfile, or process-uniqueness mechanism exists anywhere in `apps/device-host-agent` today.
|
||||
- This repo has no CI configuration (no `.github/workflows`); tests are run locally by whichever machine the developer/operator uses. The primary development machine observed in this session is Windows; the documented production target is macOS (`docs/MACOS_IPHONE_SETUP.md`). Any locking mechanism must be correct on both without a CI matrix to catch a platform-specific regression.
|
||||
- `filelock` 3.29.7 is already present in `uv.lock` as a transitive dependency (pulled in by unrelated ML-related packages), but is not a direct dependency of any workspace member today.
|
||||
- The recently archived `host-agent-dependency-supervisor` (`apps/device-host-agent/host_agent/dependency_supervisor.py`) spawns Appium/Runtime child processes via `subprocess.Popen` and is architecturally adjacent — also concerned with Host Agent process lifecycle. Its design Decision 5 explicitly chose stdlib-only `subprocess.Popen` over adding a dependency for spawning. That precedent is weighed below (Decision 2) but not followed as-is.
|
||||
- `apps/device-host-agent/tests/test_app.py` was audited for existing `create_application()` call patterns: all 9 call sites are one-per-test-function (no test constructs two applications against the same `identity_path` in one process today), and each uses a pytest `tmp_path`-scoped identity path. A new duplicate-instance test therefore needs to keep the first `HostAgentApplication`'s lock handle alive across the assertion — see Decision 5.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Guarantee at most one Host Agent process is ever actively participating in the claim/heartbeat protocol for a given `identity_path` state directory at a time.
|
||||
- Fail fast and loudly when a duplicate instance is detected, before any control-plane side effect (enrollment, heartbeat, claim) occurs.
|
||||
- Require zero manual cleanup after an unclean shutdown (crash, `kill -9`, power loss).
|
||||
- Work correctly on both macOS (production target) and Windows (a real development machine for this repo) with no CI matrix to lean on.
|
||||
|
||||
**Non-Goals:**
|
||||
- No distributed/cross-machine locking. This only prevents duplicate *local* processes for one installation; it does not address two different physical hosts being misconfigured with the same `host_id` (a separate concern, partially already handled by device-identity-ownership-conflict behavior in `device-pool`).
|
||||
- No change to the claim/heartbeat/lease protocol itself (`host-agent-protocol` is unmodified).
|
||||
- No general-purpose process supervision or auto-restart. This is an exclusivity guard on the Host Agent's *own* process, not a supervisor of *other* processes (contrast with `host-agent-dependency-supervisor`).
|
||||
- No reliance on the `console_port` bind as the exclusivity mechanism — that conflict is real but surfaces too late (deep inside `run_async()`, after enrollment/heartbeat side effects have already started) and is silently bypassed if two instances ever end up configured with different ports.
|
||||
|
||||
## Decisions
|
||||
|
||||
**1. Lock file location: `identity_path.parent / "host_agent.lock"`, no new config field.**
|
||||
Matches the existing convention of colocating per-installation auxiliary files next to `identity_path` (`app.py:178,229`). A lock scoped to a directory independent of the identity it protects would be a foot-gun if the two were ever configured to diverge, and a dedicated env var would just duplicate information `HOST_AGENT_IDENTITY_PATH` already carries.
|
||||
|
||||
**2. Use `filelock` (new direct dependency) rather than hand-rolled `fcntl`/`msvcrt` branching.**
|
||||
Alternative considered: a small in-repo module branching on `sys.platform`, calling `fcntl.flock(fd, LOCK_EX | LOCK_NB)` on POSIX and `msvcrt.locking(fd, LK_NBLCK, 1)` on Windows — matching this repo's general preference for avoiding new dependencies (`host-agent-dependency-supervisor` Decision 5).
|
||||
Rejected in favor of `filelock` because:
|
||||
- This repo has no CI matrix, so a platform-specific bug in whichever branch isn't the implementer's local OS could ship unverified. Concretely, the production target (macOS/`fcntl`) differs from a development machine already in use for this repo (Windows/`msvcrt`).
|
||||
- `filelock` is small, pure-Python, has zero dependencies of its own, is already resolved in this repo's `uv.lock` transitively (so promoting it to a direct dependency of `device-host-agent` adds no new package to the lock, only a new dependency edge), and is widely used (`virtualenv`, `tox`, `huggingface_hub` all depend on it).
|
||||
- Getting advisory locking subtly wrong (a missed non-blocking flag, a probe/acquire race) would reintroduce exactly the class of bug this change exists to eliminate. This is a case where "don't reinvent it" outweighs the general new-dependency-avoidance default — unlike `subprocess.Popen`, which stdlib already handles with no cross-platform pitfalls.
|
||||
|
||||
**3. Acquire the lock as the first action inside `create_application()`, before `resolve_host_identity()`.**
|
||||
`startup_config.identity_path` is already resolved from config/env at function entry, so this is the earliest point with the information needed, and it precedes every side-effecting call `create_application()` makes. A duplicate instance is rejected before it can enroll, heartbeat, or claim — not merely before it can serve console traffic.
|
||||
|
||||
**4. Non-blocking acquisition; fail immediately rather than wait or retry.**
|
||||
Alternative considered: block/retry with backoff until the lock frees up, treating a duplicate launch as "wait your turn." Rejected because a Host Agent is normally started by an operator or a process supervisor expecting an immediate up/down signal; silently blocking would look identical to a hang and would mask the actual problem (something didn't clean up the previous instance) instead of surfacing it. `filelock.FileLock(path, timeout=0)` raises `Timeout` immediately if already held; `create_application()` translates this into a clear, named "duplicate instance" error.
|
||||
|
||||
**5. The lock handle is owned by `HostAgentApplication` for its full lifetime, released in `run_async()`'s existing `finally` block.**
|
||||
The acquired `filelock.FileLock` is stored as a field on the returned `HostAgentApplication` (not a bare local variable inside `create_application()`), so it stays held for as long as the object is alive, and is explicitly released during the existing graceful-shutdown `finally` block in `run_async()` (`app.py:88-111`) rather than left to eventual garbage collection. Two consequences:
|
||||
- A clean restart doesn't need to wait on GC/OS cleanup timing — the next process can acquire the lock immediately after graceful shutdown completes.
|
||||
- A test can hold a first instance's lock open (by keeping its `HostAgentApplication`/lock reference alive) and assert that a second `create_application()` call against the same `identity_path` raises — this is the concrete new test called out in tasks.md.
|
||||
|
||||
**6. Release also relies on OS-level advisory-lock semantics, not an explicit-unlock-only or pidfile-liveness-check design.**
|
||||
Even without the explicit release in Decision 5, the lock is released automatically when the holding process's file descriptor closes for any reason — clean exit, unhandled exception, `SIGKILL`, crash — because that's a property `flock`/`LockFileEx` the kernel enforces, not application code. This sidesteps the entire "stale pidfile after a crash" problem class that a hand-rolled `os.path.exists()`-based lock or a PID-liveness-check design would otherwise need to solve (including PID-reuse races and cross-platform "is this PID still alive" checks).
|
||||
- Subprocess-inheritance note: `dependency_supervisor.py` spawns Appium/Runtime via `subprocess.Popen`. Per PEP 446, file descriptors opened by Python (including `filelock`'s underlying `os.open`) are non-inheritable by default unless explicitly passed via `pass_fds`, which this change does not do — so a spawned child cannot accidentally keep the lock held after the parent Host Agent process exits. This is confirmed as a non-issue rather than left open, since it follows directly from a documented Python default already in effect for every other file descriptor this codebase opens.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[Risk] A hung-but-not-dead process holds the lock and blocks a legitimate restart.** → Mitigation: intentional, matches Goal 2 ("fail fast and loudly"). A hung process should be diagnosed and killed by the operator/supervisor, not silently bypassed — the point of this change is to stop pretending a second instance is safe to run. The rejection error names the lock file path so an operator can inspect what's holding it (e.g. `lsof`, `Get-Process`).
|
||||
- **[Risk] New direct dependency (`filelock`).** → Mitigation: already resolved in `uv.lock` transitively today (no new package added to the lock, only a new direct edge), the package itself is small and dependency-free, and is widely used in the Python ecosystem. See Decision 2 for the full rationale on why this outweighs the stdlib-only precedent from `host-agent-dependency-supervisor`.
|
||||
- **[Risk] This addresses the symptom (duplicate processes racing) rather than a confirmed single trigger.** → Accepted: the incident diagnosis could not confirm, without access to the affected Mac mini, whether the trigger was a deploy-triggered restart race, an orphaned process, or operator error. This change makes all three safe by construction rather than requiring the exact trigger to be identified first. If host process history later points to a specific deploy-tooling bug (e.g. a supervisor not waiting for the old process to exit), that would be a separate, complementary fix outside this change's scope.
|
||||
- **[Trade-off] Lock scope is per-`identity_path`, not machine-global.** → Accepted per Non-Goals: a machine could legitimately run multiple independent installations pointed at different identity paths; this is also simpler to test, since parallel test runs never share lock state unless they explicitly share a path.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
- Fully additive: existing single-instance deployments are unaffected beyond a new lock file appearing next to `host_identity.json` (inside the already-untracked `tasks/`-style state directory).
|
||||
- No configuration changes required to adopt — the lock is unconditional, not opt-in/opt-out, since there is no legitimate scenario where running two instances against the same identity is desirable.
|
||||
- Rollback: revert the code change; there is no persistent state or schema to unwind (the lock file is inert once the code no longer checks it).
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Should a rejected duplicate-launch attempt also be surfaced somewhere operator-visible beyond process logs (e.g. recorded by the console-owning first instance via `ConsoleHistoryStore`)? Left for a follow-up if log-only proves insufficient in practice — the rejected instance itself never starts a console to display it from, so this would require the *surviving* instance to detect and record the contention, which is more than this change's scope requires.
|
||||
@@ -0,0 +1,25 @@
|
||||
## Why
|
||||
|
||||
A production task dispatch failed with `DeviceNotFoundError: unknown device device-8967d09f0d5f4cf49f7361a9f0dcb0ce` (host `host-311f68...`, 2026-07-14) even though the Host Agent's own console showed that same device as `connected`. Root-cause tracing confirmed: `DeviceManager` (`device/manager.py`) is in-process, in-memory, and per-process, with no persistence and no reactive removal. `TaskScheduler.assign()` (`cloud/scheduler.py`) dispatches to a `host_id` based on a periodically-synced `DevicePool` snapshot, with no concept of *which OS process* is currently answering that host's long-poll. If two Host Agent processes ever run concurrently under the same host identity — e.g. a supervisor starting a replacement before a hung previous instance has fully exited, or an operator accidentally leaving a second instance running in another terminal/tmux pane — both heartbeat and both claim under the same `host_id`, and a claim can land on whichever instance answers next, including one whose own in-memory `DeviceManager` never registered the device. Nothing in the codebase today prevents this: a repo-wide search confirms no pidfile, lockfile, or `flock`-style guard exists anywhere in `apps/device-host-agent`, and both `identity_path` and the device-config store default to process-working-directory-relative paths, which makes an accidental duplicate launch easy to trigger unnoticed.
|
||||
|
||||
## What Changes
|
||||
|
||||
- `create_application()` (`apps/device-host-agent/host_agent/app.py:145`) acquires a local, exclusive, non-blocking instance lock as its first action, before `resolve_host_identity()` or any device-enrollment network call.
|
||||
- The lock file is colocated with the existing per-installation state directory (`identity_path.parent` — the same directory `app.py` already uses for `host_console_history.sqlite3` and `host_governance_policy.json`), so this requires no new configuration surface.
|
||||
- If the lock is already held by a live process, the Host Agent logs a clear, actionable "duplicate instance" error (distinct from a generic startup failure) and exits non-zero without contacting the control plane, enrolling devices, or starting the console/heartbeat/claim loops.
|
||||
- The lock releases automatically on process exit for any reason — clean shutdown, unhandled exception, or a forced kill — via OS-level advisory file locking rather than a pidfile/sentinel-existence check, so no stale-lock cleanup step is ever required after an unclean shutdown.
|
||||
- New direct dependency: `filelock`, added to `apps/device-host-agent/pyproject.toml` (already present transitively in `uv.lock` today; see design.md for why a maintained cross-platform library was chosen over hand-rolled `fcntl`/`msvcrt` branching for this specific mechanism).
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `host-agent-single-instance-lock`: a local, per-installation exclusive-execution guarantee that prevents two Host Agent processes sharing the same `identity_path` state directory from running — and participating in the claim/heartbeat protocol — concurrently.
|
||||
|
||||
### Modified Capabilities
|
||||
(none — `host-agent-protocol`'s outbound protocol behavior is unchanged; this change only gates whether a second local process is ever allowed to begin participating in that protocol)
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected code: `apps/device-host-agent/host_agent/app.py` (`create_application()`/`HostAgentApplication` wiring), a new `apps/device-host-agent/host_agent/instance_lock.py` module, `apps/device-host-agent/pyproject.toml` (new `filelock` dependency).
|
||||
- Not affected: `cloud.*`, `apps/cloud-api`, the outbound claim/heartbeat/lease protocol itself, `device/manager.py`, `driver/*`.
|
||||
- Operational impact: an operator or supervisor that unintentionally starts a second instance now gets an immediate, clear local failure at startup instead of a delayed, confusing `DeviceNotFoundError` surfacing minutes later on an unrelated task dispatch. A legitimate restart (old process fully exited before the new one starts) is unaffected, since the lock releases on exit.
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Host Agent acquires an exclusive local instance lock before any control-plane side effect
|
||||
The Host Agent SHALL acquire an exclusive, non-blocking lock scoped to its configured `identity_path` state directory as the first action of application startup, before resolving host identity, enrolling any device, or contacting the control plane.
|
||||
|
||||
#### Scenario: Lock acquired on normal startup
|
||||
- **WHEN** a Host Agent starts and no other process holds the lock for its `identity_path` state directory
|
||||
- **THEN** it acquires the lock and proceeds to identity resolution, device enrollment, and the heartbeat/claim loops as before
|
||||
|
||||
#### Scenario: Duplicate instance is rejected before any network call
|
||||
- **WHEN** a second Host Agent process starts against the same `identity_path` state directory while a first process is still running
|
||||
- **THEN** the second process fails to acquire the lock and exits without resolving host identity, enrolling any device, or otherwise contacting the control plane
|
||||
|
||||
### Requirement: Duplicate-instance rejection is immediate and clearly identified
|
||||
The Host Agent SHALL fail immediately, without waiting or retrying, when the instance lock is already held, and SHALL log an error that identifies the failure as a duplicate-instance conflict distinct from other startup failures.
|
||||
|
||||
#### Scenario: Operator sees an actionable error
|
||||
- **WHEN** a duplicate Host Agent instance fails to acquire the lock
|
||||
- **THEN** the logged error names the lock file location and identifies the cause as another instance already running, rather than a generic or unrelated startup failure
|
||||
|
||||
### Requirement: The instance lock releases without manual intervention after any process exit
|
||||
The Host Agent SHALL release its instance lock when its process exits for any reason, including graceful shutdown, an unhandled exception, or a forced kill, without requiring any manual cleanup step before a subsequent instance can start.
|
||||
|
||||
#### Scenario: Lock is released after graceful shutdown
|
||||
- **WHEN** a running Host Agent instance completes its graceful shutdown sequence
|
||||
- **THEN** a new Host Agent instance started afterward against the same `identity_path` state directory successfully acquires the lock
|
||||
|
||||
#### Scenario: Lock is released after an unclean process exit
|
||||
- **WHEN** a running Host Agent instance is terminated forcefully (e.g. killed) without running its shutdown sequence
|
||||
- **THEN** a new Host Agent instance started afterward against the same `identity_path` state directory successfully acquires the lock without any manual lock file cleanup
|
||||
|
||||
### Requirement: Instance lock scope is per identity, not machine-global
|
||||
The instance lock SHALL be scoped to the Host Agent's configured `identity_path` state directory, such that two Host Agent processes configured with different `identity_path` values SHALL be able to run concurrently on the same machine.
|
||||
|
||||
#### Scenario: Independent identities do not contend
|
||||
- **WHEN** two Host Agent processes start on the same machine with different `identity_path` state directories
|
||||
- **THEN** both acquire their respective locks and run concurrently without either being rejected
|
||||
@@ -0,0 +1,30 @@
|
||||
## 1. Dependency
|
||||
|
||||
- [x] 1.1 Add `filelock` to `apps/device-host-agent/pyproject.toml` `dependencies` (already resolved transitively in `uv.lock`; this only adds a direct edge).
|
||||
- [x] 1.2 Run `uv lock` (workspace-wide) and confirm the resolved `filelock` version/hash is unchanged from what's already pinned transitively.
|
||||
|
||||
## 2. Instance lock module
|
||||
|
||||
- [x] 2.1 Create `apps/device-host-agent/host_agent/instance_lock.py` wrapping `filelock.FileLock`: a function/class that takes the state directory (derived from `identity_path.parent`), acquires `host_agent.lock` inside it with `timeout=0`, and raises a dedicated `InstanceAlreadyRunningError` (naming the lock file path) on `filelock.Timeout` instead of leaking the library's own exception type.
|
||||
- [x] 2.2 Expose an explicit `release()` (or context-manager `__exit__`) that unlocks and closes the underlying handle, per design.md Decision 5.
|
||||
- [x] 2.3 Unit tests for `instance_lock.py`: acquire succeeds when free; acquire raises `InstanceAlreadyRunningError` when already held (same process, second handle against the same path); release-then-reacquire succeeds; two different paths never contend.
|
||||
|
||||
## 3. Wiring into application startup/shutdown
|
||||
|
||||
- [x] 3.1 In `create_application()` (`apps/device-host-agent/host_agent/app.py:145`), acquire the instance lock as the first statement, before `resolve_host_identity()`, using `startup_config.identity_path.parent`.
|
||||
- [x] 3.2 Add an `instance_lock` field to `HostAgentApplication` (`app.py:35-42`) so the held lock stays alive for the object's full lifetime rather than being released when `create_application()` returns.
|
||||
- [x] 3.3 Release the lock in `run_async()`'s existing shutdown `finally` block (`app.py:88-111`), alongside the existing heartbeat/console/supervisor teardown.
|
||||
- [x] 3.4 In `cli.py:main()`, catch `InstanceAlreadyRunningError` around the `create_application(...).run()` call, print a clear duplicate-instance error to stderr (naming the lock path), and exit non-zero — matching the existing `LocalAccountSetupError` handling pattern already in that function.
|
||||
- [x] 3.5 Unit tests in `test_app.py`: a second `create_application()` call against the same `identity_path` (while the first `HostAgentApplication`'s lock is still held) raises `InstanceAlreadyRunningError` before any enrollment call occurs (assert the enrollment client's `enroll_host`/`enroll_device` are never invoked for the second call); two `create_application()` calls against different `identity_path` values both succeed and can coexist.
|
||||
- [x] 3.6 Unit test confirming the lock is released after `run_async()` completes its shutdown sequence, allowing an immediately-following `create_application()` call against the same `identity_path` to succeed (simulating a clean restart).
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [x] 4.1 Add a short note to `docs/MACOS_IPHONE_SETUP.md` describing the new duplicate-instance error (what it means, how to resolve it — find and stop the other process) near the existing Host Agent startup instructions.
|
||||
|
||||
## 5. Validation
|
||||
|
||||
- [x] 5.1 Run `uv run --all-packages pytest -m "not integration"` and confirm no regressions.
|
||||
- [x] 5.2 `ruff check` and `ruff format --check` on all changed/new files.
|
||||
- [x] 5.3 `openspec validate host-agent-single-instance-lock --strict` and fix any reported issues.
|
||||
- [ ] 5.4 Manual verification: start one Host Agent instance, attempt to start a second against the same identity, confirm the second exits immediately with the duplicate-instance error and the first is unaffected; stop the first, confirm a subsequent start succeeds. *(Pending: requires a real Host Agent deployment environment — covered indirectly by `test_second_create_application_against_held_lock_raises_before_enrollment`, `test_lock_released_after_run_async_allows_restart`, and `test_independent_paths_never_contend` in `test_app.py`/`test_instance_lock.py`.)*
|
||||
@@ -569,6 +569,7 @@ dependencies = [
|
||||
{ name = "device-agent-runtime" },
|
||||
{ name = "device-cloud-platform" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "filelock" },
|
||||
{ name = "httpx" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
@@ -579,6 +580,7 @@ requires-dist = [
|
||||
{ name = "device-agent-runtime", editable = "." },
|
||||
{ name = "device-cloud-platform", editable = "packages/cloud-platform" },
|
||||
{ name = "fastapi", specifier = ">=0.115.0" },
|
||||
{ name = "filelock", specifier = ">=3.0" },
|
||||
{ name = "httpx", specifier = ">=0.27.0" },
|
||||
{ name = "jinja2", specifier = ">=3.1" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" },
|
||||
|
||||
Reference in New Issue
Block a user