exlab_wizard.orchestrator.quiescence_poller#

Operator-free, per-file quiescence-driven NAS sync poller.

Operator-free per-file NAS sync design (2026-05-21). The QuiescenceSyncPoller is the single auto-sync trigger for every run pending NAS sync – orchestrator-staged runs and runs acquired directly on nas-mode equipment. It supersedes the sentinel/manifest StagingWatcher and its five-state ingest.json machine.

Each sweep (poll_once):

  1. Discover runs in both roots: * stage-mode – run-leaf directories under

    config.orchestrator.staging_root;

    • nas-mode – run-leaf directories under each nas-mode equipment’s local_root tree (<local_root>/<equipment_id>/<project>/{Runs, TestRuns}/<Run_*>).

  2. Per-file quiescence. The poller keeps an in-memory snapshot across sweeps: for each file, its (st_size, st_mtime_ns) signature and the wall-clock time the signature was first observed. A file is quiet once that signature has been observed unchanged for at least config.sync.quiescence_minutes. Files matching any config.sync.ignore_globs glob and the .exlab-wizard/ cache dir are skipped. Eligibility is measured from the poller’s own observations across sweeps – not the absolute age of mtime – because transports (rsync -t, rclone) preserve the source mtime.

  3. Per-file eligibility. A quiet file is eligible when its current (st_size, st_mtime_ns) signature differs from the synced_signature recorded for it in the run’s sync_state.json (a file with no record, or a record carrying a stale signature, is eligible; a file matching its recorded signature has already synced at its current state and is skipped).

  4. Enqueue. Each discovered run with at least one eligible file is enqueued via nas_sync.enqueue(run_path, files=[...]) carrying the run-relative paths of the eligible files. enqueue itself owns the re-queue / no-op decision (a terminal job with a fresh subset is re-armed; an active job is a no-op), so the poller no longer needs the coarse “skip run with a job” guard.

The poller is safe to cancel at any await point: it carries no on-disk state of its own (the snapshot is purely in-memory and is rebuilt by re-observing the filesystem on the next sweep).

Classes

FileSnapshot(signature, first_seen_monotonic)

One file's observation record carried forward across sweeps.

NASSyncLike(*args, **kwargs)

The NAS-sync surface the poller and the Phase 3 staging code use.

QuiescenceSyncPoller(*, config, nas_sync, ...)

Polls every run pending NAS sync and enqueues runs with eligible files.

class exlab_wizard.orchestrator.quiescence_poller.FileSnapshot(signature, first_seen_monotonic)[source]#

Bases: object

One file’s observation record carried forward across sweeps.

  • signature – the (st_size, st_mtime_ns) last observed.

  • first_seen_monotonic – the time.monotonic() value at which that exact signature was first observed. Reset whenever the signature changes; the settle window is measured from it.

Parameters:
first_seen_monotonic: float#
signature: tuple[int, int]#
class exlab_wizard.orchestrator.quiescence_poller.NASSyncLike(*args, **kwargs)[source]#

Bases: Protocol

The NAS-sync surface the poller and the Phase 3 staging code use.

enqueue is what the poller itself calls; the staging router and ui/mount run-status code additionally consult status / get_by_run_path / list_all. They are declared here so the protocol documents the full surface that deps.nas_sync must satisfy. Tests pass in-memory stubs that record the calls.

async enqueue(run_path, files=Ellipsis)[source]#
Parameters:
Return type:

Any

async get_by_run_path(run_path)[source]#
Parameters:

run_path (Path)

Return type:

Any

async list_all()[source]#
Return type:

Any

async status(run_path)[source]#
Parameters:

run_path (Path)

Return type:

str

class exlab_wizard.orchestrator.quiescence_poller.QuiescenceSyncPoller(*, config, nas_sync, sync_state_writer)[source]#

Bases: object

Polls every run pending NAS sync and enqueues runs with eligible files.

Constructor dependencies are a Config, a NASSyncClient-shaped sync client, and a SyncStateWriter used (read-only) to skip files already synced at their current (st_size, st_mtime_ns) signature.

The start/stop/loop lifecycle mirrors the retired StagingWatcher; poll_once is exposed so tests can drive the poller synchronously.

Parameters:
apply_config(config)[source]#

Swap the cached config in place so a live settings save applies.

poll_once re-reads quiescence_minutes, the equipment list, the staging root, and the ignore globs from self._config every sweep, and _loop() re-reads poll_interval_seconds each iteration – so reassigning the config here makes every sync setting take effect on the next sweep without a tray relaunch.

Parameters:

config (Config)

Return type:

None

async poll_once(*, now_monotonic=None)[source]#

Run one discovery + quiescence + enqueue sweep.

now_monotonic is injectable so tests can drive the settle window with a controllable clock; production callers leave it None and the poller reads time.monotonic().

Returns the list of run paths enqueued on this sweep (in discovery order) so tests can assert exactly which runs fired.

Parameters:

now_monotonic (float | None)

Return type:

list[Path]

async start()[source]#

Start the background polling task. Idempotent.

Returns immediately; the task runs until stop() is called or the surrounding event loop tears down.

Return type:

None

async stop()[source]#

Cancel the background task and wait for it to exit. Idempotent.

Return type:

None