Skip to content

Commit 9da4afc

Browse files
finalerock44claude
andcommitted
feat(cloud): add --cancel-previous to supersede the previous CI run
Sends the opt-in flag with the submission; the API derives the CI context (repo + branch/PR + check name) from the run metadata and cancels the previous run's still-queued tests. The superseded run's own CLI, still polling, now exits 0 instead of failing the build: any result carrying `superseded_by:` settles the verdict as SUPERSEDED, even alongside a test that had already failed, because this run no longer speaks for the commit. The failure is still printed and still in tests[] under --json, where SUPERSEDED is a third value of an existing documented field. Sent as its own field rather than inside `config`, which is stamped onto every result row and shipped to the runner — this is a submission directive, not run configuration. Not exposed on the MCP tool: it has no CI metadata to derive a context from, so the flag could only ever be a no-op there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 934cc28 commit 9da4afc

5 files changed

Lines changed: 206 additions & 5 deletions

File tree

‎src/commands/cloud.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ export const cloudCommand = defineCommand({
194194
collectRepeatedFlag(rawArgs, ['--exclude-tags']),
195195
);
196196
let flows = args.flows as string | undefined;
197+
const cancelPrevious = Boolean(args['cancel-previous']);
197198
const googlePlay = Boolean(args['google-play']);
198199
const ignoreShaCheck = Boolean(args['ignore-sha-check']);
199200
// Single opt-in for client-side envelope encryption of every sensitive
@@ -819,6 +820,7 @@ export const cloudCommand = defineCommand({
819820
androidNoSnapshot,
820821
apiUrl,
821822
appBinaryId: finalBinaryId,
823+
cancelPrevious,
822824
cliVersion,
823825
commonRoot,
824826
continueOnFailure,

‎src/config/flags/execution.flags.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ import type { ArgsDef } from 'citty';
44
* Test execution and flow management flags
55
*/
66
export const executionFlags = {
7+
'cancel-previous': {
8+
type: 'boolean',
9+
default: false,
10+
description:
11+
'Cancel the still-queued tests of the previous run from the same CI context (repo + branch/PR + check name, read from your CI metadata). Tests already running are left to finish; cancelled tests are refunded at 75%. Does nothing outside CI.',
12+
},
713
config: {
814
type: 'string',
915
description:

‎src/services/results-polling.service.ts‎

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,36 @@ export function deviceFromResultRow(r: {
8181
};
8282
}
8383

84+
/**
85+
* Was this row cancelled because a newer run from the same CI context
86+
* replaced it (`dcd cloud --cancel-previous`)?
87+
*
88+
* Read through a structural cast for the same reason deviceFromResultRow
89+
* does: the committed generated types are regenerated wholesale from dev's
90+
* swagger and lag the API, and this must work against an API that already
91+
* sends the field.
92+
*/
93+
export function isSupersededRow(r: unknown): boolean {
94+
const reason = (r as { cancellation_reason?: string | null } | null)
95+
?.cancellation_reason;
96+
return typeof reason === 'string' && reason.startsWith('superseded_by:');
97+
}
98+
99+
/** The upload that superseded this run, for the console link. */
100+
export function supersedingUploadId(results: unknown[]): string | undefined {
101+
for (const r of results) {
102+
const reason = (r as { cancellation_reason?: string | null } | null)
103+
?.cancellation_reason;
104+
if (typeof reason === 'string' && reason.startsWith('superseded_by:')) {
105+
return reason.slice('superseded_by:'.length) || undefined;
106+
}
107+
}
108+
return undefined;
109+
}
110+
84111
export interface PollingResult {
85112
consoleUrl: string;
86-
status: 'FAILED' | 'PASSED';
113+
status: 'FAILED' | 'PASSED' | 'SUPERSEDED';
87114
tests: Array<{
88115
/** Device this result ran on (present when the API reports it). */
89116
device?: TestDevice;
@@ -339,13 +366,24 @@ export class ResultsPollingService {
339366
): PollingResult {
340367
const resultsWithoutEarlierTries = this.filterLatestResults(results);
341368

369+
// ANY superseded row settles the whole verdict, even alongside a test
370+
// that had genuinely failed before the newer run replaced this one: this
371+
// run no longer speaks for the commit, so failing the build on its behalf
372+
// is wrong. Actions reaches the same conclusion — a cancelled run's
373+
// conclusion is `cancelled`, whatever had already failed inside it. The
374+
// failure is still printed and still in `tests[]`; only the exit code
375+
// changes.
376+
const superseded = resultsWithoutEarlierTries.some(isSupersededRow);
377+
342378
return {
343379
consoleUrl,
344380
// Anything other than an explicit pass (CANCELLED, ERROR, a status we
345381
// don't know about yet) must fail the run — this gates CI exit codes.
346-
status: resultsWithoutEarlierTries.every((result) => result.status === 'PASSED')
347-
? 'PASSED'
348-
: 'FAILED',
382+
status: superseded
383+
? 'SUPERSEDED'
384+
: resultsWithoutEarlierTries.every((result) => result.status === 'PASSED')
385+
? 'PASSED'
386+
: 'FAILED',
349387
tests: resultsWithoutEarlierTries.map((r) => ({
350388
// r carries config/simulator_name at runtime; the committed generated
351389
// types lag the API (regenerated wholesale from dev's swagger), so read
@@ -386,8 +424,12 @@ export class ResultsPollingService {
386424
const pending = statusCounts.PENDING || 0;
387425
const queued = statusCounts.QUEUED || 0;
388426
const running = statusCounts.RUNNING || 0;
427+
// CANCELLED is terminal, so it counts as completed. Without it a
428+
// cancelled or superseded run's footer sticks at "8/12 completed"
429+
// forever, having already stopped polling.
430+
const cancelled = statusCounts.CANCELLED || 0;
389431
const total = results.length;
390-
const completed = passed + failed;
432+
const completed = passed + failed + cancelled;
391433

392434
const summary = formatTestSummary({
393435
completed,
@@ -562,6 +604,37 @@ export class ResultsPollingService {
562604
testMetadata,
563605
);
564606

607+
if (output.status === 'SUPERSEDED') {
608+
// Exit 0: a newer run of the same CI context replaced this one, so
609+
// failing the build here would fail it for work nobody is waiting on.
610+
// Falls through to the success return below — RunFailedError, and with
611+
// it the exit code 2 in `dcd cloud`, is never reached.
612+
if (logger && !json) {
613+
const newer = supersedingUploadId(updatedResults);
614+
logger('\n');
615+
logger(
616+
ui.warn(
617+
'Run superseded by a newer run from the same CI context — exiting 0',
618+
),
619+
);
620+
if (newer) {
621+
logger(
622+
ui.branch(
623+
ui.fields([
624+
[
625+
'superseded by',
626+
colors.url(consoleUrl.replace(uploadId, newer)),
627+
],
628+
]),
629+
),
630+
);
631+
}
632+
logger('\n');
633+
}
634+
635+
return output;
636+
}
637+
565638
if (output.status === 'FAILED') {
566639
if (debug && logger) {
567640
logger(`[DEBUG] Some tests failed, returning failed status`);

‎src/services/test-submission.service.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ export interface TestSubmissionConfig {
1818
androidNoSnapshot?: boolean;
1919
apiUrl?: string;
2020
appBinaryId: string;
21+
/**
22+
* Ask the API to cancel the still-queued tests of the previous run from
23+
* the same CI context. Sent as its own field rather than inside `config`,
24+
* which is stamped onto every result row and shipped to the runner — this
25+
* is a one-off submission directive, not run configuration.
26+
*/
27+
cancelPrevious?: boolean;
2128
cliVersion: string;
2229
commonRoot: string;
2330
continueOnFailure?: boolean;
@@ -86,6 +93,7 @@ export class TestSubmissionService {
8693
cliVersion,
8794
env = [],
8895
metadata = [],
96+
cancelPrevious = false,
8997
googlePlay = false,
9098
androidApiLevel,
9199
androidDevice,
@@ -308,6 +316,12 @@ export class TestSubmissionService {
308316
);
309317
}
310318

319+
// Only sent when asked for, so every other submission's wire shape is
320+
// unchanged and the flag is simply ignored by an older API.
321+
if (cancelPrevious) {
322+
fields.cancelPrevious = 'true';
323+
}
324+
311325
this.setOptionalFields(fields, {
312326
androidApiLevel,
313327
androidDevice,

‎test/unit/superseded-run.test.ts‎

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { expect } from 'chai';
2+
3+
import {
4+
isSupersededRow,
5+
ResultsPollingService,
6+
supersedingUploadId,
7+
} from '../../src/services/results-polling.service.js';
8+
9+
// A superseded run is one `dcd cloud --cancel-previous` replaced: a newer run
10+
// of the same CI context cancelled its queued tests. The verdict below is what
11+
// decides whether that older CI job exits 0 or fails the build, so it is worth
12+
// pinning down separately from the polling loop around it.
13+
const row = (overrides: Record<string, unknown> = {}) => ({
14+
id: 1,
15+
test_file_name: 'flow.yaml',
16+
status: 'PASSED',
17+
retry_of: null,
18+
created_at: '2026-09-17T10:00:00.000Z',
19+
duration_seconds: 1,
20+
fail_reason: null,
21+
simulator_name: 'pixel-7',
22+
...overrides,
23+
});
24+
25+
const cancelledBySupersede = (id: number) =>
26+
row({
27+
id,
28+
status: 'CANCELLED',
29+
cancellation_reason: 'superseded_by:newer-upload',
30+
});
31+
32+
// buildPollingResult is private; it is the whole point of this file, so reach
33+
// it rather than re-implementing the verdict in the test.
34+
const verdict = (results: unknown[]) =>
35+
(
36+
new ResultsPollingService() as unknown as {
37+
buildPollingResult: (
38+
r: unknown[],
39+
uploadId: string,
40+
consoleUrl: string,
41+
) => { status: string };
42+
}
43+
).buildPollingResult(results, 'upload-1', 'https://console/upload-1').status;
44+
45+
describe('superseded runs', () => {
46+
describe('isSupersededRow', () => {
47+
it('matches only the superseded token', () => {
48+
expect(isSupersededRow(cancelledBySupersede(1))).to.equal(true);
49+
expect(isSupersededRow(row({ status: 'CANCELLED' }))).to.equal(false);
50+
expect(
51+
isSupersededRow(row({ cancellation_reason: 'user' })),
52+
).to.equal(false);
53+
expect(isSupersededRow(row())).to.equal(false);
54+
expect(isSupersededRow(null)).to.equal(false);
55+
});
56+
});
57+
58+
describe('supersedingUploadId', () => {
59+
it('reads the newer upload id out of the reason', () => {
60+
expect(supersedingUploadId([row(), cancelledBySupersede(2)])).to.equal(
61+
'newer-upload',
62+
);
63+
});
64+
65+
it('is undefined when nothing was superseded', () => {
66+
expect(supersedingUploadId([row()])).to.equal(undefined);
67+
});
68+
});
69+
70+
describe('the run verdict', () => {
71+
it('is PASSED when every test passed', () => {
72+
expect(verdict([row(), row({ id: 2 })])).to.equal('PASSED');
73+
});
74+
75+
it('is FAILED for an ordinary cancel, as before', () => {
76+
// No reason on the row: someone cancelled this run by hand, and the
77+
// build should still go red.
78+
expect(verdict([row(), row({ id: 2, status: 'CANCELLED' })])).to.equal(
79+
'FAILED',
80+
);
81+
});
82+
83+
it('is SUPERSEDED when a newer run replaced this one', () => {
84+
expect(verdict([row(), cancelledBySupersede(2)])).to.equal('SUPERSEDED');
85+
});
86+
87+
it('is SUPERSEDED even when a test had already genuinely failed', () => {
88+
// This run no longer speaks for the commit — the newer one does — so it
89+
// must not fail the build. The failure is still reported in tests[].
90+
const results = [
91+
row({ id: 1, status: 'FAILED', fail_reason: 'assertion failed' }),
92+
cancelledBySupersede(2),
93+
];
94+
95+
expect(verdict(results)).to.equal('SUPERSEDED');
96+
});
97+
98+
it('is unchanged when every test finished before the newer run arrived', () => {
99+
// Nothing was still queued, so nothing was cancelled and no marker was
100+
// written: the verdict is whatever it would have been.
101+
expect(
102+
verdict([row(), row({ id: 2, status: 'FAILED' })]),
103+
).to.equal('FAILED');
104+
});
105+
});
106+
});

0 commit comments

Comments
 (0)