Skip to content

Commit 560f668

Browse files
authored
Add repeated stability runs with recovery retries (#28)
1 parent 565dea5 commit 560f668

2 files changed

Lines changed: 214 additions & 0 deletions

File tree

examples/repeat-counter-suite.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { mkdirSync, writeFileSync } from "node:fs";
2+
import { readFile, writeFile } from "node:fs/promises";
3+
import os from "node:os";
4+
import path from "node:path";
5+
import { type TestCase } from "../src/index.js";
6+
7+
const counterDir = path.join(os.tmpdir(), "skillgym-repeat-counter-suite");
8+
const stableCounterPath = path.join(counterDir, "stable.json");
9+
const flakyCounterPath = path.join(counterDir, "flaky.json");
10+
11+
mkdirSync(counterDir, { recursive: true });
12+
writeFileSync(stableCounterPath, JSON.stringify({ value: 0 }, null, 2), "utf8");
13+
writeFileSync(flakyCounterPath, JSON.stringify({ value: 0 }, null, 2), "utf8");
14+
15+
const suite: TestCase[] = [
16+
{
17+
id: "repeat-counter-stable",
18+
prompt: "Say only: stable repeat counter demo",
19+
async assert() {
20+
const next = await incrementCounter(stableCounterPath);
21+
console.log(next);
22+
},
23+
},
24+
{
25+
id: "repeat-counter-flaky",
26+
prompt: "Say only: flaky repeat counter demo",
27+
async assert() {
28+
const next = await incrementCounter(flakyCounterPath);
29+
console.log(next);
30+
31+
if (next === 3 || next === 4) {
32+
throw new Error(`intentional failure at counter ${String(next)}`);
33+
}
34+
},
35+
},
36+
];
37+
38+
export default suite;
39+
export { counterDir, stableCounterPath, flakyCounterPath };
40+
41+
async function incrementCounter(filePath: string): Promise<number> {
42+
const current = await readCounter(filePath);
43+
const next = current + 1;
44+
await writeFile(filePath, JSON.stringify({ value: next }, null, 2), "utf8");
45+
return next;
46+
}
47+
48+
async function readCounter(filePath: string): Promise<number> {
49+
const contents = JSON.parse(await readFile(filePath, "utf8")) as { value?: unknown };
50+
if (typeof contents.value !== "number") {
51+
throw new Error(`Invalid counter file: ${filePath}`);
52+
}
53+
54+
return contents.value;
55+
}

test/runner/execute-suite.reporter.test.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,6 +1020,149 @@ test("executeSuite stops on a failed repetition and keeps averages from successf
10201020
);
10211021
});
10221022

1023+
test("executeSuite repeats asserts with persisted temp state and retries failed repetitions", async () => {
1024+
const outputDir = await createTempDir();
1025+
const stateDir = await createTempDir();
1026+
const stableCounterPath = path.join(stateDir, "stable-counter.json");
1027+
const flakyCounterPath = path.join(stateDir, "flaky-counter.json");
1028+
const runner = createRunnerInfo("open", { type: "opencode", model: "openai/gpt-5" });
1029+
1030+
await writeFile(stableCounterPath, JSON.stringify({ value: 0 }, null, 2), "utf8");
1031+
await writeFile(flakyCounterPath, JSON.stringify({ value: 0 }, null, 2), "utf8");
1032+
1033+
const stableSeen: number[] = [];
1034+
const flakySeen: number[] = [];
1035+
const suitePath = path.join(stateDir, "repeat-suite.ts");
1036+
1037+
const adapter: RunnerAdapter = {
1038+
async run(input: RunInput): Promise<RunHandle> {
1039+
return {
1040+
startedAt: "2026-04-03T10:00:00.000Z",
1041+
endedAt: "2026-04-03T10:00:01.000Z",
1042+
durationMs: 1_000,
1043+
stdoutPath: path.join(input.artifactsDir, "stdout.log"),
1044+
stderrPath: path.join(input.artifactsDir, "stderr.log"),
1045+
};
1046+
},
1047+
async collect(handle: RunHandle): Promise<RawRunArtifacts> {
1048+
return {
1049+
stdout: "",
1050+
stderr: "",
1051+
stdoutPath: handle.stdoutPath,
1052+
stderrPath: handle.stderrPath,
1053+
startedAt: handle.startedAt,
1054+
endedAt: handle.endedAt,
1055+
durationMs: handle.durationMs,
1056+
};
1057+
},
1058+
async normalize(input: RunInput) {
1059+
return createSessionReport({
1060+
runner,
1061+
prompt: input.prompt,
1062+
finalOutput: input.prompt,
1063+
});
1064+
},
1065+
async explain() {
1066+
throw new Error("not used in repeat-state test");
1067+
},
1068+
};
1069+
1070+
const cases: TestCase[] = [
1071+
{
1072+
id: "repeat-counter-stable",
1073+
prompt: "Print the stable repeat counter.",
1074+
async assert() {
1075+
const next = await incrementCounter(stableCounterPath);
1076+
stableSeen.push(next);
1077+
console.log(next);
1078+
},
1079+
},
1080+
{
1081+
id: "repeat-counter-flaky",
1082+
prompt: "Print the flaky repeat counter.",
1083+
async assert() {
1084+
const next = await incrementCounter(flakyCounterPath);
1085+
flakySeen.push(next);
1086+
console.log(next);
1087+
1088+
if (next === 3 || next === 4) {
1089+
throw new Error(`intentional failure at counter ${String(next)}`);
1090+
}
1091+
},
1092+
},
1093+
];
1094+
1095+
const result = await executeSuite(suitePath, cases, {
1096+
cwd: outputDir,
1097+
outputDir,
1098+
repeat: 4,
1099+
repeatFailure: 2,
1100+
isInteractive: false,
1101+
config: {
1102+
runners: {
1103+
open: { agent: { type: "opencode", model: "openai/gpt-5" } },
1104+
},
1105+
},
1106+
executeRunnerFn: (testCase, runnerInfo, _unusedAdapter, options) => {
1107+
return executeRunner(testCase, runnerInfo, adapter, options);
1108+
},
1109+
});
1110+
1111+
const stableResult = result.cases.find(
1112+
(caseResult) => caseResult.caseId === "repeat-counter-stable",
1113+
)?.runnerResults[0];
1114+
const flakyResult = result.cases.find(
1115+
(caseResult) => caseResult.caseId === "repeat-counter-flaky",
1116+
)?.runnerResults[0];
1117+
1118+
expect(stableSeen).toEqual([1, 2, 3, 4]);
1119+
expect(flakySeen).toEqual([1, 2, 3, 4, 5, 6]);
1120+
expect(await readCounter(stableCounterPath)).toBe(4);
1121+
expect(await readCounter(flakyCounterPath)).toBe(6);
1122+
1123+
expect(stableResult).toMatchObject({
1124+
passed: true,
1125+
status: "passed",
1126+
repeatTarget: 4,
1127+
completedRepetitions: 4,
1128+
successfulRepetitions: 4,
1129+
});
1130+
expect(stableResult?.repetitions?.map((entry) => entry.attempt)).toEqual([1, 1, 1, 1]);
1131+
1132+
expect(flakyResult).toMatchObject({
1133+
passed: true,
1134+
status: "passed",
1135+
repeatTarget: 4,
1136+
completedRepetitions: 4,
1137+
successfulRepetitions: 4,
1138+
attempt: 1,
1139+
});
1140+
expect(flakyResult?.repetitions).toHaveLength(4);
1141+
expect(flakyResult?.repetitions?.map((entry) => entry.attempt)).toEqual([1, 1, 3, 1]);
1142+
expect(flakyResult?.repetitions?.[2]?.attempts?.map((entry) => entry.passed)).toEqual([
1143+
false,
1144+
false,
1145+
true,
1146+
]);
1147+
expect(flakyResult?.repetitions?.[2]?.attempts?.map((entry) => entry.error?.message)).toEqual([
1148+
"intentional failure at counter 3",
1149+
"intentional failure at counter 4",
1150+
undefined,
1151+
]);
1152+
expect(flakyResult?.repetitions?.[2]?.attempts?.[0]?.artifactDir).toBe(
1153+
path.join(result.outputDir, "repeat-counter-flaky", runner.pathKey, "repeat-3"),
1154+
);
1155+
expect(flakyResult?.repetitions?.[2]?.attempts?.[1]?.artifactDir).toBe(
1156+
path.join(result.outputDir, "repeat-counter-flaky", runner.pathKey, "repeat-3", "attempt-2"),
1157+
);
1158+
expect(flakyResult?.repetitions?.[2]?.attempts?.[2]?.artifactDir).toBe(
1159+
path.join(result.outputDir, "repeat-counter-flaky", runner.pathKey, "repeat-3", "attempt-3"),
1160+
);
1161+
expect(flakyResult?.leafArtifactDir).toBe(
1162+
path.join(result.outputDir, "repeat-counter-flaky", runner.pathKey, "repeat-4"),
1163+
);
1164+
});
1165+
10231166
test("executeSuite filters cases by tags with OR semantics and preserves result metadata", async () => {
10241167
const outputDir = await createTempDir();
10251168
const executed: string[] = [];
@@ -1372,6 +1515,22 @@ async function createTempDir(): Promise<string> {
13721515
return tempDir;
13731516
}
13741517

1518+
async function incrementCounter(filePath: string): Promise<number> {
1519+
const current = await readCounter(filePath);
1520+
const next = current + 1;
1521+
await writeFile(filePath, JSON.stringify({ value: next }, null, 2), "utf8");
1522+
return next;
1523+
}
1524+
1525+
async function readCounter(filePath: string): Promise<number> {
1526+
const contents = JSON.parse(await readFile(filePath, "utf8")) as { value?: unknown };
1527+
if (typeof contents.value !== "number") {
1528+
throw new Error(`Invalid counter file: ${filePath}`);
1529+
}
1530+
1531+
return contents.value;
1532+
}
1533+
13751534
function createSnapshotRuntime(filePath: string): SnapshotRuntimeOptions {
13761535
return {
13771536
enabled: true,

0 commit comments

Comments
 (0)