Status board¶
Updated at the end of every session. States: todo · in-progress · in-review · done · blocked.
Current milestone: M2 Fleet, faults, two clocks — in progress (M1 First scenario passes closed with v0.10.0 on 2026-10-18; M0 Hello, CI closed with v0.3.0) Repository: https://github.com/Dimitrios-Kafetzis/Fieldproof Next task: P2-T06 Runner hardening, run matrix and run database.
Phase 0 — Foundations¶
| Task | Title | State | PR | Notes |
|---|---|---|---|---|
| P0-T01 | Repository bootstrap and GitHub setup | done | #1 | 2026-10-14: bootstrap commit pushed to main; Apache-2.0, NOTICE, CoC 2.1, CONTRIBUTING, SECURITY, CODEOWNERS, templates, editorconfig, session guide added; main protected (PR required, linear history, enforce admins, conversation resolution, no force push); squash-only merges; 24 project labels; private vulnerability reporting on. Required status checks list is empty until P0-T05 wires CI. |
| P0-T02 | Host environment setup | done | #2 | 2026-10-14: host prepared and verified. Installed (pinned): Docker 29.9.0 + Compose 5.6.0 + Buildx 0.38.0, containerd 2.4.2, qemu-user-static 1:8.2.2 (binfmt POF), gh 2.45.0, nvm v0.40.8, Node 22.23.3, pnpm 12.11.2, uv 0.13.1, pre-commit 4.7.0. tools/host-setup.sh (idempotent, one sudo), tools/host-check.sh (passes twice), tests/host/test_host_check.sh (18 pass), docs/setup/host.md, .nvmrc. Both definition-of-done container commands pass. Hand-off: the maintainer must log out and back in once so the docker group is active without sg docker. Sudo from editor tasks works through SUDO_ASKPASS with a clean-env zenity helper (VS Code snap breaks GTK); documented in the troubleshooting section. |
| P0-T03 | Base container images | done | #5 | 2026-10-14: three images from snapshot-pinned apt sources (Ubuntu 20261010T000000Z, ROS 2026-10-05): base-ros2 (Jazzy ros-base, non-root fieldproof, tini), sim (Gazebo Harmonic 8.15 through the Jazzy gz-*-vendor packages pulled by ros_gz, Mesa EGL headless), dev (colcon + mixins, clang 18 tooling, uv 0.13.1, rosdep, vcstool, sudo). docker/compose.yaml with profiles sim/dev/images and compose.gpu.yaml for /dev/dri; root Makefile (images, up, down, shell, prune, test-images); .devcontainer/devcontainer.json; docs/setup/containers.md. Verified: make up runs gz sim -s -r --headless-rendering empty.sdf, ros2 topic list and gz topic -l work from dev; the camera world renders 320x240 frames on the Radeon 860M through /dev/dri (radeonsi) and with LIBGL_ALWAYS_SOFTWARE=1 (llvmpipe); cold build 406 s (NO_CACHE=1 make images, fresh cache-mount namespace) with the Ubuntu snapshot host healthy; the same host spent part of the session returning 502/503 and up to 16 s per request, during which cold builds took 17 to 20 min, hence fieldproof-apt-install (parallel, mirror-first package fetch) and the pinned lists kept in the base image; follow-up in #4. Hand-off: sizes 0.9/3.1/4.1 GB; the ROS snapshot key expires 2027-06-01; rosdep update is a manual step; the shared-checkout collision with the parallel P0-T04 session is noted under open questions. |
| P0-T04 | Developer tooling and quality gates | done | #3 | 2026-10-14: .pre-commit-config.yaml with 29 pinned hooks (ruff, mypy, clang-format, cmake-format/lint, eslint, prettier, yamllint, markdownlint-cli2, shellcheck, hadolint container, check-jsonschema, gitleaks, commitlint, pre-commit-hooks); root pyproject.toml, .clang-format, .clang-tidy (config only, runs in the sim container from P0-T07), .cmake-format.yaml, eslint.config.cjs (CommonJS on purpose, see tooling.md), .prettierrc.yaml, .yamllint.yaml, .markdownlint-cli2.yaml, .hadolint.yaml, commitlint.config.mjs, .gitleaks.toml; Makefile (setup, lint, lint-files, lint-commits, secrets, fmt, test, test-tooling, test-host, clean); tests/tooling/test_quality_gates.sh (29 pass); docs/setup/tooling.md; CONTRIBUTING, README and the standards updated. Planning docs reflowed (whitespace only) to pass markdownlint. Both definition-of-done checks pass: pre-commit run --all-files is green and git commit -m 'Added developer tooling' is rejected by the commit-msg hook. Hand-off: P0-T07 adds the engine's packages to the mypy hook's additional_dependencies and wires clang-tidy into the sim container build; P0-T05 runs make lint and make test in CI. |
| P0-T05 | CI/CD pipeline | done | #6, #11, #12 | 2026-10-14: workflows ci.yml (lint, tooling/CI/host tests, docker buildx bake build of base-ros2/sim/dev from a GHCR registry cache, image tests; single required gate job CI), pr-title.yml (commitlint on the title, required check PR title), images.yml (push sha-<short>, syft SBOM, Trivy report to code scanning, fail on a fixed CRITICAL with docker/trivy-policy.rego exempting linux-libc-dev, then tag main/vX.Y.Z from the scanned digest; reusable), release.yml (release-please simple, version.txt, pre-1.0 bumps, release PR chore(release): release X.Y.Z, calls images.yml for the tag), docs.yml placeholder; Dependabot (actions, docker, pip /engine, npm /cockpit, weekly, grouped, build(deps)); every action SHA-pinned; actionlint hook; tools/ci/ (changed-areas, bake-cache, free-disk, branch-protection); tests/ci/test_workflows.sh (35 pass); make lint-title, make test-ci, make branch-protection; test_images.sh --no-build; docs/setup/ci.md; README badges. Verified on GitHub: #6 CI green (lint 2 min, image job 16 min cold, 6 min with the cache, image tests 19 pass with llvmpipe rendering); throwaway #7 (ruff/mypy error) CI red; throwaway #8 (docs only) CI green with the image job skipped; both closed, branches deleted. Three runner fixes were needed: pin buildx v0.38.0 and set COMPOSE_PROFILES, run bake from docker/, pin Compose v5.6.0. Also resolves issue #4 (option 2, on the maintainer's request): the base Dockerfile's scratch stage apt-lists publishes /var/lib/apt/lists as fieldproof-apt-lists:<APT_SNAPSHOT>-<ROS_SNAPSHOT> from images.yml; docker/apt-lists-context.sh seeds later builds from it (local or GHCR, FIELDPROOF_APT_LISTS=off to force the snapshot hosts); seeded cold build locally 467 s with all three apt-get update refreshes skipped; CI fallback build 10.5 min; image tests now 27 checks. Hand-off: (1) run make branch-protection (the session's gh api writes to repository settings were denied) so CI and PR title become required and actions SHA pinning is enforced, then merge #6 with its title; (2) on the first push to main set the four GHCR packages (base-ros2, sim, dev, apt-lists) to public and check they are linked to the repo; (3) add the secret RELEASE_PLEASE_TOKEN (fine-grained PAT, contents + pull requests read/write) so the release PR gets checks; (4) Dependabot reports /engine and /cockpit missing until P0-T07. Resolves open question 4 with the Conventional Commit title option. 2026-10-15: #6 merged with CI and PR title as required checks; follow-ups #11 (fix(release): first release is 0.1.0, not 1.0.0) and #12 (fix(ci): weekly Pins workflow with docker/pins-check.sh replaces the Dependabot docker updater, issue #9) merged; issues #4 and #9 closed; v0.1.0 tagged and released by the release pull request #10. |
| P0-T06 | Documentation site | done | #15, #16, #17 | 2026-10-15: MkDocs Material site from mkdocs.yml (navigation for plan, decisions, setup, reference; Mermaid fences; light/dark palettes; strict validation: a broken link, missing anchor or page outside the nav fails the build). Toolchain locked in tools/docs (uv project: mkdocs-material 9.7.7, MkDocs 1.6.1, pymdown-extensions 12.3). tools/docs/adr_index.py generates docs/adr/README.md (now with dates and linked "Superseded by" statuses); the adr-index pre-commit hook rewrites it on commit and tools/docs/mkdocs_hooks.py regenerates it and fills the Decisions nav before every build, so a new ADR needs no mkdocs.yml edit. New pages docs/index.md, docs/reference/index.md, docs/setup/docs.md. make docs, docs-serve, docs-links (lychee 0.24.2 container, .lychee.toml: external links checked, fragments via index_files, site mounted under /srv/Fieldproof so the 404 page's root-relative links resolve, the live-site canonical and GitHub edit/view links excluded because they exist only after a merge), test-docs. docs.yml builds, link-checks and uploads the site on pull requests and pushes to main touching the site sources, and deploys to Pages from main (pages: write, id-token: write, environment github-pages); ci.yml gains the Docs job (make test-docs) in the CI gate; Dependabot pip entry for tools/docs; two check-yaml instances because mkdocs.yml carries a !!python/name tag. Tests: tests/docs/test_docs.sh 38 pass, tooling 30 pass (new bad-ADR case), workflows 36 pass; strict build 0.4 s; lychee 1881 links OK, 0 errors; all hooks green. Hand-off: (1) merge #15; docs.yml then deploys and the definition of done is checked with curl -sSI https://dimitrios-kafetzis.github.io/Fieldproof/ (200) and the ADR page listing nine ADRs; (2) the first deployment creates the github-pages environment; if the deploy job is denied, confirm the Pages source is "GitHub Actions" in the repository settings; (3) Material prints a warning that MkDocs 2.0 will drop plugins and themes; the lock pins MkDocs 1.6.1, so a Dependabot bump of mkdocs to 2.x needs a look before merging; (4) the ADR index now shows the full ADR headings rather than the hand-written short titles. 2026-10-15: #15 merged and the site deployed; follow-ups #17 (fix(docs): the docs test retries the lychee image pull) and #16 (fix(release): a feat bumps the minor version below 1.0) merged; v0.2.0 released by the release pull request #13. |
| P0-T07 | Monorepo layout and package skeletons | done | #18, #20 | 2026-10-16: layout per 04-engineering-standards.md with a buildable, tested package per layer. engine/: uv project fieldproof (hatchling, Pydantic 2.14.1, Typer 0.27.3, Rich 15.0.0, pytest 9.1.1 in uv.lock), fieldproof --version and -V print fieldproof 0.2.0, 5 pytest tests. sim/: fieldproof_worlds (fieldproof_empty.sdf, gz sdf --check as a ctest, env hook on GZ_SIM_RESOURCE_PATH) and fieldproof_gz_plugins (C++17 against gz-sim8 from the Jazzy vendor packages: fieldproof_gz_plugins_core version library, fieldproof::HelloSystem system plugin, GoogleTest suite, env hook on GZ_SIM_SYSTEM_PLUGIN_PATH). bridge/fieldproof_bridge (ament_python, pytest) and robots/fieldproof_robots (ament_cmake, ament_cmake_pytest on the installed layout); every ROS package runs ament_xmllint. cockpit/: pnpm 12.11.2 workspace (packageManager, minimumReleaseAge 7 days, exact versions), apps/cockpit Vite 8.3.4 + React 18.3.1 + TypeScript 5.9.3 strict stub with 4 Vitest 5.0.3 tests and a production build; packages/ reserved. examples/, assets/LICENSES/, tests/README.md, ARCHITECTURE.md. tools/ros/colcon.sh build\|test\|tidy drives colcon and run-clang-tidy in the dev container; make test-engine, test-cockpit, test-ros, tidy, test-layout; make test now runs Python, C++ (ctest) and TS tests. One version everywhere: release-please extra-files update the engine, both cockpit package.json, the four package.xml and the bridge module with version.txt. Tooling: mypy hook carries the engine's dependencies at the lock versions, mypy_path resolves the packages, cmake-format hooks get PyYAML (they crashed without a CMake file to show it), yamllint skips pnpm-lock.yaml, .clang-tidy header filter targets the fieldproof_* packages, ament test/ directories get the test rule exceptions, ROS Python packages carry a pytest.ini (container pytest 7.4 rejects the root pytest 9 settings). CI: jobs Engine tests (uv cache) and Cockpit tests (pnpm cache, pnpm build), make test-layout in the tooling job, make test-ros and make tidy at the end of the image job, whose path filter now also fires on sim/, bridge/, robots/, tools/ros/, .clang-tidy. Docs: docs/setup/workspaces.md, docs/reference/cli.md, README map, tooling/ci pages. Tests: layout 79, workflows 39, tooling 30, docs 38; colcon 9 (sim) + 3 (bridge) + 2 (robots) tests, clang-tidy clean; CI green on #18 after two runner-only fixes (the CLI prints its version without Rich styling because FORCE_COLOR split --version across escape codes in the help test, and the image job chowns the ROS workspaces to uid 1000 because the runner's checkout belongs to uid 1001). Follow-up fix(release) #20: the first release pull request (#19, 0.3.0) was red because uv.lock recorded the engine's static version; the engine version is now dynamic (hatchling reads version.txt), the engine left the release-please extra-files, and the layout test checks that uv.lock carries no project version. Hand-off: (1) merge #18; (2) a Dependabot bump of an engine dependency or of TypeScript must also bump the mypy or eslint hook in .pre-commit-config.yaml (make test-layout says which), so expect to amend those PRs; (3) React stays at 18 and TypeScript at 5.x as the standards say, while React 19.3 and TypeScript 7 are current: P3-T02 should decide and, if it moves, edit the standards table; (4) make test-ros leaves the dev container running (make down); (5) the dev image has no xacro package, which P1-T01 needs for the URDF, so that task adds ros-jazzy-xacro to docker/dev/Dockerfile (and sim, if SDF generation happens at build time); (6) this session worked in a git worktree under an ignored directory (open question 6). 2026-10-16: #18 merged; follow-up #20 merged; v0.3.0 released by the release pull request #19. P1-T01 found that the robots pytest test had never been registered (see its notes). |
Phase 1 — Vertical slice¶
| Task | Title | State | PR | Notes |
|---|---|---|---|---|
| P1-T01 | Reference UGV model | done | #21 | 2026-10-16: robots/fieldproof_robots/models/ugv_ref/ (kept under models/ as the P0-T07 skeleton laid out, rather than the backlog's fieldproof_robots/ugv_ref/): xacro URDF (urdf/ugv_ref.urdf.xacro with inertials.xacro and sensors.xacro macros; chassis with sloped nose, two drive wheels, two caster spheres, base_footprint, 2D GPU lidar 10 Hz, IMU 100 Hz, RGB-D 640×480 15 Hz with camera_optical_link, GNSS 5 Hz behind gps:=true, gaussian noise on every sensor, gz_frame_id per sensor, namespace and frame_prefix arguments), Gazebo DiffDrive (odometry 50 Hz, velocity and acceleration limits) and JointStatePublisher systems, robot.yaml manifest draft (fieldproof.dev/v0alpha1: frames, drive, interfaces, sensors with topics, types, rates, frames, noise), model.config, meshes/chassis.obj + .mtl with LICENSE.txt (original work, Apache-2.0). scripts/generate_model.py renders xacro → URDF → SDF (gz sdf -p) at build time from the CMake function fieldproof_add_robot_model, installed next to the sources so model://ugv_ref resolves. launch/spawn_ugv_ref.launch.py (xacro at launch time with name, namespace, frame_prefix, world, pose, gps, use_sim_time; robot_state_publisher + ros_gz_sim create -topic robot_description). Needed around it: ros-jazzy-xacro in the sim image (dev inherits); fieldproof_empty.sdf now loads the Sensors (ogre2), Imu and NavSat systems and carries spherical coordinates; tools/ros/colcon.sh sources earlier workspaces as underlays (robots on sim); robots/pytest.ini moved to the workspace root because ament_add_pytest_test probes pytest from build/ where the root pyproject.toml (minversion 8) made the container's pytest 7.4 fail, so P0-T07's robots pytest test was silently never registered; root mypy gets namespace_packages = false (the launch/ directory of the bridge package on mypy_path shadowed the ROS launch module) and overrides for xacro, gz.*, yaml, tf2_msgs, launch_testing_ros. Tests: check_urdf and gz sdf --check as ctests, test/test_ugv_ref.py (16 pytest checks: files, mesh URIs and licence, manifest against SDF and URDF, noise, xacro defaults, gps:=false/namespace:=ugv1 rendering) and test/test_ugv_ref_launch.py (launch_testing: headless server with the empty world, spawn through the launch file, every output at its declared rate in sim time ±10 % with the declared frame, drive 1 m on /cmd_vel and compare the odometry delta with the ground truth within 10 cm, /tf_static frames, create exit 0, no unresolved resource). Definition of done verified in the dev container: tools/ros/colcon.sh test green (sim 9, bridge 3, robots 31 results, the launch test in 11 s on the Radeon 860M through /dev/dri); all hooks green. Docs: docs/reference/robots.md, workspaces and containers pages, READMEs. Hand-off: (1) merge the PR; (2) no real robot URDF was supplied (open question 2), the reference model stands in and a real description takes the same xacro → SDF path; (3) for P1-T03: Harmonic's NavSat fix carries no header frame_id (gz_header_frame_id: false in the manifest) and image/camera_info are labelled camera_link (x forward) while the ROS contract wants camera_optical_link, so the bridge relabels; (4) for P1-T02: make up still runs Gazebo's own empty.sdf, which has no sensor systems, and the sim container has no Fieldproof worlds or models, so the world task should decide how the sim service gets them (bind mount of the install spaces or an image layer) and set FIELDPROOF_WORLD; (5) the spawn prints one sdformat warning per sensor (gz_frame_id is not in the SDF 1.11 spec, copied through) and robot_state_publisher warns about the root link's inertia, both documented as benign in docs/reference/robots.md; (6) the dev container prints Failed to create /home/fieldproof/.cache/mesa_shader_cache (Permission denied) because the dev-cache volume is root-owned, harmless but worth a chown in the image entrypoint later. 2026-10-16: #21 merged; the maintainer decided that the reference vehicles up to 1.0.0 are realistic synthetic examples and no real URDF will be supplied (open question 2, vehicle part). |
| P1-T02 | Reference world and asset pipeline | done | #23 | 2026-10-16: sim/fieldproof_worlds/worlds/depot.sdf: a roofless 32 m × 20 m depot building (racking hall with three rows of four pallet_rack_section and 2.8 m aisles, 2.5 m corridor, office, workshop, loading bay with a 4 m roller door, 1.2 to 4 m door openings) and a fenced 32 m × 22 m yard with a 10 % ramp, 0.8 m plateau and 16 % ramp down with kerbs, a jersey-barrier chicane at the gate, cones, drums, pallets and dynamic cardboard boxes; seven named world frames (spawn_dock, spawn_hall, goal_*) for scenarios; 1 ms step, RTF 1.0, the same systems as the empty world. Asset pipeline: assets/fuel.lock (fieldproof.dev/v0alpha1 FuelLock: owner, name, version, directory, SPDX licence, size, SHA-256, used_by) with four OpenRobotics models (CC0/CC-BY-4.0, box or cylinder collisions), tools/assets/fetch_fuel.py (fetch idempotent and offline once cached, check, add; refuses a digest mismatch and archive entries escaping the model directory), make assets; the worlds package fetches at build time and installs the Fuel models with its own primitive models/pallet and models/traffic_cone under share/fieldproof_worlds/models (env hook); assets/README.md convention, assets/fuel/ ignored. Stack (P1-T01 hand-off 4): the compose sim service bind-mounts the worlds, package models, Fuel models and robot models read-only under /fieldproof/* with GZ_SIM_RESOURCE_PATH, defaults to FIELDPROOF_WORLD=depot.sdf, and make up depends on make assets. Map: scripts/world_map.py renders docs/reference/worlds/depot-map.svg from the collision geometry (deterministic, checked for currency). Tooling: tools/ros/colcon.sh run, root pytest minversion lowered to 7.4 so the container pytest runs tests/, CI image job caches the Fuel archives, chowns assets, runs make test-worlds with FIELDPROOF_WORLD_RTF_MIN=0 (software rendering), tooling job runs make test-assets; changed-areas.sh adds assets/, tools/assets/, tests/worlds/; image tests expect the depot. Finding: in gz-physics a mesh collision against the ground plane costs a large share of every step (the Fuel Euro pallet and Construction Cone halved the RTF, dynamic boxes on mesh pallets took it to 0.32), so no Fieldproof world or installed model has a mesh collision and test_worlds.py enforces it. Tests: tests/worlds/test_depot.py (load under 10 s with every model, UGV spawned at spawn_dock with lidar, IMU, camera and odometry publishing, uncapped RTF ≥ 1.0 over 30 sim s via set_physics, no unresolved resource), tests/assets/test_assets.sh (19 checks against a file:// Fuel stand-in), test_worlds.py (10 pytest checks) plus gz sdf --check on worlds and models. Definition of done verified on the Radeon 860M: see the PR body (load about 2 s, RTF about 2.6 with the UGV and all sensors). Docs: docs/reference/worlds.md, containers, workspaces, READMEs. Hand-off: (1) merge the PR; (2) make up now needs network once (make assets) and starts the depot; FIELDPROOF_WORLD=fieldproof_empty.sdf make up for the empty world; (3) for P1-T03: the depot is the world of its definition of done, spawn at spawn_dock (x:=10 y:=3 yaw:=-1.5708); the world frames are the pose source P1-T05 should read for scenarios; (4) CI's world test prints the RTF it reached under llvmpipe but does not gate on it, by design; (5) the host disk was at 98 % during the session (Docker build cache 52 GB, 43 GB reclaimable with docker builder prune), worth a look before the next image build; (6) the stale P0-T04 worktree under the ignored worktrees directory can be removed (git worktree remove); (7) the first CI run of #23 failed in P1-T01's ugv_ref_launch test on the GNSS rate (5.55 Hz measured against 5 Hz under software rendering, outside the 10 % tolerance), unrelated to this task and filed as issue #24 (flaky); the re-run failed the same way on the lidar (11.19 Hz against 10 Hz): both are 9/8 of the declared rate, so the first message of a sensor is published at creation, off its update grid, and a nine-interval measurement counts one near-zero interval. Fixed in this PR (the measurement discards the first sample), since the required check blocked the task. 2026-10-16: #23 merged; v0.4.0 released by the release pull request #22. |
| P1-T03 | Bridge configuration generator and contract tests | done | #25 | 2026-10-17 (merged the same day, v0.5.0): bridge/fieldproof_bridge: manifest.py reads and validates the manifest's interfaces (apiVersion, frames, the four drive interfaces, every sensor output, optional qos blocks); generator.py plans and writes a bundle per vehicle (fieldproof bridge gen robot.yaml [-o DIR] [-n NS] [--frame-prefix P] [--no-clock] [--no-robot-state-publisher] [--without SENSOR], also ros2 run fieldproof_bridge bridge_gen): ros_gz_bridge.yaml (parameter_bridge entries with fully qualified names on both sides, /clock), ros_gz_bridge_<frame>.yaml (one more instance per relabelled frame through override_frame_id, found in ros_gz 1.0.24, so the camera images and camera_info move to camera_optical_link in C++), params.yaml (rclcpp qos_overrides per topic, adapter settings), bridge.yaml, contract.yaml (topic, type, role, frame, rate, QoS, provider) and a bridge.launch.py. Adapters (rclpy): imu_adapter (covariance diagonals from the noise figures), navsat_adapter (frame, DIAGONAL_KNOWN covariance, STATUS_FIX, GPS service on the fix Gazebo publishes without a frame), throttle (joint states from the 1 kHz physics rate to 50 Hz by header stamps), fed through hidden <ns>/_bridge/<topic> topics so ros2 topic list shows exactly the contract. contract.py (contract_check <bundle>): graph (endpoint and type), qos (reliability, durability), frame, rate in sim time with the first stamp discarded, no extra topics. launch/bridge.launch.py (manifest:= generates at launch, bundle:= reuses; one parameter_bridge per configuration, the adapters, robot_state_publisher on the manifest's xacro with the spawn's arguments). Manifest additions in ugv_ref/robot.yaml: drive.tf (Pose_V → global /tf), joint_states.gz_rate: physics + rate_hz: 50, qos per output (the camera outputs reliable with depth 5: under best effort the 1 to 5 MB messages lost UDP fragments, 13 Hz of 15 on the cloud locally and 9 Hz of 15 on the images on the CI runner); the spawn launch gains robot_state_publisher:=false and uses create -string. Engine: fieldproof bridge gen runs the same generator through a uv path dependency on bridge/fieldproof_bridge, which gains a pyproject.toml (dynamic version from __init__.py, so no re-lock on release; no requires-python, colcon's setup.py probe cannot parse it; no PEP 639 license, setuptools 68 rejects it; setup.py keeps packages, data_files and tests_require); pyyaml 6.0.3 in the engine and the mypy hook; release-please no longer tracks setup.py; the layout test checks the path dependency; mypy overrides for rosidl_runtime_py and for subclassing rclpy's Node. Workspace order sim robots bridge (colcon.sh, Makefile, CI). Tests: bridge pytest 41 (generator 28, adapters 9, the launch contract test in the depot at spawn_dock: 41 contract findings green, IMU and fix fields, ROS cmd_vel drives 0.5 m, create exit 0; 10 s with software rendering), robots 31, sim 23; engine 9, layout 80, docs 38, workflows 39; all hooks green. Docs: docs/reference/bridge.md, CLI, robots, workspaces, READMEs. Hand-off: (1) merge #25; (2) for P1-T04: the bridge container runs ros2 launch fieldproof_bridge bridge.launch.py manifest:=... namespace:=... (or the engine pre-generates with fieldproof bridge gen and mounts the bundle; its paths are absolute, so generate where it is launched), and the UGV must then be spawned with robot_state_publisher:=false; /clock is bridged once per bridge, so P2-T01 decides the multi-vehicle clock; (3) the contract QoS follows the manifest: everything reliable (camera outputs with a queue of 5), which Nav2 and SLAM Toolbox subscribers accept; a best-effort output remains declarable but its rate check then also measures transport loss; (4) pre-commit run --all-files checks only tracked files, so stage new files before trusting it (this session committed late because of that); (5) the P1-T02 note about disk space still applies. |
| P1-T04 | Robot runtime container and vehicle manifest (×2) | done | #27, #29 | 2026-10-17, session 1 (#27, merged, v0.6.0): manifest schema v0 as Pydantic models in the engine (fieldproof.manifest): the model sections of P1-T01 (metadata, description, frames, dimensions, drive, sensors, unknown keys rejected) plus runtime (tier T1/T2/T3 of ADR-0007, image, build with base, ros_distro, rosdistro_commit, repos[] at 40-digit commit pins, sources[], apt, rosdep_skip_keys, colcon_args; entrypoint as launch or command; params; drivers[] the driver-swap map whose provides must be interfaces of the manifest; ready with lifecycle, topic, service, action and node conditions; resources cpus/memory/pids; env). extends: a manifest inherits another's sections (deep merge, child wins, parent description paths rebased), resolved by the bridge's reader (fieldproof_bridge.manifest.read_manifest_data) so bridge.launch.py manifest:=examples/vehicles/ugv_nav2/robot.yaml yields the reference UGV's contract. fieldproof manifest validate and fieldproof manifest schema; engine/schemas/robot.schema.json (generated, make manifest-schema, checked current by a test) feeds a new check-jsonschema hook on every robot.yaml. Dockerfile generator (fieldproof.vehicle, fieldproof vehicle dockerfile, build and run): two stages on fieldproof-base-ros2 (build tools; rosdep source list and ROSDISTRO_INDEX_URL pinned to a ros/rosdistro commit of 2026-10-03; vcs import of vehicle.repos; local sources through a vehicle-source build context; rosdep install --simulate lists the apt packages, fieldproof-apt-install fetches them, non-apt keys fail the build; colcon build --merge-install into /opt/vehicle; runtime stage with only the exec dependencies, the install space, an entry point sourcing both setup files, the resolved manifest at /opt/fieldproof/robot.yaml, OCI and io.fieldproof.* labels, non-root, tini, the entrypoint as CMD); hadolint-clean. vehicle run composes docker run with the manifest's budget and environment on the fieldproof network. Example vehicle examples/vehicles/ugv_nav2/: robot.yaml extends the reference UGV and adds the runtime (image fieldproof-vehicle-ugv-nav2:local built from src, entrypoint bringup.launch.py use_sim_time:=true drivers:=false, six drivers in the swap map covering every interface, readiness on Nav2's and SLAM Toolbox's lifecycle nodes plus /map and /navigate_to_pose, 2 CPUs / 2 GiB); src/ugv_nav2_bringup (ament_cmake): bringup.launch.py (field defaults use_sim_time:=false drivers:=true), navigation.launch.py (the Nav2 servers under the lifecycle manager, the package's own launch because nav2_bringup pulls Gazebo, the TurtleBot models and rviz into the image: 5.0 GB versus 3.9 GB), drivers.launch.py (ros2_control base, robot_state_publisher, rplidar, microstrain IMU, realsense, nmea GNSS, parameters in config/drivers.yaml), config/nav2.yaml and config/slam.yaml from the Jazzy defaults adapted to the platform; driver packages are exec_depend condition="$UGV_TARGET == field", so the simulation image installs none. Tests: engine 67 pytest (manifest 44, vehicle 14 new), bridge unit 31 (3 new for extends), tests/vehicles/test_vehicles.sh (make test-vehicles, also in CI's image job): manifests, schema, generated Dockerfile and entry point lint, image build, contents, labels, and the vehicle on the fieldproof network seen from a second container; layout 80, docs 38, workflows 39, tooling 30. Verified locally: the image builds in about 8 min after the base image with empty apt caches (dependency install 4 min, export 1.5 min) and 3 min with the BuildKit apt cache warm; DDS discovery works across containers on the compose bridge network without --ipc=host (SLAM Toolbox active after 5 s, Nav2 servers inactive and answering); make test-vehicles 38 passed. Hand-off: (1) merge #27; (2) session 2 delivers the rest of the definition of done: the readiness probe (runtime.ready evaluated inside the container, as an image HEALTHCHECK and fieldproof vehicle wait), the integration test under tests/integration/ that starts sim + bridge + vehicle in the depot, waits for Nav2 active, sends NavigateToPose to a world frame and asserts arrival (also the CI step), and whatever the bridge needs to run as its own container (today it runs from the dev container: tools/ros/colcon.sh run ros2 launch fieldproof_bridge bridge.launch.py manifest:=examples/vehicles/ugv_nav2/robot.yaml, with the UGV spawned robot_state_publisher:=false); (3) Nav2 cannot reach active without the bridge: the controller server's costmap blocks in activation until odom -> base_link exists, which is why the graph test checks inactive and the readiness conditions need the whole stack; (4) the runtime image is 3.9 GB because the Jazzy Nav2 debs declare dev packages (boost headers, cmake, three LLVM versions, VTK) as dependencies, not because of the build stage; slimming is a follow-up, not this task's; (5) fieldproof vehicle build needs network for rosdep (raw.githubusercontent.com) and the apt snapshots; CI's image job runs it after the base image build, expect about 10 min more there; (6) the P1-T02 disk note still applies (the vehicle image adds 3.9 GB locally). Session 2 (#29), 2026-10-17: readiness probe fieldproof-vehicle-ready (engine/src/fieldproof/vehicle_ready.py, standalone: standard library, PyYAML and rclpy from ros-base, copied unchanged into every vehicle image at /usr/local/bin): evaluates runtime.ready inside the container (lifecycle nodes through their get_state services, asked at once, unavailable and unresponsive as pending details, the latter being a Nav2 server blocked in its activation; topic publishers with type; services; action servers through their _action/send_goal service; nodes), polls until every condition holds (exit 0) or the timeout passes (exit 1), prints a line whenever the pending set changes, --json, --quiet; with runtime.ready the generated Dockerfile declares it as the image's HEALTHCHECK (15 s interval, 60 s start period, 3 retries, a 10 s wait per check), so docker inspect reports healthy and compose's --wait can gate on it. fieldproof vehicle wait MANIFEST [--name] [--timeout] [--interval] [--quiet] [--dry-run] runs the probe through docker exec with the manifest's ready.timeout_s. Bridge image docker/bridge/Dockerfile (compose service bridge, profile bridge; in make images, make down, make prune, the bake cache, images.yml and its scan matrix): the sim image plus fieldproof_bridge and fieldproof_robots built into /opt/fieldproof_bridge (colcon in the first stage only), default command fieldproof-bridge = bridge.launch.py manifest:=$FIELDPROOF_MANIFEST (compose: FIELDPROOF_VEHICLE_MANIFEST, default the example vehicle, the repository mounted read-only at /workspace so extends and the description resolve); the same image spawns the UGV as a one-shot compose run. Integration test tests/integration/test_ugv_nav2.py (make test-integration: pytest on the host from the engine's environment driving Docker, the last step of CI's image job): sim with the depot → spawn at spawn_dock → bridge → fieldproof vehicle run → fieldproof vehicle wait → Docker healthy → ros2 action send_goal /navigate_to_pose 4 m ahead in map from inside the vehicle container → SUCCEEDED and the ground truth from gz topic -e /world/depot/pose/info within 0.5 m of the goal's world point (the map frame starts at the spawn pose) → no node died; the containers are stopped afterwards (not the dev container), logs printed on failure, FIELDPROOF_IT_KEEP=1 keeps them. Docs: manifest.md (probe, vehicle wait, the integration test), cli.md, bridge.md (the bridge container), containers.md (image, profile), ci.md, tests/README.md. Tests: engine 86 pytest (19 new for the probe, 4 for the generator and CLI), tests/vehicles +4 checks (probe, HEALTHCHECK, vehicle wait exit 1 and unhealthy without the bridge), tests/images +3 (bridge image), workflows 39, layout 80, docs 38, tooling 30. Verified locally: make test-integration 3 passed in 27 s: every ready condition held 5.5 s after the vehicle started, the goal SUCCEEDED, ugv_ref moved from (10.00, 3.00) to (10.01, −0.79) in the world, 0.21 m from the goal, 3.79 m travelled. Hand-off: (1) merge #29; (2) the Images workflow will create the GHCR package fieldproof-bridge on the merge: set it public like the others (open question 7); (3) CI's image job grows by the bridge image build (about a minute, cached afterwards) and the integration test (under a minute here, a few minutes with software rendering); (4) for P1-T05: the orchestrator can gate on the vehicle container's health (docker inspect, the Docker SDK's health attribute) or run the probe through exec; the bridge container and the spawn are plain docker runs of fieldproof-bridge with FIELDPROOF_MANIFEST and the vehicle directory mounted; the three containers share ROS_DOMAIN_ID and GZ_PARTITION=fieldproof on the fieldproof network; (5) the bridge image adds 3.3 GB locally (mostly the shared sim layers, so little extra disk). 2026-10-17: #29 merged; v0.7.0 released by the release pull request #30. |
| P1-T05 | Scenario schema v0 and runner v0 | done | #31, #37 | 2026-10-17: scenario schema v0 as Pydantic models in the engine (fieldproof.scenario): apiVersion: fieldproof.dev/v0alpha1 (the manifest's and the Fuel lock's convention, rather than the architecture's fieldproof/v1alpha1 placeholder, now updated), kind: Scenario, metadata, world (a file of the workspace's worlds directory whose <frame name> elements are the poses), vehicles[] (name, manifest relative to the scenario, namespace, spawn as a frame or x/y/z/yaw, image and env overrides), time (ADR-0006: mode sim or realtime, rtf, tolerance, timeout_s), a timeline[] stub with one action (navigate_to_pose with a goal in the world, at, timeout_s) and an assertions[] stub (name and type among the P1-T06 primitives, parameters free), outputs (directory, record, topics, logs); unknown keys rejected, durations as numbers or 1m30s. fieldproof validate resolves scenarios against the workspace (world file, frames, manifests with a runtime, goals) without Docker; fieldproof schema prints the JSON Schema, engine/schemas/scenario.schema.json (make scenario-schema, checked current by a test) feeds a check-jsonschema hook on examples/scenarios/. Workspace configuration fieldproof.toml (fieldproof.config): found by walking up from the scenario's directory, then the current one; [runs] directory, [docker] network, image_prefix, image_tag, render, ros_domain_id, [stack] the simulator's mount sources and the Fuel lock, [timeouts] world_s, spawn_s, bridge_s, teardown_s; the compose stack's FIELDPROOF_IMAGE_PREFIX/FIELDPROOF_IMAGE_TAG/FIELDPROOF_RENDER/ROS_DOMAIN_ID override the file and the command line overrides both; the repository's file lists the defaults. Runner v0 (fieldproof.runner on the fieldproof.backend protocol; fieldproof.docker_backend on the Docker SDK 7.2.0 with types-docker for mypy): plan() resolves a scenario into container specifications (the simulator with the compose stack's four mounts and GPU or software rendering, a one-shot spawn and a bridge per vehicle on the bridge image with the workspace at /workspace, the vehicle with its manifest's image, environment and budget), the run id <scenario-hash>-<timestamp>-<short-uuid> and the run directory; Run executes the phases preparing (images, Fuel models, network) → sim (gz service -l lists the world; time.rtf through set_physics) → spawn → bridges (the odometry topic appears) → vehicles (the image's readiness probe through exec, its lines shown) → running (the timeline in order within time.timeout_s; navigate_to_pose through ros2 action send_goal inside the vehicle with the goal expressed in the map frame that starts at the spawn pose; assertions recorded as skipped) → teardown (reverse order, logs saved, then a sweep by the io.fieldproof.run label) → finished, whatever ends the run (a failed gate, the budget, Ctrl-C/SIGTERM with a second signal ignored during the teardown, a daemon error, an unexpected exception). runs/<run_id>/ holds scenario.yaml, run.json (rewritten at every phase), engine.log (JSON lines) and logs/*.log; statuses passed/failed/timeout/error/interrupted with exit codes 0/1/1/1/130. fieldproof run with --config, --runs-dir, --run-id, --render, --domain-id, --network, --keep, --quiet, --dry-run (the plan as JSON); make run. Example examples/scenarios/ugv_nav_to_goal.yaml (the example vehicle at spawn_dock, a goal 4 m ahead, one reach_pose assertion declared for P1-T06). Readiness fix found by the pull request's first CI run: the P1-T04 integration test failed there (and on main after #29 merged) because Nav2 aborted the goal sent right after readiness, before SLAM Toolbox had published the map → odom transform (Timed out waiting for transform from base_link to map); the readiness probe gains a tf condition (tf: map, child: base_link: a chain of transforms seen on /tf or /tf_static links the frames, 0.5 s of listening per evaluation), the manifest schema a TfCondition, and the example vehicle's ready list the transform, so both tests send the goal only once Nav2 can plan. Tests: engine 208 pytest (122 new: schema and errors, configuration, the plan and the state machine against a fake backend and clock through every ending, the commands, the Docker backend against a stub client, the tf condition), 94 % line coverage with an 85 % gate in make test-engine (pytest-cov); tests/vehicles 41 (the probe reports 10 conditions pending); tests/integration/test_run.py (fieldproof run of the example against the real stack: exit 0, run directory complete, nothing left; a second run interrupted with SIGINT once its simulator exists: exit 130, nothing left); tooling 30, ci 39, docs 38, layout 80. Verified locally: make test-integration 5 passed in 72 s with the rebuilt vehicle image, the scenario run 29 s end to end (vehicle ready 6 s after start with the transform, goal SUCCEEDED), docker ps -a --filter label=io.fieldproof.run empty afterwards; in CI (software rendering) the scenario run took 46 s and the interrupted run exited 130 with nothing left. Docs: docs/reference/scenarios.md, CLI, index, tooling, CI, containers, manifest, bridge pages, READMEs. Hand-off: (1) merge #31; (2) fieldproof run and the integration tests need make images, make vehicle-image and make assets (a worktree has no assets/fuel); (3) once in four local runs Nav2's lifecycle manager stalled after controller_server lost its change_state response, so the run ended failed after the 180 s readiness gate with a clean teardown: issue #32 (flaky), which can also hit either integration test in CI (distinct from the transform race above, which is fixed); (4) CI's image job grows by two more stack bring-ups (about a minute here, a few minutes with software rendering) and the vehicle image must be rebuilt locally (make vehicle-image) for the new probe; (5) for P1-T06: the assertion models live in fieldproof.scenario.Assertion (extra="allow" today), the runner records them in Run.run_timeline, a recorder container belongs in Plan.containers() with its own role and label, the ground truth comes from gz topic -e /world/<name>/pose/info through backend.exec on the simulator (as tests/integration/test_ugv_nav2.py does), and outputs.record/topics are already parsed; (6) for P2-T01: the schema allows several vehicles and the runner starts them in sequence, but the example stack expects the root namespace and /clock is bridged per bridge, so one vehicle per run until then; runs share the configured network; (7) time.mode: realtime validates and is refused by the runner until P2-T04; (8) the manifest CLI still prints errors with Rich markup on, so a bracketed name such as drivers[lidar] is swallowed there (the scenario commands escape it). 2026-10-17: #31 merged. The CI run on main then failed twice in three attempts in the scenario integration test (the goal aborted while the P1-T04 test passed: the runner sends the goal the instant the probe reports ready, the P1-T04 test waits for Docker's health check first) and once in the vehicle test's --show-args check (#36). Follow-up #37 (merged): on a CPU-starved host the controller could not transform the goal (map → odom stamped only 0.2 s ahead, the costmaps' default 0.3 s tolerance) and the behaviour tree gave up on follow_path after Nav2's 20 ms acknowledgement timeout; the example vehicle now sets transform_tolerance: 0.5 on both costmaps, transform_timeout: 0.5 in slam.yaml and default_server_timeout: 1000 in bt_navigator (eight of eight loaded software-rendering runs pass, one in three aborted before), and tests/integration/test_run.py prints the run's logs on failure (#35 closed, #34 folded in). v0.8.0 released by the release pull request #33. The --show-args check now retries once and keeps its output (#36). #32, the rare lifecycle stall, stays open as flaky. The stale P0-T04 worktree is removed. |
| P1-T06 | Assertions v0, recording and reports | done | #39 | 2026-10-18: assertions v0 as typed models in the scenario schema (fieldproof.scenario, a discriminated union on type, unknown keys rejected): reach_pose (vehicle, target placement, tolerance_m, yaw_tolerance_rad, within_s), within_time (action = a timeline entry's name, max_s), topic_rate (topic, vehicle, min_hz, max_hz, window_s, after_s, message_type), no_collision (vehicle, topic, ignore), topic_value (topic, vehicle, field, condition, mode always/eventually, after_s, within_s); the condition is a whitelisted Python expression over value (fieldproof.expression, compiled at validation time); references are checked (vehicle names, timeline entry labels). Streaming evaluator (fieldproof.assertions): the runner resolves every assertion (PlannedAssertion: topics under the namespace, the target in the world, the model), each checker resolves once as soon as the stream decides it (pass at the first pose within tolerance, fail at the first contact, at a deadline, at the first slow window or violating value), the rest is settled when the timeline completes, pending ones are skipped when the run ends early; evidence (deciding sample, extremes, counts, the final pose) on every verdict. Recorder container (role recorder, phase recorder between bridges and vehicles): the bridge image running engine/src/fieldproof/observer.py bind-mounted read-only from the engine (no image rebuild) as the host's uid:gid (HOME=/tmp, ROS_LOG_DIR), the run directory mounted read-write at /fieldproof/run; the observer reads observe.json, subscribes to the ROS topics the assertions name (type discovered from the graph, best-effort/volatile QoS) and to the Gazebo ground truth through the gz.transport13 Python bindings (/world/<w>/pose/info at 10 Hz per model, the vehicles' contact sensors), streams JSON lines to samples.jsonl (the runner tails it every 0.5 s while the action command runs in a thread), runs ros2 bag record -s mcap with the allow-list (--include-hidden-topics when listed; -a when empty) into recording/, and publishes the side channels /fieldproof/events (every engine.log line), /fieldproof/assertions (every verdict, from assertions.jsonl) and /fieldproof/metrics (1 Hz: sim time, RTF, rates, poses) as std_msgs/String JSON; timeouts.recorder_s (60 s) gates its ready line. Rates are measured in simulation time (software rendering in CI runs below real time); deadlines are on the wall clock like the timeline. Ground truth for no_collision: a <sensor type="contact"> on the reference UGV's chassis collision (base_link_collision, topic <ns>/contacts, 50 Hz), outside the manifest's interface (nothing bridged; robots tests exempt contact sensors and check it) — the bridge image must be rebuilt (make images). Reports: report.json and report.md written at the end of every run (fieldproof.report: verdict, exit code, durations incl. simulation time covered, assertions with evidence, timeline, phases, artefacts with the MCAP first); fieldproof report RUN (--format md or json, --write) renders them again from a run directory, run.json or a run id. Exit codes: a failed assertion ends the run failed (1) once the timeline completes, the reason naming the first failed assertion (assertion arrived_in_time failed: ... (and 1 more)); fieldproof run prints the verdict (PASS/FAIL/...), one line per assertion and the report path. Example ugv_nav_to_goal.yaml now declares the five primitives and records 11 topics; ugv_nav_fails_on_time.yaml fails on purpose (within_time 1 s). Also: ContainerSpec.user, RunRecord.vehicles/observation, RunLog thread-safe, Run(executor=...) injectable, entry_label(). Tests: engine 320 pytest (112 new: expressions, every primitive on synthetic streams, the evaluator on the recorded stream of a real run in engine/tests/fixtures/ (five pass; with a 1 s within_time exactly that one fails), the observer's pure parts and loop on fake ROS/Gazebo adapters, the reports, the runner's recorder phase and verdicts on the fake backend, the CLI), 95 % line coverage; robots 17 pytest + launch test (contact sensor); tests/integration/test_run.py: the example passes with per-assertion evidence, the MCAP read back with the mcap library (13 channels incl. the side channels, ROS 2 schemas on every channel), the reports; the failing example exits 1 naming arrived_in_time. Verified locally (GPU): fieldproof run of the example 35 s end to end, 5/5 assertions passed (reached_goal 0.49 m at 9.2 s, lidar 9.2–9.6 Hz, speed ≤ 0.56 m/s), MCAP 7.6 MB / 28 649 messages; make test-integration 6 tests; make test-ros sim+robots green (with assets/fuel copied into the worktree); make test-docs 38, make test-layout 80, pre-commit clean. Docs: new docs/reference/assertions.md; scenarios, CLI, robots, containers, CI, index, examples README. The pull request's first CI run found three things the GPU host did not: with software rendering on the two-core runner the lidar arrives at about 3 Hz of simulation time (the example's topic_rate now asks for 2 Hz and the docs say why), rosbag2 fails with Permission denied when the recorder's working directory is the image user's home and the uids differ (the recorder now works in the run directory, ContainerSpec.working_dir), Rich colours the run's output on the runner (the integration test strips them), and under load the last verdicts published on /fieldproof/assertions were not all in the MCAP (the observer now gives the recorder a second after the final relay before SIGINT). A local rerun of the failing example saw Nav2 abort the goal once (drive_ahead: goal ABORTED, the #32/#37 family), which fails that test the same way it would fail the passing one. The CI run of the final commit is green (make test-integration 6 passed in about 2.5 min on the runner, lidar at 4.6 to 4.8 Hz, five of five verdicts in the MCAP). Hand-off: (1) merge the PR; (2) rebuild the bridge image locally (make images) for the contact sensor, the vehicle image is unchanged; (3) open runs/<id>/recording/recording_0.mcap in Foxglove Studio to confirm the DoD's last item by eye (the test confirms a valid MCAP with ROS 2 schemas); (4) a hidden topic listed in outputs.topics (/navigate_to_pose/_action/status) is recorded by rosbag2 without a message definition, so it was dropped from the example; (5) for P1-T07: the integration suite now takes about 2 min locally (three stack bring-ups), the JUnit output and the MCAP/report artefacts are what the workflow uploads, fieldproof report --format json has the verdict and exit_code; (6) for P2-T05: on_fail: abort, KPI collectors, container resource metrics on /fieldproof/metrics; for P2-T04: sim-time deadlines. 2026-10-18: #39 merged as ed03651; CI on main green (run 39105292762, the image job with the three stack bring-ups included); release-please opened the 0.9.0 release PR (#40). The P1-T06 worktree is removed. |
| P1-T07 | First scenario in CI and M1 demo | done | #42 | 2026-10-18: JUnit with every run: write_reports renders junit.xml next to report.json and report.md (one testsuite named after the scenario with the run's facts as properties; one testcase per assertion under <scenario>.assertions (a failure with reason and evidence, skipped for skipped or pending), per timeline entry under <scenario>.timeline (a failure unless ok) and run for the run itself (a failure when failed, an error when timed out, errored or interrupted); the suite's counts are what a reporter sums); fieldproof report --format junit renders the same. Scenario workflow .github/workflows/scenario-tests.yml (not a required check): on every push to main, nightly (20 4 * * * UTC), on workflow_dispatch (inputs image-tag, default main, and scenario) and on pull requests touching the workflow, the wrapper, examples/, engine/, fieldproof.toml or the Makefile; on ubuntu-24.04 it frees disk, caches the Fuel archives, pulls the published base-ros2, sim and bridge images at FIELDPROOF_IMAGE_TAG, runs make assets and make vehicle-image (the example vehicle on the published base), fieldproof validate, then tools/ci/scenario-run.sh --runs-dir runs --render software, uploads runs/ (every attempt: MCAP, reports, JUnit, logs; artefact scenario-run-<run>-<attempt>, 14 days) and the final junit.xml (scenario-junit-…) whatever the outcome, and fails if a container with the io.fieldproof.run label is left. Flaky-run policy in tools/ci/scenario-run.sh: one retry (--retries N, 0 disables) unless the attempt exited 130 or 2; ::warning:: on the retry, a Flaky scenario annotation, a Flaky line in the job summary and the step output flaky=true when the retry passes; the last attempt's exit code, junit.xml, report.md and report.json (copied to the top of runs/) and the outputs run_id, run_dir, verdict, exit_code, attempts are the step's; the summary carries the final attempt's report. M1 demo examples/demo_m1.sh (make demo, DEMO_FLAGS=): prerequisites, pull the published images (--local for make images, --no-pull), make assets, the vehicle image when missing (--rebuild-vehicle), fieldproof run with the run id m1-demo-<timestamp> (--scenario FILE), then the verdict and assertions, the report and MCAP paths and how to open it in Foxglove Studio; --dry-run prints the commands. Quick start in the README and docs/index.md (clone, tools/host-setup.sh, examples/demo_m1.sh), a Scenario tests badge, the status line moved to Phase 1. Tests: engine 324 pytest (JUnit: counts, a failure with evidence, skipped and pending, a run without verdict, control characters dropped; --format junit, --write writes the three files), 95 % coverage; tests/ci 57 (the workflow's rules; the wrapper against a stub fieldproof: pass, flaky, two failures, 130 and 2 not retried, --retries 0/2, --run-id, outputs, summary, copied files; the demo's usage and dry runs with FIELDPROOF_IMAGE_TAG, --scenario, --local); tests/integration/test_run.py checks the JUnit document of the passing (7 cases, nothing failed) and the failing example (two failures); docs 38, layout 80, tooling 30. Verified locally (GPU, local images): examples/demo_m1.sh --local PASS in 33 s wall clock (five of five assertions, MCAP written), make test-integration 6 passed in 115 s. Docs: README, docs/index.md, docs/setup/ci.md (Scenario tests), CLI, assertions, scenarios, reference index, examples READMEs. Hand-off: (1) review and merge #42; (2) the definition of done's three consecutive green runs on main accrue after the merge (each push to main and the nightly run count; gh run list --workflow scenario-tests.yml --branch main shows them) and can be hurried with gh workflow run scenario-tests.yml --ref main; (3) run the demo on the workstation (examples/demo_m1.sh, which pulls the published images, or --local) and open runs/m1-demo-<timestamp>/recording/recording_0.mcap in Foxglove Studio to sign off M1 here; (4) the quick start from a fresh clone was verified by the workflow's steps on the runner (clone, published images, make assets, make vehicle-image, the run) rather than by a human on a new machine; (5) the merged branch task/P1-T06-status is still in the local clone (git branch -D task/P1-T06-status; the session could not force-delete it); (6) the scenario workflow uses the published main images, so a pull request that changes the images is covered by CI's image job and not by this workflow until the merge publishes them. 2026-10-18: #42 merged as e57f32a by the maintainer. Definition of done, CI part, met: three consecutive green Scenario tests runs on main for e57f32a, each PASS on attempt 1 with software rendering against the published images: 39115570785 (the push, 5 min), 39115652304 (dispatch, 10 min: queued behind the first) and 39116328742 (dispatch, 5 min); a fourth dispatch (39115644508) was cancelled before starting because GitHub keeps one pending run per concurrency group, which ci.md now says (dispatch one at a time). CI on main green (39115570868); Images published sha-e57f32a and retagged main. The P1-T07 worktree and branch are removed; the merged task/P1-T06-status branch is still in the local clone (hand-off item 5). M1 signed off 2026-10-18 (the maintainer, with the working session doing the check at their request): v0.10.0 released by #43 (images tagged v0.10.0, SBOMs attached; its push to main added a fourth green Scenario tests run, 39119717477). The demo ran on the maintainer's workstation in its quick-start form, examples/demo_m1.sh with the published main images (pulled, the assets, the vehicle image already built): PASS in 33 s wall clock (timeline 10.6 s, 9.9 s of simulation), five of five assertions (reached_goal 0.49 m at 9.2 s, lidar 9.2 to 9.4 Hz, no contact, speed within limit), run m1-demo-20261018T103519Z, MCAP 6.7 MB with 13 topics (/odom 1042, /tf 2158, /scan 208, /plan 9, the three side channels). Foxglove Studio is not installed on the host, so the recording was decoded instead with mcap-ros2-support and plotted: /odom runs 3.80 m in a straight line from the spawn frame, the ground truth on /fieldproof/metrics from spawn_dock (10, 3) to (9.97, -0.80), 0.2 m short of the goal (10, -1) and inside the 0.5 m tolerance, Nav2's last /plan overlaying the path; the five verdicts are on /fieldproof/assertions. The roadmap's M1 line therefore holds: fieldproof run examples/ugv_nav_to_goal.yaml ends with PASS and a report, and the MCAP shows the robot's path. Follow-up #46 (unplanned, PR #47): the M1 demo video. A spectator camera sensor in the depot's yard with Gazebo's CameraVideoRecorder system (idle it renders nothing); outputs.video in schema v0 (true or camera/format), the runner starting and stopping the recorder with the timeline, waiting for the file, copying it out of the simulator (Backend.copy_from) into video/<camera>.mp4 and reporting it (record, reports, JUnit property, fieldproof run's last line; best effort, --video/--no-video overrides); the example records its drive, the demo prints the path, the integration test checks the file; docs/media/m1-demo.gif on the README and the site's home page, the MP4 attached to the v0.10.0 release. Cost on the GPU workstation: the drive takes about twice as long in wall-clock time (RTF 0.9 to 0.45, lidar 9 to 4 Hz of simulation time), the same at 30, 15 or 10 fps; with software rendering on the CI runner the lidar fell to 0.4 to 1.2 Hz and the example's 2 Hz floor failed (the video itself was recorded), so a video changes what a scenario measures: the example keeps video off, the demo turns it on with --video, and CI exercises the real recorder on the failing example (which asserts no sensor rate) in the integration test. |
Phase 2 — Scenario engine¶
| Task | Title | State | PR | Notes |
|---|---|---|---|---|
| P2-T01 | Multi-vehicle orchestration and resource budgets | done | #49 | 2026-10-18: namespaces end to end: a scenario vehicle's namespace reaches the spawn launch, the bridge (namespace:=), the readiness probe (the example manifest's ready conditions are now relative, lifecycle: bt_navigator, resolved against FIELDPROOF_NAMESPACE, which the probe takes as --namespace too), the recorder, the timeline (/ugv1/navigate_to_pose) and the vehicle's own launch through the manifest's new runtime.entrypoint.launch.namespace_arg (the runner appends namespace:=ugv1 to the container command; a namespaced vehicle whose launch names no argument is refused by fieldproof validate); frame_prefix (vehicles[].frame_prefix, frame_prefix_arg, FIELDPROOF_FRAME_PREFIX) is plumbed the same way for a stack that keeps one shared /tf. TF convention decided: one tree per vehicle (Nav2's multi-robot convention): the reference manifest's drive.tf.ros_topic is relative (tf), the bridge publishes /<ns>/tf and /<ns>/tf_static (the generator records them in bridge.yaml, the launch remaps robot_state_publisher), frames keep their names, goals stay in the vehicle's map; a manifest writing /tf keeps a shared tree with a frame prefix. A ROS domain per run: fieldproof.toml ros_domain_id = "auto" (the new default) derives ROS_DOMAIN_ID from the run id (1..100, label io.fieldproof.domain); --domain-id, ROS_DOMAIN_ID=N or a number in the file pin it; the compose stack stays on 0. Fast DDS discovery server per run (discovery = "server", FIELDPROOF_DISCOVERY, fieldproof run --discovery server): a container of the bridge image running fastdds discovery (role discovery, waited for until Server is running), every ROS container pointed at it (ROS_DISCOVERY_SERVER=<host>:11811, ROS_SUPER_CLIENT=TRUE). DDS profile: runtime.dds_profile (the example ships src/ugv_nav2_bringup/config/fastdds.xml, a UDPv4-only transport with 8 MB buffers) bind-mounted read-only at /opt/fieldproof/dds/profile.xml and named to the RMW (FASTRTPS_DEFAULT_PROFILES_FILE, CYCLONEDDS_URI, NDDS_QOS_PROFILES), by fieldproof run and fieldproof vehicle run. Budgets: resources.cpuset (--cpuset-cpus), vehicles[].resources overrides per vehicle (Resources.merged), ContainerSpec.cpuset, the budget event summing the fleet against the host (Backend.host() from docker info, a budget.oversubscribed warning), vehicle.budget per vehicle, the applied budget in run.json vehicles[].resources. Parallel start: spawns, bridges and vehicles on a thread pool (one worker per vehicle; DockerBackend keeps one SDK client per thread, since the SDK's session is not thread-safe), the first vehicle's bridge alone bridging /clock (clock:=false for the others), depends_on (validated: unknown names, self, cycles) starting a vehicle once the named ones are ready (vehicle.dependency), a failed dependency leaving its dependents unstarted; concurrent actions: every timeline entry starts at its at on a worker, the main thread observes between polls, the first entry not ending ok fails the run and marks the rest cancelled, the budget names the running entries. Contract checks: --check-contracts runs ros2 run fieldproof_bridge contract_check inside each bridge on the bundle the bridge wrote (bundle_out:=/tmp/fieldproof-bridge, a new launch argument), counts in run.json vehicles[].contract, a failure is status error. Fleet example examples/scenarios/ugv_fleet.yaml: three ugv_nav2 vehicles (/ugv1 from spawn_dock into the yard, /ugv2 from spawn_hall along the hall, /ugv3 from (−8, −5) across the yard), ugv2 with memory: 3g, ugv3 depends_on: [ugv1], three goals at 0s, twelve assertions. The example vehicle became fleet-ready: bringup.launch.py wraps SLAM Toolbox in PushRosNamespace + SetRemap of the TF topics and both launches rewrite their parameter files with nav2_common's RewrittenYaml(root_key=namespace) as nav2_bringup does, since a ROS 2 parameter file keyed by bare node names reaches the root node only (the first fleet run had Nav2 on its default DWB controller and SLAM on /scan and /map, readiness timing out at 180 s); slam.yaml sets map_name: map (slam_toolbox's default is the absolute /map); package.xml adds nav2_common (already in the image). Tests: engine 383 pytest (test_fleet_runner.py 20 new: the fleet plan, the discovery server, frame prefix, refusals, runs with dependencies, failed dependency, contract checks passing and failing, actions truly concurrent on a thread pool, a failing action, the budget during actions, oversubscription; plus config, manifest, probe namespace, Docker backend budget/host/per-thread client, CLI --discovery/--check-contracts, vehicle run --namespace), 95 % coverage; bridge 32 generator + the contract launch test (45 under colcon); tests/integration/test_fleet.py (skips hosts under 8 cores unless FIELDPROOF_IT_FLEET=1): limits from docker inspect and docker stats while the vehicles run, domain/namespace/DDS profile per container, no foreign subscriber of /ugv1/scan or /ugv1/odom, no root publisher of /tf, /map, /scan, then the verdict, the MCAP and nothing left; docs 38, layout 80, ci 57, vehicles 41 (--no-build). Verified locally (GPU, 16 cores): the fleet PASS in 85 s wall clock, three vehicles ready 37 s after start, every goal reached within 0.49 m at 27–29 s of simulation, contract checks 54/52/52 passed, docker stats 2GiB/3GiB/2GiB and 167–202 % CPU at the 2-core quotas; the single-vehicle example with --discovery server and a cpuset: 0-3, pids: 2048 override PASS in 35 s (domain 43 auto, HostConfig NanoCpus 2e9, CpusetCpus 0-3, PidsLimit 2048); tests/integration 7 passed (the root-namespace runs unchanged); make test-ros sim+robots+bridge green on a rerun (the first run's bridge contract test failed once on /odom 44.7 Hz and /tf 38.6 Hz measured right after three colcon builds, a load flake; 45/45 alone). Docs: scenarios (new Fleets section, phases, record, limits), manifest (namespace_arg, cpuset, dds_profile, the probe's namespace, the example), bridge (TF rule, bundle_out, contract checks), CLI, containers, CI (the fleet is not run on the 4-core runner), robots, READMEs. Hand-off: (1) review and merge #49; (2) the images were rebuilt from the worktree, so the :local tags of the main checkout now carry this branch's bridge and vehicle images (make images && make vehicle-image on main restores them); (3) the fleet integration test skips the GitHub runner (8-core gate), so the fleet is verified on workstations only until a larger runner exists, which the CI doc says; (4) with ros_domain_id = "auto" two runs started at once collide with odds of 1/100, --domain-id settles it (documented); (5) tests/integration/test_fleet.py asserts docker stats prints 2GiB/3GiB, which assumes Docker's binary units (29.x does); (6) next task P2-T02 (network emulation) builds on the per-vehicle containers and the discovery option. 2026-10-18: #49 merged as 8dae9b3 by the maintainer; CI on main passed on the pull request's final commit (the Lint job once rerun after a transient Go proxy error installing gitleaks). |
| P2-T02 | Network emulation | done | #57 | 2026-10-20: radio links (fieldproof.network, docs/reference/network.md): a scenario's network section puts every vehicle behind a radio link to a ground station. Topology: per run a radio network (fieldproof-<short>-radio: the ground station, every vehicle) and per vehicle an onboard network (its bridge, the recorder; the /clock bridge joins all of them); per vehicle a link holder (role link: the bridge image as root with NET_ADMIN, sleep infinity, DNS name = the vehicle's) whose network namespace the vehicle container joins (network_mode: container:<holder>), so the stack under test gets no privilege and the link is shaped from its first packet; the holder shapes its radio interface both ways with tc netem (inbound through an ifb device) under a two-band prio root, ICMP (the probes) on its own netem without the rate, everything else through the whole profile; vehicle-to-vehicle traffic crosses both links; discovery: server is refused with links; the networks carry io.fieldproof.run and are removed at teardown (--keep keeps them). A bridge on two networks starts through a GZ_IP wrapper (its address on the route to the simulator): gz-transport advertised its first interface's address, Docker's interface order is not stable, and with the onboard address first cmd_vel never reached Gazebo. Profiles (latency_ms one way, jitter_ms normal, loss_pct, reorder_pct, rate_kbit): presets wifi_good (5 ms ± 2, 0.1 %, 50 Mbit/s), lte_rural (60 ms ± 20, 1 %, 4 Mbit/s), rf_900mhz_5km (40 ms ± 10, 3 %, 200 kbit/s), network.profiles for the scenario's own, network.links per vehicle. Timeline events link.set (profile), link.drop (optional duration_s, then the configured profile comes back), link.restore; the timeline is now a discriminated union on action. Ground station (role ground, engine/src/fieldproof/ground.py mounted like the observer, as the host uid, on the radio only): publishes each vehicle's heartbeat from its manifest's new runtime.link.heartbeat (std_msgs/Header, best effort) and probes every link with ICMP from an unprivileged ping socket, ten a second, one window per second to network.jsonl (relayed onto the new /fieldproof/network side channel). Measurement node: the runner splits the windows into periods per profile, estimates one-way latency (half the round trip), jitter and loss, and judges each period at the end (within 10 % or 0.5 ms over 20 replies; a drop must answer nothing; insufficient otherwise); network.verify off/warn (default)/fail; each link's traffic counters (tc -s -j) are read before the teardown; all of it in run.json network and a Radio links section of report.md. New phase network (between recorder and vehicles), timeouts.ground_s (60 s). topic_value's within_s now also ends an always window. Example vehicle ugv_nav2 gains the loss-of-link failsafe link_monitor (between the collision monitor's cmd_vel_safe and the base's cmd_vel: armed by the first heartbeat, after 2 s without one link_state false and zero velocity at 20 Hz, resumes at the next heartbeat) and runtime.link in its manifest; the vehicle image must be rebuilt. DoD scenario examples/scenarios/ugv_link_loss.yaml: lte_rural, the vehicle drives 8 m along the hall from spawn_hall, link.drop at 5 s for 20 s, seven assertions (link lost reported, odometry and cmd_vel at zero from 9 s to 24 s, link back reported, goal reached, in time, no collision), verify: fail. The bridge image adds iproute2, iperf3, iputils-ping. DDS behaviour measured and documented with the recommended QoS: the example's Nav2 stack sends ~1.4 MB of discovery to every new remote participant (3 s of a saturated 4 Mbit/s link, round trips up to 980 ms, once a spurious 0.4 s failsafe trip before the timeline), heartbeats back 0.2 s after a 10 s drop and 2.5 s after a 25 s one (the 20 s lease expired, rediscovery). Tests: engine 429 pytest (test_network.py, test_ground.py, test_network_runner.py 46 new, plus backend, assertions, observer), 94 % coverage; tests/integration/test_radio_links.py: ping and iperf containers on a link the engine shapes (every preset: latency within 10 % from 100 pings, UDP throughput within 10 % of the rate both ways, drop, restore) and the DoD scenario end to end. Verified locally (GPU, 16 cores): presets measured at 5.01/5.00, 59.91/60.00, 40.17/40.00 ms and 48.3/48.3 Mbit/s, 3.78/3.83 Mbit/s, 190/187 kbit/s; ugv_link_loss passed five runs of five after the last fixes (period latencies 59.0 to 62.7 ms against 60, drops unanswered, goal in 39 to 44 s); make test-integration 8 passed, 1 failed: test_fleet (no radio links) failed on its bridge contract rates (/odom 31 to 38 Hz, cameras 12 to 13 Hz against 50 and 15 Hz ±10 %) and once on a Nav2 goal response timeout (#32's family), with the host at a load average of 12 to 18; a control run of test_fleet from main failed the same way, so it is the host's load, not this change (CI skips the fleet on its 4-core runner); docs 38, layout 80, ci 57, images 38, vehicles 41, pre-commit on all files clean. The pull request's first CI run passed everything new on the runner (sch_netem and ifb load; ping/iperf within 10 % on every preset, lte_rural 58.1 ms) but holds_while_lost: the failsafe's 2 s are simulation time, and under software rendering the vehicle reported the link lost 4.4 s after the drop, still moving at the window's start; the example's windows now start 8 s after the drop (after_s: 13s) and allow 10 s for the report. The second run passed all seven assertions on the runner but counted 5 of 197 probes as answered during the drop: a period ended when the next profile's docker exec returned, half a second after the profile had changed; a period now ends when the change starts and the next begins when it is done. The third CI run (39411010360) is green: on the runner the presets measured 5.06, 60.51 and 40.21 ms, the scenario's seven assertions passed and every link period passed (61.8 and 63.5 ms against 60, the drop unanswered); the scenario workflow, docs and PR title are green too. Also fixed: DockerBackend.find raced a spawn worker removing its container during an interrupted run (#56). Hand-off: (1) review and merge #64; (2) rebuild the images (make images && make vehicle-image): the :local tags of the main checkout now carry this branch's bridge and vehicle images; (3) CI loads sch_netem and ifb before the integration tests (the runner's kernel is the open question); a host whose kernel lacks them gets the tc error and the modprobe hint (docs/setup/host.md); (4) the scenario workflow still runs ugv_nav_to_goal against the published images, which only carry tc after this merges; (5) one local run of the old dock route hit #32 (Nav2 lifecycle stall), two hit NavFn's unseen-goal failure, which moved the example to the hall; (6) next task P2-T03 (event injection) can build its fault events on the link holder (container.pause, link events as plugins). 2026-10-20: #57 merged as 9b3e988 by the maintainer (closing #56); on main CI, Images (the published fieldproof-bridge:main now carries tc, ip, iperf3 and ping), Scenario tests, Docs and Release are green; release-please opened the 0.13.0 release PR (#58). |
| P2-T03 | Event injection framework (×2) | done | #60, #64 | 2026-10-20, session 1: the timeline DSL (engine/src/fieldproof/dsl.py, docs/reference/events.md): every timeline entry carries a trigger: at is an offset from it, the trigger being the start of the timeline, the successful end of another entry (after: <name>) or the first sample of the observer's stream that satisfies a condition (when: a topic field like a topic_value assertion, or a metric sim_time, rtf, the vehicle's ground-truth x, y, z, yaw, distance_to a placement; within_s bounds the wait, the entry is skipped past it); every repeats an entry at a period from its first start, bounded by count or until_s or by the rest of the timeline (occurrences name, name#2, ..., never overlapping). Times are wall clock from the start of the timeline (runner v0); when: {metric: sim_time} is the one sim-time trigger. Validated across entries: after names, self, cycles, repeated entries, unique names, when needs a vehicle. The scheduler (fieldproof/schedule.py): pure, driven by the runner (what is due, earliest first; how long to wait; idle when nothing runs and nothing can still start; when without within_s and every without a bound are passive; settling what never ran as skipped, which a within_time assertion fails at once). The event plugin API (fieldproof/events/): Event with action, Entry (a TimelineBase model with the parameters; the schema and the JSON Schema come from it), plan(entry, PlanContext) (resolved against the world and the vehicles before any container starts) and apply(entry, resolved, EventContext, record) (ros2 in the vehicle, exec, the backend, the log, hold, expect_exit, mark_paused, the links); the registry loads the built-ins and the fieldproof.events entry points (a broken or colliding plugin is reported, not fatal; fieldproof events lists everything, exit 2 with a problem; fieldproof validate warns); the scenario schema's timeline union is built from the registry (TimelineEntry, typed as TimelineBase for mypy). Built-in events: navigate_to_pose and link.set/link.drop/link.restore ported unchanged; new container.pause (optional duration_s; the teardown resumes a container left paused), container.resume, container.kill (signal; the run expects the container gone, check_alive skips it, its log is kept) and param.set (ros2 param set; asks again once a second while the CLI says Node not found, up to timeout_s); Backend gains pause, unpause, kill (Docker and the fake). The record: every occurrence in run.json timeline[] (trigger, occurrence, params, t_s, sim_s, detail), action.start/action.end lines of engine.log carrying it (relayed onto /fieldproof/events by the observer as before), action.triggered/action.skipped/action.settled; the dry run shows triggers and resolved parameters. Example plugin examples/plugins/example_events (example.announce, ros2 topic pub --once from the vehicle's container; a dev dependency of the engine in uv.lock, so it is registered after uv sync and its tests run with the engine's; the committed scenario.schema.json includes it). Example scenario examples/scenarios/ugv_events.yaml (the drive of ugv_nav_to_goal plus say_hello at 2 s every 5 s ×3, tighten_failsafe when the vehicle is 1 m from its spawn, freeze_stack 1 s after the drive for 3 s, stop_stack after the pause; seven assertions). Tests: engine 523 pytest (test_dsl.py 42, test_schedule.py 18, test_events.py 16, test_events_runner.py 13, the plugin's 5), 94.8 % coverage; tests/integration/test_event_injection.py (every entry ok with its trigger, the seven assertions, the container seen paused then exited by docker inspect, the log lines, three /announce messages in the MCAP, nothing left); docs 38, layout 80, ci 57, pre-commit on all files clean. Verified locally (GPU, 16 cores): the events scenario PASS in 42 s wall clock (the when held at 7.2 s, the drive 14.0 s, pause and kill as planned); the first run failed on ros2 param set answering Node not found for /link_monitor 9 s into the timeline while the node existed (the CLI daemon's graph lags), hence the retry. make test-integration three times over: the events scenario passed every time (42 s wall clock, the when at 7.2 s, param.set 4.4 s, pause and kill as planned); run 1 was before the after list (the pause froze a param.set still asking ros2 under the suite's load and the kill ended it with exit 137, hence after: [drive_ahead, tighten_failsafe]), run 2 failed only the test's own docker inspect watch thread, which missed the 3 s pause under load (the test now reads the backend's container.paused/container.resumed/container.killed lines), run 3 passed the events test and failed test_fleet on the test harness's ros2 node list --no-daemon inside ugv1 timing out after 180 s with the host at a load average of 8 (the fleet passed in runs 1 and 2; the P2-T02 notes record the same load sensitivity); the other seven tests passed every time. Also after now takes a list of names (triggered when every one has ended ok, skipped when any did not). Docs: events.md (new), scenarios, assertions, cli, network, index, nav, READMEs, ci. Hand-off: (1) review and merge #60; (2) session 2 has left: the Gazebo-side C++ plugin exposing world-mutation services (sim/fieldproof_gz_plugins) and the events spawn_object, remove_object, teleport, set_wind on it; the ROS-side fault-injector nodes inserted between the bridge and the vehicle (the bridge bundle or the vehicle launch must route the sensor topics through them) and the events gps.deny, gps.offset, imu.bias, sensor.dropout, sensor.freeze, battery.drain, actuator.limit; the two definition-of-done scenarios with assertions (an obstacle spawned on the planned path with Nav2 replanning, in the lidar's view, and a GPS denial) and their integration tests; the Events page's built-in table and known limits updated; (3) design notes for session 2: a world event is an Event whose apply calls a Gazebo service through context.exec in the sim container (gz service) or a bridge; a fault event needs the fault-injector node's parameters set through param.set-like calls, so EventContext.ros2 serves it; when: {metric: distance_to} is the trigger for the obstacle (the vehicle a few metres from the spawn point, the obstacle placed on the planned path ahead, in the lidar's view, since NavFn fails on unseen goals); assertion windows must leave 8 s of margin for software rendering in CI; (4) the images were not changed, so the :local tags still carry main's builds; (5) ros2 param set can answer Node not found on a node that exists (the CLI daemon's graph lags), which the event retries; a plugin running other ros2 CLI commands that look the graph up should expect the same; (6) with the fake backend's synchronous executor, occurrences run one after another in the order of their due time, so a scenario variant in the unit tests must not leave an every running into a kill or a pause (the real stack has the same race, which after orders). 2026-10-21: #60 merged as 759c0ea by the maintainer; on main CI (the image job's integration tests included), Images (the :main tags now carry the session's engine and the unchanged bridge and vehicle images), Scenario tests, Docs and Release are green; Dependabot's dependency graph picked up the example plugin. 2026-10-21, session 2: the WorldEvents plugin (sim/fieldproof_gz_plugins, fieldproof::WorldEvents, docs/reference/worlds.md#the-world-events-plugin): a world system advertising /world/<name>/fieldproof/{spawn,remove,teleport,set_wind} (gz.msgs.EntityFactory, Entity, Pose, Wind in; a gz.msgs.StringMsg report out, error: ... when refused: a name taken, SDF that does not parse, no such model); every request is queued by the service thread, applied against the ECM in PreUpdate (SdfEntityCreator, WorldPoseCmd, the wind entity's seed and velocity plus the message WindEffects reads) and answered once applied, where Gazebo's UserCommands answers at queue time (its /blocking variants answer a bare Boolean); both worlds load it with WindEffects (test_worlds.py requires both); gtest test_world_events.cpp on gz::sim::TestFixture (7 tests: a headless server with physics and WindEffects, zero gravity, every service through gz-transport); the sim image gains a build stage (build-essential, cmake, colcon) that installs the package into /opt/fieldproof_sim, GZ_SIM_SYSTEM_PLUGIN_PATH points at its lib, the plugin libraries carry INSTALL_RPATH $ORIGIN (compose: named context fieldproof-gz-plugins-src). The fault injectors (bridge/fieldproof_bridge/adapters, docs/reference/bridge.md#adapters): every adapter is an injector through the FaultInjection mixin (dynamic dropout and freeze parameters, a set_parameters callback); the GNSS adapter adds deny (NO_FIX, NaN position, unknown covariance) and offset_enu_m, the IMU adapter gyro_bias_radps and accel_bias_mps2; new nodes relay (any injectable type: the laser scan, the odometry, ranges...; the frame relabelled if asked), command_relay (cmd_vel from the vehicle through max_linear_mps/max_angular_radps onto the hidden topic the bridge forwards to Gazebo) and battery (a synthetic sensor_msgs/msg/BatteryState from the manifest's new optional power.battery section: full voltage, empty voltage, capacity, rate; drain_to then drain_duration_s start a linear ramp in sim time); the generator routes every injectable output and the command through them (RELAYED_TYPES; images and point clouds stay in C++), records the contract topic and manifest sensor each injector serves in bridge.yaml (Bundle.injectors(), Bundle.injector(what)); the reference UGV declares a 29.4/21 V, 20 Ah battery. The events (fieldproof/events/world.py, faults.py; docs/reference/events.md): spawn_object (a shape box/cylinder/sphere with its size standing on the placement, or a model:// uri; static or a dynamic body with its inertia; colour), remove_object, teleport (a vehicle keeps its spawn height; or any model), set_wind (velocity, enabled), calling the plugin through the new EventContext.gz_service in the sim container and judging the report; gps.deny, gps.offset, imu.bias, sensor.dropout, sensor.freeze (sensor: a manifest sensor, a drive interface odometry/joint_states/command, battery, or a topic), battery.drain (to_pct over duration_s, waited out), actuator.limit, resolving their injector at plan time from VehicleHandle.bundle() (the bridge generator on the vehicle's manifest; the camera, a missing battery or GNSS are refused with the known injectors named) and setting its parameters through the new EventContext.bridge_ros2 in the bridge container (the shared set_parameter of param.set, with the Node not found retry), holding duration_s and lifting; world.changed/world.refused and fault.injected/fault.lifted/fault.refused log lines; SPAWN_HEIGHT_M moved to fieldproof.world. Entry points and BUILTIN_MODULES list the eleven; fieldproof events 20 actions; both schemas re-exported. The scenarios: examples/scenarios/ugv_obstacle_replan.yaml (the 4.5 m drive from spawn_dock through the roller door into the yard; spawn_crate when the vehicle is 0.5 m from its spawn puts a static 0.6 m box at (10, 0.8), 1.7 m ahead on the straight line, in the lidar's view; clear_crate after the drive; six assertions: within_time on both events, reach_pose, arrived_in_time, topic_rate on plan at 0.1 Hz over 10 s windows, no_collision) and ugv_gps_denied.yaml (the 4 m drive, gps.deny at 5 s for 20 s, drive_back to the dock after both; seven assertions: topic_value on gps/fix status.status present within 4 s, < 0 always from 13 s to 24 s, back eventually after 25 s, within_time on the denial and the return, reach_pose, no_collision). Tests: engine 551 pytest (test_world_fault_events.py 28 new, plus the bridge CLI, scenario, fakes), 95 % coverage; sim 14 (test_world_events.cpp 7), robots 33 (the NavSat noise check), bridge 55 (test_faults.py 10, the generator's routing, the contract launch test through the relays) under make test-ros; tests/integration/test_world_events.py (the plugin's services on a bare simulator; the obstacle scenario end to end, with the vehicle's detour read from the ground truth) and tests/integration/test_fault_injection.py (each fault set in the real bridge and observed on its topic; the GNSS-denial scenario end to end); docs 38, layout 80, ci 57, pre-commit on all files clean. Verified locally (GPU, 16 cores): ugv_obstacle_replan PASS in 47 s wall clock (the when held at 6.0 s of simulation, the crate spawned in 1.0 s, Nav2's path published at 0.4 to 1.0 Hz over 19 windows, the goal reached at 22.7 s within 0.49 m, the vehicle 0.86 m off the straight line beside the crate, no contact, the crate removed in 0.5 s); ugv_gps_denied PASS in 71 s (the fix present in 18 messages before 4 s, denied in all 51 messages from 13 s to 24 s, back at 28.4 s, the denial in place 23.6 s wall clock, the goal at 12.5 s, the return in 20.6 s, no contact); the plugin's services on the bare simulator in 65 s with the obstacle run; the six fault checks in 3.5 min. make test-integration 18 passed in 12 min 36 s (ugv_nav_to_goal, the interrupted run, the failing example, ugv_fleet, the radio links and ugv_link_loss, ugv_events, the plugin's services and ugv_obstacle_replan (detour 0.97 m), the six fault checks and ugv_gps_denied, every one PASS; host load average 2 to 11); images 38 (--quick), vehicles 41 (--no-build). Found on the way: (a) the reference UGV's NavSat noise was applied in degrees (the manifest's 1 m became 1°, the fix wandering by 111 km; nothing asserted on its value before): fixed in the branch since the offset check needs a stable fix, issue #63; (b) two stacks on one ROS domain on the same host cross-talk even on separate Docker networks (a run saw the compose stack's ugv_ref contact and its clock jumped), so the fault integration test tears its compose stack down before the scenario run; (c) rclpy delivers a double array parameter as array('d'), not a list; (d) a gtest that links a library carrying GZ_ADD_PLUGIN hides every plugin from gz-plugin's loader, so the world events test loads the plugin through the server only; (e) ros2 topic echo interleaves A message was lost!!! lines on stdout under load. CI on #64: the first run (software rendering, four cores) found three things the GPU host did not: the obstacle drive took 200 s of simulation time and Nav2's path came at 0.2 Hz (the scenario's floor is now 0.1 Hz over 10 s windows, the drive's limit 6 min), the battery drain runs in simulation time so a fixed wall-clock wait was short (the check now waits for the percentage), and ros2 topic echo interleaves A message was lost!!! with indented total count lines (skipped by the reader); the second run passed make test-integration 17 passed, 1 skipped (the fleet) in 17 min (ugv_obstacle_replan 99 s, the crate at 42 s, the goal at 94 s; ugv_gps_denied 124 s, the fix denied at 38.6 s and lifted at 62.7 s, the return at 120 s) and the ROS workspaces, and failed on make tidy (constants, literal suffixes, optional reads, [[nodiscard]] in the new C++, fixed); the third run (39662771816) is green: CI (the image job with make test-integration and the ROS workspaces in 24 min 40 s), the scenario workflow, docs and the PR title; the plan-only commit after it failed CI once on the P1-T04 vertical-slice test, whose ground-truth reader choked on two JSON documents from gz topic -n 1 under load while the goal was reached (#65, the reader now decodes the first object), and the next run is the one to merge on. Hand-off: (1) review and merge #64; (2) rebuild the images (make images && make vehicle-image): the :local tags of the main checkout now carry this branch's sim (the plugin), bridge (the injectors, the NavSat fix) and vehicle images; (3) the camera topics take no fault (C++ path), the battery is synthetic, set_wind moves only links that enable wind (none on the UGV), a fault has no probabilistic mode: all in events.md's known limits; (4) the compose stack and a fieldproof run must not coexist on one domain on a host (b above): the integration tests keep them apart, a hand run should too (make down first); (5) next task P2-T04 (clock management) can schedule the timeline in simulation time on the same scheduler; the fault injectors' ramps already run on the simulation clock. 2026-10-22: #64 merged as ea84e55 by the maintainer (closing #63 and #65); on main Images (the published :main tags now carry the plugin, the injectors and the NavSat fix), Scenario tests, Docs and Release are green; CI (39740645203) failed once on the P2-T02 radio-link scenario, ugv_link_loss verifying 1 of 184 probes answered while the link was dropped with its seven assertions passed (a period-edge flake, issue #67; the rerun of the job is green); release-please opened the 0.15.0 release PR (#66). The session worktree and branch are removed. 2026-10-22/23: the release PR failed CI four times on four load flakes of a runner at half speed (image job 52 min against 24), each in a test the release did not change; hardened in #71 (a dropped link period tolerates the probes in flight, ugv_link_loss drops for 30 s with wider windows, the bridge contract test takes a wider rate tolerance for the rendering sensors in CI, the example stack's progress checker allows 20 s and the obstacle drive 8 min; #67, #69, #70) and #72 (a link's initial period is judged from the start of the timeline, past the bring-up's discovery traffic; a three-probe drop tolerance; #67); v0.15.0 (#66, with #71) and v0.15.1 (#73, with #72) released; #74 filed (the session-1 events scenario's /announce messages missed by the recorder on a loaded runner, open). |
| P2-T04 | Clock management and determinism | done | #76 | 2026-10-23: the clock modes (fieldproof.clock, fieldproof.observer.ClockManager, docs/reference/time.md; ADR-0006): time.mode is sim (free-running at rtf, the timeline on the wall clock as before), realtime (the physics pinned to RTF 1.0, the drift between simulation time and the wall clock measured over the timeline with paused periods left out, a clock.drift warning per metrics window outside tolerance, the verdict locked/drifted against max_drift_s judged when the timeline completes, on_drift: fail\|warn) or lockstep (below); run.json gains clock (mode, target and measured RTF with its windows, drift, violations, verdict, the lockstep's steps and holds) and seed, the reports a Clock line, /fieldproof/metrics and the clock samples drift_s, mode, target_rtf, paused, gz_rtf (the observer now follows /world/<w>/stats). The seed: a top-level seed (0..2^31-1: Gazebo's Ruby launcher converts --seed to a C int) or fieldproof run --seed, Gazebo's default 0 when absent (recorded), passed as gz sim --seed and FIELDPROOF_SEED to every container. Lockstep: once a vehicle is ready its clock agent starts (fieldproof/lockstep.py, mounted into a container of the bridge image, role clock, network_mode: container:<vehicle>, which Docker chains behind a link holder); it acknowledges every step boundary of /clock on /fieldproof/lockstep and repeats its last ack each second; at the start of the timeline the engine waits for every agent, then tells the observer's clock manager through clock.jsonl in the run directory to lock (the world run to the next 0.1 s boundary half a second ahead with run_to_sim_time and paused there: the origin), and from then on how far it may step (run_until: the timeline's next due time rounded up to the grid, or the current time while an occurrence is being started, released when the event calls context.started(record) (navigate_to_pose on Goal accepted), returns, or after 10 s), the manager requesting one step at a time and the next only once every agent acknowledged the previous; the timeline, the assertions' deadlines and the record's t_s count simulation seconds from the origin (Evaluator(sim_base=True)); a step unacknowledged within ack_timeout_s is a lockstep.timeout sample that fails the run; release at the end. Events and commands: sim.pause (optional duration_s), sim.resume, sim.step (steps or duration_s; pauses, steps, waits on the world's statistics until the target is reached, since a resume sent during a burst is undone when it ends; refused in lockstep); EventContext.started and gz_topic; fieldproof clock <run> status, pause, resume or step (--steps or --seconds; --json for the statistics) on a running (or kept) run through gz service/gz topic in its simulator; fieldproof compare <run-a> <run-b> (--tolerance-m, 0.25 m; --json; fieldproof.determinism: the ground-truth pose samples in timeline seconds from clock.origin_sim_s, the second run interpolated at the first's samples, the largest, mean and final deviation per vehicle; exit 1 past the tolerance). Examples: ugv_lockstep.yaml (seed 7, the drive at 1 s of simulation, odom_x/odom_y value assertions streaming the odometry for comparison), ugv_realtime.yaml (on_drift: warn, no rate window since the test pauses by hand), ugv_sim_control.yaml (pause at 4 s, resume on when: {metric: rtf, condition: value < 0.05}, a 0.5 s step, resume again). Tests: engine 586 pytest (test_clock.py 12: the clock manager on a fake world with fake agents, the monitor, the protocol; test_lockstep.py 3; test_lockstep_runner.py 8 with a fake clock manager played on the run's sleep; test_compare.py 6; the real-time, drift, sim-event and seed runner tests), 93 % coverage; tests/integration/test_clock_modes.py (the real-time run with fieldproof clock exercised on the running simulator, the metrics in the MCAP decoded; the sim-control run) in make test-integration, tests/integration/test_determinism.py (two lockstep runs, fieldproof compare and the odometry streams within 0.25 m) behind FIELDPROOF_DETERMINISM=1 (make test-determinism), a second job of the scenario workflow on pushes to main, nightly and on demand; docs 38, layout 80, ci 57, pre-commit on all files clean. Verified locally (16 cores, Mesa rendering): make test-determinism PASS in 99 s: two lockstep runs (93 and 94 steps of 0.1 s, longest wait for an acknowledgement 0.74 s, no request lost, the goal sent at exactly 1.0 s of simulation in both, the drive 8.3 and 8.4 s of simulation, 9.3 s of simulation in 15.4 s of wall clock) deviate by 0.036 m at most in the ground truth (mean 0.015 m) and 0.033 m in the odometry (mean 0.012 m) over 9.7 s; the real-time run PASS (drift -2.97 s and -6.77 s over the drive at a measured RTF of 0.84 and 0.64: this host does not hold real time with the UGV under Mesa rendering, so the verdict is drifted and the example warns; fieldproof clock status/pause/status --json/resume on the running simulator, the pause in the clock's paused_s and in the metrics); the sim-control run PASS (paused at 4.0 s, resumed at 6.6 s on the observed real-time factor, the 0.5 s step at 8.6 s, resumed again at 11.6 s, the goal reached, 3 s paused left out of the drift); make test-integration 20 passed, 1 skipped (the determinism test without its variable) in 16 min 52 s. Found on the way: (a) rclpy timers of a use_sim_time node stop with a paused world (the observer's timers now run on the steady clock, or a pause froze the observer itself); (b) a gz-transport request from a Python process whose node delivers subscription callbacks loses its reply a few times per hundred (2 s timeouts in bursts; 4 of 100 reproduced, 0 of 150 without subscriptions), so the observer makes its world-control requests through a helper process (observer.py --world-control, JSON lines over pipes; 0 of 100 lost at 3.8 ms each under the same load); (c) a multi_step burst re-pauses the world when it ends, undoing a resume sent meanwhile; (d) the example stack's lifecycle manager shuts Nav2 down after 4 s without a heartbeat on the wall clock, so a pause longer than that aborts the goal (a known limit in time.md, and the kind of watchdog a pause is there to reveal); (e) two fieldproof runs on one host at once cross-talk (pose samples from two worlds), so verification runs must not overlap. CI on #76: six image-job runs (4 cores, software rendering, RTF 0.3). Run 1 failed on the real-time test (the pause by hand while the goal was in flight outlasted the stack's 4 s heartbeat on the slow runner; the clock command is now exercised on the run's kept simulator after the verdict, the recorder stopped before the MCAP is read) and on ugv_obstacle_replan; run 2 on the P1-T04 vertical slice with the Nav2 bringup stall of #32; run 3 on ugv_obstacle_replan again, whose drive took 266 s when it passed here against 34 s on main the same day: the cause was the seed, since an unseeded run drew a random one where Gazebo's launcher had always used 0, so every run had a lidar noise sequence of its own and the detour is sensitive to it (an unseeded run uses seed 0 again, recorded; the drive took 44 s in run 4); run 4 failed on the events scenario's missing /announce messages (#74), run 5 on the GPS-denial scenario with the stall of #32 again, run 6 is green. Also found there: a clock.drift warning every second in sim mode (now counted silently in the record, only realtime warns) and fieldproof clock status printing a zero real-time factor (the statistics parser took the real_time_factor line for the real_time section; fixed with a test). The stall of #32 hit two of six runs, which is the rate the P1-T05 notes recorded; it is the one thing that still needs a rerun now and then. 2026-10-23: #76 merged as c6ec1d0 by the maintainer; on main Images (the published :main engine now carries the clock modes; the sim, bridge and vehicle images are unchanged), Docs and Release are green and release-please opened the 0.16.0 release PR (#77); CI (40006922045) failed once on ugv_obstacle_replan stalling with /plan silent from 455 s, with seed 0, the same stall main showed before this task (run 39759983270), so it is that drive's own flake and not the seed (rerun requested); the scenario workflow's new determinism job failed before its first run because pytest creates --basetemp but not its parent (runs), fixed in the plan PR with mkdir -p runs in the step; the plan PR's own image job then met three more flakes of the runner: ugv_link_loss at 66.85 ms against 60 ± 6 ms and the shaped link's throughput at 210 kbit/s against 187 ± 10 % (P2-T02's measurements), ugv_obstacle_replan stalling again (filed as #79, with seed 0 on main before and after this task) and the fault tests' ros2 topic echo reader taking Fast DDS's coloured [RTPS_TRANSPORT_SHM Error] Failed init_port line for the value (the reader now skips it); the latency flake then hit three attempts in a row (#80: 66 to 68 ms for the 60 ms profile over short windows, the netem delay exact), so the plan PR adds a fixed 5 ms allowance for the host's own round trip to the link latency tolerance (LATENCY_HOST_ALLOWANCE_MS; network.md, the link-loss scenario's comments and P2-T02's integration test updated), a decision P2-T02's owner may revisit with the options listed in #80. 2026-10-25: #78 merged as c827e0a (the plan, the determinism job's runs directory, the echo reader, the link latency tolerance with a 5 ms host allowance on the engine's and the test's side, and the runner taking the timeline's time after an ended occurrence's duration, since the events test caught an after entry due 13 ms before t_s + duration_s of its predecessor); on main Images, Docs, Release and both scenario-workflow runs are green, and the determinism job's first runs on the runner pass: two lockstep runs on the published images (software rendering, RTF about 0.25: 7.9 and 8.0 s of simulation for the drive in 36 s of wall clock each) deviate by 0.021 m in the ground truth (mean 0.008 m) and 0.032 m in the odometry (mean 0.008 m) over 9.2 s, against the 0.25 m the test asserts; CI's image job failed once more on ugv_obstacle_replan (#79, rerun). Hand-off: (1) nothing to merge for this task; (2) the images did not change (the observer and the agent are mounted), the :local tags carry main's builds; (3) the real-time lock is a host property: the maintainer's machine measures RTF 0.6 to 0.9 with one UGV, so ugv_realtime.yaml warns and the fidelity tiers of Phase 6 will set fail on hosts that hold it; (4) what two lockstep runs still do not share is the stack's own work between steps (Nav2 free on its timers, the rendering thread, DDS), hence a tolerance rather than identity; an agent acknowledging on the stack's behalf after its work (runtime.lockstep in the manifest) is the next step towards identity; (5) P2-T06's matrix should draw its sweeps from the recorded seed, and fieldproof runs diff can grow from fieldproof compare; (6) a Dependabot alert (2 critical) is open on main's dependency graph, seen at push. 2026-10-26: the image job then failed all four attempts of #84 (the one-line fieldproof clock timeout fix) on #32 and #74 alone, and main's plan-only commit stayed red through five reruns; hardened in #84: the example vehicle gained bringup_watchdog (next to link_monitor in ugv_nav2_bringup), which retries a Nav2 bringup the lifecycle manager aborted after one lost transition call (Jazzy's nav2_lifecycle_manager 1.3.13 has no retry of its own: the servers go back to unconfigured through their change_state services, then manage_nodes STARTUP again; the stack test pauses the manager, publishes its abort line on /rosout and sees the stack active again; #32), and the example plugin publishes reliable, transient local and kept alive for 2 s so a late recorder subscription still gets the message (#74), ugv_events.yaml retimed with it (announces at 1, 4 and 7 s, the freeze 5 s after the mission: a paused container answers no exec, and the longer announcements had run into it). |
| P2-T05 | Assertion library v1 and KPIs | done | #83 | 2026-10-25: the assertion plugin API (fieldproof.assertions is a package: core.py the API, the registry and the evaluator; docs/reference/assertions.md): an assertion type is a class with a type, a Pydantic Spec (deriving from fieldproof.dsl.AssertionBase: name, type, on_fail, vehicle_names()/action_names() for the scenario's reference checks), a plan(spec, context) hook (a PlanContext with the world, the vehicles, the timeline's labels, the other assertions' names and helpers that refuse with a ScenarioError: vehicle, topic, place, action, signal) returning a PlannedAssertion (topic, target, model, resolved, the Watch entries the observer needs) and a checker(planned) factory; types are found through the fieldproof.assertions entry point group (the built-ins registered the same way), the scenario's assertions[] union is built from the registry like the timeline's, the committed scenario.schema.json includes installed plugins, fieldproof assertions [--json] lists the types and the KPI collectors (exit 2 with a broken plugin, which fieldproof validate warns about too); examples/plugins/example_assertions (example.visits_waypoints, a dev dependency like the event plugin). The library v1: geofence (polygon/circle/box with z limits, inside/outside, clearance to the boundary), min_distance (to another vehicle, a world model whose pose the observer then streams, or a placement; closest approach), path_efficiency (path length from the ground truth over an action's run or a window, the straight line to a target or the last pose, min_ratio, max_path_m, speeds), mission_time (a span from an entry's start to an entry's end, or the whole timeline; max_s, min_s), message_latency (the observer measures the age of stamped messages against the simulation clock, p95/max/mean over a sliding window once a second; fails on a message without a header), sequence (steps in order: an action started/finished ok, a signal condition, another assertion's verdict, with within_s and max_gap_s; a step is watched only after the previous one; verdicts given earlier count; assertions a sequence waits for are settled before it, Checker.depends_on), resource_usage (CPU mean over a window and memory per sample of the vehicle's or bridge's container), lifecycle_state (the observer asks get_state once a second and subscribes to transition_event; eventually/always; unavailable/unresponsive count as not in state) and the temporal combinators always/never/eventually over named signals (fieldproof.dsl.Signal, what a when is made of: topic fields and run or ground-truth metrics; the condition reads the signals' names, fieldproof.expression now compiles with names; for_s holds). Every checker reports metrics (Checker.metrics(), a value and a unit per name) and keeps an excerpt (the last six samples it noted) attached to every verdict; the Markdown report prints the excerpt of each failed assertion under the table. KPI collectors: a kpis section (path, mission_time, distance, rate, latency, resources, value with count_if) sharing the trackers of the assertion types, measured/missing and never failing; the run's KPIs are one flat map <name>.<metric> to value, unit, source and type in run.json kpis (the collectors under kpi_collectors), report.json, a KPI table in report.md, kpi.<name> JUnit properties, kpis.jsonl at the end of the timeline and /fieldproof/kpis. on_fail: abort: the timeline is cancelled the moment the assertion fails (running entries cancelled, the rest skipped, pending assertions skipped, the reason says so). The resource monitor (fieldproof.resources): from the start of the timeline the engine samples every container of the run (vehicles, bridges, sim, recorder) through the daemon's statistics stream (Backend.usage, one thread per container, CPU % as docker stats computes it, the working set without the page cache) into resources.jsonl, fed to the evaluator and relayed by the observer onto /fieldproof/resources; run.json resources has the containers, the count and the last sample of each. Side channels now six (/fieldproof/resources, /fieldproof/kpis). Examples: every UGV scenario rewritten with v1 assertions and KPIs (ugv_nav_to_goal 13 assertions and 5 collectors: a geofence around the yard, the drive's efficiency ≥ 0.6, a mission time, the odometry's p95 age ≤ 250 ms, the stack ≤ 250 % CPU over 10 s (the daemon's samples overshoot the two-core quota: 211 % seen) and ≤ 1800 MiB, bt_navigator always active, a sequence goal sent → forward command → goal reached, an eventually over distance and speed; the fleet's min_distance 2 m between vehicles and a fleet mission_time; the obstacle scenario's 0.5 m from the crate, the detour's efficiency and its story; the link loss's eventually ... for_s 10s and never over link state and speed and a sequence of its verdicts; the events' and GPS denial's sequences); the fake-backend unit tests load the examples through fakes.v0_text() (the # --- assertion library v1 block stripped), the integration tests expect the new verdicts. Tests: engine 644 pytest (test_assertions_v1.py 30 on synthetic streams, test_kpi.py 5, test_assertion_registry.py 7, test_resources.py 3, the observer's latency/lifecycle/relays, the runner's KPIs, resources and abort, the reports' KPI table, excerpts and JUnit properties, the plugin's 4), 94 % coverage; docs 38, layout 80, pre-commit clean. Verified locally (16 cores, Mesa rendering): tests/integration/test_run.py::test_01 PASS: all 13 assertions and 5 collectors (odom p95 age 2.0 ms of simulation, bt_navigator active in 13 samples, the vehicle container peaking at 201 % CPU and 465 MiB over 11 samples, drive 3.80 m for 4.00 m straight, efficiency 1.00, lidar 9.0 Hz), the MCAP with the six side channels; the full batch of the changed scenarios (test_run.py ×3, events, faults, radio links, world events, fleet) 12 of 15 in 16 min 42 s: ugv_gps_denied (denial_story 4 steps by 52.2 s), ugv_link_loss (holds_while_lost_combined held 10.0 s by 17.2 s, never_moves_while_lost over 718 evaluations, outage_story 4 steps; the link at 58.9/60.1 ms), ugv_obstacle_replan (clears_the_crate 0.79 m closest over 422 samples, detour_is_short 5.61 m for 4.50 m straight, efficiency 0.80, replan_story), the fault injectors, the interrupted and the failing run; the three failures were the example's JUnit case count (now ASSERTIONS + 2), the events scenario's events_in_order (its first step, the param.set triggered by a when, finished 1.5 s after the drive it preceded in the list: a sequence watches each step only once the previous happened, so the example's sequence now starts at the drive; passed on the rerun, 3 steps by 19.6 s, 9 of 9) and the fleet on a Nav2 stall of ugv1_to_yard (no result in 300 s, the #32/#79 family), then on a goal ABORTED of ugv3_across_yard at 52 s with the two other vehicles already at their goals, then PASS on the third run: 15 of 15 in 104 s (ugv1_ugv2_apart 12.65 m closest, ugv2_ugv3_apart 21.01 m over 660 samples, fleet_in_time 36.6 s), so the fleet's flake rate on this host is unchanged by the task; a second run of the example peaked at 211 % CPU (the daemon's samples overshoot the two-core quota), hence the example's 250 % limit. The first real run caught the collectors' results leaking into run.json assertions (fixed: the evaluator returns verdicts only). CI on #83 (4 cores, software rendering): the Scenario tests workflow's run of ugv_nav_to_goal on the published images passed with all 13 assertions in 5 min 16 s; Lint, Engine tests, Docs, Tooling, Cockpit and Host green; the image job failed its first attempt on two known flakes (#74: two of the three /announce messages in the MCAP; the link-loss scenario's drop period with 4 of 282 probes answered at the edge of the drop against DROP_EDGE_TOLERANCE 3, the P2-T02 family) and its second on ugv_obstacle_replan stalling with /plan silent from 466 s (#79, seen on main before this task); the third attempt (run 40238539352 on 79de883) passed, all checks green. Hand-off: (1) review and merge the PR; (2) no image changes (the observer is mounted; the resource monitor is engine-side); (3) for P2-T06: runs diff can diff the flat kpis map; the collectors' kpi_collectors carry status and evidence; (4) distances are between model origins (document says so); a footprint-aware min_distance and per-process resource accounting belong to the fidelity tiers of Phase 6; (5) the message_latency of a rendering sensor measures how far the sensor thread lags the simulator in simulation time (the example measures odom and collects scan as a KPI). 2026-10-27: merged as 5ba8558 and released as 0.17.0 (#88, tag v0.17.0); main then took #84 (the CI hardening: the example vehicle's bringup_watchdog for #32, the example plugin's kept-alive announce for #74, both issues closed, ugv_events.yaml retimed to say_hello at 1 s every 3 s and the freeze 5 s after the mission, its events_in_order sequence unchanged) and #87 (actions bump). Open questions for the maintainer: (a) the link-loss drop edge answered 4 of 282 probes against DROP_EDGE_TOLERANCE 3 once on the 4-core runner (the P2-T02 family of #80); if it recurs, file it and consider a tolerance relative to the probe rate rather than a count; (b) the example's resource_usage limit of 250 % CPU reflects the daemon's samples overshooting the two-core quota; a host with a different quota needs its own limit until the fidelity tiers of Phase 6 derive it from the manifest; (c) the fleet integration test still fails one run in three on this host on Nav2 (#79's family), unchanged by the task and skipped on 4 cores. |
| P2-T06 | Runner hardening, run matrix and run database | todo | ||
| P2-T07 | Field log replay source | todo | ||
| P2-T08 | Vehicle onboarding SDK and M2 demo | todo |
Phase 3 — Cockpit¶
| Task | Title | State | PR | Notes |
|---|---|---|---|---|
| P3-T01 | Cockpit backend | todo | ||
| P3-T02 | Frontend scaffold and design system | todo | ||
| P3-T03 | 3D world view (×2) | todo | ||
| P3-T04 | Sensor and navigation overlays | todo | ||
| P3-T05 | Scenario control, timeline and replay | todo | ||
| P3-T06 | Fleet dashboard and diagnostics | todo | ||
| P3-T07 | Cockpit packaging, docs and M3 demo | todo |
Phase 4 — Drones¶
| Task | Title | State | PR | Notes |
|---|---|---|---|---|
| P4-T01 | PX4 SITL integration | todo | ||
| P4-T02 | ArduPilot SITL integration | todo | ||
| P4-T03 | Reference UAV models | todo | ||
| P4-T04 | Aerial events and assertions | todo | ||
| P4-T05 | Companion computer stacks and M4 demo | todo |
Phase 5 — USVs¶
| Task | Title | State | PR | Notes |
|---|---|---|---|---|
| P5-T01 | Hydrodynamics and environment plugins (×2) | todo | ||
| P5-T02 | Reference USV model | todo | ||
| P5-T03 | Marine worlds | todo | ||
| P5-T04 | Marine events, assertions and scenarios | todo | ||
| P5-T05 | Cockpit marine layer and M5 demo | todo |
Phase 6 — Fidelity¶
| Task | Title | State | PR | Notes |
|---|---|---|---|---|
| P6-T01 | ARM64 target emulation (×2) | todo | ||
| P6-T02 | Compute budget profiles | todo | ||
| P6-T03 | Sensor noise calibration from field logs | todo | ||
| P6-T04 | Hardware in the loop | todo | ||
| P6-T05 | ROS 1 compatibility | todo | ||
| P6-T06 | Sim-versus-field comparison and M6 demo | todo |
Phase 7 — Product¶
| Task | Title | State | PR | Notes |
|---|---|---|---|---|
| P7-T01 | Scenario library and templates | todo | ||
| P7-T02 | World editor in the cockpit | todo | ||
| P7-T03 | Run history dashboard and trends | todo | ||
| P7-T04 | Plugin SDK and versioned APIs | todo | ||
| P7-T05 | Security hardening and supply chain | todo | ||
| P7-T06 | Release v1.0 | todo |
Open questions for the maintainer¶
- ~~GitHub owner and visibility~~ Resolved 2026-10-14: public repo at https://github.com/Dimitrios-Kafetzis/Fieldproof.
- Any real vehicle repository, URDF, field logs or flight controller available for the optional inputs. Vehicle part resolved 2026-10-16 (after #21): the reference vehicles up to 1.0.0 are realistic synthetic examples and no real URDF will be supplied; P4-T03 and P5-T02 follow the same rule. Field logs (P2-T07, P6-T03) and a flight controller (P6-T04) remain open.
- Preferred PX4 and ArduPilot releases (defaults: latest stable at the time of P4).
- ~~Squash-merge commits on
maincarry the PR title, which is not a Conventional Commit~~ Resolved by P0-T05 (2026-10-14), confirmed by the maintainer with the merge of #6: PR titles are Conventional Commits (type(scope): summary), checked by thePR titleworkflow; the task ID goes in the body. commitlint keeps exempting the four historicalP0-Txx ... (#N)commits. Consequence: #6 is titledfeat(ci): ..., so release-please opened the0.1.0release PR, merged as #10 on 2026-10-15 (v0.1.0). - commitlint requires a scope and accepts the repository-wide scopes
repo,plan,tools,deps,releasein addition to the layers (the history already usedrepoandplan). Confirm or trim the list incommitlint.config.mjs. - Parallel sessions share one clone. On 2026-10-14 the P0-T04 session switched branches inside the
main working tree while P0-T03 was on its branch; both sessions then moved to
git worktreecheckouts under an ignored directory. Should05-working-model.mdrequire a worktree per session (git worktree add ../fieldproof-<task> task/<id>-<slug>)? Proposed as a one-line amendment for the maintainer to approve. The P0-T06 and P0-T07 sessions already worked that way. - ~~GHCR package visibility: packages created by a workflow start private; set them to public
after the first push to
main~~ Resolved 2026-10-17:fieldproof-base-ros2,fieldproof-sim,fieldproof-dev,fieldproof-apt-listsandfieldproof-bridgeall answer anonymous pulls (checked against the registry's token endpoint, since the session'sghtoken has noread:packagesscope), soFIELDPROOF_IMAGE_TAG=main make upworks without a login and local builds seed their package lists from GHCR. - ~~Repository settings changes (
make branch-protection) must be run by the maintainer~~ Done 2026-10-15:CIandPR titleare required checks onmain;tools/ci/branch-protection.sh --checkshows any later drift.