Skip to content

Commit df1fb6b

Browse files
committed
fix: make a knownDivergence waiver cover exactly one failure, not any failure
P1 from re-review, and a real flaw: the code did not do what its own comment claimed. runScenario() collapsed every unexpected outcome and every invariant failure into `misbehaved`, then turned ANY of them green if the scenario carried a declaration. So while the #1299 scrollUntilVisible waiver is open, upstream Maestro could start failing too — or a different invariant could break — and the scheduled job would still report known-divergence and pass. A waiver for one bug was silently amnesty for the next. That is the exact failure this oracle exists to prevent, committed one commit after building the guard against it. knownDivergence now requires an `expected` signature: both engines' outcomes plus each declared invariant's status. The runner matches it exactly — - matches -> known-divergence (green, tracked) - misbehaves differently -> failed (red): not the failure the waiver covers - stops misbehaving -> stale-declaration (red): remove the declaration #1299's signature pins what runs 29504440599/29510020718 actually observed: maestro=pass, agent-device=fail, settle invariant no-data. Tests prove unrelated failures stay red under an open waiver: upstream also failing, our engine unexpectedly passing, a different invariant status, and a new invariant appearing are each NOT covered. A signature where both engines pass is rejected outright as describing no divergence. Also retains replay-timing.ndjson as a run artifact (review evidence note): the invariants are computed from that trace, so a report saying "tapRetries was 0" cannot be audited once the runner is gone without it.
1 parent 5a49465 commit df1fb6b

4 files changed

Lines changed: 142 additions & 15 deletions

File tree

.github/workflows/conformance-differential.yml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,10 +139,16 @@ jobs:
139139
--trace-root "${{ github.workspace }}/.agent-device" \
140140
${DIFFERENTIAL_ONLY:+--only "$DIFFERENTIAL_ONLY"}
141141
142-
- name: Upload report
142+
# Keep the trace, not just the verdict: the engine-side invariants are
143+
# computed FROM replay-timing.ndjson, so without it a report saying
144+
# "invariant violated: tapRetries was 0" cannot be audited or debugged
145+
# after the runner is gone.
146+
- name: Upload report and trace
143147
if: always()
144148
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
145149
with:
146150
name: conformance-differential-ios
147-
path: .tmp/conformance-differential/differential-report.json
151+
path: |
152+
.tmp/conformance-differential/differential-report.json
153+
.agent-device/**/replay-timing.ndjson
148154
if-no-files-found: ignore

scripts/maestro-conformance/differential/run.test.ts

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@ import assert from 'node:assert/strict';
55
import fs from 'node:fs';
66
import path from 'node:path';
77
import { fileURLToPath } from 'node:url';
8-
import { test } from 'node:test';
9-
import { DIFFERENTIAL_APP_ID, DIFFERENTIAL_SCENARIOS } from './scenarios.ts';
10-
import { parseRunnerArgs, selectScenarios, validateScenarios } from './run.ts';
8+
import { describe, test } from 'node:test';
9+
import {
10+
DIFFERENTIAL_APP_ID,
11+
DIFFERENTIAL_SCENARIOS,
12+
type DivergenceSignature,
13+
} from './scenarios.ts';
14+
import { matchesSignature, parseRunnerArgs, selectScenarios, validateScenarios } from './run.ts';
1115

1216
const CONFORMANCE_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
1317

@@ -66,6 +70,64 @@ test('every knownDivergence carries a tracking issue', () => {
6670
assert.deepEqual(problems, [], problems.join('\n'));
6771
});
6872

73+
// A waiver must cover the ONE failure it was granted for. Without an exact
74+
// signature it is blanket amnesty: while a gap is open the job would also
75+
// swallow upstream regressing or a different invariant breaking — hiding the
76+
// next bug behind the last one.
77+
describe('knownDivergence signature matching', () => {
78+
const sig: DivergenceSignature = { maestro: 'pass', agentDevice: 'fail', invariants: ['no-data'] };
79+
const engine = (outcome: 'pass' | 'fail') => ({
80+
engine: 'maestro' as const,
81+
outcome,
82+
exitCode: outcome === 'pass' ? 0 : 1,
83+
});
84+
const inv = (status: 'held' | 'violated' | 'no-data') =>
85+
({ invariant: { kind: 'stepDurationBelow', command: 'tapOn', maxMs: 1, because: 'x' }, status, detail: '' }) as never;
86+
87+
test('matches the declared failure exactly', () => {
88+
assert.equal(matchesSignature(sig, engine('pass'), engine('fail'), [inv('no-data')]), true);
89+
});
90+
91+
test('upstream also failing is NOT covered by the waiver', () => {
92+
// The #1299 shape is maestro=pass. If Maestro starts failing too, that is a
93+
// different problem and must not ride in green on this declaration.
94+
assert.equal(matchesSignature(sig, engine('fail'), engine('fail'), [inv('no-data')]), false);
95+
});
96+
97+
test('our engine unexpectedly passing is NOT covered (declaration is stale)', () => {
98+
assert.equal(matchesSignature(sig, engine('pass'), engine('pass'), [inv('no-data')]), false);
99+
});
100+
101+
test('a different invariant outcome is NOT covered', () => {
102+
assert.equal(matchesSignature(sig, engine('pass'), engine('fail'), [inv('violated')]), false);
103+
});
104+
105+
test('a new invariant appearing is NOT covered', () => {
106+
assert.equal(
107+
matchesSignature(sig, engine('pass'), engine('fail'), [inv('no-data'), inv('violated')]),
108+
false,
109+
);
110+
});
111+
112+
test('every declaration states its expected signature', () => {
113+
for (const scenario of DIFFERENTIAL_SCENARIOS) {
114+
const declared = scenario.knownDivergence;
115+
if (!declared) continue;
116+
assert.ok(declared.expected, `${scenario.id}: knownDivergence must declare an expected signature`);
117+
assert.ok(
118+
['pass', 'fail'].includes(declared.expected.maestro) &&
119+
['pass', 'fail'].includes(declared.expected.agentDevice),
120+
`${scenario.id}: signature must state both engines' outcomes`,
121+
);
122+
// A waiver that expects both engines to pass is not a divergence at all.
123+
assert.ok(
124+
!(declared.expected.maestro === 'pass' && declared.expected.agentDevice === 'pass'),
125+
`${scenario.id}: a signature where both engines pass describes no divergence`,
126+
);
127+
}
128+
});
129+
});
130+
69131
test('every device flow targets the fixture app the workflow installs', () => {
70132
for (const scenario of DIFFERENTIAL_SCENARIOS) {
71133
const body = fs.readFileSync(path.join(CONFORMANCE_DIR, scenario.flow), 'utf8');

scripts/maestro-conformance/differential/run.ts

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ import { spawnSync } from 'node:child_process';
1313
import fs from 'node:fs';
1414
import path from 'node:path';
1515
import { fileURLToPath } from 'node:url';
16-
import { DIFFERENTIAL_SCENARIOS, type DifferentialScenario } from './scenarios.ts';
16+
import {
17+
DIFFERENTIAL_SCENARIOS,
18+
type DifferentialScenario,
19+
type DivergenceSignature,
20+
} from './scenarios.ts';
1721
import { type InvariantResult, evaluateInvariants, readTrace } from './invariants.ts';
1822

1923
const HERE = path.dirname(fileURLToPath(import.meta.url));
@@ -124,6 +128,24 @@ function findTimingTrace(root: string | undefined): string | undefined {
124128
return found.sort((a, b) => b.mtimeMs - a.mtimeMs)[0]?.file;
125129
}
126130

131+
/**
132+
* Does the observed failure match the one the waiver was granted for, exactly?
133+
* Both engines' outcomes and every declared invariant status must line up; any
134+
* deviation means this is a different failure and must stay red.
135+
*/
136+
export function matchesSignature(
137+
expected: DivergenceSignature,
138+
maestro: EngineResult,
139+
agentDevice: EngineResult,
140+
invariants: InvariantResult[],
141+
): boolean {
142+
if (maestro.outcome !== expected.maestro) return false;
143+
if (agentDevice.outcome !== expected.agentDevice) return false;
144+
const expectedInvariants = expected.invariants ?? [];
145+
if (expectedInvariants.length !== invariants.length) return false;
146+
return expectedInvariants.every((status, index) => invariants[index]?.status === status);
147+
}
148+
127149
function runScenario(scenario: DifferentialScenario, options: RunnerOptions): ScenarioReport {
128150
const flowPath = path.join(CONFORMANCE_DIR, scenario.flow);
129151
const platformArgs = options.platform ? ['--platform', options.platform] : [];
@@ -151,17 +173,25 @@ function runScenario(scenario: DifferentialScenario, options: RunnerOptions): Sc
151173
const misbehaved = outcomeDiverged || invariantFailed;
152174

153175
// A declared divergence is an expected, tracked gap: it keeps the run green so
154-
// the oracle is not blocked on the engine bug it just found. But a declaration
155-
// that no longer reproduces FAILS — the fix PR has to delete it, which is what
156-
// turns the differential into the acceptance test for its own findings.
176+
// the oracle is not blocked on the engine bug it just found. But the waiver
177+
// covers ONE precise failure — matched exactly below — so while the gap is
178+
// open the job still catches anything else (upstream regressing, a different
179+
// invariant breaking). And a declaration that no longer reproduces FAILS, so
180+
// the fix PR has to delete it; that is what turns the differential into the
181+
// acceptance test for its own findings.
157182
const declared = scenario.knownDivergence;
158-
const status = declared
183+
const matchesDeclared =
184+
declared !== undefined && matchesSignature(declared.expected, maestro, agentDevice, invariants);
185+
const status = !declared
159186
? misbehaved
160-
? ('known-divergence' as const)
161-
: ('stale-declaration' as const)
162-
: misbehaved
163187
? ('failed' as const)
164-
: ('ok' as const);
188+
: ('ok' as const)
189+
: matchesDeclared
190+
? ('known-divergence' as const)
191+
: misbehaved
192+
? // Diverged, but not the way the waiver describes: not covered.
193+
('failed' as const)
194+
: ('stale-declaration' as const);
165195

166196
return {
167197
id: scenario.id,

scripts/maestro-conformance/differential/scenarios.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,30 @@ export type DifferentialOutcome = 'pass' | 'fail';
3939
* no issue behind it is exactly how "temporarily expected" becomes permanent
4040
* without anyone deciding to make it so.
4141
*/
42+
/**
43+
* The EXACT failure a declaration covers.
44+
*
45+
* Without this a declaration is blanket amnesty: any failure at all would turn
46+
* the scenario green, so while a gap is open the job would also swallow an
47+
* unrelated regression — upstream Maestro starting to fail, or a different
48+
* invariant breaking. A waiver must cover the one failure it was granted for and
49+
* nothing else, so the signature is matched exactly and any deviation is red.
50+
*/
51+
export type DivergenceSignature = {
52+
/** Outcome each engine is expected to produce while the gap is open. */
53+
maestro: DifferentialOutcome;
54+
agentDevice: DifferentialOutcome;
55+
/** Expected status of each declared engine invariant, in declaration order. */
56+
invariants?: Array<'held' | 'violated' | 'no-data'>;
57+
};
58+
4259
export type KnownDivergence = {
4360
/** Why this scenario currently fails, and what it blocks. */
4461
reason: string;
4562
/** Issue tracking the fix. Required — see above. */
4663
tracking: string;
64+
/** The precise failure this waiver covers. Anything else stays red. */
65+
expected: DivergenceSignature;
4766
};
4867

4968
export type DifferentialScenario = {
@@ -90,8 +109,18 @@ export const DIFFERENTIAL_SCENARIOS: DifferentialScenario[] = [
90109
'agent-device settled in a different order than upstream (sleep-before vs sleep-after capture) or never latches within the shared budget.',
91110
knownDivergence: {
92111
reason:
93-
'Caught by this differential on its first working run: our scrollUntilVisible times out finding home-open-form where Maestro 2.5.1 scrolls to it and passes (maestro=pass, agent-device=fail). The flow cannot reach its tapOn step, so bug class 4 has no working device detector until that engine bug is fixed. Declared rather than chased inline — the instrument does not block on repairing what it just measured.',
112+
'Caught by this differential on its first working run: our scrollUntilVisible times out finding home-open-form where Maestro 2.5.1 scrolls to it and passes. The flow cannot reach its tapOn step, so bug class 4 has no working device detector until that engine bug is fixed. Declared rather than chased inline — the instrument does not block on repairing what it just measured.',
94113
tracking: 'https://github.com/callstack/agent-device/issues/1299',
114+
// Exactly what runs 29504440599 and 29510020718 observed. If upstream
115+
// starts failing too, or the flow fails for a different reason, the
116+
// signature stops matching and the job goes red — a waiver for THIS bug
117+
// must not silently cover the next one.
118+
expected: {
119+
maestro: 'pass',
120+
agentDevice: 'fail',
121+
// The tap step is never reached, so the settle invariant has no data.
122+
invariants: ['no-data'],
123+
},
95124
},
96125
},
97126
{

0 commit comments

Comments
 (0)