mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
885c1486bb
* test(ci): single-retry policy for enumerated contention-flaky files * fix: satisfy fallow * test(ci): read failures through a lane reporter so timeouts stay distinguishable * test(ci): cover the lane reporter and drop its duplicated boilerplate * chore(fallow): own the retry lane's tool-loaded export seams * test(ci): block retries on non-test failures and classify timeouts structurally * test(ci): decide retry eligibility from runner metadata and route gate verdicts through blockers * refactor(ci): name the retry policy's rules in code instead of comments * test(ci): mark runner-aborted timeouts inside the runner instead of inferring them * test(ci): make timeout provenance a per-run secret, not a writable flag Cover direct task.meta mutation in the real child-Vitest fixture gate. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(ci): retry the failed files in the first run's project and coverage modes Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix: drop deleted repo-health file from the retry list after #1480 Rebase onto main post-#1480: the SkillGym/repo-health descope deleted scripts/repo-health/run.test.ts, whose CONTENTION_RETRY_FILES entry would now fail this PR's own missing-file check, and inlined the slow-test budgets into the reporter, resolving the budgets-module import. Envelope comments now point at scripts/lib/lane-envelope.ts instead of the closed #1430. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FUv7bvbWNryuXgSBuqTtep --------- Co-authored-by: Michał Pierzchała <thymikee@gmail.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
150 lines
4.3 KiB
TypeScript
150 lines
4.3 KiB
TypeScript
// Orchestration for the single-retry policy (#1419): run the suite, and when it
|
|
// fails only with timeout-shaped failures in enumerated files, rerun exactly
|
|
// those files once. Kept free of process spawning and file writing so both
|
|
// acceptance cases — an injected assertion failure fails on the first run, an
|
|
// injected timeout passes on retry — are driven directly in the gate test.
|
|
|
|
import { laneEnvelope, type LaneEnvelope } from './lane-envelope.ts';
|
|
import {
|
|
CONTENTION_RETRY_FILES,
|
|
expiredRetryEntries,
|
|
formatRetrySummary,
|
|
normalizeTestFile,
|
|
planContentionRetry,
|
|
type RetryOutcome,
|
|
type RetryPlan,
|
|
type RunBlocker,
|
|
type TestFailure,
|
|
} from './contention-retry.ts';
|
|
|
|
export type TestRun = {
|
|
ok: boolean;
|
|
failures: readonly TestFailure[];
|
|
/** Non-test failures; any of them forbids a retry. */
|
|
blockers?: readonly RunBlocker[];
|
|
};
|
|
|
|
/** Telemetry payload written to the shared lane envelope (scripts/lib/lane-envelope.ts). */
|
|
export type ContentionRetryTelemetry = {
|
|
/** Files rerun in this job (one entry per file, not per failed test). */
|
|
retried: ReadonlyArray<{ file: string; testNames: readonly string[]; trackingIssue: string }>;
|
|
retryCount: number;
|
|
retryOutcome: RetryOutcome | null;
|
|
listSize: number;
|
|
};
|
|
|
|
export type ContentionRetryResult = {
|
|
ok: boolean;
|
|
summary: string;
|
|
envelope: LaneEnvelope<ContentionRetryTelemetry>;
|
|
};
|
|
|
|
export type ContentionRetryOptions = {
|
|
/** Runs the whole suite. */
|
|
runAll: () => Promise<TestRun>;
|
|
/** Reruns exactly the given files. */
|
|
runFiles: (files: readonly string[]) => Promise<TestRun>;
|
|
commit: string;
|
|
configHash: string;
|
|
vitestVersion: string;
|
|
startedAtMs: number;
|
|
now?: () => number;
|
|
/** Clock for the waiver expiry gate. */
|
|
today?: Date;
|
|
};
|
|
|
|
const LANE_ID = 'unit-contention-retry';
|
|
|
|
export async function runWithContentionRetry(
|
|
options: ContentionRetryOptions,
|
|
): Promise<ContentionRetryResult> {
|
|
const today = options.today ?? new Date();
|
|
const expired = expiredRetryEntries(today);
|
|
if (expired.length > 0) {
|
|
const lines = expired.map(
|
|
(entry) => `- \`${entry.file}\` (review by ${entry.reviewBy}, ${entry.trackingIssue})`,
|
|
);
|
|
return finish(options, {
|
|
ok: false,
|
|
summary: [
|
|
'## Contention retry (#1419)',
|
|
'',
|
|
'Retry list expired — renew the review date or remove the entry:',
|
|
'',
|
|
...lines,
|
|
'',
|
|
].join('\n'),
|
|
plan: undefined,
|
|
outcome: null,
|
|
});
|
|
}
|
|
|
|
const first = await options.runAll();
|
|
if (first.ok) {
|
|
return finish(options, { ok: true, summary: '', plan: undefined, outcome: null });
|
|
}
|
|
|
|
const plan = planContentionRetry(first.failures, first.blockers ?? []);
|
|
if (!plan.retry) {
|
|
return finish(options, {
|
|
ok: false,
|
|
summary: formatRetrySummary({ plan }),
|
|
plan,
|
|
outcome: null,
|
|
});
|
|
}
|
|
|
|
const retried = await options.runFiles(plan.files);
|
|
const outcome: RetryOutcome = retried.ok ? 'passed' : 'failed';
|
|
return finish(options, {
|
|
ok: retried.ok,
|
|
summary: formatRetrySummary({ plan, outcome }),
|
|
plan,
|
|
outcome,
|
|
});
|
|
}
|
|
|
|
function finish(
|
|
options: ContentionRetryOptions,
|
|
state: {
|
|
ok: boolean;
|
|
summary: string;
|
|
plan: RetryPlan | undefined;
|
|
outcome: RetryOutcome | null;
|
|
},
|
|
): ContentionRetryResult {
|
|
const plan = state.plan;
|
|
// Keyed by file, matching what actually reran.
|
|
const retried = plan?.retry
|
|
? plan.files.map((file) => ({
|
|
file,
|
|
testNames: plan.failures
|
|
.filter((failure) => normalizeTestFile(failure.file) === file)
|
|
.map((failure) => failure.testName),
|
|
trackingIssue:
|
|
CONTENTION_RETRY_FILES.find((entry) => entry.file === file)?.trackingIssue ?? '',
|
|
}))
|
|
: [];
|
|
return {
|
|
ok: state.ok,
|
|
summary: state.summary,
|
|
envelope: laneEnvelope<ContentionRetryTelemetry>({
|
|
lane: LANE_ID,
|
|
commit: options.commit,
|
|
tool: { vitest: options.vitestVersion },
|
|
configHash: options.configHash,
|
|
startedAtMs: options.startedAtMs,
|
|
now: options.now?.(),
|
|
// The retry set is enumerated, not randomized.
|
|
seed: null,
|
|
result: state.ok ? 'pass' : 'fail',
|
|
data: {
|
|
retried,
|
|
retryCount: retried.length,
|
|
retryOutcome: state.outcome,
|
|
listSize: CONTENTION_RETRY_FILES.length,
|
|
},
|
|
}),
|
|
};
|
|
}
|