Files
callstack__agent-device/.github/workflows/conformance-differential.yml
Michał Pierzchała edba835365 fix(ci): spawn the differential's agent-device CLI as argv, not one option (#2069)
* fix(ci): spawn the differential's agent-device CLI as argv, not one option

The Conformance Differential nightly has been red since 2026-08-25: every
scenario reported `infrastructure-failed ... agent-device=fail` under

  node: bad option: --experimental-strip-types src/bin.ts

`runAgentDeviceEngine` took the CLI as a single `cliPath` string and spawned
`[cliPath, ...args]`, but AGENT_DEVICE_CLI — which every device workflow sets,
and which is also this runner's default — is a command *line*: a node flag plus
the entry script. Node aborted on the combined token before the CLI loaded, so
the oracle compared nothing on all six scenarios.

Tokenize the variable once, in `resolveAgentDeviceCliArgv` beside the spawn it
feeds, and hand `runAgentDeviceEngine` an argv array. A path containing spaces
stays expressible as a single array element (the property the string signature
was protecting), while a flag plus a script can no longer collapse into one
option.

The fixture the old tests used was a bare `.mjs` path, which cannot tell an argv
from a command line, so the regression test runs the workflow's own shape end to
end — flag plus script — and fails with the exact CI signature without the fix.

* fix(ci): split the differential CLI env into entry path and node flags

Addresses the P1 review on #2069. Tokenizing AGENT_DEVICE_CLI on whitespace
fixed the flags-plus-script shape but broke the other one: an override like
`/tmp/agent device.mjs`, which main passes through intact, became two arguments.
One variable cannot encode both — any delimiter that separates flags from the
entry can also occur inside a path.

So the two concerns become two variables that cannot be confused:

  AGENT_DEVICE_CLI             the entry script — ONE path, never split
  AGENT_DEVICE_CLI_NODE_FLAGS  node flags — split on whitespace, which is exact
                               because a node flag cannot contain a space

Defaults reproduce today's behavior, and the empty string runs an entry that
needs no flags.

The regression now runs through the production route the review asked for —
environment, parseRunnerArgs, runScenario, spawn — rather than calling
runAgentDeviceEngine with a hand-built argv, which cannot see the environment
contract at all. Each direction is pinned by its own case, verified against both
broken implementations: main's unsplit string fails "node flags stay separate
arguments", and the whitespace split fails "a CLI path containing spaces reaches
the spawn unsplit".

The maestro stub stays out of the spaced directory on purpose: runMaestroEngine
still splits its command on spaces, and a spaced stub path would fail these
tests for the other engine's reason.
2026-08-27 10:47:27 +02:00

174 lines
7.4 KiB
YAML

name: Conformance Differential
# Layer 3 of the Maestro conformance oracle: run a small set of flows through
# BOTH real Maestro and agent-device on a live device and compare app-observable
# outcomes, plus assert engine-side timing invariants over agent-device's own
# replay trace. This is the only layer that can catch settle-loop ordering, which
# has no reflectable upstream constant.
#
# Scheduled: this path is proven end-to-end (run 29504440599 executed both
# engines against the real fixture app). Scenarios that currently diverge are
# DECLARED via `knownDivergence` in differential/scenarios.ts with a tracking
# issue, so the schedule stays green on known gaps and fails only on undeclared
# ones — the same contract layer 1 has. A declaration that stops reproducing also
# fails, so a fix cannot land while leaving the oracle blind.
on:
schedule:
- cron: '0 5 * * *'
workflow_dispatch:
inputs:
only:
description: 'Single scenario id to run (default: all)'
required: false
permissions:
contents: read
# setup-fixture-app looks up the cached test-app binary via the artifacts API.
actions: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
# Entry script and node flags are separate on purpose: one variable cannot
# encode both without either corrupting a path that contains spaces or
# spawning the whole line as a single node option.
AGENT_DEVICE_CLI: 'src/bin.ts'
AGENT_DEVICE_CLI_NODE_FLAGS: '--experimental-strip-types'
DIFFERENTIAL_ONLY: ${{ github.event.inputs.only || '' }}
# CI should not phone home, and it keeps `maestro --version` to just the
# version instead of prefixing an analytics notice.
MAESTRO_CLI_NO_ANALYTICS: '1'
jobs:
differential-ios:
name: iOS Conformance Differential
runs-on: macos-26
timeout-minutes: 90
env:
IOS_RUNTIME_VERSION: '26.2'
AGENT_DEVICE_XCUITEST_ARCHS: 'arm64'
AGENT_DEVICE_STATE_DIR: ${{ github.workspace }}/.tmp/agent-device-state
AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/ios-runner-derived
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup toolchain
uses: ./.github/actions/setup-node-pnpm
- name: Restore and build iOS XCTest runner
uses: ./.github/actions/setup-apple-runner-build
with:
derived-path: ${{ env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH }}
cache-key-prefix: ios-runner-prebuilt
cache-key-suffix: -ios-${{ env.IOS_RUNTIME_VERSION }}-arm64
gate: swift-runner-ios
xcuitest-platform: ios
xcuitest-destination: generic/platform=iOS Simulator
- name: Boot simulator
uses: ./.github/actions/boot-ios-test-simulator
with:
runtime-version: ${{ env.IOS_RUNTIME_VERSION }}
preferred-device-name: iPhone 17 Pro
# Installs the cached Release test-app and refreshes its JS. Shared with any
# other job that needs a controlled app to drive (see #320) — the artifact
# is repo-wide, so once the producer builds a fingerprint every workflow
# gets it as a download.
- name: Setup fixture app
id: fixture-app
uses: ./.github/actions/setup-fixture-app
with:
device-name: iPhone 17 Pro
# The action guarantees *an* app is installed; this asserts it is the one
# the scenarios actually target, so a fixture rename cannot leave the
# differential driving the wrong app.
- name: Verify the installed app is the one the scenarios target
run: |
set -euo pipefail
EXPECTED=$(node --experimental-strip-types -e \
"import('./packages/maestro/test/conformance/differential/scenarios.ts').then(m => console.log(m.DIFFERENTIAL_APP_ID))")
ACTUAL="${{ steps.fixture-app.outputs.app-id }}"
echo "expected=$EXPECTED actual=$ACTUAL (source=${{ steps.fixture-app.outputs.source }})"
if [ "$ACTUAL" != "$EXPECTED" ]; then
echo "::error::Installed fixture app is $ACTUAL but the scenarios target $EXPECTED; they would fail vacuously."
exit 1
fi
# Pin the Maestro CLI to the SAME version the oracle pins for layers 1-2,
# read from pinned-upstream.json so the two can never drift apart. The
# installer honours MAESTRO_VERSION; assert what actually landed.
- name: Install pinned Maestro CLI
run: |
set -euo pipefail
MAESTRO_VERSION=$(node -p "require('./scripts/maestro-conformance/pinned-upstream.json').version")
export MAESTRO_VERSION
echo "Pinning Maestro CLI to $MAESTRO_VERSION (from pinned-upstream.json)"
curl -fsSL "https://get.maestro.mobile.dev" | bash
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"
- name: Verify the Maestro CLI matches the oracle pin
run: |
set -euo pipefail
EXPECTED=$(node -p "require('./scripts/maestro-conformance/pinned-upstream.json').version")
# `maestro --version` can print notices (e.g. the analytics banner)
# around the version, so match the semver line rather than the whole
# output. Empty ACTUAL fails the comparison below, as it should.
ACTUAL=$(maestro --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | tail -1 || true)
echo "expected=$EXPECTED actual=$ACTUAL"
if [ "$ACTUAL" != "$EXPECTED" ]; then
echo "::error::Layer-3 Maestro CLI is $ACTUAL but the oracle pins $EXPECTED. A differential against a different Maestro proves nothing about the pinned behavior."
exit 1
fi
# --trace-root lets the runner find agent-device's replay-timing.ndjson and
# assert engine-side invariants (e.g. the settle loop latches instead of
# burning its budget) — outcome parity alone cannot see that.
- name: Run differential
if: env.DIFFERENTIAL_ONLY == ''
uses: ./.github/actions/run-gate
with:
gate: maestro-differential
args: |
--platform
ios
--out-dir
${{ github.workspace }}/.tmp/conformance-differential
--trace-root
${{ github.workspace }}/.agent-device
- name: Run filtered differential
if: env.DIFFERENTIAL_ONLY != ''
uses: ./.github/actions/run-gate
with:
gate: maestro-differential
args: |
--platform
ios
--out-dir
${{ github.workspace }}/.tmp/conformance-differential
--trace-root
${{ github.workspace }}/.agent-device
--only
${{ env.DIFFERENTIAL_ONLY }}
# Keep the trace, not just the verdict: the engine-side invariants are
# computed FROM replay-timing.ndjson, so without it a report saying
# "invariant violated: tapRetries was 0" cannot be audited or debugged
# after the runner is gone.
- name: Upload report and trace
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: conformance-differential-ios
include-hidden-files: true
path: |
.tmp/conformance-differential/differential-report.json
.agent-device/**/replay-timing.ndjson
if-no-files-found: ignore