ravensight-playtest 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +249 -14
  2. package/addons/ravensight_driver/driver.gd +406 -8
  3. package/addons/ravensight_simulate/simulate.gd +360 -0
  4. package/docs/godot-driver.md +70 -8
  5. package/package.json +4 -3
  6. package/src/api/README.md +4 -1
  7. package/src/api/client.js +92 -3
  8. package/src/api/errors.js +17 -1
  9. package/src/api/http.js +43 -17
  10. package/src/assets/formats.js +267 -0
  11. package/src/assets/index.js +260 -0
  12. package/src/assets/integration.js +429 -0
  13. package/src/assets/inventory.js +337 -0
  14. package/src/assets/measure.js +392 -0
  15. package/src/cli.js +165 -0
  16. package/src/commands/assets.js +62 -0
  17. package/src/commands/check.js +4 -1
  18. package/src/commands/create.js +734 -0
  19. package/src/commands/fakeCore.js +16 -1
  20. package/src/commands/feature.js +845 -0
  21. package/src/commands/fix.js +578 -68
  22. package/src/commands/init.js +19 -3
  23. package/src/commands/profile.js +25 -6
  24. package/src/commands/replay.js +204 -0
  25. package/src/commands/resume.js +7 -0
  26. package/src/commands/run.js +200 -24
  27. package/src/commands/template.js +70 -0
  28. package/src/commands/validate.js +414 -0
  29. package/src/commands/validateHosted.js +292 -0
  30. package/src/create/scaffold.js +192 -0
  31. package/src/run/acceptance.js +212 -0
  32. package/src/run/args.js +18 -1
  33. package/src/run/checks.js +705 -0
  34. package/src/run/dimension.js +95 -0
  35. package/src/run/drivers/godot-observation.js +59 -11
  36. package/src/run/drivers/godot-project.js +201 -31
  37. package/src/run/drivers/godot.js +532 -45
  38. package/src/run/drivers/web.js +18 -0
  39. package/src/run/evidence.js +170 -0
  40. package/src/run/featureLoop.js +288 -0
  41. package/src/run/fixLoop.js +82 -12
  42. package/src/run/git.js +110 -20
  43. package/src/run/json.js +65 -9
  44. package/src/run/labsOutcome.js +136 -0
  45. package/src/run/liveWork.js +103 -0
  46. package/src/run/model.js +105 -5
  47. package/src/run/openPr.js +107 -0
  48. package/src/run/paths.js +4 -1
  49. package/src/run/pendingRegister.js +81 -0
  50. package/src/run/personaLoop.js +111 -4
  51. package/src/run/profile.js +68 -7
  52. package/src/run/replay.js +236 -0
  53. package/src/run/repoTools.js +227 -27
  54. package/src/run/report.js +7 -1
  55. package/src/run/resume.js +19 -1
  56. package/src/run/secretScan.js +35 -0
  57. package/src/run/settle.js +41 -0
  58. package/src/run/tools.js +33 -4
  59. package/src/run/trace.js +124 -0
  60. package/src/simulation/godotPack.js +112 -0
  61. package/src/ui/index.js +61 -9
  62. package/src/upload/allowlist.js +6 -2
  63. package/src/upload/index.js +5 -4
  64. package/templates/godot-2d/README.md +36 -0
  65. package/templates/godot-2d/addons/ravensight/LICENSE +21 -0
  66. package/templates/godot-2d/addons/ravensight/Ravensight.gd +1823 -0
  67. package/templates/godot-2d/export_presets.cfg +27 -0
  68. package/templates/godot-2d/gitignore +10 -0
  69. package/templates/godot-2d/project.godot +58 -0
  70. package/templates/godot-2d/scenes/main.tscn +67 -0
  71. package/templates/godot-2d/scripts/autoload/game_config.gd +83 -0
  72. package/templates/godot-2d/scripts/autoload/game_state.gd +44 -0
  73. package/templates/godot-2d/scripts/autoload/telemetry.gd +37 -0
  74. package/templates/godot-2d/scripts/main.gd +32 -0
  75. package/templates/godot-2d/scripts/player.gd +10 -0
  76. package/templates/godot-2d/scripts/state_hook.gd +16 -0
  77. package/templates/godot-2d/template.json +14 -0
  78. package/templates/godot-2d/tools/inject_release_key.sh +19 -0
  79. package/templates/godot-2d/tools/selftest.gd +70 -0
  80. package/templates/godot-3d/README.md +49 -0
  81. package/templates/godot-3d/addons/ravensight/LICENSE +21 -0
  82. package/templates/godot-3d/addons/ravensight/Ravensight.gd +1823 -0
  83. package/templates/godot-3d/export_presets.cfg +27 -0
  84. package/templates/godot-3d/gitignore +10 -0
  85. package/templates/godot-3d/project.godot +56 -0
  86. package/templates/godot-3d/scenes/main.tscn +154 -0
  87. package/templates/godot-3d/scripts/autoload/game_config.gd +83 -0
  88. package/templates/godot-3d/scripts/autoload/game_state.gd +44 -0
  89. package/templates/godot-3d/scripts/autoload/telemetry.gd +37 -0
  90. package/templates/godot-3d/scripts/beacon.gd +20 -0
  91. package/templates/godot-3d/scripts/main.gd +49 -0
  92. package/templates/godot-3d/scripts/player.gd +19 -0
  93. package/templates/godot-3d/scripts/state_hook.gd +25 -0
  94. package/templates/godot-3d/template.json +16 -0
  95. package/templates/godot-3d/tools/inject_release_key.sh +19 -0
  96. package/templates/godot-3d/tools/selftest.gd +77 -0
@@ -1,4 +1,6 @@
1
+ import { execFile } from 'node:child_process';
1
2
  import { randomUUID } from 'node:crypto';
3
+ import { promisify } from 'node:util';
2
4
  import { appendFile, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
3
5
  import path from 'node:path';
4
6
  import { optionsFrom, unknownOptionNotes } from '../run/args.js';
@@ -6,18 +8,30 @@ import { getDeps } from '../run/deps.js';
6
8
  import { createDriver } from '../run/drivers/index.js';
7
9
  import EXIT from '../run/exit.js';
8
10
  import { applyFix, fixSystem, MAX_PLAN_IMAGES, planContent, planFix, resolveFixMaxActions, summariseFix } from '../run/fixLoop.js';
9
- import { createGit, fixBranchName, preflight, startBranch } from '../run/git.js';
11
+ import { abandonBranch, createGit, fixBranchName, preflight, startBranch } from '../run/git.js';
10
12
  import { createHeartbeat } from '../run/heartbeat.js';
11
13
  import { createModelClient } from '../run/model.js';
12
14
  import { jobDir, runDir as runDirFor, RUN_FILES, screenshotsDir } from '../run/paths.js';
13
15
  import { runPersona } from '../run/personaLoop.js';
14
- import { createRepoTools, PROFILE_TOOLS } from '../run/repoTools.js';
15
- import { assertNoSecrets } from '../run/secretScan.js';
16
+ import { replayTrace } from '../run/replay.js';
17
+ import { loadTrace, replayReport, replaySentence } from './replay.js';
18
+ import { createRepoTools, PROFILE_TOOLS, WRITE_TOOLS } from '../run/repoTools.js';
19
+ import { recordPrOpened } from '../run/labsOutcome.js';
20
+ import { settleFailureLine } from '../run/settle.js';
21
+ import { acceptanceLines, buildAcceptance, fixV2Live, frozenAcceptance, verifyBriefFromAcceptance } from '../run/acceptance.js';
22
+ import { checkResultText, createCheckRunner, detectTestRunner, withRunCheck } from '../run/checks.js';
23
+ import { isGodotProject } from '../run/drivers/godot-project.js';
24
+ import { checksStillFailing, evidenceReport, withEvidenceIntact } from '../run/evidence.js';
25
+ import { openPullRequest, shellQuote } from '../run/openPr.js';
26
+ import { assertNoSecrets, withoutQuotedSecrets } from '../run/secretScan.js';
27
+ import * as pendingRegister from '../run/pendingRegister.js';
16
28
  import * as stateFile from '../run/state.js';
17
29
  import { createUsageLedger, writeUsageFile } from '../run/usage.js';
18
- import { createInterruptHandler, deliver, driverOptions, exitCodeFor, explainRegisterRefusal, formatCents, resolveSetup, resolveTarget, skillFor, usableLocalBrief } from './run.js';
30
+ import { createInterruptHandler, deliver, driverOptions, exitCodeFor, explainRegisterRefusal, formatCents, godotRefusal, resolveSetup, resolveTarget, skillFor, usableLocalBrief, startBalanceLine, startBalanceShort } from './run.js';
19
31
  import { loadLocalBrief } from '../briefLocal.js';
20
32
 
33
+ const execFileAsync = promisify(execFile);
34
+
21
35
  /**
22
36
  * `ravensight-playtest fix <target>`: take one finding, change the code on a
23
37
  * new branch, replay the finding to see whether the change worked, and hand
@@ -25,10 +39,12 @@ import { loadLocalBrief } from '../briefLocal.js';
25
39
  *
26
40
  * Three rules shape everything else in this file:
27
41
  *
28
- * 1. **It edits, it never publishes.** All git usage lives in
42
+ * 1. **It edits, it never publishes on its own.** Git usage lives in
29
43
  * `src/run/git.js`, which has no commit, no push and no way to reach a
30
44
  * forge. The command ends by printing the pull request command; running it
31
- * is the developer's decision, made with the diff in front of them.
45
+ * is the developer's decision, made with the diff in front of them. The
46
+ * one exception is Fix v2's `--open-pr` (`src/run/openPr.js`), which still
47
+ * asks at its own prompt after a verified fix and never forces a push.
32
48
  * 2. **It buys the answer "no" as cheaply as possible.** The triage call comes
33
49
  * before the first edit, because a finding that is design intent should
34
50
  * cost a refunded unit rather than a branch full of changes nobody wanted.
@@ -65,6 +81,18 @@ export const REPRODUCED_ABOVE = 0.65;
65
81
  /** A changeset carries at most this many files. */
66
82
  export const MAX_CHANGESET_FILES = 20;
67
83
 
84
+ /**
85
+ * Fix v2: at most this many repair turn sets after the first apply, each
86
+ * started by a failed check or a verify that still reproduces the finding.
87
+ */
88
+ export const MAX_REPAIRS = 2;
89
+
90
+ /** A repair turn set's own action ceiling, inside the same run budget. */
91
+ export const REPAIR_MAX_ACTIONS = 20;
92
+
93
+ /** The checks run after every apply turn set, in this order. */
94
+ export const GATE_CHECKS = Object.freeze(['godot_import', 'godot_script_check', 'project_tests']);
95
+
68
96
  /**
69
97
  * @param {string} target `<jobId>/<label>` or a finding id
70
98
  * @param {Object} flags what commander hands over
@@ -96,6 +124,9 @@ export async function fixCommand(target, flags = {}, injected = {}) {
96
124
  export function parseTarget(target) {
97
125
  const value = String(target || '').trim();
98
126
  if (!value) return null;
127
+ // A finding's id is `<runId>/<label>`, so a run id on the left means the whole
128
+ // value names the finding, not a label inside a job.
129
+ if (value.startsWith('pt_run_')) return { jobId: null, label: null, findingId: value };
99
130
  const slash = value.lastIndexOf('/');
100
131
  if (slash > 0 && slash < value.length - 1) {
101
132
  return { jobId: value.slice(0, slash), label: value.slice(slash + 1), findingId: null };
@@ -129,6 +160,24 @@ export function hostedTargetRefusal(driverName, target) {
129
160
  return `${target} is not served from this machine, so a verify run there cannot see a change that only exists in this working tree. Serve the build locally and pass --build-url, or pass --no-verify to fix without verifying.`;
130
161
  }
131
162
 
163
+ /**
164
+ * What became of a fix's unit when its verify could not boot the game, from the
165
+ * server's own answers: the unit comes back up to a limit per account.
166
+ *
167
+ * @param {{finished: Object|null, run: Object|null, priceCents: number}} input
168
+ * @returns {string}
169
+ */
170
+ export function bootRefundLine({ finished, run, priceCents }) {
171
+ const start = 'The game did not boot for the verify run';
172
+ if (run && run.changeset && run.changeset.boot_refund_limited) {
173
+ return `${start}, and this account has used its refunds for that in the last 30 days, so the ${formatCents(priceCents)} is kept.`;
174
+ }
175
+ const cents = finished ? Number(finished.refunded_cents) || 0 : 0;
176
+ if (cents > 0) return `${start}, so the ${formatCents(cents)} comes back.`;
177
+ if (!finished) return `${start}. The job page says what came back once it settles.`;
178
+ return `${start}, and nothing came back of the ${formatCents(priceCents)} charged.`;
179
+ }
180
+
132
181
  /**
133
182
  * The verdict in the words a developer reads.
134
183
  *
@@ -137,6 +186,10 @@ export function hostedTargetRefusal(driverName, target) {
137
186
  */
138
187
  export function verifyWords(verify) {
139
188
  if (!verify) return 'not verified';
189
+ // A verify run whose game never booted played a black screen: whatever the
190
+ // replay's report says, it says nothing about the fix.
191
+ if (verify.failed === 'game_boot_failed') return `not verified, the game did not boot (${verify.detail || 'see godot.log in the verify run directory'})`;
192
+ if (verify.failed) return `not verified, the verify run failed (${verify.detail || verify.failed})`;
140
193
  const score = verify.reproduced;
141
194
  if (typeof score !== 'number' || !Number.isFinite(score)) return 'unknown, the verify run reached no verdict';
142
195
  if (score < FIXED_BELOW) return `fixed (reproduced ${score})`;
@@ -171,7 +224,7 @@ async function fix(target, flags, injected, deps, log) {
171
224
 
172
225
  const wantsVerify = options.verify !== false;
173
226
  const driverName = options.driver || (context.config && context.config.driver) || 'playwright_web';
174
- const buildTarget = resolveTarget(options, context.config, driverName);
227
+ const buildTarget = resolveTarget(options, context.config, driverName, repoRoot);
175
228
 
176
229
  // Said before anything is charged, because it is the difference between a
177
230
  // verify that means something and one that does not.
@@ -185,6 +238,11 @@ async function fix(target, flags, injected, deps, log) {
185
238
  log(refusal);
186
239
  return EXIT.ENVIRONMENT;
187
240
  }
241
+ const noGodot = await godotRefusal({ driverName, godotPath: options.godotPath, projectDir: buildTarget, injected });
242
+ if (noGodot) {
243
+ log(noGodot);
244
+ return EXIT.ENVIRONMENT;
245
+ }
188
246
  if (driverName === 'playwright_web') {
189
247
  log(`The verify run plays ${buildTarget}, which has to serve this working tree. Rebuild and serve the branch before the verify, or its answer is about the old build.`);
190
248
  }
@@ -218,16 +276,29 @@ async function fix(target, flags, injected, deps, log) {
218
276
  // repro steps and the persona's words are written in full.
219
277
  const finding = enrichFinding(listed, evidence.reportJson);
220
278
 
221
- const branch = options.branch || fixBranchName(finding.label || finding.finding_id);
279
+ const pendingScope = { kind: 'fix', gameId, target: finding.finding_id };
280
+ const pendingRequest = options.idempotencyKey ? null : await pendingRegister.load(repoRoot, pendingScope);
281
+ const branch = pendingRequest ? pendingRequest.branch : options.branch || fixBranchName(finding.label || finding.finding_id);
222
282
  const git = injected.git || createGit({ root: repoRoot });
223
283
  // Every refusal now, the one mutation after the price is agreed: a developer
224
284
  // who says no to the charge should not be left on a new branch.
225
- const checked = await preflight({ git, branch, allowDirty: Boolean(options.allowDirty), create: false });
285
+ const checked = await preflight({ git, branch, allowDirty: Boolean(options.allowDirty), create: false, reuseBranch: Boolean(pendingRequest) });
286
+ if (pendingRequest) {
287
+ log(`The last attempt at this fix got no clear answer, so this run asks about that same request on ${branch}. If it was charged, this answers with that job.`);
288
+ checked.from = pendingRequest.from;
289
+ }
226
290
  if (!checked.ok) {
227
291
  log(checked.reason);
228
292
  return EXIT.ENVIRONMENT;
229
293
  }
230
294
 
295
+ // --open-pr commits what the fix changed, and a dirty tree makes that a mixture
296
+ // of two people's work. Refused now, because nothing later can tell them apart.
297
+ if (options.openPr && checked.dirty) {
298
+ log('--open-pr publishes a commit, and this working tree has uncommitted changes that would be mixed into it. Commit or stash them, or leave out --open-pr. Nothing was charged.');
299
+ return EXIT.ENVIRONMENT;
300
+ }
301
+
231
302
  // A fix job asks for exactly one persona, the one who filed the finding: the
232
303
  // server creates the verify run from it, and both zero and two are a 400.
233
304
  const personaSlug = finding.persona || ((pack.personas || [])[0] || {}).slug || null;
@@ -235,7 +306,33 @@ async function fix(target, flags, injected, deps, log) {
235
306
  log('That finding names no persona and the content pack has none, so there is nobody to verify the fix with.');
236
307
  return EXIT.ENVIRONMENT;
237
308
  }
238
- const estimate = await api.jobs.estimate(gameId, { modules: [...FIX_MODULES], personas: [personaSlug] });
309
+ // Fix v2 (Labs beta) only when the server says it is live for this game.
310
+ // Anything else, including a server that has never heard of Labs, is the standard fix.
311
+ const labs = !options.noLabs && (await fixV2Live(api, gameId, {
312
+ onUnfunded: () => log('Fix v2 (Labs beta) needs an account that has added at least $10.00 in top-ups, so this is an ordinary fix.')
313
+ }));
314
+ const acceptance = labs ? buildAcceptance(finding, { persona: personaSlug, reproSteps: reproStepsOf(finding) }) : null;
315
+ if (acceptance) {
316
+ log('Fix v2 (Labs beta) is on for this game: named checks, up to two repairs, and an evidence report.');
317
+ for (const line of acceptanceLines(acceptance)) log(line);
318
+ }
319
+ // Tests are the developer's code run with the model's edits in place, so
320
+ // they run only with --allow-tests or a yes at a terminal, asked now,
321
+ // before anything is charged.
322
+ let allowTests = Boolean(options.allowTests);
323
+ if (labs && !allowTests) {
324
+ const projectDir = await checkProjectDir({ driverName, buildTarget, repoRoot });
325
+ const runner = projectDir ? await detectTestRunner(projectDir) : null;
326
+ const askTests = injected.confirmTests || (process.stdin.isTTY && deps.ui && deps.ui.confirm);
327
+ if (runner && askTests) {
328
+ allowTests = Boolean(await askTests(`This project ships ${runner === 'gut' ? 'GUT' : 'gdUnit4'} tests. Let the fix run them, with its edits in place, on this machine?`, { yes: false }));
329
+ }
330
+ }
331
+ if (options.openPr && !labs) {
332
+ log('--open-pr is part of Fix v2 (Labs beta), which is not on for this game, so it is ignored and the pull request command is printed instead.');
333
+ }
334
+
335
+ const estimate = await api.jobs.estimate(gameId, { modules: [...FIX_MODULES], personas: [personaSlug], ...(acceptance ? { acceptance: true } : {}) });
239
336
  const priceCents = Number.isFinite(Number(estimate.estimate_cents)) ? Number(estimate.estimate_cents) : Number(estimate.price_cents);
240
337
  if (!Number.isFinite(priceCents)) {
241
338
  // Sending `confirm_price_cents: undefined` would be refused as
@@ -244,8 +341,15 @@ async function fix(target, flags, injected, deps, log) {
244
341
  log('The server quoted no price for this fix, so nothing was charged. Run "ravensight-playtest check" and try again.');
245
342
  return EXIT.ENVIRONMENT;
246
343
  }
247
- const offer = `${formatCents(priceCents)} fix attempt (includes one verify run). Balance: ${formatCents(estimate.balance_cents)}.`;
344
+ const offer = `${formatCents(priceCents)} fix attempt (includes one verify run). Balance: ${formatCents(estimate.balance_cents)}.`
345
+ + (labs ? ' The checks and the verify run execute the edited project on this machine with a reduced environment.' : '');
248
346
  log(offer);
347
+ const floorLine = startBalanceLine(estimate);
348
+ if (floorLine) log(floorLine);
349
+ if (estimate.ok === false && floorLine) {
350
+ log(`${startBalanceShort(estimate)} An ordinary fix, with --no-labs, starts on its price alone.`);
351
+ return EXIT.ENVIRONMENT;
352
+ }
249
353
  if (estimate.ok === false) {
250
354
  log(`That is more than the balance covers. Top up at ${estimate.topup_url || 'ravensight.io'} and run this again.`);
251
355
  return EXIT.ENVIRONMENT;
@@ -260,42 +364,86 @@ async function fix(target, flags, injected, deps, log) {
260
364
  }
261
365
  }
262
366
 
263
- const started = await startBranch({ git, branch });
367
+ const started = pendingRequest ? await pendingRegister.returnToBranch({ git, branch }) : await startBranch({ git, branch });
264
368
  if (!started.ok) {
265
369
  log(started.reason);
266
370
  return EXIT.ENVIRONMENT;
267
371
  }
268
372
  log(`branch ${branch}, from ${checked.head.slice(0, 8)}${checked.from ? ` on ${checked.from}` : ''}.`);
269
373
 
270
- const idempotencyKey = options.idempotencyKey || randomUUID();
374
+ // Every way out before a job exists has to leave the developer where they were,
375
+ // unless nobody knows whether a job exists. Then the key and the branch are the way back.
376
+ const noJob = async () => {
377
+ await pendingRegister.clear(repoRoot, pendingScope);
378
+ await abandonBranch({ git, branch, from: checked.from, head: checked.head });
379
+ };
380
+ const remember = key => pendingRegister.save(repoRoot, pendingScope, { idempotencyKey: key, branch, from: checked.from, head: checked.head });
381
+ let idempotencyKey = options.idempotencyKey || (pendingRequest && pendingRequest.idempotencyKey) || randomUUID();
382
+ const registerBody = {
383
+ modules: [...FIX_MODULES],
384
+ personas: [personaSlug],
385
+ finding_id: finding.finding_id,
386
+ driver: driverName,
387
+ cli_version: cliVersion,
388
+ confirm_price_cents: priceCents,
389
+ brief_version: brief ? brief.version ?? null : null,
390
+ build: buildTarget
391
+ ? { kind: driverName === 'playwright_web' ? 'url' : 'godot_project', ref: buildTarget }
392
+ : { kind: 'repo', ref: repoRoot },
393
+ repo: { commit: checked.head, dirty: Boolean(checked.dirty), branch },
394
+ max_actions: Number.isInteger(options.maxActions) ? options.maxActions : null,
395
+ pack_versions: { pack: pack.pack_version },
396
+ ...(acceptance ? { acceptance } : {})
397
+ };
398
+ // A failed register, whichever attempt: `undefined` means it was an exception to rethrow.
399
+ const registerFailed = async error => {
400
+ if (pendingRegister.outcomeUnknown(error)) {
401
+ log(pendingRegister.unknownOutcomeMessage('this fix', branch));
402
+ return EXIT.ENVIRONMENT;
403
+ }
404
+ await noJob();
405
+ const handled = explainRegisterRefusal(error, log);
406
+ return handled !== null ? handled : undefined;
407
+ };
271
408
  let registered;
272
409
  try {
273
- registered = await api.jobs.register(gameId, {
274
- modules: [...FIX_MODULES],
275
- personas: [personaSlug],
276
- finding_id: finding.finding_id,
277
- driver: driverName,
278
- cli_version: cliVersion,
279
- confirm_price_cents: priceCents,
280
- brief_version: brief ? brief.version ?? null : null,
281
- build: buildTarget
282
- ? { kind: driverName === 'playwright_web' ? 'url' : 'godot_project', ref: buildTarget }
283
- : { kind: 'repo', ref: repoRoot },
284
- repo: { commit: checked.head, dirty: Boolean(checked.dirty), branch },
285
- max_actions: Number.isInteger(options.maxActions) ? options.maxActions : null,
286
- pack_versions: { pack: pack.pack_version }
287
- }, { idempotencyKey });
410
+ await remember(idempotencyKey);
411
+ registered = await api.jobs.register(gameId, registerBody, { idempotencyKey });
288
412
  } catch (error) {
289
- const handled = explainRegisterRefusal(error, log);
290
- if (handled !== null) return handled;
291
- throw error;
413
+ if (acceptance && error && ['labs_not_enabled', 'funding_required'].includes(error.code)) {
414
+ // Labs went off, or the funding floor stopped being met, between the status read
415
+ // and the register. Nothing was charged by a refusal, so register the ordinary
416
+ // fix instead, on a fresh key because the body differs.
417
+ log(`The server refused Fix v2 for this game (${error.code}), so this is an ordinary fix.`);
418
+ delete registerBody.acceptance;
419
+ idempotencyKey = randomUUID();
420
+ try {
421
+ await remember(idempotencyKey);
422
+ registered = await api.jobs.register(gameId, registerBody, { idempotencyKey });
423
+ } catch (retryError) {
424
+ const exit = await registerFailed(retryError);
425
+ if (exit !== undefined) return exit;
426
+ throw retryError;
427
+ }
428
+ } else {
429
+ const exit = await registerFailed(error);
430
+ if (exit !== undefined) return exit;
431
+ throw error;
432
+ }
292
433
  }
434
+ await pendingRegister.clear(repoRoot, pendingScope);
293
435
 
294
436
  const jobId = registered.job_id;
295
437
  // Runs are told apart by module and nothing else: the fix run carries
296
438
  // `persona: null`, and only the verify run carries the slug.
297
439
  const fixRun = (registered.runs || []).find(entry => entry.module === 'fix');
298
- const verifyRun = (registered.runs || []).find(entry => entry.module === 'persona_playtest') || null;
440
+ const verifyRuns = (registered.runs || []).filter(entry => entry.module === 'persona_playtest');
441
+ const verifyRun = verifyRuns[0] || null;
442
+ // The contract the server froze. None means the server kept none, and the
443
+ // fix runs as the standard fix does: no checks, no repairs, no evidence report.
444
+ const frozen = acceptance ? frozenAcceptance(registered) : null;
445
+ if (acceptance && !frozen) log('The server did not keep the acceptance contract, so this fix runs without checks or repairs.');
446
+ const v2 = Boolean(frozen);
299
447
 
300
448
  await mkdir(jobDir(repoRoot, jobId), { recursive: true });
301
449
  const journal = stateFile.newState({
@@ -317,7 +465,8 @@ async function fix(target, flags, injected, deps, log) {
317
465
  findingId: finding.finding_id,
318
466
  verify: wantsVerify,
319
467
  allowDirty: Boolean(options.allowDirty),
320
- maxActions: options.maxActions ?? null
468
+ maxActions: options.maxActions ?? null,
469
+ ...(v2 ? { labs: 'fix_v2' } : {})
321
470
  }
322
471
  });
323
472
  for (const entry of registered.runs || []) {
@@ -331,7 +480,7 @@ async function fix(target, flags, injected, deps, log) {
331
480
 
332
481
  if (!fixRun) {
333
482
  log('The server registered the job without a fix run, so there is nothing to spend a model turn from.');
334
- await skipRun({ api, gameId, jobId, run: verifyRun, reason: 'the job has no fix run', log });
483
+ for (const entry of v2 ? verifyRuns : [verifyRun]) await skipRun({ api, gameId, jobId, run: entry, reason: 'the job has no fix run', log });
335
484
  // Settled rather than abandoned: the job is charged and unplayable, so the
336
485
  // refund is owed now and not in half an hour when the sweep notices.
337
486
  await settle({ api, gameId, jobId, repoRoot, journal, reason: 'the job has no fix run', log });
@@ -354,6 +503,7 @@ async function fix(target, flags, injected, deps, log) {
354
503
  onError: () => {}
355
504
  });
356
505
  const inFlight = new Map();
506
+ const abort = new AbortController();
357
507
  const interrupt = createInterruptHandler({
358
508
  api,
359
509
  gameId,
@@ -366,7 +516,9 @@ async function fix(target, flags, injected, deps, log) {
366
516
  // this job's verify persona against a half-applied edit would answer a
367
517
  // question nobody asked. A fix is abandoned and refunded instead.
368
518
  message: 'finishing the turn in flight, then stopping. A fix cannot be resumed, so this one is being given back.',
369
- injected
519
+ injected,
520
+ // The verify run listens to this: every turn it plays after a Ctrl-C is model spend.
521
+ onInterrupt: () => abort.abort()
370
522
  });
371
523
 
372
524
  try {
@@ -387,10 +539,11 @@ async function fix(target, flags, injected, deps, log) {
387
539
  heartbeat.stop();
388
540
  inFlight.delete(fixRun.run_id);
389
541
  log(`${finding.label || finding.finding_id} reads as design intent, not a defect (confidence ${verdict.confidence}, from ${verdict.source || 'the brief'}), so nothing was edited.`);
542
+ for (const entry of v2 ? verifyRuns : [verifyRun]) await skipRun({ api, gameId, jobId, run: entry, reason: 'the finding is design intent', log });
390
543
  await completeFixRun({ api, gameId, jobId, runId: fixRun.run_id, body: { state: 'failed', reason: 'intended' }, log });
391
- await skipRun({ api, gameId, jobId, run: verifyRun, reason: 'the finding is design intent', log });
392
- await settle({ api, gameId, jobId, repoRoot, journal, reason: 'the finding is design intent', log });
393
- log('The fix unit was refunded. Mark the finding as intended in the dashboard so no later job spends a turn on it.');
544
+ const intended = await settle({ api, gameId, jobId, repoRoot, journal, reason: 'the finding is design intent', log });
545
+ if (Number(intended?.refunded_cents) > 0) log('The fix unit was refunded.');
546
+ log('Mark the finding as intended in the dashboard so no later job spends a turn on it.');
394
547
  return EXIT.OK;
395
548
  }
396
549
 
@@ -401,7 +554,8 @@ async function fix(target, flags, injected, deps, log) {
401
554
  jobId,
402
555
  branch,
403
556
  baseCommit: checked.head,
404
- engine: pack.engine || null
557
+ engine: pack.engine || null,
558
+ ...(v2 ? { runCheck: true } : {})
405
559
  });
406
560
  const codeBrief = pack.code_brief || null;
407
561
  // `graph_context` only exists in a bundle asked for with this job's id: it
@@ -432,14 +586,26 @@ async function fix(target, flags, injected, deps, log) {
432
586
  if (plan.hypothesis) log(`hypothesis: ${plan.hypothesis}`);
433
587
 
434
588
  const maxActions = resolveFixMaxActions(options, pack);
435
- const applied = await applyFix({
589
+ // Fix v2: one repository tool surface for the first apply and every
590
+ // repair, so the per-run write cap counts all of them, plus run_check.
591
+ const checks = v2
592
+ ? createCheckRunner({
593
+ projectDir: await checkProjectDir({ driverName, buildTarget, repoRoot }),
594
+ godotPath: options.godotPath || null,
595
+ allowTests,
596
+ allowToolScripts: Boolean(options.allowToolScripts),
597
+ changedFiles: async () => projectRelative(await git.diffStat(), repoRoot, await checkProjectDir({ driverName, buildTarget, repoRoot })),
598
+ ...(injected.checkRunner ? injected.checkRunner : {})
599
+ })
600
+ : null;
601
+ const applyRepo = v2 ? createRepoTools({ root: repoRoot, tools: WRITE_TOOLS }) : undefined;
602
+ const applyArgs = {
436
603
  client,
437
604
  jobId,
438
605
  runId: fixRun.run_id,
439
606
  repoRoot,
440
607
  system,
441
608
  plan,
442
- maxActions,
443
609
  codeBrief,
444
610
  graphContext,
445
611
  ledger,
@@ -454,6 +620,11 @@ async function fix(target, flags, injected, deps, log) {
454
620
  appendFile(path.join(directory, 'transcript.jsonl'), `${JSON.stringify({ at: new Date().toISOString(), ...progress })}\n`).catch(() => {});
455
621
  },
456
622
  shouldStop: () => interrupt.signalled || heartbeat.canceled
623
+ };
624
+ let applied = await applyFix({
625
+ ...applyArgs,
626
+ maxActions,
627
+ ...(v2 ? { acceptance: frozen, repo: withRunCheck(applyRepo, checks) } : {})
457
628
  });
458
629
 
459
630
  // A Ctrl-C, or the job cancelled from the dashboard. Either way this run is
@@ -465,16 +636,16 @@ async function fix(target, flags, injected, deps, log) {
465
636
  heartbeat.stop();
466
637
  inFlight.delete(fixRun.run_id);
467
638
  const why = interrupt.signalled ? 'interrupted by the developer' : 'cancelled';
639
+ for (const entry of v2 ? verifyRuns : [verifyRun]) await skipRun({ api, gameId, jobId, run: entry, reason: `the fix was ${why}`, log });
468
640
  await completeFixRun({ api, gameId, jobId, runId: fixRun.run_id, body: { state: 'failed', reason: 'error' }, log });
469
- await skipRun({ api, gameId, jobId, run: verifyRun, reason: `the fix was ${why}`, log });
470
641
  try {
471
642
  await stateFile.patchRun(repoRoot, journal, fixRun.run_id, { state: 'failed', quit_reason: 'error' });
472
643
  } catch {
473
644
  // The journal is a convenience here; the server's own run state is the
474
645
  // record, and it has just been told.
475
646
  }
476
- await settle({ api, gameId, jobId, repoRoot, journal, reason: `the fix was ${why}`, log });
477
- log(`The fix was ${why} and cannot be resumed, so the unit was given back. The branch ${branch} holds whatever was written, and nothing was committed or pushed.`);
647
+ const stopped = await settle({ api, gameId, jobId, repoRoot, journal, reason: `the fix was ${why}`, log });
648
+ log(`The fix was ${why} and cannot be resumed${Number(stopped?.refunded_cents) > 0 ? ', so the unit was given back' : ''}. The branch ${branch} holds whatever was written, and nothing was committed or pushed.`);
478
649
  return EXIT.CANCELED;
479
650
  }
480
651
 
@@ -484,10 +655,10 @@ async function fix(target, flags, injected, deps, log) {
484
655
  heartbeat.stop();
485
656
  inFlight.delete(fixRun.run_id);
486
657
  log(`Nothing was changed, so there is nothing to review. The branch ${branch} is empty; delete it or stay on it.`);
658
+ for (const entry of v2 ? verifyRuns : [verifyRun]) await skipRun({ api, gameId, jobId, run: entry, reason: 'nothing was changed', log });
487
659
  await completeFixRun({ api, gameId, jobId, runId: fixRun.run_id, body: { state: 'failed', reason: 'no_change' }, log });
488
- await skipRun({ api, gameId, jobId, run: verifyRun, reason: 'nothing was changed', log });
489
- await settle({ api, gameId, jobId, repoRoot, journal, reason: 'nothing was changed', log });
490
- log('The fix unit was refunded.');
660
+ const unchanged = await settle({ api, gameId, jobId, repoRoot, journal, reason: 'nothing was changed', log });
661
+ if (Number(unchanged?.refunded_cents) > 0) log('The fix unit was refunded.');
491
662
  return EXIT.OK;
492
663
  }
493
664
 
@@ -498,7 +669,69 @@ async function fix(target, flags, injected, deps, log) {
498
669
 
499
670
  // 4. Verify, by replaying the finding against the build.
500
671
  let verified = null;
501
- if (wantsVerify && verifyRun) {
672
+ const verifies = [];
673
+ let repairs = 0;
674
+ // A script that does not parse or a project that does not import is not a
675
+ // change anyone can review, so it blocks the upload and the unit comes back
676
+ // (playtest_fix_check_failed), as it does for a feature. A failing project
677
+ // test ships, and the report says so first.
678
+ let checksBlock = false;
679
+ let usedVerifyRuns = 0;
680
+ if (v2) {
681
+ // Fix v2: checks after every apply turn set, verify once they pass, and
682
+ // at most MAX_REPAIRS repair turn sets started by a failed check or a
683
+ // verify that still reproduces. The contract never changes.
684
+ const verifyArgs = {
685
+ deps, api, client, gameId, jobId, repoRoot, pack, brief, finding, driverName,
686
+ target: buildTarget, options, journal,
687
+ maxActions: Math.min(VERIFY_MAX_ACTIONS, maxActions),
688
+ makeDriver: injected.driverFactory || createDriver,
689
+ signal: abort.signal,
690
+ acceptance: frozen,
691
+ sourceRunId,
692
+ ...(injected.replayer ? { replayer: injected.replayer } : {}),
693
+ log
694
+ };
695
+ for (;;) {
696
+ const gate = await runGateChecks(checks, log);
697
+ checksBlock = gate.importFailed || gate.failed.some(result => result.name === 'godot_script_check');
698
+ const canRepair = repairs < MAX_REPAIRS && applied.stop !== 'budget' && !interrupt.signalled && !heartbeat.canceled;
699
+ let repairReason = null;
700
+ let repairDetail = '';
701
+ if (gate.failed.length > 0) {
702
+ repairReason = gate.failed.some(result => result.status === 'check_refused')
703
+ ? 'the checks were refused because of what the change edited'
704
+ : `the ${gate.failed.map(result => result.name).join(' and ')} check${gate.failed.length > 1 ? 's' : ''} failed`;
705
+ repairDetail = gate.failed.map(checkResultText).join('\n\n');
706
+ } else if (wantsVerify && usedVerifyRuns < verifyRuns.length) {
707
+ verified = await runVerify({ ...verifyArgs, entry: verifyRuns[usedVerifyRuns] });
708
+ usedVerifyRuns += 1;
709
+ verifies.push(verified);
710
+ const score = verified && !verified.failed ? verified.reproduced : null;
711
+ // A repair after verify needs a fresh verify run to judge it; the
712
+ // server registers spares for Fix v2, and without one there is
713
+ // nothing to re-verify with, so no repair is spent.
714
+ if (typeof score === 'number' && score > REPRODUCED_ABOVE && usedVerifyRuns < verifyRuns.length) {
715
+ repairReason = `the verify run still reproduced the finding (${score})`;
716
+ repairDetail = `verify run ${verified.run_id}: ${verifyWords(verified)}. The acceptance contract is unchanged: ${frozen.actual} should become: ${frozen.expected}`;
717
+ }
718
+ }
719
+ if (!repairReason || !canRepair) break;
720
+ repairs += 1;
721
+ log(`repair ${repairs} of ${MAX_REPAIRS}: ${repairReason}.`);
722
+ applied = await applyFix({
723
+ ...applyArgs,
724
+ maxActions: Math.min(REPAIR_MAX_ACTIONS, maxActions),
725
+ acceptance: frozen,
726
+ repair: { attempt: repairs, of: MAX_REPAIRS, reason: repairReason, detail: repairDetail },
727
+ repo: withRunCheck(applyRepo, checks)
728
+ });
729
+ if (interrupt.signalled || heartbeat.canceled) break;
730
+ }
731
+ for (const spare of verifyRuns.slice(usedVerifyRuns)) {
732
+ await skipRun({ api, gameId, jobId, run: spare, reason: checksBlock ? 'the fix did not pass its checks' : (wantsVerify ? 'no repair needed it' : 'skipped with --no-verify'), log });
733
+ }
734
+ } else if (wantsVerify && verifyRun) {
502
735
  verified = await runVerify({
503
736
  deps,
504
737
  api,
@@ -516,14 +749,61 @@ async function fix(target, flags, injected, deps, log) {
516
749
  journal,
517
750
  maxActions: Math.min(VERIFY_MAX_ACTIONS, maxActions),
518
751
  makeDriver: injected.driverFactory || createDriver,
752
+ signal: abort.signal,
519
753
  log
520
754
  });
521
755
  } else {
522
756
  await skipRun({ api, gameId, jobId, run: verifyRun, reason: wantsVerify ? 'no verify run was registered' : 'skipped with --no-verify', log });
523
757
  }
758
+ if (interrupt.signalled || heartbeat.canceled) {
759
+ heartbeat.stop();
760
+ inFlight.delete(fixRun.run_id);
761
+ await completeFixRun({ api, gameId, jobId, runId: fixRun.run_id, body: { state: 'failed', reason: 'error' }, log });
762
+ await settle({ api, gameId, jobId, repoRoot, journal, reason: 'the fix was interrupted', log });
763
+ log(`The fix was interrupted and cannot be resumed, so the unit was given back. The branch ${branch} holds whatever was written.`);
764
+ return EXIT.CANCELED;
765
+ }
766
+
767
+ // A repair may have touched more files than the first apply did.
768
+ const finalChanged = v2 ? await git.diffStat() : changed;
769
+ const finalFiles = v2 ? finalChanged.slice(0, MAX_CHANGESET_FILES) : files;
770
+
771
+ if (v2 && checksBlock) {
772
+ // A fix the project cannot import or parse is not uploaded: there is no
773
+ // review it deserves. The evidence stays local for the developer.
774
+ heartbeat.stop();
775
+ inFlight.delete(fixRun.run_id);
776
+ const body = evidenceReport({
777
+ prBody: `# Fix ${finding.label || finding.finding_id}: not uploaded\n\nThis change does not pass its checks.`,
778
+ summarySource: 'local',
779
+ acceptance: frozen,
780
+ finding,
781
+ sourceRunId,
782
+ plan,
783
+ files: finalFiles,
784
+ checks: checks.results,
785
+ verifies,
786
+ repairs,
787
+ maxRepairs: MAX_REPAIRS,
788
+ wantsVerify,
789
+ thresholds: { fixedBelow: FIXED_BELOW, reproducedAbove: REPRODUCED_ABOVE }
790
+ });
791
+ await writeFile(path.join(directory, RUN_FILES.reportMd), body, 'utf8').catch(() => {});
792
+ const stillFailing = checksStillFailing(checks.results);
793
+ const refusedCheck = checks.results.some(result => stillFailing.includes(result.name) && result.status === 'check_refused');
794
+ const blockedBy = refusedCheck
795
+ ? 'The checks were refused for this change'
796
+ : (stillFailing.includes('godot_import') ? 'The project does not import with this change' : 'This change does not pass its script check');
797
+ log(`${blockedBy} after ${repairs} repair(s), so the changeset was not uploaded. The evidence is in ${path.join(directory, RUN_FILES.reportMd)} and the branch ${branch} holds the edit.`);
798
+ // `check_failed` is the server's own word for this, so the ledger and the
799
+ // dashboard say the fix failed its checks rather than that a run crashed.
800
+ await completeFixRun({ api, gameId, jobId, runId: fixRun.run_id, body: { state: 'failed', reason: 'check_failed' }, log });
801
+ await settle({ api, gameId, jobId, repoRoot, journal, reason: 'the fix did not pass its checks', log });
802
+ return EXIT.FAILED;
803
+ }
524
804
 
525
805
  // 5. The pull request text, from the diff that exists.
526
- const fallback = localSummary({ finding, plan, files, verify: verified, branch, applied });
806
+ const fallback = localSummary({ finding, plan, files: finalFiles, verify: verified, branch, applied });
527
807
  // Both budget stops mean the apply loop left off early rather than because
528
808
  // it was finished, so both carry the caveat. Only the 402 skips the
529
809
  // summarise call, because only the 402 means there is nothing left to
@@ -538,24 +818,48 @@ async function fix(target, flags, injected, deps, log) {
538
818
  system,
539
819
  finding: findingForPrompt(finding),
540
820
  plan,
541
- files,
821
+ files: finalFiles,
542
822
  verify: verified,
823
+ thresholds: { fixedBelow: FIXED_BELOW, reproducedAbove: REPRODUCED_ABOVE },
543
824
  branch,
544
825
  fallback,
545
826
  ledger,
546
827
  log
547
828
  });
548
829
 
549
- const changeset = {
830
+ // Fix v2: the pull request body is the evidence report, which keeps what
831
+ // was measured apart from what a model wrote.
832
+ const prBody = v2
833
+ ? withEvidenceIntact(description => evidenceReport({
834
+ prBody: description,
835
+ summarySource: summary.source,
836
+ acceptance: frozen,
837
+ finding,
838
+ sourceRunId,
839
+ plan,
840
+ files: finalFiles,
841
+ checks: checks.results,
842
+ verifies,
843
+ repairs,
844
+ maxRepairs: MAX_REPAIRS,
845
+ wantsVerify,
846
+ budgetTouched,
847
+ thresholds: { fixedBelow: FIXED_BELOW, reproducedAbove: REPRODUCED_ABOVE }
848
+ }), summary.prBody)
849
+ : summary.prBody;
850
+
851
+ const changeset = withoutQuotedSecrets({
550
852
  schema_version: '1.0',
551
853
  finding_id: finding.finding_id,
552
854
  branch,
553
855
  base_commit: checked.head,
554
- files,
555
- verify: verified ? { run_id: verified.run_id, reproduced: verified.reproduced } : null,
856
+ files: finalFiles,
857
+ // A verify that never played the fix (its game did not boot, or the run
858
+ // died) is no verify at all: the changeset says unverified.
859
+ verify: verified && !verified.failed ? { run_id: verified.run_id, reproduced: verified.reproduced } : null,
556
860
  pr_title: summary.prTitle,
557
- pr_body: summary.prBody
558
- };
861
+ pr_body: prBody
862
+ }, log);
559
863
 
560
864
  await heartbeat.beat();
561
865
  heartbeat.stop();
@@ -584,19 +888,34 @@ async function fix(target, flags, injected, deps, log) {
584
888
  // whole diagnosis: "pr_title too long" is actionable, "422" is not.
585
889
  for (const detail of changesetDetails(error)) log(` ${detail}`);
586
890
  }
587
- await settle({ api, gameId, jobId, repoRoot, journal, reason: '', log });
588
-
589
- report({ changeset, directory, reportPath, verified, options, log, injected, deps, budget: budgetTouched });
891
+ const finished = await settle({ api, gameId, jobId, repoRoot, journal, reason: '', log });
892
+
893
+ if (verified && verified.failed === 'game_boot_failed') {
894
+ // The server refunds a fix whose verify run could not boot
895
+ // (playtest_fix_unverified_boot) only up to the account's limit, so this says
896
+ // what its answer says and never assumes.
897
+ const answer = await api.runs.get(gameId, jobId, fixRun.run_id).catch(() => null);
898
+ log(bootRefundLine({ finished, run: (answer && (answer.run || answer)) || null, priceCents: registered.price && registered.price.price_cents }));
899
+ }
900
+ let opened = null;
901
+ if (v2 && options.openPr) {
902
+ opened = await maybeOpenPr({ repoRoot, branch, changed: finalChanged, written: applyRepo.written, changeset, reportPath, verified, injected, deps, log });
903
+ if (opened.ok) await recordPrOpened({ api, gameId, repoRoot, journal, url: opened.url });
904
+ }
905
+ const stillFailing = v2 ? checksStillFailing(checks.results) : [];
906
+ if (stillFailing.length > 0) log(`checks failed on this change: ${stillFailing.join(', ')}. The verify run did not play it, and the unit is kept because a change was delivered. Read the evidence report before you use it.`);
907
+ if (!opened || !opened.ok) report({ changeset, directory, reportPath, verified, options, log, injected, deps, budget: budgetTouched });
590
908
  // Exit 2 is "the money ran out", which a warning is not: a warning means
591
909
  // the run wrapped up early with budget still in hand.
592
- return applied.stop === 'budget' ? EXIT.BUDGET : EXIT.OK;
910
+ if (applied.stop === 'budget') return EXIT.BUDGET;
911
+ return opened && !opened.ok ? EXIT.ENVIRONMENT : EXIT.OK;
593
912
  } catch (error) {
594
913
  heartbeat.stop();
595
914
  const code = exitCodeFor(error);
596
915
  log(`fix failed: ${String(error && error.message)}`);
597
916
  log(`The branch ${branch} still holds whatever was written. Nothing was committed or pushed.`);
598
- await failFixRun({ api, gameId, jobId, runId: fixRun.run_id, log });
599
917
  await skipRun({ api, gameId, jobId, run: verifyRun, reason: 'the fix failed', log });
918
+ await failFixRun({ api, gameId, jobId, runId: fixRun.run_id, log });
600
919
  await settle({ api, gameId, jobId, repoRoot, journal, reason: 'the fix failed', log });
601
920
  return code;
602
921
  } finally {
@@ -763,17 +1082,34 @@ export async function runVerify({
763
1082
  journal,
764
1083
  maxActions,
765
1084
  makeDriver,
1085
+ acceptance = null,
1086
+ sourceRunId = null,
1087
+ replayer = replayTrace,
1088
+ signal,
766
1089
  log
767
1090
  }) {
768
1091
  const directory = runDirFor(repoRoot, jobId, entry.run_id);
769
1092
  await mkdir(directory, { recursive: true });
1093
+
1094
+ // FIX V2 REPLAYS THE SOURCE RUN FIRST. It costs no model call, and it can say
1095
+ // one thing for certain: if the game answers identically at every recorded step,
1096
+ // the fix changed nothing on the path that found the problem. It cannot say the
1097
+ // problem is gone, because after a divergence the recorded inputs no longer mean
1098
+ // anything, so a divergence goes to the persona like a run with no trace at all.
1099
+ let divergence = null;
1100
+ if (acceptance && driverName === 'godot_driver') {
1101
+ const replayed = await replaySourceTrace({ api, gameId, jobId, repoRoot, finding, target, godotPath: options.godotPath, directory, sourceRunId, entry, log, replayer });
1102
+ if (replayed.unchanged) return closeVerifyOnUnchanged({ deps, api, gameId, jobId, repoRoot, entry, journal, directory, log, report: replayed.report });
1103
+ divergence = replayed.divergence;
1104
+ }
1105
+
770
1106
  const persona = (pack.personas || []).find(candidate => candidate.slug === entry.persona) || (pack.personas || [])[0];
771
1107
  if (!persona) {
772
1108
  log('The content pack has no persona to verify with, so the fix is reported unverified.');
773
1109
  return null;
774
1110
  }
775
1111
  const headless = driverName === 'godot_driver' ? options.headless === true : options.headed !== true;
776
- const driver = await makeDriver(driverName, driverOptions({ headless, driverName, target, directory, entry, jobId }));
1112
+ const driver = await makeDriver(driverName, driverOptions({ headless, driverName, target, directory, entry, jobId, godotPath: options.godotPath, realtime: options.realtime }));
777
1113
  const ledger = createUsageLedger();
778
1114
  const heartbeat = createHeartbeat({
779
1115
  api,
@@ -810,13 +1146,17 @@ export async function runVerify({
810
1146
  runDirectory: directory,
811
1147
  skill: skillFor(pack, 'persona_playtest'),
812
1148
  // The brief fetched above, as `run` does: the pack does not always carry one.
813
- brief: verifyBrief(brief || pack.brief || null, finding),
1149
+ // Fix v2 builds the verify brief from the frozen contract alone.
1150
+ brief: withDivergence(acceptance
1151
+ ? verifyBriefFromAcceptance(brief || pack.brief || null, acceptance)
1152
+ : verifyBrief(brief || pack.brief || null, finding), divergence),
814
1153
  localBrief,
815
1154
  intentNotes: pack.intent_notes || [],
816
1155
  schemas: pack.schemas || [],
817
1156
  maxActions,
818
1157
  heartbeat,
819
1158
  ledger,
1159
+ signal,
820
1160
  log: message => log(`verify: ${message}`)
821
1161
  });
822
1162
  } catch (error) {
@@ -825,7 +1165,8 @@ export async function runVerify({
825
1165
  await stateFile.patchRun(repoRoot, journal, entry.run_id, { state: failed.state, error: String(error && error.message), quit_reason: failed.quitReason });
826
1166
  await deliver({ deps, api, gameId, jobId, runId: entry.run_id, directory, outcome: failed, options, log, journal, repoRoot });
827
1167
  log(`the verify run failed (${String(error && error.message)}), so the fix is reported unverified.`);
828
- return null;
1168
+ const booted = !(error && (error.code === 'game_boot_failed' || /^game_boot_failed: /.test(String(error.message))));
1169
+ return { run_id: entry.run_id, reproduced: null, source: null, failed: booted ? 'verify_failed' : 'game_boot_failed', detail: String(error && error.message).replace(/^game_boot_failed: /, '').slice(0, 200) };
829
1170
  }
830
1171
  heartbeat.stop();
831
1172
 
@@ -848,10 +1189,100 @@ export async function runVerify({
848
1189
  log(`the verify verdict could not be read (${String(error && error.message)}).`);
849
1190
  }
850
1191
  const verdict = (run && run.verify_verdict) || null;
851
- const reproduced = verdict && Number.isFinite(Number(verdict.reproduced)) ? Number(verdict.reproduced) : null;
1192
+ // Strictly a number. `Number(null)` is 0, and the server answers
1193
+ // `{ reproduced: null, source: 'unavailable' }` when nobody could judge, so a
1194
+ // coercion here reads a judge being down as "fixed (reproduced 0)".
1195
+ const reproduced = verdict && typeof verdict.reproduced === 'number' && Number.isFinite(verdict.reproduced) ? verdict.reproduced : null;
852
1196
  return { run_id: entry.run_id, reproduced, source: (verdict && verdict.source) || null };
853
1197
  }
854
1198
 
1199
+ /**
1200
+ * Replay the source run's trace against the fix, on this working tree. Never
1201
+ * fails the verify: anything short of a full match, including no trace at all,
1202
+ * answers "not unchanged" and the persona decides. The outcome is recorded on the
1203
+ * job, free, when the server takes it.
1204
+ *
1205
+ * @returns {Promise<{unchanged: boolean, divergence: Object|null}>}
1206
+ */
1207
+ export async function replaySourceTrace({ api, gameId, jobId, repoRoot, finding, target, godotPath, directory, sourceRunId, entry, log, replayer = replayTrace }) {
1208
+ const runId = sourceRunId || finding.run_id || null;
1209
+ if (!runId) return { unchanged: false, divergence: null };
1210
+ const trace = await loadTrace({ repoRoot, runId, jobId: finding.job_id || null, api, gameId });
1211
+ if (!trace) return { unchanged: false, divergence: null };
1212
+ let result;
1213
+ try {
1214
+ result = await replayer({ text: trace.text, projectDir: target, godotPath, runDir: path.join(directory, 'trace-replay') });
1215
+ } catch (error) {
1216
+ log(`verify: the source run's trace could not be replayed (${String(error && error.message)}). A persona replays the finding instead.`);
1217
+ return { unchanged: false, divergence: null };
1218
+ }
1219
+ log(`verify: ${replaySentence(result)}`);
1220
+ const sha = await headSha(repoRoot);
1221
+ const report = sha ? replayReport(result, { ref: 'fix-branch', sha }) : null;
1222
+ if (report && entry) {
1223
+ try {
1224
+ await api.runs.recordReplay(gameId, jobId, entry.run_id, report);
1225
+ } catch {
1226
+ // A record is for reading later. The verify does not depend on it.
1227
+ }
1228
+ }
1229
+ // The server decides a verify from this whole report, so a replay that cannot
1230
+ // name its commit cannot close one: a persona replays the finding instead.
1231
+ // The same test as the server's `replayProvesIdentical`: a replay that compared
1232
+ // nothing, or ran no ticks, matched only because it did nothing.
1233
+ const proven = result.matched === true && result.fully_replayed === true && !result.stopped && !result.first_divergence && result.steps_compared > 0 && result.ticks > 0;
1234
+ return { unchanged: Boolean(report) && proven, divergence: result.first_divergence || null, report };
1235
+ }
1236
+
1237
+ async function headSha(repoRoot) {
1238
+ try {
1239
+ const { stdout } = await execFileAsync('git', ['-C', repoRoot, 'rev-parse', 'HEAD']);
1240
+ return stdout.trim();
1241
+ } catch {
1242
+ return null;
1243
+ }
1244
+ }
1245
+
1246
+ /**
1247
+ * The trace matched everywhere, so the verify run is finished without a model.
1248
+ * The verdict comes back from the server's own record of this complete, the same
1249
+ * as a persona verify's, and it says the finding is still there.
1250
+ */
1251
+ export async function closeVerifyOnUnchanged({ deps, api, gameId, jobId, repoRoot, entry, journal, directory, log, report }) {
1252
+ log('verify: every recorded step answered the same, so this fix changed nothing on the path that found the problem. No model was called.');
1253
+ for (const state of ['launching', 'playing', 'reporting', 'uploading']) {
1254
+ await api.runs.transition(gameId, jobId, entry.run_id, state, { reason: '' });
1255
+ }
1256
+ await deps.finalizeRun(directory, {
1257
+ api,
1258
+ gameId,
1259
+ jobId,
1260
+ runId: entry.run_id,
1261
+ body: { state: 'succeeded', actions_taken: 0, quit_reason: 'goal_reached', trace_replay: report }
1262
+ });
1263
+ await stateFile.patchRun(repoRoot, journal, entry.run_id, { state: 'succeeded', completed: true, uploaded: true });
1264
+ const answer = await api.runs.get(gameId, jobId, entry.run_id);
1265
+ const run = (answer && (answer.run || answer)) || null;
1266
+ const verdict = (run && run.verify_verdict) || null;
1267
+ // The same reading as a persona verify's: `Number(null)` is 0, which would read a
1268
+ // judge that could not answer as "fixed (reproduced 0)".
1269
+ const reproduced = verdict && typeof verdict.reproduced === 'number' && Number.isFinite(verdict.reproduced) ? verdict.reproduced : null;
1270
+ return { run_id: entry.run_id, reproduced, source: (verdict && verdict.source) || 'trace_replay' };
1271
+ }
1272
+
1273
+ /** Tell the verify persona where the fix first moved the game, as a starting point. */
1274
+ export function withDivergence(brief, divergence) {
1275
+ if (!divergence || !brief) return brief;
1276
+ const diff = Object.entries(divergence.game_state_diff || {}).slice(0, 5).map(([key, pair]) => `${key}: ${JSON.stringify(pair.expected)} became ${JSON.stringify(pair.actual)}`);
1277
+ return {
1278
+ ...brief,
1279
+ fields: {
1280
+ ...(brief.fields || {}),
1281
+ verify_trace_note: `Replaying the original run against this fix first differed at tick ${divergence.frame ?? 'unknown'}${diff.length ? ` (${diff.join('; ')})` : ''}. That shows the fix changed behaviour there, not that the problem is gone. Check for yourself.`
1282
+ }
1283
+ };
1284
+ }
1285
+
855
1286
  /**
856
1287
  * The verify run's brief: what it is being asked to reproduce, then the brief
857
1288
  * a persona usually plays against. The task comes first because it is the one
@@ -942,11 +1373,13 @@ function report({ changeset, reportPath, verified, options, log, injected, deps,
942
1373
  log(`verify: ${verifyWords(verified)}`);
943
1374
  if (budget) log('The fix stopped early because its model budget was spent or nearly spent, so read the diff carefully: it may be half a change.');
944
1375
  log('Nothing was committed and nothing was pushed. Read the diff, commit it, then:');
945
- log(` gh pr create --title "${String(changeset.pr_title).replace(/"/g, '\\"')}" --body-file ${reportPath}`);
1376
+ log(` gh pr create --title ${shellQuote(changeset.pr_title)} --body-file ${shellQuote(reportPath)}`);
946
1377
  }
947
1378
 
948
1379
  /**
949
- * Complete the fix run, tolerating a refusal. The money is settled by
1380
+ * Complete the fix run, tolerating a refusal. Every caller completes or skips
1381
+ * the verify run(s) first: the server reads the verify run's record when the
1382
+ * fix run completes (a verify that could not boot refunds the unit). The money is settled by
950
1383
  * `finish` either way, and a refused complete must not turn a fix that worked
951
1384
  * into a crash.
952
1385
  */
@@ -977,11 +1410,13 @@ async function skipRun({ api, gameId, jobId, run, reason, log }) {
977
1410
 
978
1411
  /** `finish` settles the job once and issues at most one refund. */
979
1412
  async function settle({ api, gameId, jobId, repoRoot, journal, reason, log }) {
1413
+ let finished = null;
980
1414
  try {
981
- const finished = await api.jobs.finish(gameId, jobId, { reason });
1415
+ finished = await api.jobs.finish(gameId, jobId, { reason });
982
1416
  log(`job ${jobId} ${finished.job ? finished.job.state : 'finished'}: refunded ${formatCents(finished.refunded_cents)}.`);
1417
+ if (finished.refund_limited_note) log(finished.refund_limited_note);
983
1418
  } catch (error) {
984
- log(`the job could not be settled now; the hourly sweep will settle it: ${String(error && error.message)}`);
1419
+ log(settleFailureLine(jobId, error));
985
1420
  }
986
1421
  journal.finished = true;
987
1422
  try {
@@ -990,6 +1425,7 @@ async function settle({ api, gameId, jobId, repoRoot, journal, reason, log }) {
990
1425
  // The journal is a convenience at this point: the server's ledger is the
991
1426
  // record of what was charged and what came back.
992
1427
  }
1428
+ return finished;
993
1429
  }
994
1430
 
995
1431
  /** The finding as the prompt sees it: no ids nobody can act on, no noise. */
@@ -1061,6 +1497,80 @@ export function changesetDetails(error) {
1061
1497
  .filter(Boolean);
1062
1498
  }
1063
1499
 
1500
+ /**
1501
+ * The Godot project the checks copy: the verify target for the Godot driver,
1502
+ * else the repository when it is a Godot project, else none.
1503
+ *
1504
+ * @returns {Promise<string|null>}
1505
+ */
1506
+ export async function checkProjectDir({ driverName, buildTarget, repoRoot }) {
1507
+ if (driverName === 'godot_driver' && buildTarget) return buildTarget;
1508
+ return (await isGodotProject(repoRoot)) ? repoRoot : null;
1509
+ }
1510
+
1511
+ /**
1512
+ * Repository relative paths as paths inside the Godot project, dropping any
1513
+ * outside it.
1514
+ */
1515
+ export function projectRelative(changed, repoRoot, projectDir) {
1516
+ if (!projectDir) return [];
1517
+ return changed
1518
+ .map(file => path.relative(projectDir, path.join(repoRoot, file.path)))
1519
+ .filter(file => file && !file.startsWith('..') && !path.isAbsolute(file));
1520
+ }
1521
+
1522
+ /**
1523
+ * Every gate check, in order. `godot_import` failing is the one that blocks an
1524
+ * upload; the others start a repair and, if they still fail, are listed as
1525
+ * remaining risks.
1526
+ *
1527
+ * @returns {Promise<{failed: Array<Object>, importFailed: boolean}>}
1528
+ */
1529
+ export async function runGateChecks(checks, log = () => {}) {
1530
+ const failed = [];
1531
+ let importFailed = false;
1532
+ for (const name of GATE_CHECKS) {
1533
+ // A refusal names the edits, not the check, so the rest would only repeat it.
1534
+ if (failed.some(result => result.status === 'check_refused')) break;
1535
+ // After a failed import the tests cannot mean anything, but the script
1536
+ // check still can: the import names the scripts that could not load
1537
+ // because of a broken one, the script check names the broken line.
1538
+ if (importFailed && name === 'project_tests') continue;
1539
+ const result = await checks.run(name);
1540
+ log(`check ${name}: ${result.status} (${result.detail})`);
1541
+ if (result.status === 'failed' || result.status === 'check_refused') {
1542
+ failed.push(result);
1543
+ // A refused check blocks the upload exactly as a failed import does:
1544
+ // nothing proved the change imports.
1545
+ if (name === 'godot_import' || result.status === 'check_refused') importFailed = true;
1546
+ }
1547
+ }
1548
+ return { failed, importFailed };
1549
+ }
1550
+
1551
+ /**
1552
+ * `--open-pr` after the fix is uploaded: only for a fix the verify scored as
1553
+ * fixed, and only with the developer's own yes.
1554
+ */
1555
+ export async function maybeOpenPr({ repoRoot, branch, changed, written = null, changeset, reportPath, verified, injected, deps, log }) {
1556
+ if (!verifyWords(verified).startsWith('fixed')) {
1557
+ log(`--open-pr opens a pull request only for a verified fix, and this one reads ${verifyWords(verified)}. Nothing was pushed.`);
1558
+ return { ok: false, reason: 'not_verified' };
1559
+ }
1560
+ const opener = injected.openPullRequest || openPullRequest;
1561
+ return opener({
1562
+ repoRoot,
1563
+ branch,
1564
+ // What the fix wrote, not whatever else is in the tree by now: a run can leave
1565
+ // files behind that are not the change.
1566
+ paths: changed.map(file => file.path).filter(file => !written || written.includes(file)),
1567
+ title: changeset.pr_title,
1568
+ bodyFile: reportPath,
1569
+ confirm: injected.confirmPr || injected.confirm || (deps.ui && deps.ui.confirm),
1570
+ log
1571
+ });
1572
+ }
1573
+
1064
1574
  function isImageName(name) {
1065
1575
  return /\.(png|jpe?g|webp)$/i.test(String(name));
1066
1576
  }