ravensight-playtest 0.3.0 → 0.3.2

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.
@@ -14,6 +14,9 @@ import { readBridgeConfig } from "../run/drivers/stdio.js";
14
14
  import { WEBDRIVER_NAMES, readDriverConfig } from '../run/drivers/webdriver.js';
15
15
  import { automationChecks } from './check.js';
16
16
  import { runPersona } from '../run/personaLoop.js';
17
+ import { loadLocalBrief, localBriefProblems } from '../briefLocal.js';
18
+ import { DEFAULT_PERSONAS as RUN_DEFAULT_PERSONAS, RUN_MODULES, SIGN_UP_ACTIONS, defaultPersonasFor, describeBreakdown, personaCount, unknownPersonaMessage, unknownPersonas } from '../runDefaults.js';
19
+ import { bold } from '../ui/index.js';
17
20
  import { reconcileDroppedFindings } from '../run/dropped.js';
18
21
  import * as stateFile from '../run/state.js';
19
22
  import { emitRunEvents } from '../run/synthetic.js';
@@ -44,8 +47,8 @@ import { emitRunEvents } from '../run/synthetic.js';
44
47
  * nothing to point at, 4 auth, 5 the proxy never answered, 10 cancelled.
45
48
  */
46
49
 
47
- /** Personas used when the developer names none. Spec 17 defaults to 2. */
48
- export const DEFAULT_PERSONAS = Object.freeze(['curious-kid', 'grumpy-veteran']);
50
+ /** Personas used when the developer names none: one since 0.3.2 (see ../runDefaults.js). */
51
+ export const DEFAULT_PERSONAS = RUN_DEFAULT_PERSONAS;
49
52
 
50
53
  /** Parallel runs. Each Chromium is 0.5 to 1.5 GB, so 2 is the honest default. */
51
54
  export const DEFAULT_CONCURRENCY = 2;
@@ -132,15 +135,26 @@ async function run(flags, injected, deps, log) {
132
135
  return EXIT.FAILED;
133
136
  }
134
137
  }
135
- const modules = ['persona_playtest', 'aggregate_report'];
138
+ // The build's own health, before anything is priced or charged: the
139
+ // Snowball round paid for a run against an API that was down.
140
+ if (!options.skipPreflight) {
141
+ const health = await healthPreflight({ config: context.config, driverName, target, fetchImpl: injected.fetch, log });
142
+ if (health && !health.ok) {
143
+ log(`Preflight failed: ${health.detail}.`);
144
+ log('No job was registered and nothing was reserved. Start the build or its API and run this again, or pass --skip-preflight to register anyway.');
145
+ return EXIT.FAILED;
146
+ }
147
+ }
148
+ const modules = [...RUN_MODULES];
136
149
  const pack = await deps.packs.get({ api, gameId, modules });
137
150
  if (options.controller === 'jev' && !pack.routing?.action_controllers?.includes('jev')) { log('This server does not currently support the requested action controller. No job was registered.'); return EXIT.ENVIRONMENT; }
138
151
  const personaSlugs = resolvePersonas(options, context.config, pack);
139
- const personas = personaSlugs.map(slug => {
140
- const found = (pack.personas || []).find(persona => persona.slug === slug);
141
- if (!found) throw new Error(`unknown persona "${slug}"; available: ${(pack.personas || []).map(entry => entry.slug).join(', ')}`);
142
- return found;
143
- });
152
+ const unknownSlugs = unknownPersonas(personaSlugs, pack.personas || []);
153
+ if (unknownSlugs.length > 0) {
154
+ log(`${unknownPersonaMessage(unknownSlugs, pack.personas || [])} Nothing was registered or charged.`);
155
+ return EXIT.ENVIRONMENT;
156
+ }
157
+ const personas = personaSlugs.map(slug => (pack.personas || []).find(persona => persona.slug === slug));
144
158
 
145
159
  // The same ceiling the server's `personaListRefusal` enforces, checked here
146
160
  // first so a list past it never reaches the network: no estimate, no brief
@@ -168,8 +182,43 @@ async function run(flags, injected, deps, log) {
168
182
  return EXIT.ENVIRONMENT;
169
183
  }
170
184
 
171
- const estimate = await api.jobs.estimate(gameId, { modules, personas: personaSlugs });
172
- log(`${personas.length} persona(s) on ${driverName}: ${formatCents(estimate.estimate_cents)}. Balance: ${formatCents(estimate.balance_cents)}.`);
185
+ let localBrief = {};
186
+ try {
187
+ localBrief = await loadLocalBrief({ repoRoot });
188
+ } catch (error) {
189
+ log(`${String(error && error.message)} The run goes ahead without expected_noise and test_account.`);
190
+ }
191
+
192
+ const runLength = options.runLength === 'long' ? 'long' : 'standard';
193
+ let estimate;
194
+ try {
195
+ estimate = await api.jobs.estimate(gameId, { modules, personas: personaSlugs, runLength });
196
+ } catch (error) {
197
+ if (error && error.code === 'invalid_estimate') {
198
+ log(`The server refused to price this job: ${String(error.message || 'invalid estimate')}${runLength === 'long' ? ' This server may not price long runs yet; run without --long.' : ''} Nothing was registered or charged.`);
199
+ return EXIT.ENVIRONMENT;
200
+ }
201
+ throw error;
202
+ }
203
+ // A long run is its own unit on the server. An older server that ignores
204
+ // run_length prices a standard run, and confirming that would buy a run
205
+ // the CLI then plays as long.
206
+ if (runLength === 'long' && !(Array.isArray(estimate.breakdown) && estimate.breakdown.some(line => line && line.unit === 'playtest_run_long'))) {
207
+ log('This server does not price long runs; nothing was registered or charged. Run without --long.');
208
+ return EXIT.ENVIRONMENT;
209
+ }
210
+ if (runLength === 'long') {
211
+ const factor = longRunFactor({ estimate, pack });
212
+ options.longRunFactor = factor.factor;
213
+ if (factor.note) log(factor.note);
214
+ }
215
+ // Said before any charge, even with --yes, in bold: how many personas and
216
+ // what they cost in total, each unit with its own price.
217
+ const units = describeBreakdown(estimate.breakdown, formatCents);
218
+ log(bold(`This run: ${personaCount(personas.length)} (${personaSlugs.join(', ')}) on ${driverName}, ${formatCents(estimate.estimate_cents)} in total${units ? `: ${units}` : ''}.`));
219
+ log(`Balance: ${formatCents(estimate.balance_cents)}.`);
220
+ if (runLength === 'long') log(longRunLine(options.longRunFactor));
221
+ for (const line of actionBudgetLines({ options, personas, pack, localBrief })) log(line);
173
222
  if (estimate.ok === false) {
174
223
  log(`That is more than the balance covers. Top up at ${estimate.topup_url || 'ravensight.io'} and run this again.`);
175
224
  // Nothing is wrong with the request; the account needs money. Spec 17's
@@ -203,6 +252,7 @@ async function run(flags, injected, deps, log) {
203
252
  build: { kind: driverName === 'playwright_web' ? 'url' : (WEBDRIVER_NAMES.includes(driverName) || driverName === 'cli_stdio') ? 'driver_config' : 'godot_project', ref: target },
204
253
  repo: { commit: (context.config && context.config.commit) || '', dirty: Boolean(context.config && context.config.dirty) },
205
254
  max_actions: Number.isInteger(options.maxActions) ? options.maxActions : null,
255
+ ...(runLength === 'long' ? { run_length: 'long' } : {}),
206
256
  pack_versions: { pack: pack.pack_version }
207
257
  }, { idempotencyKey });
208
258
  } catch (error) {
@@ -227,6 +277,8 @@ async function run(flags, injected, deps, log) {
227
277
  priceCents: registered.price && registered.price.price_cents,
228
278
  options: {
229
279
  maxActions: options.maxActions ?? null,
280
+ runLength,
281
+ ...(runLength === 'long' ? { longRunFactor: options.longRunFactor } : {}),
230
282
  controller: options.controller || 'standard',
231
283
  concurrency: options.concurrency ?? DEFAULT_CONCURRENCY,
232
284
  uploadVideo: Boolean(options.uploadVideo),
@@ -257,6 +309,7 @@ async function run(flags, injected, deps, log) {
257
309
  journal,
258
310
  config: context.config,
259
311
  brief,
312
+ localBrief,
260
313
  injected,
261
314
  log
262
315
  });
@@ -371,6 +424,7 @@ export async function playJob({
371
424
  journal,
372
425
  config,
373
426
  brief = null,
427
+ localBrief: givenLocalBrief = null,
374
428
  injected = {},
375
429
  log = () => {},
376
430
  finish = true
@@ -382,6 +436,17 @@ export async function playJob({
382
436
 
383
437
  const directory = jobDir(repoRoot, jobId);
384
438
  const personaRuns = runs.filter(entry => entry.module === 'persona_playtest');
439
+ // expected_noise and test_account, which never go to the server's brief.
440
+ let localBrief = givenLocalBrief || injected.localBrief || null;
441
+ if (!localBrief) {
442
+ try {
443
+ localBrief = await loadLocalBrief({ repoRoot });
444
+ } catch (error) {
445
+ log(`${String(error && error.message)} The run goes ahead without expected_noise and test_account.`);
446
+ localBrief = {};
447
+ }
448
+ }
449
+ for (const problem of localBriefProblems(localBrief)) log(`.ravensight/brief.local.json: ${problem}; that field is ignored.`);
385
450
  const aggregateRun = runs.find(entry => entry.module === 'aggregate_report') || null;
386
451
 
387
452
  const results = [];
@@ -423,6 +488,7 @@ export async function playJob({
423
488
  inFlight,
424
489
  makeDriver,
425
490
  brief,
491
+ localBrief,
426
492
  log
427
493
  }));
428
494
  }
@@ -517,6 +583,7 @@ export async function playPersonaRun({
517
583
  inFlight = null,
518
584
  makeDriver,
519
585
  brief = null,
586
+ localBrief = {},
520
587
  log
521
588
  }) {
522
589
  const runId = entry.run_id;
@@ -525,7 +592,7 @@ export async function playPersonaRun({
525
592
  if (inFlight) inFlight.set(runId, { state: 'queued' });
526
593
 
527
594
  const skill = skillFor(pack, 'persona_playtest');
528
- const maxActions = resolveMaxActions(options, persona, pack);
595
+ const maxActions = resolveMaxActions(options, persona, pack, entry);
529
596
  // Headless is right for a browser and wrong for Godot: a headless Godot
530
597
  // routes no GUI input, so it is a smoke-test mode rather than a way to play.
531
598
  const headless = driverName === 'godot_driver' ? options.headless === true : options.headed !== true;
@@ -581,6 +648,7 @@ export async function playPersonaRun({
581
648
  // The brief this command fetched and checked is complete, not the
582
649
  // pack's copy, which a pack bundle does not always carry.
583
650
  brief: brief || pack.brief || null,
651
+ localBrief: usableLocalBrief(localBrief),
584
652
  intentNotes: pack.intent_notes || [],
585
653
  schemas: pack.schemas || [],
586
654
  maxActions,
@@ -698,6 +766,18 @@ export async function playPersonaRun({
698
766
  return { runId, persona: persona.slug, state: outcome.state, quitReason: outcome.quitReason };
699
767
  }
700
768
 
769
+ /**
770
+ * A run's duration as `complete` sends it: `{ wall_seconds, model_seconds }`,
771
+ * each a number or null.
772
+ *
773
+ * @param {{wall_seconds?: number, model_seconds?: number}} duration
774
+ * @returns {{wall_seconds: number|null, model_seconds: number|null}}
775
+ */
776
+ export function durationBody(duration) {
777
+ const value = key => (duration && Number.isFinite(Number(duration[key])) && duration[key] !== null ? Number(duration[key]) : null);
778
+ return { wall_seconds: value('wall_seconds'), model_seconds: value('model_seconds') };
779
+ }
780
+
701
781
  /**
702
782
  * Upload the run directory and complete the run, through cli-core's
703
783
  * `finalizeRun` so the 422 `upload_unverified` repair pass lives in one place.
@@ -723,12 +803,7 @@ export async function deliver({ deps, api, gameId, jobId, runId, directory, outc
723
803
  };
724
804
  // The run card's duration. The server lane accepts `duration`; an older
725
805
  // server ignores a key it does not know.
726
- if (outcome.duration) {
727
- body.duration = {
728
- wall_seconds: Number.isFinite(Number(outcome.duration.wall_seconds)) ? Number(outcome.duration.wall_seconds) : null,
729
- model_seconds: Number.isFinite(Number(outcome.duration.model_seconds)) ? Number(outcome.duration.model_seconds) : null
730
- };
731
- }
806
+ if (outcome.duration) body.duration = durationBody(outcome.duration);
732
807
  if (outcome.report) body.report = outcome.report;
733
808
  // `report_md` is what the server lints for its four required sections. Not
734
809
  // sending it means `report_lint` is silently empty on every run, which reads
@@ -910,6 +985,8 @@ async function aggregatePhase({
910
985
  log
911
986
  }) {
912
987
  const runId = aggregateRun ? aggregateRun.run_id : null;
988
+ // Created here, so its wall clock is measured from the start of the
989
+ // aggregate phase: what the summary step's card shows as its duration.
913
990
  const ledger = createUsageLedger();
914
991
  const heartbeat = runId
915
992
  ? createHeartbeat({ api, gameId, jobId, runId, onUsage: usage => ledger.recordServerUsage(usage), onError: () => {} })
@@ -966,7 +1043,7 @@ async function aggregatePhase({
966
1043
  log('The aggregate was built locally: this job has no aggregate_report run to spend a model turn from, so the dedup and judgement passes were skipped.');
967
1044
  }
968
1045
 
969
- const published = await publishAggregate({ deps, api, gameId, jobId, repoRoot, directory, options, log });
1046
+ const published = await publishAggregate({ deps, api, gameId, jobId, repoRoot, directory, options, log, duration: durationBody(ledger.duration()) });
970
1047
  if (published === 'finished') reached = 'succeeded';
971
1048
  else if (published) await advance('uploading');
972
1049
 
@@ -976,7 +1053,8 @@ async function aggregatePhase({
976
1053
  state: reached === 'uploading' ? 'succeeded' : 'failed',
977
1054
  actions_taken: 0,
978
1055
  quit_reason: reached === 'uploading' ? 'goal_reached' : 'error',
979
- reason: reached === 'uploading' ? '' : 'the aggregate was not published'
1056
+ reason: reached === 'uploading' ? '' : 'the aggregate was not published',
1057
+ duration: durationBody(ledger.duration())
980
1058
  });
981
1059
  } catch (error) {
982
1060
  log(`aggregate run ${runId}: complete was refused (${String(error && error.message)}).`);
@@ -996,7 +1074,7 @@ async function aggregatePhase({
996
1074
  * sent, 'published' when the server has it, 'finished' when it also closed
997
1075
  * the aggregate run
998
1076
  */
999
- async function publishAggregate({ deps, api, gameId, jobId, repoRoot, directory, options, log }) {
1077
+ async function publishAggregate({ deps, api, gameId, jobId, repoRoot, directory, options, log, duration = null }) {
1000
1078
  if (options.dryUpload) {
1001
1079
  log('The aggregate was not published: --dry-upload.');
1002
1080
  return false;
@@ -1023,7 +1101,11 @@ async function publishAggregate({ deps, api, gameId, jobId, repoRoot, directory,
1023
1101
  md_size: markdown.size,
1024
1102
  md_sha256: markdown.sha256,
1025
1103
  json_size: json.size,
1026
- json_sha256: json.sha256
1104
+ json_sha256: json.sha256,
1105
+ // The server closes the aggregate run on this call, so the duration
1106
+ // rides here as well as on the run's own complete; an older server
1107
+ // ignores a key it does not know.
1108
+ ...(duration ? { duration } : {})
1027
1109
  });
1028
1110
  // The server finishes the aggregate run itself when it accepts the
1029
1111
  // report, and says so in `run`. A caller that then tries to move the
@@ -1246,6 +1328,22 @@ export function capabilityNotesFor(pack, driverName) {
1246
1328
  return notes;
1247
1329
  }
1248
1330
 
1331
+ /**
1332
+ * The local brief with any field that failed its check left out, so a
1333
+ * malformed test account never reaches the prompt half filled.
1334
+ *
1335
+ * @param {Object} localBrief
1336
+ * @returns {Object}
1337
+ */
1338
+ export function usableLocalBrief(localBrief) {
1339
+ const out = {};
1340
+ for (const key of ['expected_noise', 'test_account']) {
1341
+ if (!localBrief || localBrief[key] === undefined) continue;
1342
+ if (localBriefProblems({ [key]: localBrief[key] }).length === 0) out[key] = localBrief[key];
1343
+ }
1344
+ return out;
1345
+ }
1346
+
1249
1347
  export function skillFor(pack, moduleName) {
1250
1348
  const entry = (pack.skills || []).find(skill => skill.module === moduleName);
1251
1349
  if (!entry) throw new Error(`the content pack has no ${moduleName} skill; run a pack sync`);
@@ -1258,23 +1356,158 @@ export function resolvePersonas(options, config, pack) {
1258
1356
  return options.personas.split(',').map(slug => slug.trim()).filter(Boolean);
1259
1357
  }
1260
1358
  if (config && Array.isArray(config.personas) && config.personas.length > 0) return config.personas;
1261
- const available = (pack.personas || []).map(persona => persona.slug);
1262
- const defaults = DEFAULT_PERSONAS.filter(slug => available.includes(slug));
1263
- return defaults.length > 0 ? defaults : available.slice(0, 2);
1359
+ return defaultPersonasFor(pack.personas || []);
1360
+ }
1361
+
1362
+ /**
1363
+ * What one persona run buys, said before the charge: the action budget each
1364
+ * persona gets (its patience in the pack, lowered by --max-actions or the
1365
+ * server's ceiling), and that a sign up form alone can take eight of them.
1366
+ *
1367
+ * @returns {Array<string>}
1368
+ */
1369
+ export function actionBudgetLines({ options = {}, personas = [], pack = {}, localBrief = {} }) {
1370
+ if (personas.length === 0) return [];
1371
+ const budgets = personas.map(persona => ({ slug: persona.slug, actions: resolveMaxActions(options, persona, pack) }));
1372
+ const same = budgets.every(entry => entry.actions === budgets[0].actions);
1373
+ const typical = Math.min(...budgets.map(entry => entry.actions));
1374
+ const lines = [
1375
+ same
1376
+ ? `Action budget: ${budgets[0].actions} actions per persona.`
1377
+ : `Action budget: ${budgets.map(entry => `${entry.slug} ${entry.actions}`).join(', ')} actions.`,
1378
+ `A persona typically reaches ${typical} actions; sign-up alone can take ${SIGN_UP_ACTIONS}.${localBrief && localBrief.test_account ? ' The brief\'s test account lets it sign in instead.' : ' A test_account in the brief lets it start past sign-up.'}`
1379
+ ];
1380
+ return lines;
1381
+ }
1382
+
1383
+ /** How long the health check waits for an answer. */
1384
+ export const HEALTH_TIMEOUT_MS = 10000;
1385
+
1386
+ /**
1387
+ * Whether a status is what the health check expects: the configured
1388
+ * `expect_status` (a number or a list of numbers), else any 2xx or 3xx.
1389
+ *
1390
+ * @param {number} status
1391
+ * @param {number|Array<number>|undefined} expected
1392
+ * @returns {boolean}
1393
+ */
1394
+ export function statusExpected(status, expected) {
1395
+ if (Array.isArray(expected) && expected.length > 0) return expected.map(Number).includes(status);
1396
+ if (Number.isInteger(Number(expected)) && expected !== null && expected !== undefined && expected !== '') return status === Number(expected);
1397
+ return status >= 200 && status < 400;
1398
+ }
1399
+
1400
+ /**
1401
+ * The free health check `run` makes before it prices or registers anything.
1402
+ * `.ravensight/config.json` may name one: `preflight: { url, expect_status }`
1403
+ * (a relative url is resolved against the build url). With none, a web
1404
+ * build's own url has to answer 2xx or 3xx. Other drivers with no configured
1405
+ * url are not checked. Redirects are not followed: a 3xx is an answer.
1406
+ *
1407
+ * @returns {Promise<{ok: boolean, url: string, status: number|null, detail: string}|null>} null when nothing was checked
1408
+ */
1409
+ export async function healthPreflight({ config = null, driverName, target, fetchImpl = null, timeoutMs = HEALTH_TIMEOUT_MS, log = () => {} }) {
1410
+ const configured = config && config.preflight && typeof config.preflight === 'object' ? config.preflight : null;
1411
+ let url = configured && typeof configured.url === 'string' && configured.url.trim() ? configured.url.trim() : null;
1412
+ const expected = configured ? configured.expect_status : undefined;
1413
+ if (url && !/^https?:\/\//i.test(url)) {
1414
+ if (driverName !== 'playwright_web' || !target) return { ok: false, url, status: null, detail: `preflight.url ${url} is not an http or https URL` };
1415
+ url = new URL(url, target).toString();
1416
+ }
1417
+ if (!url) {
1418
+ if (driverName !== 'playwright_web' || !/^https?:\/\//i.test(String(target || ''))) return null;
1419
+ url = String(target);
1420
+ }
1421
+ const doFetch = fetchImpl || globalThis.fetch;
1422
+ const wanted = Array.isArray(expected) && expected.length > 0 ? expected.join(' or ') : (expected !== undefined && expected !== null && expected !== '' ? String(expected) : '2xx or 3xx');
1423
+ log(`preflight: GET ${url}, expecting ${wanted}`);
1424
+ try {
1425
+ const response = await doFetch(url, { method: 'GET', redirect: 'manual', signal: AbortSignal.timeout(timeoutMs) });
1426
+ const status = Number(response.status);
1427
+ const ok = statusExpected(status, expected);
1428
+ return { ok, url, status, detail: ok ? `${url} answered ${status}` : `GET ${url} answered ${status}, expected ${wanted}` };
1429
+ } catch (error) {
1430
+ const reason = error && error.name === 'TimeoutError' ? `no answer in ${Math.round(timeoutMs / 1000)} seconds` : String((error && error.cause && error.cause.code) || (error && error.message) || error);
1431
+ return { ok: false, url, status: null, detail: `GET ${url} failed: ${reason}` };
1432
+ }
1433
+ }
1434
+
1435
+ /** The factor a long run's limits use when the server names none. */
1436
+ export const FALLBACK_LONG_RUN_FACTOR = 2;
1437
+
1438
+ /**
1439
+ * How many times a standard run's limits a long run gets, from the server:
1440
+ * the estimate's `long_run_factor`, a run's `limits.long_run_factor`, or the
1441
+ * pack's client routing `long_run_factor`. Only when none of them says does it
1442
+ * fall back to 2, with a note saying so.
1443
+ *
1444
+ * @returns {{factor: number, source: string, note: string|null}}
1445
+ */
1446
+ export function longRunFactor({ estimate = null, run = null, pack = null } = {}) {
1447
+ const sources = [
1448
+ ['the estimate', estimate && estimate.long_run_factor],
1449
+ ['the run limits', run && run.limits && run.limits.long_run_factor],
1450
+ ['the content pack', pack && pack.routing && pack.routing.long_run_factor]
1451
+ ];
1452
+ for (const [source, value] of sources) {
1453
+ const factor = Number(value);
1454
+ if (Number.isInteger(factor) && factor >= 1) return { factor, source, note: null };
1455
+ }
1456
+ return { factor: FALLBACK_LONG_RUN_FACTOR, source: 'fallback', note: `The server did not say how much longer a long run is, so the local action budget assumes ${FALLBACK_LONG_RUN_FACTOR}x; the server's own limits still apply.` };
1457
+ }
1458
+
1459
+ /** "twice", "3 times": a factor as the run line says it. */
1460
+ function times(factor) {
1461
+ return factor === 2 ? 'twice' : `${factor} times`;
1462
+ }
1463
+
1464
+ /**
1465
+ * Said under the price when `--long` was passed; the price itself comes from the estimate.
1466
+ *
1467
+ * @param {number} [factor]
1468
+ * @returns {string}
1469
+ */
1470
+ export function longRunLine(factor = FALLBACK_LONG_RUN_FACTOR) {
1471
+ return `Long run: each persona gets ${times(factor)} the actions, model budget and wall time of a standard run, and is priced as a long run in the total above.`;
1264
1472
  }
1265
1473
 
1474
+ /** The line for the default factor, kept for callers that print it as is. */
1475
+ export const LONG_RUN_LINE = longRunLine();
1476
+
1477
+ /** A standard run's local action budget when nothing else sets one. */
1478
+ export const DEFAULT_MAX_ACTIONS = 25;
1479
+
1266
1480
  /**
1267
1481
  * The action budget is the smallest of the three ceilings that exist: what the
1268
- * developer asked for, the persona's own patience, and what routing.json allows
1269
- * per run (`max_actions`, which arrives in the pack's client routing subset).
1482
+ * developer asked for, the persona's own patience, and what the run is allowed
1483
+ * (`max_actions`, which arrives in the pack's client routing subset).
1484
+ *
1485
+ * A long run (`options.runLength === 'long'`) uses the run's own action limit
1486
+ * when the server's register answer carries one on the run (`max_actions`, or
1487
+ * `limits.max_actions`), and otherwise the server's long run factor (see
1488
+ * {@link longRunFactor}) times the routing ceiling. The persona's patience is
1489
+ * multiplied too, or a long run would stop where a standard one does.
1490
+ * `--max-actions` is the developer's own ceiling and is never doubled.
1491
+ *
1492
+ * @param {Object} options
1493
+ * @param {Object} persona
1494
+ * @param {Object} pack
1495
+ * @param {Object|null} [run] the run as register answered it
1496
+ * @returns {number}
1270
1497
  */
1271
- export function resolveMaxActions(options, persona, pack) {
1498
+ export function resolveMaxActions(options, persona, pack, run = null) {
1499
+ const factor = options && options.runLength === 'long'
1500
+ ? (Number.isInteger(options.longRunFactor) && options.longRunFactor >= 1 ? options.longRunFactor : longRunFactor({ run, pack }).factor)
1501
+ : 1;
1502
+ const fromServer = Number(run && ((run.limits && run.limits.max_actions) ?? run.max_actions)) || null;
1503
+ const routing = Number(pack && pack.routing && pack.routing.max_actions) || null;
1504
+ const patience = Number(persona && persona.patience_actions) || null;
1272
1505
  const candidates = [
1273
- Number.isInteger(options.maxActions) ? options.maxActions : null,
1274
- Number(persona && persona.patience_actions) || null,
1275
- Number(pack && pack.routing && pack.routing.max_actions) || null
1506
+ Number.isInteger(options && options.maxActions) ? options.maxActions : null,
1507
+ patience === null ? null : patience * factor,
1508
+ fromServer || (routing === null ? null : routing * factor)
1276
1509
  ].filter(value => Number.isFinite(value) && value > 0);
1277
- return candidates.length > 0 ? Math.min(...candidates) : 25;
1510
+ return candidates.length > 0 ? Math.min(...candidates) : DEFAULT_MAX_ACTIONS * factor;
1278
1511
  }
1279
1512
 
1280
1513
  export function resolveTarget(options, config, driverName) {
@@ -196,7 +196,7 @@ export function clusterFindings(runs) {
196
196
  *
197
197
  * @returns {Promise<Map<string, {same: boolean, title: string|null}>>}
198
198
  */
199
- export async function judgeClusters({ client, jobId, runId, clusters, log = () => {} }) {
199
+ export async function judgeClusters({ client, jobId, runId, clusters, ledger = null, log = () => {} }) {
200
200
  const verdicts = new Map();
201
201
  // A certain cluster is one finding written twice; nothing to ask.
202
202
  const ambiguous = clusters.filter(cluster => cluster.members.length > 1 && !cluster.certain);
@@ -214,6 +214,7 @@ export async function judgeClusters({ client, jobId, runId, clusters, log = () =
214
214
  ].join('\n');
215
215
 
216
216
  try {
217
+ const askedAt = Date.now();
217
218
  const answer = await callStepWithRetry({
218
219
  client,
219
220
  jobId,
@@ -222,6 +223,7 @@ export async function judgeClusters({ client, jobId, runId, clusters, log = () =
222
223
  messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
223
224
  maxTokens: 512
224
225
  });
226
+ if (ledger) ledger.record({ model: answer.model, usage: answer.usage, ms: Date.now() - askedAt });
225
227
  for (const entry of parseJsonArray(answer.text)) {
226
228
  const cluster = ambiguous[Number(entry.cluster)];
227
229
  if (!cluster) continue;
@@ -400,7 +402,7 @@ export async function buildAggregate({
400
402
  const { kept, observations } = applyQualityGate(inputs);
401
403
  const clusters = clusterFindings(kept);
402
404
  const verdicts = aggregateRunId
403
- ? await judgeClusters({ client, jobId, runId: aggregateRunId, clusters, log })
405
+ ? await judgeClusters({ client, jobId, runId: aggregateRunId, clusters, ledger, log })
404
406
  : new Map();
405
407
 
406
408
  const issues = clusters.flatMap(cluster => toIssues(cluster, verdicts.get(cluster.signature)));
@@ -523,6 +525,7 @@ export async function judgeAggregate({ client, jobId, runId, ranked, inputs, bri
523
525
  ].join('\n');
524
526
 
525
527
  try {
528
+ const askedAt = Date.now();
526
529
  const answer = await callStepWithRetry({
527
530
  client,
528
531
  jobId,
@@ -531,7 +534,7 @@ export async function judgeAggregate({ client, jobId, runId, ranked, inputs, bri
531
534
  messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
532
535
  maxTokens: 8192
533
536
  });
534
- if (ledger) ledger.record({ model: answer.model, usage: answer.usage });
537
+ if (ledger) ledger.record({ model: answer.model, usage: answer.usage, ms: Date.now() - askedAt });
535
538
  return parseJsonObject(answer.text);
536
539
  } catch (error) {
537
540
  // A budget or cancel refusal decides the caller's exit code and has to
package/src/run/args.js CHANGED
@@ -35,6 +35,9 @@ const BOOLEAN = new Map([
35
35
  ['--headed', 'headed'],
36
36
  ['--headless', 'headless'],
37
37
  ['--verbose', 'verbose'],
38
+ // A long persona run: twice the actions, model budget and wall time of a
39
+ // standard run, priced by the server as its own unit.
40
+ ['--long', 'long'],
38
41
  // Native drivers only: register without the free automation probe first.
39
42
  ['--skip-preflight', 'skipPreflight'],
40
43
  // Resume only: replay an interrupted run from the start instead of writing a
@@ -106,6 +109,7 @@ export function optionsFrom(flags = {}) {
106
109
  return {
107
110
  options: {
108
111
  ...options,
112
+ runLength: runLengthFor(options),
109
113
  ...stripUndefined({
110
114
  repo: flags.repoRoot || flags.repo,
111
115
  apiUrl: flags.apiUrl,
@@ -128,8 +132,21 @@ export function optionsFrom(flags = {}) {
128
132
  };
129
133
  }
130
134
 
135
+ /** The two run lengths the server prices: `standard`, and `long` at twice a standard run. */
136
+ export const RUN_LENGTHS = Object.freeze(['standard', 'long']);
137
+
138
+ /**
139
+ * `long` when `--long` was passed, otherwise `standard`.
140
+ *
141
+ * @param {{long?: boolean}} options
142
+ * @returns {'standard'|'long'}
143
+ */
144
+ export function runLengthFor(options = {}) {
145
+ return options.long === true ? 'long' : 'standard';
146
+ }
147
+
131
148
  function stripUndefined(value) {
132
149
  return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
133
150
  }
134
151
 
135
- export default { parseRunArgv, optionsFrom, unknownOptionNotes };
152
+ export default { parseRunArgv, optionsFrom, unknownOptionNotes, runLengthFor };