ravensight-playtest 0.4.0 → 0.5.1

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.
@@ -7,7 +7,7 @@ import { withRollingCacheBreakpoint } from './fixLoop.js';
7
7
  import { callStepWithRetry, ProxyError, STEPS } from './model.js';
8
8
  import { RUN_FILES } from './paths.js';
9
9
  import { appendNormalisedMarkdown, checkEvidence, createReportValidator, lintMarkdownSections, normaliseEnums, normaliseEvidence, repairInstruction, REQUIRED_MARKDOWN_SECTIONS, stampReport, writeReportFiles } from './report.js';
10
- import { assertNoSecrets } from './secretScan.js';
10
+ import { assertNoSecrets, scanForSecrets } from './secretScan.js';
11
11
  import { ACTION_TOOLS, PLAY_TOOLS, SWIPE_TOOL, REPORT_TOOLS, executeTool } from './tools.js';
12
12
  import { createTranscript } from './transcript.js';
13
13
  import { createUsageLedger, writeUsageFile } from './usage.js';
@@ -35,14 +35,17 @@ import { MIN_PASSWORD_CHARS } from '../briefLocal.js';
35
35
  * - `X-Playtest-Budget: warn` at 80 percent of the run budget starts a wrap
36
36
  * up, so the report (which is the product) gets written while there is
37
37
  * still budget to write it with.
38
- * - A 402 means the money is gone. Nothing further is asked of the model,
39
- * including the report: a partial `report.md` and `report.json` are
40
- * written locally from the notes (`partial: true`, each note an
38
+ * - An act 402 stops gameplay. If its authoritative figures show room, the
39
+ * cheaper report phase may still fit. Otherwise partial `report.md` and
40
+ * `report.json` files are written locally from the notes (`partial: true`, each note an
41
41
  * unreviewed finding), `complete` is sent with `state: budget_exceeded`
42
42
  * and that report, and the process exits 2. The notes are the one thing
43
43
  * the run bought before the money ran out, so they are not thrown away.
44
44
  */
45
45
 
46
+ /** Why a report is partial when the money ran out. */
47
+ const BUDGET_PARTIAL_REASON = 'The run reached its model budget before the report could be completed. What the persona saw is below, and report.json carries the notes as unreviewed findings.';
48
+
46
49
  /**
47
50
  * Order across notes and screenshots, so a partial report can attach the
48
51
  * screenshot nearest to each note. Module wide and only ever compared within
@@ -161,6 +164,9 @@ export async function runPersona(args) {
161
164
  // what the screen looked like next and a finding can cite where a failure
162
165
  // is visible.
163
166
  const observations = [];
167
+ // What the persona did, kept out here so a report can still say so when the
168
+ // play loop dies part way through.
169
+ const actionRecords = [];
164
170
  let imageBudget = maxImages;
165
171
 
166
172
  await mkdir(path.join(runDirectory, 'screenshots'), { recursive: true });
@@ -211,7 +217,7 @@ export async function runPersona(args) {
211
217
  // game", and a launch that hangs or dies should leave the server holding
212
218
  // that rather than still holding `queued`.
213
219
  await advance('launching');
214
- await driver.launch({ target, runDir: runDirectory, recordVideo, viewport });
220
+ await driver.launch({ target, runDir: runDirectory, recordVideo, viewport, signal: args.signal });
215
221
  await advance('playing');
216
222
 
217
223
  const play = await playLoop({
@@ -221,6 +227,7 @@ export async function runPersona(args) {
221
227
  notes,
222
228
  screenshots,
223
229
  observations,
230
+ actionRecords,
224
231
  imageBudget,
225
232
  keepObservations,
226
233
  onImageBudget: next => {
@@ -231,8 +238,9 @@ export async function runPersona(args) {
231
238
  outcome = { ...outcome, ...play };
232
239
 
233
240
  const endedAt = new Date().toISOString();
234
- await driverStop(driver, outcome, log);
241
+ await driverStop(driver, outcome, log, play.canceled ? 'canceled' : 'completed');
235
242
 
243
+ if (args.signal?.aborted || heartbeat?.canceled) play.canceled = true;
236
244
  if (play.canceled) {
237
245
  outcome.state = 'canceled';
238
246
  outcome.quitReason = play.quitReason || 'error';
@@ -240,24 +248,17 @@ export async function runPersona(args) {
240
248
  return finish(outcome, ledger, runDirectory, transcript, heartbeat);
241
249
  }
242
250
 
243
- if (play.budgetExceeded) {
251
+ if (play.budgetExceeded && !play.reportBudgetAvailable) {
244
252
  outcome.state = 'budget_exceeded';
245
253
  outcome.quitReason = 'cost_cap';
246
- // Nothing more is asked of the model: there is no budget left to ask
247
- // with, and a refused call would just be another 402.
248
- // A report that cannot be written (a secret shaped note, a full disk)
249
- // must not turn an out of budget run into a failed one.
250
- try {
251
- await writePartialReport({ outcome, runDirectory, persona, driverName, target, notes, screenshots, play, skill, ledger, runId, jobId, startedAt, endedAt });
252
- } catch (writeError) {
253
- outcome.report = null;
254
- outcome.reportMarkdown = null;
255
- log(`the partial report could not be written: ${String(writeError && writeError.message)}`);
256
- }
254
+ // No positive remaining budget was established by the refusal.
255
+ // Preserve the notes without authorizing another model call.
256
+ await writeSomeReport({ outcome, runDirectory, persona, driverName, target, notes, screenshots, actionRecords, play, skill, ledger, runId, jobId, startedAt, endedAt, log });
257
257
  return finish(outcome, ledger, runDirectory, transcript, heartbeat);
258
258
  }
259
259
 
260
260
  await advance('reporting');
261
+ if (args.signal?.aborted || heartbeat?.canceled) throw new Error('The playthrough was interrupted before reporting.');
261
262
  onProgress({ phase: 'reporting', persona: persona.slug });
262
263
 
263
264
  const written = await reportPhase({
@@ -288,31 +289,48 @@ export async function runPersona(args) {
288
289
  outcome.report = written.report;
289
290
  outcome.reportMarkdown = written.markdown;
290
291
  outcome.reportLint = written.lint;
291
- outcome.state = 'succeeded';
292
- outcome.quitReason = written.report.quit_reason;
292
+ outcome.state = play.budgetExceeded ? 'budget_exceeded' : 'succeeded';
293
+ outcome.quitReason = play.budgetExceeded ? 'cost_cap' : written.report.quit_reason;
293
294
  outcome.quitDetail = written.report.quit_detail || '';
294
295
  return finish(outcome, ledger, runDirectory, transcript, heartbeat);
295
296
  } catch (error) {
296
- await driverStop(driver, outcome, log);
297
+ await driverStop(driver, outcome, log, args.signal?.aborted ? 'canceled' : 'failed');
297
298
  if (error instanceof ProxyError && error.isBudget) {
298
299
  outcome.state = 'budget_exceeded';
299
300
  outcome.quitReason = 'cost_cap';
300
301
  // The money ran out in the report step itself: the notes are still
301
302
  // worth keeping, by the same rule as a 402 during play.
302
- try {
303
- const play = { actionsTaken: outcome.actionsTaken, quitReason: 'cost_cap', quitDetail: String(error.message || '') };
304
- await writePartialReport({ outcome, runDirectory, persona, driverName, target, notes, screenshots, play, skill, ledger, runId, jobId, startedAt, endedAt: new Date().toISOString() });
305
- } catch (writeError) {
306
- log(`the partial report could not be written: ${String(writeError && writeError.message)}`);
307
- }
303
+ const play = { actionsTaken: outcome.actionsTaken, quitReason: 'cost_cap', quitDetail: String(error.message || '') };
304
+ await writeSomeReport({ outcome, runDirectory, persona, driverName, target, notes, screenshots, actionRecords, play, skill, ledger, runId, jobId, startedAt, endedAt: new Date().toISOString(), log, preservedMarkdown: error.completedReportMarkdown });
308
305
  } else if (error instanceof ProxyError && error.isClosed) {
309
306
  outcome.state = 'canceled';
310
307
  outcome.quitReason = 'error';
311
308
  } else {
312
309
  outcome.state = 'failed';
313
310
  outcome.quitReason = 'error';
311
+ // A run that broke still reports what it saw and why it stopped. The
312
+ // state is still `failed`, so the unit is still refunded; the report
313
+ // costs no model call. An abort is the person stopping it, not a failure.
314
+ if (!args.signal?.aborted) {
315
+ const detail = safeErrorDetail(error, localBrief);
316
+ // The play loop's count only reaches `outcome` when it returns, and this
317
+ // may be the play loop dying.
318
+ const actionsTaken = Math.max(outcome.actionsTaken || 0, actionLogFrom(actionRecords).length);
319
+ const play = { actionsTaken, quitReason: 'error', quitDetail: detail };
320
+ await writeSomeReport({
321
+ outcome, runDirectory, persona, driverName, target, notes, screenshots, actionRecords, play, skill, ledger, runId, jobId, startedAt, endedAt: new Date().toISOString(), log,
322
+ quitReason: 'error',
323
+ // The notes stay in report.md and out of report.json. Nothing reviewed
324
+ // them, they may be symptoms of the crash, and what report.json holds
325
+ // becomes findings, analyst memory and graph nodes on the server.
326
+ findings: [],
327
+ reason: `The run stopped on an error before its report was written: ${detail}`,
328
+ bareReason: 'The run stopped on an error before its report was written.'
329
+ });
330
+ }
314
331
  }
315
- outcome.quitDetail = String(error && error.message ? error.message : error);
332
+ // Sent to the server as the run's reason, so it is as safe as the report.
333
+ outcome.quitDetail = safeErrorDetail(error, localBrief);
316
334
  transcript.write({ kind: 'error', message: outcome.quitDetail });
317
335
  await finish(outcome, ledger, runDirectory, transcript, heartbeat);
318
336
  throw Object.assign(error, { outcome });
@@ -325,8 +343,12 @@ export async function runPersona(args) {
325
343
  * @returns {Promise<Object>}
326
344
  */
327
345
  async function playLoop({
346
+ // The observation and action lines of the transcript, kept for the report
347
+ // step's "Actions you took" list and for a partial report.
348
+ actionRecords = [],
328
349
  api,
329
350
  controller = 'standard',
351
+ signal: argsSignal,
330
352
  client,
331
353
  jobId,
332
354
  runId,
@@ -355,11 +377,11 @@ async function playLoop({
355
377
  const identity = runIdentity({ persona, runId });
356
378
  const secrets = secretsOf(localBrief);
357
379
  const noisePatterns = (localBrief && Array.isArray(localBrief.expected_noise)) ? localBrief.expected_noise : [];
358
- if (controller === 'jev') system.push({ type: 'text', text: JEV_CONTROLLER_PROMPT });
380
+ if (['jev', 'rune'].includes(controller)) system.push({ type: 'text', text: JEV_CONTROLLER_PROMPT });
359
381
  const availableTools = driver.supportedActions
360
382
  ? [...PLAY_TOOLS, ...(driver.supportedActions.includes('swipe') ? [SWIPE_TOOL] : [])].filter(tool => !ACTION_TOOLS.includes(tool.name) || driver.supportedActions.includes(tool.name))
361
383
  : PLAY_TOOLS;
362
- const playTools = controller === 'jev' ? availableTools.map(tool => ({
384
+ const playTools = ['jev', 'rune'].includes(controller) ? availableTools.map(tool => ({
363
385
  ...tool,
364
386
  input_schema: {
365
387
  ...tool.input_schema,
@@ -372,9 +394,6 @@ async function playLoop({
372
394
  }
373
395
  })) : availableTools;
374
396
  const recentActions = [];
375
- // The observation and action lines of the transcript, kept for the report
376
- // step's "Actions you took" list.
377
- const actionRecords = [];
378
397
  let latestObservation = '';
379
398
  let visualObservation = false;
380
399
  const messages = [];
@@ -384,6 +403,7 @@ async function playLoop({
384
403
  let quitDetail = '';
385
404
  let budgetWarned = false;
386
405
  let budgetExceeded = false;
406
+ let reportBudgetAvailable = false;
387
407
  let canceled = false;
388
408
  let unchanged = 0;
389
409
  const maxTurns = maxActions * 3 + 20;
@@ -426,7 +446,7 @@ async function playLoop({
426
446
  });
427
447
 
428
448
  while (turns < maxTurns && actions < maxActions) {
429
- if (heartbeat && heartbeat.canceled) {
449
+ if (argsSignal?.aborted || (heartbeat && heartbeat.canceled)) {
430
450
  canceled = true;
431
451
  quitReason = 'error';
432
452
  quitDetail = 'the job was cancelled';
@@ -455,6 +475,7 @@ async function playLoop({
455
475
  } catch (error) {
456
476
  if (error instanceof ProxyError && error.isBudget) {
457
477
  budgetExceeded = true;
478
+ reportBudgetAvailable = hasPositiveRemainingBudget(error);
458
479
  quitReason = 'cost_cap';
459
480
  quitDetail = error.message;
460
481
  break;
@@ -485,6 +506,7 @@ async function playLoop({
485
506
  if (typeof onBudgetWarning === 'function') await onBudgetWarning();
486
507
  }
487
508
 
509
+ if (argsSignal?.aborted || heartbeat?.canceled) { canceled = true; quitReason = 'error'; quitDetail = 'the job was cancelled'; break; }
488
510
  if (answer.toolUses.length === 0) {
489
511
  // No tool call is how a persona stops: there is no quit tool in the
490
512
  // step's allowlist, and adding one would be refused by the proxy.
@@ -495,14 +517,15 @@ async function playLoop({
495
517
  }
496
518
 
497
519
  let selection = null;
498
- if (controller === 'jev') {
520
+ if (['jev', 'rune'].includes(controller)) {
499
521
  try {
500
- selection = await selectAction({ api, jobId, runId, persona, observation: `${visualObservation ? '' : latestObservation}\nScreen interpretation: ${answer.toolUses.map(tool => tool.input?.visible_state || '').filter(Boolean).join('\n') || answer.text}`, toolUses: answer.toolUses, recentActions, intentNotes });
522
+ selection = await selectAction({ api, jobId, runId, persona, controller, observation: `${visualObservation ? '' : latestObservation}\nScreen interpretation: ${answer.toolUses.map(tool => tool.input?.visible_state || '').filter(Boolean).join('\n') || answer.text}`, toolUses: answer.toolUses, recentActions, intentNotes });
501
523
  } catch (error) {
502
524
  if (error instanceof ProxyError && error.isBudget) { budgetExceeded = true; quitReason = 'cost_cap'; quitDetail = error.message; break; }
503
525
  if (error instanceof ProxyError && error.isClosed) { canceled = true; quitDetail = error.message; break; }
504
526
  throw error;
505
527
  }
528
+ if (argsSignal?.aborted || heartbeat?.canceled) { canceled = true; quitReason = 'error'; quitDetail = 'the job was cancelled'; break; }
506
529
  transcript.write({ kind: 'action_selection', source: selection.source || 'jev', status: selection.status, confidence: selection.confidence, selected_tool_id: selection.toolId, reason: selection.reason });
507
530
  if (selection.status !== 'selected') { quitReason = 'persona_quit'; quitDetail = 'Ravensight Playtester stopped for review: ' + selection.reason; break; }
508
531
  if (selection.budget === 'warn' && !budgetWarned) {
@@ -518,6 +541,7 @@ async function playLoop({
518
541
  // its actions changes the screen, a later ref may name something else.
519
542
  let screenChangedThisTurn = false;
520
543
  for (const toolUse of answer.toolUses) {
544
+ if (argsSignal?.aborted || heartbeat?.canceled) { canceled = true; quitReason = 'error'; quitDetail = 'the job was cancelled'; break; }
521
545
  if (selection && toolUse.id !== selection.toolId) { results.push({ type: 'tool_result', tool_use_id: toolUse.id, content: 'Alternative not selected. No action executed.' }); continue; }
522
546
  const notesBefore = notes.length;
523
547
  const executed = await executeTool({
@@ -660,6 +684,7 @@ async function playLoop({
660
684
  quitDetail,
661
685
  budgetWarned,
662
686
  budgetExceeded,
687
+ reportBudgetAvailable,
663
688
  canceled,
664
689
  imageBudget,
665
690
  finalScreenshot: final ? final.relativePath : null,
@@ -773,7 +798,7 @@ async function reportPhase({
773
798
  // never escalates to a dearer tier after a warning, and a 402 on a report
774
799
  // turn gets one retry with the output ceiling cut to what the refusal says
775
800
  // still fits.
776
- const escalate = shouldEscalate(persona) && !play.budgetWarned;
801
+ const escalate = shouldEscalate(persona) && !play.budgetWarned && !play.budgetExceeded;
777
802
  let maxTokens = REPORT_MAX_TOKENS;
778
803
  let shrunk = false;
779
804
  let lastCandidate = null;
@@ -815,6 +840,14 @@ async function reportPhase({
815
840
  log(`the model budget ran out on a follow-up report turn; keeping the report.json already written (${lint.length} problem(s)).`);
816
841
  break;
817
842
  }
843
+ // A completed markdown write can survive a cut-off JSON write. Keep
844
+ // that draft without promoting its prose into structured findings.
845
+ if (typeof markdown === 'string' && markdown.trim()) {
846
+ const safeMarkdown = scrubSecrets(markdown, secrets);
847
+ if (Buffer.byteLength(safeMarkdown, 'utf8') <= 256 * 1024 && scanForSecrets(safeMarkdown).length === 0) {
848
+ error.completedReportMarkdown = safeMarkdown;
849
+ }
850
+ }
818
851
  throw error;
819
852
  }
820
853
  ledger.record({ model: answer.model, usage: answer.usage, ms: Date.now() - askedAt });
@@ -869,7 +902,10 @@ async function reportPhase({
869
902
  if (enumFixed.changes.length > 0) {
870
903
  transcript.write({ kind: 'report_values_normalised', attempt, changes: enumFixed.changes.map(change => ({ id: change.id, field: change.field, from: change.from, to: change.to })) });
871
904
  }
872
- const capped = normalised ? capNetworkOnlyFindings(stampReport(enumFixed.report, facts), { notes, screenshots }) : null;
905
+ const reportCandidate = play.budgetExceeded
906
+ ? { ...enumFixed.report, partial: true, quit_reason: 'cost_cap', quit_detail: play.quitDetail }
907
+ : enumFixed.report;
908
+ const capped = normalised ? capNetworkOnlyFindings(stampReport(reportCandidate, facts), { notes, screenshots }) : null;
873
909
  const runnerDrop = capped ? dropRunnerRejectedFindings(capped, { notes, observations }) : { report: null, dropped: [] };
874
910
  for (const entry of runnerDrop.dropped) {
875
911
  transcript.write({ kind: 'finding_dropped', id: entry.finding.id, severity: entry.finding.severity, title: entry.finding.title, reason: entry.reason });
@@ -947,7 +983,7 @@ async function reportPhase({
947
983
  // after the report is settled, so a refusal can cost the check and never
948
984
  // the report.
949
985
  report = scrubSecrets(report, secrets);
950
- if (!shrunk) {
986
+ if (!shrunk && !play.budgetExceeded) {
951
987
  const checked = await checkClaimsAgainstScreenshots({ client, jobId, runId, report, screenshots, runDirectory, ledger, transcript, log });
952
988
  if (checked.downgraded.length > 0) {
953
989
  report = checked.report;
@@ -955,7 +991,7 @@ async function reportPhase({
955
991
  log(`downgraded ${checked.downgraded.length} finding(s) their own screenshot contradicts: ${checked.downgraded.map(entry => `${entry.id} (${entry.from} to ${entry.to}, ${entry.screenshot})`).join(', ')}`);
956
992
  }
957
993
  } else {
958
- transcript.write({ kind: 'claim_check_skipped', reason: 'the report turn needed a smaller output ceiling to fit the budget' });
994
+ transcript.write({ kind: 'claim_check_skipped', reason: play.budgetExceeded ? 'gameplay stopped for budget; preserving the report budget' : 'the report turn needed a smaller output ceiling to fit the budget' });
959
995
  }
960
996
 
961
997
  if (!markdown) {
@@ -1116,6 +1152,13 @@ export async function checkClaimsAgainstScreenshots({ client, jobId, runId, repo
1116
1152
  /** The smallest output ceiling worth retrying a report turn with. */
1117
1153
  export const REPORT_MIN_TOKENS = 1536;
1118
1154
 
1155
+ /** A refused act is not proof that a smaller, cheaper report cannot fit. */
1156
+ function hasPositiveRemainingBudget(error) {
1157
+ const { usd_used: used, usd_max: maximum, projected_usd: projected } = error.figures || {};
1158
+ return [used, maximum, projected].every(value => typeof value === 'number' && Number.isFinite(value))
1159
+ && used >= 0 && maximum > used && projected > maximum - used;
1160
+ }
1161
+
1119
1162
  /** Whether a 402 carried the three figures a retry is sized from. */
1120
1163
  export function hasBudgetFigures(error) {
1121
1164
  const figures = (error && error.figures) || {};
@@ -1437,9 +1480,10 @@ async function safeScreenshot(driver, name, screenshots, transcript, observation
1437
1480
  }
1438
1481
  }
1439
1482
 
1440
- async function driverStop(driver, outcome, log) {
1483
+ async function driverStop(driver, outcome, log, reason) {
1441
1484
  try {
1442
- const stopped = await driver.stop();
1485
+ const stopped = await driver.stop({ reason });
1486
+ if (stopped?.recording) outcome.recording = stopped.recording;
1443
1487
  if (stopped && stopped.videoPath) outcome.videoPath = stopped.videoPath;
1444
1488
  // The Godot driver captures frames rather than video, and stitching them
1445
1489
  // needs ffmpeg, which is optional. No video is a normal outcome: say
@@ -1483,7 +1527,7 @@ async function finish(outcome, ledger, runDirectory, transcript, heartbeat) {
1483
1527
  * screenshot list and says plainly that it is partial, rather than
1484
1528
  * inventing a persona verdict nobody gave.
1485
1529
  */
1486
- export function renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason }) {
1530
+ export function renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, actions = [], play, reason }) {
1487
1531
  const good = notes.filter(note => note.kind === 'good');
1488
1532
  const bad = notes.filter(note => note.kind === 'bug' || note.kind === 'confusing');
1489
1533
  const network = notes.filter(note => note.automatic && note.url);
@@ -1502,6 +1546,7 @@ export function renderFallbackMarkdown({ persona, driverName, target, notes, scr
1502
1546
  '## Why this report is partial',
1503
1547
  reason,
1504
1548
  '',
1549
+ ...(actions.length > 0 ? ['## What the persona did', actionLogLines(actions), ''] : []),
1505
1550
  '## What worked',
1506
1551
  list(good),
1507
1552
  '',
@@ -1575,7 +1620,7 @@ export function notesToFindings(notes, screenshots) {
1575
1620
  description: text,
1576
1621
  repro_steps: [`Seen after ${Number(note.at_action) || 0} action(s) in this run.`],
1577
1622
  evidence: shot
1578
- ? [{ type: 'screenshot', ref: shot.relativePath }]
1623
+ ? [{ type: 'screenshot', ref: String(shot.relativePath).replace(/\\/g, '/') }]
1579
1624
  : [{ type: 'transcript_step', ref: `action ${Number(note.at_action) || 0}` }]
1580
1625
  });
1581
1626
  }
@@ -1589,9 +1634,9 @@ export function notesToFindings(notes, screenshots) {
1589
1634
  * and no persona verdict was recorded; `partial` is what tells a reader not
1590
1635
  * to read them as one.
1591
1636
  */
1592
- async function writePartialReport({ outcome, runDirectory, persona, driverName, target, notes, screenshots, play, skill, ledger, runId, jobId, startedAt, endedAt }) {
1637
+ async function writePartialReport({ outcome, runDirectory, persona, driverName, target, notes, screenshots, actionRecords = [], play, skill, ledger, runId, jobId, startedAt, endedAt, quitReason = 'cost_cap', reason = BUDGET_PARTIAL_REASON, findings = null, preservedMarkdown = null }) {
1593
1638
  const report = stampReport({
1594
- findings: notesToFindings(notes, screenshots),
1639
+ findings: findings || notesToFindings(notes, screenshots),
1595
1640
  sentiment: { fun: 3, clarity: 3, frustration: 3 },
1596
1641
  would_continue: false,
1597
1642
  partial: true
@@ -1606,24 +1651,70 @@ async function writePartialReport({ outcome, runDirectory, persona, driverName,
1606
1651
  actionsTaken: play.actionsTaken,
1607
1652
  usage: ledger.toReportUsage(),
1608
1653
  promptVersion: promptVersionOf(skill) || 'unknown',
1609
- quitReason: 'cost_cap',
1654
+ quitReason,
1610
1655
  quitDetail: play.quitDetail
1611
1656
  });
1612
- const markdown = renderFallbackMarkdown({
1657
+ const fallbackMarkdown = renderFallbackMarkdown({
1613
1658
  persona,
1614
1659
  driverName,
1615
1660
  target,
1616
1661
  notes,
1617
1662
  screenshots,
1663
+ actions: actionLogFrom(actionRecords),
1618
1664
  play,
1619
- reason: 'The run reached its model budget before the report step. What the persona saw is below, and report.json carries the notes as unreviewed findings.'
1665
+ reason
1620
1666
  });
1667
+ const markdown = preservedMarkdown
1668
+ ? ['# Playtest (partial report draft)', '',
1669
+ 'The run reached its model budget before this draft could be completed and checked. It is preserved below for review.',
1670
+ '', preservedMarkdown, '', '---', '', fallbackMarkdown].join('\n')
1671
+ : fallbackMarkdown;
1621
1672
  const stamped = stampGeneratedAt(markdown, new Date().toISOString(), reportFacts({ persona, driverName, target, play }));
1622
1673
  await writeReportFiles({ runDirectory, markdown: stamped, report });
1623
1674
  outcome.report = report;
1624
1675
  outcome.reportMarkdown = stamped;
1625
1676
  }
1626
1677
 
1678
+ /**
1679
+ * An error message fit to leave this machine: the brief's own secrets replaced,
1680
+ * and the whole message withheld if anything else in it is shaped like a secret.
1681
+ */
1682
+ function safeErrorDetail(error, localBrief) {
1683
+ const text = scrubSecrets(String(error && error.message ? error.message : error), secretsOf(localBrief));
1684
+ if (scanForSecrets(text).length > 0) return 'The error message was withheld because it contained something shaped like a secret.';
1685
+ return text.slice(0, 400);
1686
+ }
1687
+
1688
+ /**
1689
+ * A run always ends with a report. If the notes cannot be written (a secret
1690
+ * shaped note, a secret in an error message), the report goes out without them
1691
+ * and says so: the reason and the action count are worth more than nothing.
1692
+ * Only a failure of the disk itself leaves no report.
1693
+ */
1694
+ async function writeSomeReport({ log, bareReason = BUDGET_PARTIAL_REASON, ...report }) {
1695
+ try {
1696
+ await writePartialReport(report);
1697
+ return;
1698
+ } catch (error) {
1699
+ log(`the partial report could not be written with its notes: ${String(error && error.message)}`);
1700
+ }
1701
+ try {
1702
+ await writePartialReport({
1703
+ ...report,
1704
+ preservedMarkdown: null,
1705
+ notes: [],
1706
+ screenshots: [],
1707
+ actionRecords: [],
1708
+ play: { ...report.play, quitDetail: '' },
1709
+ reason: `${bareReason} The notes were left out of this report because they could not be written safely.`
1710
+ });
1711
+ } catch (error) {
1712
+ report.outcome.report = null;
1713
+ report.outcome.reportMarkdown = null;
1714
+ log(`the partial report could not be written: ${String(error && error.message)}`);
1715
+ }
1716
+ }
1717
+
1627
1718
  async function writePartialMarkdown({ runDirectory, persona, driverName, target, notes, screenshots, play, reason }) {
1628
1719
  const markdown = stampGeneratedAt(renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason }), new Date().toISOString(), reportFacts({ persona, driverName, target, play }));
1629
1720
  assertNoSecrets('report.md', markdown);
package/src/run/resume.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { optionsFrom } from './args.js';
2
2
  import { getDeps } from './deps.js';
3
3
  import EXIT from './exit.js';
4
+ import { runsMatchPlan } from './controllers.js';
4
5
  import { runDir as runDirFor } from './paths.js';
5
6
  import { buildPartialReport, readTranscript, shouldFinalize } from './regenerate.js';
6
7
  import { reconcileDroppedFindings } from './dropped.js';
@@ -57,6 +58,13 @@ export async function resumeJob({ api, gameId, jobId, job, runs, state, repoRoot
57
58
  log(`There is no journal for ${jobId} in this repository, so there is nothing to resume from. "ravensight-playtest upload ${jobId}" can still drain any pending uploads.`);
58
59
  return EXIT.ENVIRONMENT;
59
60
  }
61
+ const controllers = job.controllers?.length ? job.controllers : null;
62
+ if (journal.job_id !== jobId || journal.game_id !== gameId ||
63
+ JSON.stringify(journal.options?.controllers || null) !== JSON.stringify(controllers) ||
64
+ !runsMatchPlan(runs || [], controllers, [...new Set((runs || []).filter(row => row.module === 'persona_playtest').map(row => row.persona))])) {
65
+ log('The saved job/controller identity does not match the server. Nothing was replayed or charged; reconcile the saved job first.');
66
+ return EXIT.ENVIRONMENT;
67
+ }
60
68
  if (!journal.idempotency_key) {
61
69
  log('That journal has no idempotency key, so the per-run tokens cannot be minted again safely. Start a new job instead.');
62
70
  return EXIT.ENVIRONMENT;
@@ -142,7 +150,9 @@ export async function resumeJob({ api, gameId, jobId, job, runs, state, repoRoot
142
150
  // nothing is charged. The tokens that come back are new ones.
143
151
  const replayed = await api.jobs.register(gameId, {
144
152
  modules,
153
+ resume_job_id: jobId,
145
154
  personas: slugs,
155
+ ...(journal.options?.controllers ? { controllers: journal.options.controllers } : {}),
146
156
  driver: driverName,
147
157
  cli_version: deps.cliVersion || api.cliVersion,
148
158
  confirm_price_cents: Number.isInteger(journal.price_cents) ? journal.price_cents : 0,
@@ -154,11 +164,10 @@ export async function resumeJob({ api, gameId, jobId, job, runs, state, repoRoot
154
164
  pack_versions: { pack: pack.pack_version }
155
165
  }, { idempotencyKey: journal.idempotency_key });
156
166
 
157
- if (!replayed.replayed) {
158
- // A register that was NOT a replay means the key did not match a stored
159
- // job, which means money just moved. Say so rather than letting it look
160
- // like a free resume.
161
- log('That idempotency key did not match a stored job, so a new job was registered and charged.');
167
+ const identity = rows => JSON.stringify(rows.map(row => [row.run_id, row.module, row.persona || null, row.controller || null]).sort((a, b) => a[0].localeCompare(b[0])));
168
+ if (replayed.replayed !== true || replayed.job_id !== jobId || identity(replayed.runs || []) !== identity(runs || [])) {
169
+ log('The server did not replay the exact saved job and controller runs. No playthrough was started. Reconcile this response before retrying.');
170
+ return EXIT.FAILED;
162
171
  }
163
172
 
164
173
  log(`resuming ${jobId}: ${outstanding.length} run(s) to play again of ${(runs || []).length}.`);
@@ -166,6 +175,8 @@ export async function resumeJob({ api, gameId, jobId, job, runs, state, repoRoot
166
175
  const resumeOptions = {
167
176
  ...options,
168
177
  controller: journal.options?.controller || options.controller || 'standard',
178
+ controllers,
179
+ recordVideo: options.recordVideo ?? journal.options?.recordVideo,
169
180
  maxActions: (journal.options && journal.options.maxActions) ?? options.maxActions,
170
181
  // The journal's answer, never the flag: a job is long or standard for good.
171
182
  runLength: journal.options?.runLength === 'long' ? 'long' : 'standard',
@@ -1,3 +1,4 @@
1
+ import { recordingCompletionBody } from './recording.js';
1
2
  import { readdir, readFile, stat } from 'node:fs/promises';
2
3
  import { join, sep } from 'node:path';
3
4
  import { ApiError, isRetryable } from '../api/errors.js';
@@ -459,10 +460,11 @@ export async function finalizeRun(runDir, options) {
459
460
  const { api, gameId, jobId, runId, body = {} } = options;
460
461
  if (!runId) throw new TypeError('finalizeRun needs a runId');
461
462
 
463
+ const completionBody = await recordingCompletionBody(runDir, body, options.includeVideo);
462
464
  await uploadRunDir(runDir, options);
463
465
 
464
466
  try {
465
- return await api.runs.complete(gameId, jobId, runId, body);
467
+ return await api.runs.complete(gameId, jobId, runId, completionBody);
466
468
  } catch (error) {
467
469
  if (!(error instanceof ApiError) || error.status !== 422 || error.code !== 'upload_unverified') {
468
470
  throw error;
@@ -470,6 +472,6 @@ export async function finalizeRun(runDir, options) {
470
472
  const paths = unverifiedPaths(error);
471
473
  if (paths.length === 0) throw error;
472
474
  await uploadRunDir(runDir, { ...options, only: paths, force: true });
473
- return api.runs.complete(gameId, jobId, runId, body);
475
+ return api.runs.complete(gameId, jobId, runId, completionBody);
474
476
  }
475
477
  }
@@ -0,0 +1,56 @@
1
+ import { open } from 'node:fs/promises';
2
+ import { constants } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ // Deliberately independent of the full local driver receipt. Never send paths,
6
+ // timestamps, targets, driver errors or recording.json as an artifact.
7
+ const STATUSES = ['complete', 'partial', 'unsupported', 'failed'];
8
+ const STOP_REASONS = [
9
+ 'stopped', 'completed', 'canceled', 'failed', 'game_exited', 'game_crashed',
10
+ 'page_closed', 'duration_limit', 'size_limit', 'frame_size_limit',
11
+ 'frame_bytes_limit', 'capture_failed', 'launch_failed', 'headless_no_viewport',
12
+ 'run_directory_required', 'encoder_unavailable', 'no_frames',
13
+ 'encoding_timeout', 'encoding_failed', 'video_finalization_failed',
14
+ 'video_unavailable', 'unknown'
15
+ ];
16
+ const MAX_METADATA_BYTES = 8192;
17
+ const MAX_DURATION_MS = 900000;
18
+
19
+ export function recordingForCompletion(metadata) {
20
+ if (!metadata || typeof metadata !== 'object' || !STATUSES.includes(metadata.status)) return null;
21
+ return {
22
+ status: metadata.status,
23
+ duration_ms: typeof metadata.durationMs === 'number' && Number.isFinite(metadata.durationMs)
24
+ && metadata.durationMs >= 0 && metadata.durationMs <= MAX_DURATION_MS ? metadata.durationMs : null,
25
+ stop_reason: STOP_REASONS.includes(metadata.reason) ? metadata.reason : 'unknown'
26
+ };
27
+ }
28
+
29
+ async function readRecording(runDir) {
30
+ let file;
31
+ try {
32
+ file = await open(join(runDir, 'recording.json'), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
33
+ const info = await file.stat();
34
+ if (!info.isFile() || info.size > MAX_METADATA_BYTES) return null;
35
+ // One bounded read also protects against a file growing after stat().
36
+ const buffer = Buffer.alloc(MAX_METADATA_BYTES + 1);
37
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
38
+ if (bytesRead > MAX_METADATA_BYTES) return null;
39
+ return recordingForCompletion(JSON.parse(buffer.subarray(0, bytesRead).toString('utf8')));
40
+ } catch {
41
+ // Missing or malformed local status must not lose the run's existing report.
42
+ return null;
43
+ } finally {
44
+ await file?.close().catch(() => {});
45
+ }
46
+ }
47
+
48
+ export async function recordingCompletionBody(runDir, body, includeVideo) {
49
+ const { recording: supplied, include_video: ignored, ...result } = body;
50
+ if (includeVideo !== true) return result;
51
+ const recording = supplied ? recordingForCompletion({
52
+ status: supplied.status, durationMs: supplied.duration_ms, reason: supplied.stop_reason
53
+ }) : await readRecording(runDir);
54
+ if (recording) Object.assign(result, { include_video: true, recording });
55
+ return result;
56
+ }