humanish 0.74.0 → 0.75.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.
@@ -46,6 +46,7 @@ import { appendSandboxReceipt } from "./sandbox-receipts.js";
46
46
  import { assertScreenshotEvidence } from "./image-evidence.js";
47
47
  import { buildObserverData } from "./observer-data.js";
48
48
  import { corepackCommandFor, needsNodeRuntime, nodeBootstrapCommand } from "./subject-runtime.js";
49
+ import { chromeCdpProbeCommand, parseChromeCdpProbeOutput } from "./chrome-cdp-probe.js";
49
50
  import { personaToDirectives, renderPersonaPromptSection } from "./persona.js";
50
51
  import { labPersonaIds, resolveCommittedPersonas } from "./persona-resolve.js";
51
52
  import { renderTaskPrompt } from "./tasks.js";
@@ -505,6 +506,37 @@ function isoNow(now) {
505
506
  return new Date(now()).toISOString();
506
507
  }
507
508
  /** Emit a phase-started event (no ok/durationMs: those belong to the matching completed event). */
509
+ /**
510
+ * Run a provisioning step and, when it fails with an EXIT CODE, run it once more (#602). A cold
511
+ * install of 0.74.0 lost its whole first live study to one transient TLS error inside the
512
+ * sandbox's `npm install`; the parallel install twenty seconds later passed, as had the ten
513
+ * before it. One retry clears that class. A TIMEOUT is not retried: its budget is already spent,
514
+ * and a second wait would double it. The retry runs under its own step name so both logs stay.
515
+ */
516
+ async function runProvisioningStepWithOneRetry(desktop, args) {
517
+ const first = await runDetachedStep(desktop, {
518
+ name: args.name,
519
+ command: args.command,
520
+ cwd: args.cwd,
521
+ timeoutMs: args.timeoutMs,
522
+ requestTimeoutMs: args.requestTimeoutMs,
523
+ ...args.timers
524
+ });
525
+ if (first.ok || first.timedOut)
526
+ return { ...first, attempts: 1 };
527
+ const retryStartedAt = args.now();
528
+ emitPhaseStarted(args.onPhase, args.now, args.retryPhase, `${args.retryMessage} (first attempt exited ${first.exitCode ?? "null"}; retrying once)`);
529
+ const second = await runDetachedStep(desktop, {
530
+ name: `${args.name}-retry`,
531
+ command: args.command,
532
+ cwd: args.cwd,
533
+ timeoutMs: args.timeoutMs,
534
+ requestTimeoutMs: args.requestTimeoutMs,
535
+ ...args.timers
536
+ });
537
+ emitPhaseCompleted(args.onPhase, args.now, retryStartedAt, args.retryPhase, second.ok, second.ok ? `${args.retryMessage}: succeeded on the second attempt` : `${args.retryMessage}: failed twice`);
538
+ return { ...second, attempts: 2, ...(first.exitCode === undefined ? {} : { firstExitCode: first.exitCode }) };
539
+ }
508
540
  function emitPhaseStarted(onPhase, now, phase, message) {
509
541
  onPhase?.({ at: isoNow(now), type: `cua-lab.subject.${phase}.started`, message });
510
542
  }
@@ -839,162 +871,70 @@ export function desktopBrowserFamily(value) {
839
871
  return "unknown";
840
872
  }
841
873
  /**
842
- * Observe-time CDP port resolution lines (pure; exported for contract tests): cached
843
- * launch-time port first, then a re-read of the profile's DevToolsActivePort marker, then the
844
- * legacy fixed 9222. The re-read is a local best-effort file read inside the already
845
- * time-bounded observer command, so a missing/garbled marker degrades to the fallback,
846
- * never a hang.
847
- */
848
- export function chromeCdpPortResolutionScript(endpoint) {
849
- return [
850
- `let cdpPort = ${endpoint.cdpPort === undefined ? "undefined" : JSON.stringify(endpoint.cdpPort)};`,
851
- `const cdpProfileDir = ${JSON.stringify(endpoint.profileDir ?? "")};`,
852
- "if (cdpPort === undefined && cdpProfileDir) {",
853
- " try {",
854
- " const { readFileSync } = await import('node:fs');",
855
- " const marker = readFileSync(cdpProfileDir + '/DevToolsActivePort', 'utf8');",
856
- " const parsed = Number.parseInt(String(marker.split('\\n')[0] ?? ''), 10);",
857
- " if (Number.isInteger(parsed) && parsed > 0) cdpPort = parsed;",
858
- " } catch {}",
859
- "}",
860
- "if (cdpPort === undefined) cdpPort = 9222;"
861
- ];
862
- }
863
- /** Shared CDP page-selection preamble: pinned target id first, then this lane's target URL,
864
- * then a single-page fallback; never an arbitrary page from a multi-page endpoint. */
865
- function chromeCdpPageSelectionScript(endpoint, targetId,
866
- /**
867
- * "pinned" (default): the launch-time target, for measurements about the ORIGINAL window
868
- * (geometry). "active": the tab the participant is driving NOW — Chrome's /json lists page
869
- * targets most-recently-focused first. The state observer must follow the participant: a
870
- * verification link that opens in a NEW tab left the pinned observer reading the old tab
871
- * forever, so the observed URL never changed again and stopWhen/task criteria went blind
872
- * (a live run's funnel read reach-dashboard 0/2 under a screenshot OF the dashboard).
874
+ * The URL / title / page-text / scroll observer behind stopWhen and task criteria. One probe per
875
+ * observation, run on the sandbox's python3 (see chrome-cdp-probe.ts for why not node: #514).
876
+ *
877
+ * "active": follow the participant to whatever tab they are driving now — never pin the state
878
+ * observer to the launch tab (a verification link that opened in a NEW tab left a pinned observer
879
+ * reading the old tab forever).
880
+ *
881
+ * `onUnavailable` fires ONCE, on the first probe that could not read the page, with the reason.
882
+ * The observer still degrades to `{}` for the loop; the callback is how a lane says out loud that
883
+ * url/text criteria are not being measured, instead of letting the funnel report 0/N (#514).
873
884
  */
874
- prefer = "pinned") {
875
- return [
876
- ...chromeCdpPortResolutionScript(endpoint),
877
- "const pages = await fetch('http://127.0.0.1:' + cdpPort + '/json').then((r) => r.json()).catch(() => []);",
878
- `const expectedTargetId = ${JSON.stringify(targetId ?? "")};`,
879
- `const expectedTargetUrl = ${JSON.stringify(endpoint.targetUrl)};`,
880
- "const normalizeUrl = (value) => String(value || '').replace(/\\/$/, '');",
881
- "const httpPages = Array.isArray(pages) ? pages.filter((entry) => entry && entry.type === 'page' && /^https?:/.test(String(entry.url || ''))) : [];",
882
- prefer === "active"
883
- ? "const page = httpPages[0] || (expectedTargetId ? httpPages.find((entry) => entry.id === expectedTargetId) : undefined);"
884
- : "const page = expectedTargetId ? httpPages.find((entry) => entry.id === expectedTargetId) : (httpPages.find((entry) => normalizeUrl(entry.url) === normalizeUrl(expectedTargetUrl)) || (httpPages.length === 1 ? httpPages[0] : undefined));"
885
- ];
886
- }
887
- export function makeChromeBrowserStateObserver(desktop, requestTimeoutMs, endpoint, targetId) {
885
+ export function makeChromeBrowserStateObserver(desktop, requestTimeoutMs, endpoint, targetId, onUnavailable) {
886
+ let reported = false;
887
+ const unavailable = (reason) => {
888
+ if (!reported) {
889
+ reported = true;
890
+ onUnavailable?.(reason);
891
+ }
892
+ return {};
893
+ };
888
894
  return async () => {
889
- const script = [
890
- // "active": follow the participant to whatever tab they are driving now — never pin the
891
- // state observer to the launch tab (see chromeCdpPageSelectionScript).
892
- ...chromeCdpPageSelectionScript(endpoint, targetId, "active"),
893
- "if (!page) { console.log('{}'); process.exit(0); }",
894
- "let text = '';",
895
- "let scrollY = undefined;",
896
- "let url = String(page.url || '');",
897
- "let title = String(page.title || '');",
898
- "if (typeof WebSocket === 'function' && page.webSocketDebuggerUrl) {",
899
- " const ws = new WebSocket(page.webSocketDebuggerUrl);",
900
- " const result = await new Promise((resolve) => {",
901
- " const timer = setTimeout(() => resolve(undefined), 1500);",
902
- " ws.onopen = () => ws.send(JSON.stringify({ id: 1, method: 'Runtime.evaluate', params: { returnByValue: true, expression: '({ url: location.href, title: document.title, text: (document.body && document.body.innerText || \"\").slice(0, 20000), scrollY: (window.scrollY || 0) })' } }));",
903
- " ws.onmessage = (event) => {",
904
- " try {",
905
- " const payload = JSON.parse(String(event.data));",
906
- " if (payload.id !== 1) return;",
907
- " clearTimeout(timer);",
908
- " resolve(payload.result && payload.result.result && payload.result.result.value);",
909
- " } catch { clearTimeout(timer); resolve(undefined); }",
910
- " };",
911
- " ws.onerror = () => { clearTimeout(timer); resolve(undefined); };",
912
- " }).finally(() => { try { ws.close(); } catch {} });",
913
- " if (result && typeof result === 'object') {",
914
- " url = typeof result.url === 'string' ? result.url : url;",
915
- " title = typeof result.title === 'string' ? result.title : title;",
916
- " text = typeof result.text === 'string' ? result.text : '';",
917
- " scrollY = typeof result.scrollY === 'number' ? result.scrollY : undefined;",
918
- " }",
919
- "}",
920
- "console.log(JSON.stringify({ url, title, text, scrollY }));"
921
- ].join("\n");
922
- const result = await desktop.commands.run(`node --input-type=module -e ${shellSingleQuote(script)}`, {
923
- requestTimeoutMs,
924
- timeoutMs: 5_000
925
- });
895
+ const result = await desktop.commands.run(chromeCdpProbeCommand({ ...endpoint, ...(targetId === undefined ? {} : { targetId }), prefer: "active", mode: "state" }), { requestTimeoutMs, timeoutMs: 5_000 });
926
896
  if (result.exitCode !== undefined && result.exitCode !== 0) {
927
- return {};
928
- }
929
- try {
930
- const parsed = JSON.parse((result.stdout ?? "{}").trim() || "{}");
931
- if (!parsed || typeof parsed !== "object") {
932
- return {};
933
- }
934
- const record = parsed;
935
- return {
936
- ...(typeof record.url === "string" && record.url.length > 0 ? { url: record.url } : {}),
937
- ...(typeof record.title === "string" && record.title.length > 0 ? { title: record.title } : {}),
938
- ...(typeof record.text === "string" && record.text.length > 0 ? { text: record.text } : {}),
939
- ...(typeof record.scrollY === "number" && Number.isFinite(record.scrollY) ? { scrollY: record.scrollY } : {})
940
- };
941
- }
942
- catch {
943
- return {};
897
+ return unavailable(`probe exited ${result.exitCode}: ${tailOf(result.stderr ?? result.stdout ?? "")}`);
944
898
  }
899
+ const parsed = parseChromeCdpProbeOutput(result.stdout);
900
+ if (parsed.unavailable !== undefined)
901
+ return unavailable(parsed.unavailable);
902
+ return {
903
+ ...(parsed.url === undefined ? {} : { url: parsed.url }),
904
+ ...(parsed.title === undefined ? {} : { title: parsed.title }),
905
+ ...(parsed.text === undefined ? {} : { text: parsed.text }),
906
+ ...(parsed.scrollY === undefined ? {} : { scrollY: parsed.scrollY })
907
+ };
945
908
  };
946
909
  }
947
910
  /**
948
911
  * Read the running browser's actual outer-window bounds and CSS layout viewport through the
949
912
  * already-enabled local Chrome DevTools endpoint. The returned values come from `window.*` in
950
913
  * the target page; requested E2B resolution is deliberately not an input to this function.
914
+ * `undefined` carries the reason the measurement is missing via `onUnavailable`, so the geometry
915
+ * warning can name the cause (a dead CDP endpoint, no python3) instead of only the symptom.
951
916
  */
952
- export function makeChromeDesktopGeometryObserver(desktop, requestTimeoutMs, endpoint, targetId) {
917
+ export function makeChromeDesktopGeometryObserver(desktop, requestTimeoutMs, endpoint, targetId, onUnavailable) {
953
918
  return async () => {
954
- const script = [
955
- ...chromeCdpPageSelectionScript(endpoint, targetId),
956
- "if (!page || typeof WebSocket !== 'function' || !page.webSocketDebuggerUrl) { console.log('{}'); process.exit(0); }",
957
- "const ws = new WebSocket(page.webSocketDebuggerUrl);",
958
- "const result = await new Promise((resolve) => {",
959
- " const timer = setTimeout(() => resolve(undefined), 1500);",
960
- " ws.onopen = () => ws.send(JSON.stringify({ id: 1, method: 'Runtime.evaluate', params: { returnByValue: true, expression: '({ browserWindow: { x: window.screenX, y: window.screenY, width: window.outerWidth, height: window.outerHeight }, viewport: { width: window.innerWidth, height: window.innerHeight, deviceScaleFactor: window.devicePixelRatio } })' } }));",
961
- " ws.onmessage = (event) => {",
962
- " try {",
963
- " const payload = JSON.parse(String(event.data));",
964
- " if (payload.id !== 1) return;",
965
- " clearTimeout(timer);",
966
- " resolve(payload.result && payload.result.result && payload.result.result.value);",
967
- " } catch { clearTimeout(timer); resolve(undefined); }",
968
- " };",
969
- " ws.onerror = () => { clearTimeout(timer); resolve(undefined); };",
970
- "}).finally(() => { try { ws.close(); } catch {} });",
971
- "console.log(JSON.stringify(result ? { ...result, targetId: String(page.id || '') } : {}));"
972
- ].join("\n");
973
- const result = await desktop.commands.run(`node --input-type=module -e ${shellSingleQuote(script)}`, {
974
- requestTimeoutMs,
975
- timeoutMs: 5_000
976
- });
919
+ const result = await desktop.commands.run(chromeCdpProbeCommand({ ...endpoint, ...(targetId === undefined ? {} : { targetId }), prefer: "pinned", mode: "geometry" }), { requestTimeoutMs, timeoutMs: 5_000 });
977
920
  if (result.exitCode !== undefined && result.exitCode !== 0) {
921
+ onUnavailable?.(`probe exited ${result.exitCode}: ${tailOf(result.stderr ?? result.stdout ?? "")}`);
978
922
  return undefined;
979
923
  }
980
- try {
981
- const parsed = JSON.parse((result.stdout ?? "{}").trim() || "{}");
982
- if (!parsed || typeof parsed !== "object")
983
- return undefined;
984
- const record = parsed;
985
- const rawWindow = record.browserWindow;
986
- const rawViewport = record.viewport;
987
- if (!isMeasuredRect(rawWindow) || !isMeasuredViewport(rawViewport))
988
- return undefined;
989
- return {
990
- browserWindow: { ...rawWindow, source: "cdp" },
991
- viewport: { ...rawViewport, source: "cdp" },
992
- ...(typeof record.targetId === "string" && record.targetId.length > 0 ? { targetId: record.targetId } : {})
993
- };
924
+ const parsed = parseChromeCdpProbeOutput(result.stdout);
925
+ if (parsed.unavailable !== undefined) {
926
+ onUnavailable?.(parsed.unavailable);
927
+ return undefined;
994
928
  }
995
- catch {
929
+ if (!isMeasuredRect(parsed.browserWindow) || !isMeasuredViewport(parsed.viewport)) {
930
+ onUnavailable?.("the page reported no usable window or viewport dimensions");
996
931
  return undefined;
997
932
  }
933
+ return {
934
+ browserWindow: { ...parsed.browserWindow, source: "cdp" },
935
+ viewport: { ...parsed.viewport, source: "cdp" },
936
+ ...(parsed.targetId === undefined ? {} : { targetId: parsed.targetId })
937
+ };
998
938
  };
999
939
  }
1000
940
  function isMeasuredRect(value) {
@@ -1063,12 +1003,18 @@ export async function captureDesktopBrowserGeometry(args) {
1063
1003
  else {
1064
1004
  warnings.push(`Browser window bounds could not be measured for lane ${args.laneId}; the live stream will use the full desktop.`);
1065
1005
  }
1006
+ let cdpUnavailable;
1066
1007
  const chromeGeometry = args.browserFamily === "chromium"
1067
1008
  ? await makeChromeDesktopGeometryObserver(args.desktop, args.requestTimeoutMs, {
1068
1009
  ...(args.launchIdentity?.cdpPort === undefined ? {} : { cdpPort: args.launchIdentity.cdpPort }),
1069
1010
  ...(args.launchIdentity?.profileDir === undefined ? {} : { profileDir: args.launchIdentity.profileDir }),
1070
1011
  targetUrl: args.targetUrl
1071
- }, args.browserTargetId)().catch(() => undefined)
1012
+ }, args.browserTargetId, (reason) => {
1013
+ cdpUnavailable = reason;
1014
+ })().catch((error) => {
1015
+ cdpUnavailable = toErrorMessage(error);
1016
+ return undefined;
1017
+ })
1072
1018
  : undefined;
1073
1019
  const browserWindow = chromeGeometry?.browserWindow ?? xdotoolWindow;
1074
1020
  const viewport = chromeGeometry?.viewport;
@@ -1079,9 +1025,12 @@ export async function captureDesktopBrowserGeometry(args) {
1079
1025
  warnings.push(`Browser window fill did not reach the requested ${args.requestedScreen[0]}x${args.requestedScreen[1]} screen for lane ${args.laneId}; measured outer bounds are ${browserWindow.width}x${browserWindow.height}.`);
1080
1026
  }
1081
1027
  if (!viewport) {
1028
+ // Name the cause, not only the symptom: the same dead DevTools channel that loses the viewport
1029
+ // loses every url/text observation, and a reader of the bundle should learn that here (#514).
1030
+ const cause = cdpUnavailable === undefined ? "" : ` DevTools probe: ${redactText(cdpUnavailable)}.`;
1082
1031
  warnings.push(args.browserFamily === "firefox"
1083
1032
  ? `Browser CSS viewport measurement is unavailable for Firefox on lane ${args.laneId}; stream.viewport is omitted instead of reading a different browser's CDP endpoint.`
1084
- : `Browser CSS viewport could not be measured for lane ${args.laneId}; stream.viewport is omitted instead of copying the requested screen resolution.`);
1033
+ : `Browser CSS viewport could not be measured for lane ${args.laneId}; stream.viewport is omitted instead of copying the requested screen resolution.${cause}`);
1085
1034
  }
1086
1035
  return {
1087
1036
  ...(browserWindowId === undefined ? {} : { browserWindowId }),
@@ -1752,7 +1701,13 @@ export async function runCuaLane(spec, deps) {
1752
1701
  ...(browserLaunchIdentity?.cdpPort === undefined ? {} : { cdpPort: browserLaunchIdentity.cdpPort }),
1753
1702
  ...(browserLaunchIdentity?.profileDir === undefined ? {} : { profileDir: browserLaunchIdentity.profileDir }),
1754
1703
  targetUrl
1755
- }, browserTargetId)
1704
+ }, browserTargetId,
1705
+ // Once per lane: a dark observation channel is a gap in the instrument, and the
1706
+ // funnel's NEVER MEASURED count needs this line to explain itself (#514).
1707
+ (reason) => {
1708
+ warnings.push(`Browser-state observer unavailable for lane ${spec.laneId} (${redactText(deps.scrubKnownValues(reason))}); ` +
1709
+ "urlIncludes/urlPathEquals/textIncludes stop conditions and task criteria are NOT being measured this session.");
1710
+ })
1756
1711
  }
1757
1712
  }
1758
1713
  : {}),
@@ -3288,13 +3243,17 @@ async function runSubjectServePipeline(desktop, args) {
3288
3243
  if (needsNodeRuntime(serveCommands)) {
3289
3244
  const runtimeStartedAt = now();
3290
3245
  emitPhaseStarted(args.onPhase, now, "runtime", "providing the Node runtime the serve pipeline needs");
3291
- const bootstrap = await runDetachedStep(desktop, {
3246
+ const bootstrap = await runProvisioningStepWithOneRetry(desktop, {
3292
3247
  name: "subject-runtime-node",
3293
3248
  command: nodeBootstrapCommand(),
3294
3249
  cwd: SUBJECT_DIR,
3295
3250
  timeoutMs: args.serve.installTimeoutMs ?? INSTALL_TIMEOUT_MS,
3296
3251
  requestTimeoutMs: args.requestTimeoutMs,
3297
- ...timers
3252
+ timers,
3253
+ retryPhase: "runtime-retry",
3254
+ retryMessage: "Node runtime bootstrap",
3255
+ onPhase: args.onPhase,
3256
+ now
3298
3257
  });
3299
3258
  let ok = bootstrap.ok;
3300
3259
  const corepack = ok ? corepackCommandFor(serveCommands) : undefined;
@@ -3311,23 +3270,39 @@ async function runSubjectServePipeline(desktop, args) {
3311
3270
  }
3312
3271
  emitPhaseCompleted(args.onPhase, now, runtimeStartedAt, "runtime", ok, ok ? "Node runtime ready" : "could not provide a Node runtime");
3313
3272
  if (!ok) {
3314
- throw new Error(`the subject's serve pipeline needs a Node runtime and this desktop template has none, and bootstrapping one failed: ${tailOf(args.scrub(bootstrap.logTail))}. Use execution.desktop.template with an image that ships Node, or change serve.install to a runtime the template provides.`);
3273
+ throw new Error(`the subject's serve pipeline needs a Node runtime and this desktop template has none, and bootstrapping one failed${bootstrap.attempts === 2 ? " twice" : ""}: ${tailOf(args.scrub(bootstrap.logTail))}. Use execution.desktop.template with an image that ships Node, or change serve.install to a runtime the template provides.`);
3315
3274
  }
3316
3275
  }
3317
3276
  if (args.serve.install) {
3318
3277
  const installStartedAt = now();
3319
3278
  emitPhaseStarted(args.onPhase, now, "install", "installing subject dependencies");
3320
- const install = await runDetachedStep(desktop, {
3279
+ const install = await runProvisioningStepWithOneRetry(desktop, {
3321
3280
  name: "subject-install",
3322
3281
  command: args.serve.install,
3323
3282
  cwd: SUBJECT_DIR,
3324
3283
  timeoutMs: args.serve.installTimeoutMs ?? INSTALL_TIMEOUT_MS,
3325
3284
  requestTimeoutMs: args.requestTimeoutMs,
3326
- ...timers
3285
+ timers,
3286
+ retryPhase: "install-retry",
3287
+ retryMessage: "subject install",
3288
+ onPhase: args.onPhase,
3289
+ now
3327
3290
  });
3328
- emitPhaseCompleted(args.onPhase, now, installStartedAt, "install", install.ok, install.ok ? "subject dependencies installed" : "subject install failed");
3291
+ emitPhaseCompleted(args.onPhase, now, installStartedAt, "install", install.ok, install.ok
3292
+ ? install.attempts === 2
3293
+ ? "subject dependencies installed (on the second attempt)"
3294
+ : "subject dependencies installed"
3295
+ : install.attempts === 2
3296
+ ? "subject install failed twice"
3297
+ : "subject install failed");
3329
3298
  if (!install.ok) {
3330
- throw new Error(`subject install ${install.timedOut ? "timed out" : `failed (exit ${install.exitCode})`}: ${tailOf(args.scrub(install.logTail))}`);
3299
+ // Lead with the line a person can act on; npm's own trace follows it (#602).
3300
+ const headline = install.timedOut
3301
+ ? `subject install timed out after ${args.serve.installTimeoutMs ?? INSTALL_TIMEOUT_MS}ms`
3302
+ : install.attempts === 2
3303
+ ? `subject install failed twice (exit ${install.firstExitCode ?? "null"}, then exit ${install.exitCode ?? "null"}); the sandbox could not complete serve.install`
3304
+ : `subject install failed (exit ${install.exitCode ?? "null"})`;
3305
+ throw new Error(`${headline}: ${tailOf(args.scrub(install.logTail))}`);
3331
3306
  }
3332
3307
  await refresh();
3333
3308
  }