test(acceptance): poll the broker publication for remote inventory
The harness waited on the daemon's remote-catalog cache file, which the broker architecture no longer writes; it now waits until vev ls remote (the broker's committed snapshot) lists the fixture session. Unaccepted input_busy refusals are resent within a bound, and the README drops the client-picker-navigate known gap.
- commit
- 4913180a9b9a781c845fc63ac3c3bfc6f256fe21
- author
- brice · · 2026-09-23 02:57 UTC
- parent
- 87a5a2a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +49 -19
M scripts/remote-picker-harness/README.md +11 -6
| @@ -70,9 +70,14 @@ exhaustive transport or geometry matrix. For a focused run, set | |||
| 70 | 70 | above (for example `warm-reuse@quic warm-reuse@stdio`); the containers, keys | |
| 71 | 71 | and artifacts still come from the same setup. | |
| 72 | 72 | ||
| 73 | - | Known gap: `client-picker-navigate` fails independently of this matrix. After | |
| 74 | - | entering the search and the exact target name, the client-owned picker commits | |
| 75 | - | the current session instead of the searched target. It fails in isolation and | |
| 76 | - | at any log level; it predates the named-direct refactor, which previously | |
| 77 | - | aborted the full run before this scenario was reached. The named-direct, warm | |
| 78 | - | reuse and hybrid scenarios above are green. | |
| 73 | + | Remote scenarios wait for the fixture session to reach the broker's | |
| 74 | + | committed publication (`vev ls remote` in the client container) before they | |
| 75 | + | search the palette: the broker owns remote observation and re-probes each host | |
| 76 | + | on its own cadence. A driver request refused with `input_busy` (not accepted) | |
| 77 | + | is resent for up to three seconds; accepted actions are never retried. | |
| 78 | + | ||
| 79 | + | `client-picker-navigate` opens the client-owned picker over the live | |
| 80 | + | attachment with `SSP`, searches the target session by name, and commits it. | |
| 81 | + | Every step must settle as `processed`: the opener on its daemon receipt, the | |
| 82 | + | search keys at the client's event-loop boundary, and the commit on the | |
| 83 | + | destination's first committed publication. | |
| @@ -70,9 +70,14 @@ exhaustive transport or geometry matrix. For a focused run, set | |||||
| 70 | above (for example `warm-reuse@quic warm-reuse@stdio`); the containers, keys | 70 | above (for example `warm-reuse@quic warm-reuse@stdio`); the containers, keys | ||
| 71 | and artifacts still come from the same setup. | 71 | and artifacts still come from the same setup. | ||
| 72 | 72 | ||||
| 73 | - | Known gap: `client-picker-navigate` fails independently of this matrix. After | 73 | + | Remote scenarios wait for the fixture session to reach the broker's |
| 74 | - | entering the search and the exact target name, the client-owned picker commits | 74 | + | committed publication (`vev ls remote` in the client container) before they |
| 75 | - | the current session instead of the searched target. It fails in isolation and | 75 | + | search the palette: the broker owns remote observation and re-probes each host |
| 76 | - | at any log level; it predates the named-direct refactor, which previously | 76 | + | on its own cadence. A driver request refused with `input_busy` (not accepted) |
| 77 | - | aborted the full run before this scenario was reached. The named-direct, warm | 77 | + | is resent for up to three seconds; accepted actions are never retried. |
| 78 | - | reuse and hybrid scenarios above are green. | 78 | + | |
| 79 | + | `client-picker-navigate` opens the client-owned picker over the live | |||
| 80 | + | attachment with `SSP`, searches the target session by name, and commits it. | |||
| 81 | + | Every step must settle as `processed`: the opener on its daemon receipt, the | |||
| 82 | + | search keys at the client's event-loop boundary, and the commit on the | |||
| 83 | + | destination's first committed publication. | |||
M scripts/remote-picker-harness/acceptance.py +38 -13
| @@ -38,8 +38,9 @@ DAEMON_LOG = "vev-daemon.log" | |||
| 38 | 38 | STDIO_LOG = "vev-stdio.log" | |
| 39 | 39 | # The broker logs one broker_remote_dial per remote physical bootstrap. | |
| 40 | 40 | BROKER_LOG = "broker/log/vev-daemon.log" | |
| 41 | - | REMOTE_CACHE_FILE = "remote-catalog-cache.json" | |
| 41 | + | REMOTE_HOST = "remote" | |
| 42 | 42 | INVENTORY_TIMEOUT_S = 45 | |
| 43 | + | INPUT_BUSY_RETRY_S = 3 | |
| 43 | 44 | ARTIFACTS_DIR = os.environ.get("VEV_ACCEPTANCE_ARTIFACTS_DIR", "").strip() | |
| 44 | 45 | # The warm-reuse scenario requires client debug events. Every driver gets | |
| 45 | 46 | # debug logging unless a scenario explicitly overrides it. | |
| @@ -47,7 +48,9 @@ DRIVER_BASE_ENV = {"VEV_LOG": "debug"} | |||
| 47 | 48 | ||
| 48 | 49 | ||
| 49 | 50 | class DriverError(Exception): | |
| 50 | - | pass | |
| 51 | + | def __init__(self, message, code=None): | |
| 52 | + | super().__init__(message) | |
| 53 | + | self.code = code | |
| 51 | 54 | ||
| 52 | 55 | ||
| 53 | 56 | class Driver: | |
| @@ -118,10 +121,30 @@ class Driver: | |||
| 118 | 121 | except json.JSONDecodeError as exc: | |
| 119 | 122 | raise DriverError(f"invalid driver JSON while reading {what}: {exc}") from exc | |
| 120 | 123 | if "error" in envelope: | |
| 121 | - | raise DriverError(f"driver error while reading {what}: {envelope['error']}") | |
| 124 | + | error = envelope["error"] | |
| 125 | + | raise DriverError(f"driver error while reading {what}: {error}", | |
| 126 | + | code=error.get("code") if isinstance(error, dict) else None) | |
| 122 | 127 | return envelope["result"] | |
| 123 | 128 | ||
| 124 | 129 | def call(self, operation, **fields): | |
| 130 | + | """Send one request; retry only an input admission the owner refused. | |
| 131 | + | ||
| 132 | + | ``input_busy`` is returned with ``accepted: false`` while the input | |
| 133 | + | owner is still delivering an earlier terminal read (for example a | |
| 134 | + | reply to a query the new attachment's first frame emitted). Nothing | |
| 135 | + | was admitted, so resending is the documented client behavior; every | |
| 136 | + | other error, and every accepted action, is reported unchanged. | |
| 137 | + | """ | |
| 138 | + | deadline = time.monotonic() + INPUT_BUSY_RETRY_S | |
| 139 | + | while True: | |
| 140 | + | try: | |
| 141 | + | return self._call_once(operation, **fields) | |
| 142 | + | except DriverError as exc: | |
| 143 | + | if exc.code != "input_busy" or time.monotonic() >= deadline: | |
| 144 | + | raise | |
| 145 | + | time.sleep(0.05) | |
| 146 | + | ||
| 147 | + | def _call_once(self, operation, **fields): | |
| 125 | 148 | self.request_id += 1 | |
| 126 | 149 | request = dict(version=1, id=self.request_id, op=operation, | |
| 127 | 150 | attachment=self.attachment, **fields) | |
| @@ -255,16 +278,18 @@ def write_artifact(name, payload): | |||
| 255 | 278 | ||
| 256 | 279 | ||
| 257 | 280 | def await_remote_inventory(container, session, timeout_s=INVENTORY_TIMEOUT_S, interval_s=0.5): | |
| 258 | - | """Wait until the local daemon publishes ``session`` for its remote host. | |
| 259 | - | ||
| 260 | - | The remote monitor observes each host on a ~15s healthy cadence, so a | |
| 261 | - | freshly created remote session is invisible to the palette until the next | |
| 262 | - | observation. Wait on the daemon's persisted catalog cache (rewritten right | |
| 263 | - | after a successful observation) rather than guessing a timeout, so the | |
| 264 | - | following navigation is deterministic and near-immediate in isolation. | |
| 281 | + | """Wait until the broker publishes ``session`` for its remote host. | |
| 282 | + | ||
| 283 | + | The broker owns remote observation: it re-probes each configured host on | |
| 284 | + | its own healthy cadence, and every attached client derives its palette | |
| 285 | + | and picker rows from the broker's committed publication. A freshly created | |
| 286 | + | remote session is invisible until the next observation, so wait on that | |
| 287 | + | publication itself (``vev ls HOST`` reads the broker snapshot) rather than | |
| 288 | + | guessing a timeout. The following palette search then asserts the row | |
| 289 | + | independently. | |
| 265 | 290 | """ | |
| 266 | - | script = (f'f="{STATE_LOG_DIR}/{REMOTE_CACHE_FILE}"; ' | |
| 267 | - | f'grep -qF "{session}" "$f" 2>/dev/null') | |
| 291 | + | display = f"{session}@{REMOTE_HOST}" | |
| 292 | + | script = f'vev ls {REMOTE_HOST} 2>/dev/null | grep -qF "{display} "' | |
| 268 | 293 | deadline = time.monotonic() + timeout_s | |
| 269 | 294 | while True: | |
| 270 | 295 | try: | |
| @@ -274,7 +299,7 @@ def await_remote_inventory(container, session, timeout_s=INVENTORY_TIMEOUT_S, in | |||
| 274 | 299 | pass | |
| 275 | 300 | if time.monotonic() >= deadline: | |
| 276 | 301 | raise DriverError( | |
| 277 | - | f"remote session {session} never reached the local inventory " | |
| 302 | + | f"remote session {session} never reached the broker publication " | |
| 278 | 303 | f"within {timeout_s}s") | |
| 279 | 304 | time.sleep(interval_s) | |
| 280 | 305 | ||
| @@ -38,8 +38,9 @@ DAEMON_LOG = "vev-daemon.log" | |||||
| 38 | STDIO_LOG = "vev-stdio.log" | 38 | STDIO_LOG = "vev-stdio.log" | ||
| 39 | # The broker logs one broker_remote_dial per remote physical bootstrap. | 39 | # The broker logs one broker_remote_dial per remote physical bootstrap. | ||
| 40 | BROKER_LOG = "broker/log/vev-daemon.log" | 40 | BROKER_LOG = "broker/log/vev-daemon.log" | ||
| 41 | - | REMOTE_CACHE_FILE = "remote-catalog-cache.json" | 41 | + | REMOTE_HOST = "remote" |
| 42 | INVENTORY_TIMEOUT_S = 45 | 42 | INVENTORY_TIMEOUT_S = 45 | ||
| 43 | + | INPUT_BUSY_RETRY_S = 3 | |||
| 43 | ARTIFACTS_DIR = os.environ.get("VEV_ACCEPTANCE_ARTIFACTS_DIR", "").strip() | 44 | ARTIFACTS_DIR = os.environ.get("VEV_ACCEPTANCE_ARTIFACTS_DIR", "").strip() | ||
| 44 | # The warm-reuse scenario requires client debug events. Every driver gets | 45 | # The warm-reuse scenario requires client debug events. Every driver gets | ||
| 45 | # debug logging unless a scenario explicitly overrides it. | 46 | # debug logging unless a scenario explicitly overrides it. | ||
| @@ -47,7 +48,9 @@ DRIVER_BASE_ENV = {"VEV_LOG": "debug"} | |||||
| 47 | 48 | ||||
| 48 | 49 | ||||
| 49 | class DriverError(Exception): | 50 | class DriverError(Exception): | ||
| 50 | - | pass | 51 | + | def __init__(self, message, code=None): |
| 52 | + | super().__init__(message) | |||
| 53 | + | self.code = code | |||
| 51 | 54 | ||||
| 52 | 55 | ||||
| 53 | class Driver: | 56 | class Driver: | ||
| @@ -118,10 +121,30 @@ class Driver: | |||||
| 118 | except json.JSONDecodeError as exc: | 121 | except json.JSONDecodeError as exc: | ||
| 119 | raise DriverError(f"invalid driver JSON while reading {what}: {exc}") from exc | 122 | raise DriverError(f"invalid driver JSON while reading {what}: {exc}") from exc | ||
| 120 | if "error" in envelope: | 123 | if "error" in envelope: | ||
| 121 | - | raise DriverError(f"driver error while reading {what}: {envelope['error']}") | 124 | + | error = envelope["error"] |
| 125 | + | raise DriverError(f"driver error while reading {what}: {error}", | |||
| 126 | + | code=error.get("code") if isinstance(error, dict) else None) | |||
| 122 | return envelope["result"] | 127 | return envelope["result"] | ||
| 123 | 128 | ||||
| 124 | def call(self, operation, **fields): | 129 | def call(self, operation, **fields): | ||
| 130 | + | """Send one request; retry only an input admission the owner refused. | |||
| 131 | + | ||||
| 132 | + | ``input_busy`` is returned with ``accepted: false`` while the input | |||
| 133 | + | owner is still delivering an earlier terminal read (for example a | |||
| 134 | + | reply to a query the new attachment's first frame emitted). Nothing | |||
| 135 | + | was admitted, so resending is the documented client behavior; every | |||
| 136 | + | other error, and every accepted action, is reported unchanged. | |||
| 137 | + | """ | |||
| 138 | + | deadline = time.monotonic() + INPUT_BUSY_RETRY_S | |||
| 139 | + | while True: | |||
| 140 | + | try: | |||
| 141 | + | return self._call_once(operation, **fields) | |||
| 142 | + | except DriverError as exc: | |||
| 143 | + | if exc.code != "input_busy" or time.monotonic() >= deadline: | |||
| 144 | + | raise | |||
| 145 | + | time.sleep(0.05) | |||
| 146 | + | ||||
| 147 | + | def _call_once(self, operation, **fields): | |||
| 125 | self.request_id += 1 | 148 | self.request_id += 1 | ||
| 126 | request = dict(version=1, id=self.request_id, op=operation, | 149 | request = dict(version=1, id=self.request_id, op=operation, | ||
| 127 | attachment=self.attachment, **fields) | 150 | attachment=self.attachment, **fields) | ||
| @@ -255,16 +278,18 @@ def write_artifact(name, payload): | |||||
| 255 | 278 | ||||
| 256 | 279 | ||||
| 257 | def await_remote_inventory(container, session, timeout_s=INVENTORY_TIMEOUT_S, interval_s=0.5): | 280 | def await_remote_inventory(container, session, timeout_s=INVENTORY_TIMEOUT_S, interval_s=0.5): | ||
| 258 | - | """Wait until the local daemon publishes ``session`` for its remote host. | 281 | + | """Wait until the broker publishes ``session`` for its remote host. |
| 259 | - | 282 | + | ||
| 260 | - | The remote monitor observes each host on a ~15s healthy cadence, so a | 283 | + | The broker owns remote observation: it re-probes each configured host on |
| 261 | - | freshly created remote session is invisible to the palette until the next | 284 | + | its own healthy cadence, and every attached client derives its palette |
| 262 | - | observation. Wait on the daemon's persisted catalog cache (rewritten right | 285 | + | and picker rows from the broker's committed publication. A freshly created |
| 263 | - | after a successful observation) rather than guessing a timeout, so the | 286 | + | remote session is invisible until the next observation, so wait on that |
| 264 | - | following navigation is deterministic and near-immediate in isolation. | 287 | + | publication itself (``vev ls HOST`` reads the broker snapshot) rather than |
| 288 | + | guessing a timeout. The following palette search then asserts the row | |||
| 289 | + | independently. | |||
| 265 | """ | 290 | """ | ||
| 266 | - | script = (f'f="{STATE_LOG_DIR}/{REMOTE_CACHE_FILE}"; ' | 291 | + | display = f"{session}@{REMOTE_HOST}" |
| 267 | - | f'grep -qF "{session}" "$f" 2>/dev/null') | 292 | + | script = f'vev ls {REMOTE_HOST} 2>/dev/null | grep -qF "{display} "' |
| 268 | deadline = time.monotonic() + timeout_s | 293 | deadline = time.monotonic() + timeout_s | ||
| 269 | while True: | 294 | while True: | ||
| 270 | try: | 295 | try: | ||
| @@ -274,7 +299,7 @@ def await_remote_inventory(container, session, timeout_s=INVENTORY_TIMEOUT_S, in | |||||
| 274 | pass | 299 | pass | ||
| 275 | if time.monotonic() >= deadline: | 300 | if time.monotonic() >= deadline: | ||
| 276 | raise DriverError( | 301 | raise DriverError( | ||
| 277 | - | f"remote session {session} never reached the local inventory " | 302 | + | f"remote session {session} never reached the broker publication " |
| 278 | f"within {timeout_s}s") | 303 | f"within {timeout_s}s") | ||
| 279 | time.sleep(interval_s) | 304 | time.sleep(interval_s) | ||
| 280 | 305 | ||||