mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 06:54:50 +01:00
fix: pre-landing review fixes (p1b applies finding fixes, api attempts reserve no host default, cross-file title pairing, paired severity counted)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
590a7db0df
commit
c300c2c3e5
12 files changed
+76
-26
No files matched your search
+1
-1
@@ -111,7 +111,7 @@ A reply binds to the `gate_rev` you read (`--gate-rev`). Bare `yes`/`no` is unpa
|
||||
|
||||
- `rec`: every item takes its recommendation, plan changes included (each prints APPLY:); `all` and `do it` are aliases.
|
||||
- `as-is`: keep every direction; no plan change.
|
||||
- `d3b uc1a`: one <id><option> token per item you set; unanswered auto items take their default (a finding item keeps the plan as written).
|
||||
- `d3b uc1a`: one <id><option> token per item you set; unanswered auto items take their default (a finding item keeps the plan as written; `p1b` applies it).
|
||||
- `rec except d3b`: recommendations, with the listed overrides.
|
||||
|
||||
## Run directory
|
||||
|
||||
+3
-3
@@ -181,7 +181,7 @@ function emit(json: boolean, lines: string[], payload: Record<string, unknown>):
|
||||
else process.stdout.write(lines.join('\n') + '\n');
|
||||
}
|
||||
|
||||
function doNext(out: string, rest: string[], json: boolean): number {
|
||||
function doNext(out: string, rest: string[], json: boolean, hostSubagent = true): number {
|
||||
const cwd = process.cwd();
|
||||
const initLines: string[] = [];
|
||||
const fresh = !fs.existsSync(path.join(out, FILES.run));
|
||||
@@ -198,7 +198,7 @@ function doNext(out: string, rest: string[], json: boolean): number {
|
||||
deadlineMin: num(rest, '--deadline'), light: rest.includes('--light'), scaffold: rest.includes('--scaffold'), scope: { ui, developerTool: rest.includes('--developer-tool'), agentPrimary: rest.includes('--agent-primary'), dx: rest.includes('--no-dx') ? false : undefined, ...(rest.includes('--docs-only') ? { docsOnly: true } : {}) } }, initLines);
|
||||
}
|
||||
const redispatch = rest.flatMap((a, i) => (a === '--redispatch' && rest[i + 1] ? [rest[i + 1]!] : []));
|
||||
const r = nextStep({ out, cwd, estimateUsd: num(rest, '--estimate-usd'), redispatch });
|
||||
const r = nextStep({ out, cwd, estimateUsd: num(rest, '--estimate-usd'), redispatch, hostSubagent });
|
||||
moment = fresh ? 'init' : r.attempts.some(a => a.new) || r.lines.some(l => l.startsWith('PHASE_DEFERRED: ')) ? 'transition' : undefined;
|
||||
const status = r.state.status;
|
||||
const lines = [...initLines, ...r.lines];
|
||||
@@ -312,7 +312,7 @@ async function doRun(out: string, rest: string[], json: boolean): Promise<number
|
||||
const deadline = num(rest, '--deadline');
|
||||
const deadlineAt = deadline ? Date.now() + deadline * 60_000 : undefined;
|
||||
for (let guard = 0; guard < 40; guard++) {
|
||||
const code = withRunLock(out, () => doNext(out, rest, true));
|
||||
const code = withRunLock(out, () => doNext(out, rest, true, false));
|
||||
if (code !== EXIT.ok) return code;
|
||||
const state = readState(out);
|
||||
if (state.status !== 'awaiting_result') {
|
||||
|
||||
+6
-4
@@ -373,8 +373,9 @@ the plan.
|
||||
auto item (a twin pair is one item; `a` applies the reviewer's fix to the
|
||||
plan and is recommended, `b` carries it into the build as a blocking task)
|
||||
and `p1` plan approval; nothing is approved. An unanswered auto item whose
|
||||
recommendation changes the plan keeps the plan's direction, so only `rec`
|
||||
or an explicit `<id>a` applies a fix; a finding carried with `b` stays
|
||||
recommendation changes the plan keeps the plan's direction, so only `rec`,
|
||||
`p1b` (approve with the recommended changes) or an explicit `<id>a` applies
|
||||
a fix; a finding carried with `b` stays
|
||||
open, so a rerun that raises it again asks again.
|
||||
4. **Answer the gate** with the reply grammar and the revision you read:
|
||||
```bash
|
||||
@@ -442,8 +443,9 @@ Concurrency and recovery: every state write is a locked read-modify-write
|
||||
`test/autoplan-answer-invalidation.test.ts`). `--spend-cap` admits each attempt
|
||||
through `spend.json` (`SPEND_CAP_EXCEEDED` refuses, exit 3); host-subagent
|
||||
spend is `unknown` and counted in `worst_case`. Under a cap (or on Capy) each
|
||||
unattended attempt reserves the default per voice unless `next --estimate-usd
|
||||
<n>` sets the reservation, which the run keeps for later phases; `submit
|
||||
unattended host-subagent attempt reserves the default per voice unless
|
||||
`next --estimate-usd <n>` sets the reservation, which the run keeps for later
|
||||
phases (`run`'s api and codex-cli attempts reserve only that flag); `submit
|
||||
--usage-usd <n>` settles an attempt at its real cost. `--deadline <min>` makes the
|
||||
`run` subcommand cancel the children it started and marks the run
|
||||
`interrupted`.
|
||||
|
||||
+11
-7
@@ -35,17 +35,20 @@ export const atCap = (state: RunState, phase: string): boolean => { const cap =
|
||||
/** The phase is about to open past its cap (a rerun-all reply reopened it). */
|
||||
export const overCap = (state: RunState, phase: string): boolean => { const cap = roundCap(state, phase); return cap !== null && roundOf(state, phase) > cap; };
|
||||
|
||||
/** Open High or Critical findings of a closed phase: the deferred counts, else bound rows not settled or answered, an outside row paired with a native one counted once. */
|
||||
/** Open High or Critical findings of a closed phase: the deferred counts, else bound rows not settled or answered, an outside row paired with a High or Critical native one counted once. */
|
||||
function openHigh(state: RunState, phase: string): number {
|
||||
const ps = state.phases[phase];
|
||||
if (ps?.deferred) return (ps.deferred.counts.High ?? 0) + (ps.deferred.counts.Critical ?? 0);
|
||||
const file = path.join(state.out, `${phase}-consensus.json`);
|
||||
let rows: ConsensusRecord['rows'] = [];
|
||||
try { rows = fs.existsSync(file) ? (JSON.parse(fs.readFileSync(file, 'utf8')) as ConsensusRecord).rows : []; } catch {}
|
||||
const paired = new Set(rows.filter(r => r.native_id).map(r => r.outside_id));
|
||||
const high = (s: string | undefined) => s === 'High' || s === 'Critical';
|
||||
const native = voiceRows(state, phase, 'native');
|
||||
const nativeHigh = new Set(native.filter(r => high(r.severity)).map(r => r.id));
|
||||
const paired = new Set(rows.filter(r => r.native_id && nativeHigh.has(r.native_id)).map(r => r.outside_id));
|
||||
const answered = settledIds(state);
|
||||
return [...voiceRows(state, phase, 'native'), ...voiceRows(state, phase, 'outside')]
|
||||
.filter(r => !paired.has(r.id) && !(r as { settled_by?: string }).settled_by && !answered.has(r.id) && (r.severity === 'High' || r.severity === 'Critical')).length;
|
||||
return [...native, ...voiceRows(state, phase, 'outside')]
|
||||
.filter(r => !paired.has(r.id) && !(r as { settled_by?: string }).settled_by && !answered.has(r.id) && high(r.severity)).length;
|
||||
}
|
||||
|
||||
/** At the round cap with High or Critical findings still open, more rounds buy nothing: the owner decides. */
|
||||
@@ -61,12 +64,13 @@ export function roundsLine(state: RunState): string {
|
||||
|
||||
/**
|
||||
* The estimate one new attempt reserves: the flag, else the run's persisted `--estimate-usd`, else the unattended
|
||||
* default when the run has a spend cap or runs on Capy, else 0 (host spend unknown, not admitted).
|
||||
* default for a host-subagent attempt when the run has a spend cap or runs on Capy, else 0 (spend unknown, not admitted;
|
||||
* `run` executes its attempts through api or codex-cli runners, never host subagents).
|
||||
*/
|
||||
export function attemptEstimate(state: RunState, flag: number | undefined, env: NodeJS.ProcessEnv): { usd: number; line?: string } {
|
||||
export function attemptEstimate(state: RunState, flag: number | undefined, env: NodeJS.ProcessEnv, hostSubagent = true): { usd: number; line?: string } {
|
||||
const set = flag ?? state.estimate_usd;
|
||||
if (set !== undefined) return { usd: set };
|
||||
if (state.session_kind !== 'unattended' || (env.GSTACK_CAPY !== '1' && state.spend_cap_usd === null)) return { usd: 0 };
|
||||
if (!hostSubagent || state.session_kind !== 'unattended' || (env.GSTACK_CAPY !== '1' && state.spend_cap_usd === null)) return { usd: 0 };
|
||||
return { usd: DEFAULT_HOST_ESTIMATE_USD, line: `ESTIMATE: ${DEFAULT_HOST_ESTIMATE_USD.toFixed(2)} per voice reserved for each host-subagent attempt (unattended default under a spend cap or on Capy; --estimate-usd <n> sets it for the rest of the run); actual host spend stays unknown` };
|
||||
}
|
||||
|
||||
|
||||
@@ -437,7 +437,7 @@ export function answerGate(state: RunState, gateRev: number, reply: string, answ
|
||||
const carried = (c: GateChoice) => itemOf(c.id).carried?.chosen === c.chosen;
|
||||
const changing = proceeds ? parsed.choices.filter(c => c.id !== 'p1' && !carried(c) && itemOf(c.id).reopens !== false && (optionOf(c).changes_plan ?? c.how === 'override')) : [];
|
||||
const tokens = (cs: GateChoice[]) => cs.map(c => `${c.id}${c.chosen}`);
|
||||
const overrides = parsed.choices.filter(c => c.how === 'override' && c.id !== 'p1');
|
||||
const overrides = parsed.choices.filter(c => c.id !== 'p1' && (c.how === 'override' || (c.how === 'auto_default' && c.chosen !== itemOf(c.id).recommended)));
|
||||
const reopenSet = new Set<Phase>(plan.chosen === 'd' ? state.phase_order : []);
|
||||
record();
|
||||
state.gate = { ...(state.gate ?? {}), answered_at: answeredAt, reply, gate_rev: gateRev, plan: plan.chosen, overrides: tokens(overrides), ...(answeredBy ? { answered_by: answeredBy } : {}), ...provenance };
|
||||
|
||||
@@ -226,8 +226,9 @@ function contradictory(a: string, b: string): boolean {
|
||||
|
||||
/**
|
||||
* Structural reconciliation. Findings associate by a title overlap of at least
|
||||
* half the words (a leading CAP code ignored), or by file:line (±3 lines), or,
|
||||
* when either finding has no line, by the same file. Association alone never
|
||||
* half the words (a leading CAP code ignored; never across two different files),
|
||||
* or by file:line (±3 lines), or, when either finding has no line, by the same
|
||||
* file. Association alone never
|
||||
* confirms: a title association is `confirmed` only when both voices state the
|
||||
* same remedy, `disagree` when severities differ by two or more levels or the
|
||||
* remedies contradict (one keeps, drops or negates what the other changes),
|
||||
@@ -266,7 +267,7 @@ export function reconcile(native: FindingRow[], outside: FindingRow[], overrides
|
||||
let match: FindingRow | undefined;
|
||||
let best = 0;
|
||||
for (const n of native) {
|
||||
if (used.has(n.id)) continue;
|
||||
if (used.has(n.id) || (o.file && n.file && o.file !== n.file)) continue;
|
||||
const score = jaccard(ot, tokens(n.title)) + (near(n) ? 0.01 : 0);
|
||||
if (score >= 0.5 && score > best) { best = score; match = n; }
|
||||
}
|
||||
|
||||
+2
-2
@@ -142,7 +142,7 @@ export function voiceState(state: RunState, phase: Phase, voice: Voice): VoiceSt
|
||||
}
|
||||
const openAttempt = (v: VoiceState) => v.attempts?.find(a => a.state === 'dispatched' || a.state === 'execution_unknown');
|
||||
|
||||
export interface NextOptions { out: string; cwd: string; estimateUsd?: number; redispatch?: string[] }
|
||||
export interface NextOptions { out: string; cwd: string; estimateUsd?: number; redispatch?: string[]; hostSubagent?: boolean }
|
||||
export interface NextResult { state: RunState; phase?: Phase; attempts: Array<{ attempt: string; voice: Voice; prompt: string; result: string; model_family: string; new: boolean }>; lines: string[] }
|
||||
|
||||
function openPhase(state: RunState, phase: Phase, cwd: string): void {
|
||||
@@ -220,7 +220,7 @@ export function nextStep(o: NextOptions): NextResult {
|
||||
if (ps.sections) lines.push(`Skipped sections: ${ps.sections.skipped.length ? ps.sections.skipped.join(',') : 'none'} (scope: ui=${state.scope.ui ? 'yes' : 'no'},dx=${state.scope.dx ? 'yes' : 'no'},source=${scopeSourceLabel(state.scope)}; checklist=${ps.sections.checklist ? 'loaded' : 'none'})`);
|
||||
const prompts = ps.prompts!;
|
||||
if (o.estimateUsd !== undefined) state.estimate_usd = o.estimateUsd;
|
||||
const estimate = attemptEstimate(state, o.estimateUsd, process.env);
|
||||
const estimate = attemptEstimate(state, o.estimateUsd, process.env, o.hostSubagent ?? true);
|
||||
if (estimate.line) lines.push(estimate.line);
|
||||
if (first) lines.push(reviewEstimateLine(state, estimate.usd));
|
||||
const fresh = (['native', 'outside'] as const).filter(voice => { const v = voiceState(state, phase!, voice); const open = openAttempt(v); return !voiceTerminal(v) && (!open || (open.state === 'execution_unknown' && o.redispatch?.includes(open.attempt))); });
|
||||
|
||||
+4
-2
@@ -3,7 +3,8 @@
|
||||
* A gate list is numbered items, each with a stable id, a recommended default
|
||||
* and lettered options; `auto` items take their recommendation when
|
||||
* unanswered (one whose recommendation changes the plan keeps the plan's
|
||||
* direction instead), `approval` items stay pending until answered. One reply
|
||||
* direction instead, unless the reply approves with the recommended changes,
|
||||
* `p1b`), `approval` items stay pending until answered. One reply
|
||||
* grammar: `rec` (every item takes its recommendation, plan changes
|
||||
* included), `<id><option>` tokens (`d3b uc1a`), or `rec except <tokens>`
|
||||
* (the same operation as listing the overrides). `all`, `approve all`,
|
||||
@@ -199,6 +200,7 @@ export function parseReply(reply: string, list: GateList, gateRev?: number, prio
|
||||
const choices: GateChoice[] = [];
|
||||
const pending: string[] = [];
|
||||
const how = (item: GateItem, chosen: string) => (chosen === item.recommended ? 'recommended' : 'override') as GateChoice['how'];
|
||||
const withChanges = overrides.get('p1') === 'b';
|
||||
for (const item of list.items) {
|
||||
const chosen = overrides.get(item.id);
|
||||
const earlier = prior[item.id] ?? item.carried?.chosen;
|
||||
@@ -206,7 +208,7 @@ export function parseReply(reply: string, list: GateList, gateRev?: number, prio
|
||||
else if (earlier !== undefined && item.options.some(o => o.key === earlier)) choices.push({ id: item.id, chosen: earlier, kind: item.kind, how: how(item, earlier), prior: true });
|
||||
else if (asIs) { const keep = keepDirection(item); choices.push({ id: item.id, chosen: keep, kind: item.kind, how: how(item, keep) }); }
|
||||
else if (rec) choices.push({ id: item.id, chosen: item.recommended, kind: item.kind, how: 'recommended' });
|
||||
else if (item.kind === 'auto') choices.push({ id: item.id, chosen: autoDefault(item), kind: item.kind, how: 'auto_default' });
|
||||
else if (item.kind === 'auto') choices.push({ id: item.id, chosen: withChanges ? item.recommended : autoDefault(item), kind: item.kind, how: 'auto_default' });
|
||||
else pending.push(item.id);
|
||||
}
|
||||
return { ...base, valid: true, choices, pending, mode: rec ? 'rec' : asIs ? 'as-is' : 'tokens' };
|
||||
|
||||
@@ -40,7 +40,7 @@ export const AUTOPLAN_DRIVER_COMMANDS: Array<{ cmd: string; does: string }> = [
|
||||
export const GATE_REPLY_FORMS: Array<{ reply: string; means: string }> = [
|
||||
{ reply: 'rec', means: 'every item takes its recommendation, plan changes included (each prints APPLY:); `all` and `do it` are aliases' },
|
||||
{ reply: 'as-is', means: 'keep every direction; no plan change' },
|
||||
{ reply: 'd3b uc1a', means: 'one <id><option> token per item you set; unanswered auto items take their default (a finding item keeps the plan as written)' },
|
||||
{ reply: 'd3b uc1a', means: 'one <id><option> token per item you set; unanswered auto items take their default (a finding item keeps the plan as written; `p1b` applies it)' },
|
||||
{ reply: 'rec except d3b', means: 'recommendations, with the listed overrides' },
|
||||
];
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { ROOT, SMALL_PLAN, boundOf, recCarrying, resultOf } from './helpers/autoplan-run-fixture';
|
||||
import { capyRunner, phaseVia, readJsonl, readRunJson, writePlan } from './helpers/autoplan-capy';
|
||||
import { settledIds } from '../lib/autoplan-confirm';
|
||||
|
||||
const r = capyRunner();
|
||||
afterAll(() => r.cleanup());
|
||||
@@ -332,5 +333,30 @@ describe('open Critical and High findings reach the gate (GBRA-223 claim 3)', ()
|
||||
expect(decisions(g.out).find(d => d.label === item.id.toUpperCase())).toMatchObject({ chosen: 'b' });
|
||||
expect(readJsonl(path.join(g.out, 'tasks.jsonl')).some(t => /No kill switch/.test(t.title))).toBe(true);
|
||||
}
|
||||
const g = gated(KILL, NONE);
|
||||
const item = gate(g.out).items.find((i: any) => i.finding);
|
||||
answer(g.out, 1, 'p1a');
|
||||
expect(readJsonl(path.join(g.out, 'attempts.jsonl')).find(e => e.event === 'answered')).toMatchObject({ answer_class: 'overrides', overrides: [`${item.id}b`] });
|
||||
expect(settledIds({ out: g.out } as any).has(item.finding[0])).toBe(false);
|
||||
});
|
||||
|
||||
test('p1b applies the finding fixes its option text lists, and an applied finding is settled', () => {
|
||||
const g = gated(KILL, NONE);
|
||||
const item = gate(g.out).items.find((i: any) => i.finding);
|
||||
expect(gate(g.out).items.find((i: any) => i.id === 'p1').options[1].text).toContain(`${item.id}a`);
|
||||
expect(answer(g.out, 1, 'p1b').stdout).toMatch(/^APPLY: /m);
|
||||
expect(decisions(g.out).find(d => d.label === item.id.toUpperCase())).toMatchObject({ chosen: 'a' });
|
||||
expect(readJsonl(path.join(g.out, 'attempts.jsonl')).find(e => e.event === 'answered')).toMatchObject({ answer_class: 'plan-changes', changes: [`${item.id}a`] });
|
||||
expect(readRunJson(g.out).status).toBe('amend_pending');
|
||||
expect(settledIds({ out: g.out } as any).has(item.finding[0])).toBe(true);
|
||||
});
|
||||
|
||||
test('at the round cap, an outside Critical paired by file with an unrelated native Medium still counts as open', () => {
|
||||
const dir = path.join(r.host.base, `gj${++n}`);
|
||||
const out = path.join(dir, 'run');
|
||||
expect(r.ap(['next', '--out', out, '--plan', writePlan(dir), '--no-ui', '--max-rounds', '1']).status).toBe(0);
|
||||
phaseVia(r, out, 'ceo', { native: [{ id: 'F1', severity: 'Medium', title: 'rename the config key', file: 'src/resolver.ts' }], outside: [{ id: 'O1', severity: 'Critical', title: 'SQL injection in lookup query', file: 'src/resolver.ts' }] });
|
||||
const eng = phaseVia(r, out, 'eng', NONE).outside.stdout;
|
||||
expect(eng.split('\n').filter(l => l.startsWith('ESCALATE:'))).toEqual(['ESCALATE: evidence sufficient, decision needed (ceo: 1 High open)']);
|
||||
});
|
||||
});
|
||||
@@ -90,4 +90,10 @@ describe('plan reviews name a file without a line (GBRA-223 claim 1)', () => {
|
||||
expect(reconcile([lined], [row('outside', 1, { severity: 'High', title: 'retry loop has no bound', file: 'src/resolver.ts', line: 90 })]).rows[0]!.disposition).toBe('new');
|
||||
expect(reconcile([native], [row('outside', 1, { severity: 'High', title: 'CAP6:src/cli.ts:fix', file: 'src/cli.ts' })]).rows[0]!.disposition).toBe('new');
|
||||
});
|
||||
|
||||
test('control: the same CAP item and wording on two different files never pairs by title', () => {
|
||||
const a = row('native', 1, { severity: 'High', title: 'CAP6:bin/gstack-upgrade:recovery no retry bound on network failure', file: 'bin/gstack-upgrade' });
|
||||
const b = row('outside', 1, { severity: 'High', title: 'CAP6:lib/telemetry-sync.ts:recovery no retry bound on network failure', file: 'lib/telemetry-sync.ts' });
|
||||
expect(reconcile([a], [b]).rows[0]).toMatchObject({ disposition: 'new', native_id: null });
|
||||
});
|
||||
});
|
||||
@@ -31,13 +31,13 @@ beforeAll(() => {
|
||||
});
|
||||
afterAll(() => { server.stop(true); fs.rmSync(TMP, { recursive: true, force: true }); });
|
||||
|
||||
async function run(name: string) {
|
||||
async function run(name: string, extra: string[] = []) {
|
||||
const dir = path.join(TMP, name);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'plan.md'), SMALL_PLAN);
|
||||
const out = path.join(dir, 'run');
|
||||
const base = `http://127.0.0.1:${server.port}`;
|
||||
const proc = Bun.spawn(['bun', AUTOPLAN, 'run', '--out', out, '--plan', path.join(dir, 'plan.md'), '--no-ui', '--no-dx', '--native-model', 'anthropic/claude-opus-5-5', '--outside-model', 'openai/gpt-6-astra', '--runner', 'api', '--native-runner', 'api'], {
|
||||
const proc = Bun.spawn(['bun', AUTOPLAN, 'run', '--out', out, '--plan', path.join(dir, 'plan.md'), '--no-ui', '--no-dx', '--native-model', 'anthropic/claude-opus-5-5', '--outside-model', 'openai/gpt-6-astra', '--runner', 'api', '--native-runner', 'api', ...extra], {
|
||||
cwd: ROOT, stdout: 'pipe', stderr: 'pipe',
|
||||
env: env({ ANTHROPIC_API_KEY: 'ak-test', OPENAI_API_KEY: 'sk-test', ANTHROPIC_BASE_URL: base, OPENAI_BASE_URL: `${base}/v1`, GSTACK_AUTOPLAN_CHECKPOINT_DIR: '', GSTACK_STATE_ROOT: path.join(TMP, 'state') }),
|
||||
});
|
||||
@@ -56,6 +56,15 @@ describe('gstack-autoplan run --native-runner api --runner api', () => {
|
||||
for (const phase of ['ceo', 'eng']) for (const voice of ['native', 'outside']) expect(r.state.phases[phase][voice]).toMatchObject({ status: 'completed', findings: 1 });
|
||||
}, 150_000);
|
||||
|
||||
test('a spend cap does not reserve the host-subagent default for api attempts', async () => {
|
||||
mode = 'ok';
|
||||
const r = await run('capped', ['--spend-cap', '8']);
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
expect(r.stdout).toContain(`GSTACK_RESULT: skill=autoplan status=gate_pending run=${r.out}`);
|
||||
expect(r.stdout).not.toMatch(/^ESTIMATE:/m);
|
||||
expect(r.stdout).toContain('GATE_SPEND: spent=0.00 worst_case=0.00 cap=8.00');
|
||||
}, 150_000);
|
||||
|
||||
test('a response stopped at the output limit is named truncated and interrupts the run', async () => {
|
||||
mode = 'cut';
|
||||
const r = await run('cut');
|
||||
|
||||
Reference in new issue
Block a user