humanish 0.34.0 → 0.36.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.
@@ -37,6 +37,7 @@ import { buildOriginMap } from "./comms-inbox.js";
37
37
  import { DEFAULT_DEVICE_PRESET, isDevicePresetName, resolveDevicePreset } from "./device-presets.js";
38
38
  import { cuaLaneValidationReason, isHttpUrl, isLoopbackUrl, MAX_CUA_LANES, subjectStateInvalidReason } from "./lab-config.js";
39
39
  import { mapWithConcurrency } from "./concurrency.js";
40
+ import { appendSandboxReceipt } from "./sandbox-receipts.js";
40
41
  import { assertScreenshotEvidence } from "./image-evidence.js";
41
42
  import { buildObserverData } from "./observer-data.js";
42
43
  import { attachObserverRuntimeStreamUrls, renderObserver } from "./observer.js";
@@ -50,8 +51,6 @@ export const CUA_ACTOR_LAB_SCHEMA = "humanish.cua-lab-result.v2";
50
51
  // The only fan-out topology this slice ships: N lanes = N independent E2B desktop sandboxes,
51
52
  // each its own world (clone/serve + subject.state per lane). Shared-world is layer 7 (#164).
52
53
  export const CUA_FANOUT_STRATEGY = "per-lane-worlds";
53
- // Default in-flight lane bound when the config does not declare execution.concurrency.
54
- const DEFAULT_CUA_CONCURRENCY = 3;
55
54
  // Env override that may only LOWER the effective concurrency (never raise concurrent paid
56
55
  // desktops — invariant 3). Read names-only into a local; the value never persists.
57
56
  const CUA_MAX_CONCURRENCY_ENV = "HUMANISH_CUA_MAX_CONCURRENCY";
@@ -115,17 +114,30 @@ export function composeLaneInstructions(args) {
115
114
  * runtime loopback/getHost address (not secret), so — mirroring the lobby-code runtime injection — this
116
115
  * augments only the instructions the model receives; the authored prompt + its digest are unchanged.
117
116
  * Returns a new spec (never mutates). Shared by the CUA + concurrent shared-world routes. */
118
- export function withInboxMission(spec, inboxUrl) {
117
+ export function withInboxMission(spec, inboxUrl, address) {
118
+ // The address is half the handoff (#351): the drain matches captured mail against the DECLARED
119
+ // address, so an actor that invents its own at signup gets an inbox that stays empty forever.
120
+ // Telling it which address to use is what makes the funnel deterministic end to end. The
121
+ // wait-steering sentence exists because a mid-flow model treats "we emailed you" as a blocker
122
+ // and ends its session — the exact give-up class a live run documented — unless told the wait
123
+ // is expected and the inbox is the next step.
124
+ const identity = address === undefined ? "" : ` Your email address is ${address} — when the app asks for an email address, enter exactly that.`;
119
125
  return {
120
126
  ...spec,
121
- instructions: `${spec.instructions}\n\nEmail inbox: when the app tells you it has emailed you (a verification link, confirmation code, or magic link), open ${inboxUrl} in the browser to read that email and follow its link or enter its code. All email the app sends you arrives there.`
127
+ instructions: `${spec.instructions}\n\nEmail inbox:${identity} When the app tells you it has emailed you (a verification link, confirmation code, or magic link), open ${inboxUrl} in the browser to read that email and follow its link or enter its code. All email the app sends you arrives there. Waiting for an email is normal, not a blocker — do not end your session while waiting; open the inbox and refresh it until the email appears.`
122
128
  };
123
129
  }
130
+ /** The lane's addressed comms recipient, when one exists — the gate AND the address source for the
131
+ * inbox instruction (#351). A lane told to check an inbox it can never receive into would stall,
132
+ * so no addressed recipient means no instruction. */
133
+ export function inboxRecipientFor(commsEmail, laneId) {
134
+ return (commsEmail.recipients ?? []).find((recipient) => recipient.lane === laneId && recipient.address !== undefined);
135
+ }
124
136
  /** True when a lane has a declared comms recipient WITH an address, so the drain can actually match the
125
137
  * mail the persona will be told to read. Gates the inbox instruction to lanes that can receive mail —
126
138
  * a lane told to check an inbox it can never receive into would just stall. */
127
139
  export function laneHasInboxRecipient(commsEmail, laneId) {
128
- return (commsEmail.recipients ?? []).some((recipient) => recipient.lane === laneId && recipient.address !== undefined);
140
+ return inboxRecipientFor(commsEmail, laneId) !== undefined;
129
141
  }
130
142
  /** Mid-run inbox-surface render cadence (ms). Coarse enough that the per-tick `cat` + file writes stay
131
143
  * cheap; fine enough that a verification email is visible seconds after the app sends it. */
@@ -195,20 +207,22 @@ function resolvePerLaneSandboxMs(config) {
195
207
  ?? timeoutMs + (provisionedRoute ? SUBJECT_PROVISION_BUDGET_MS + stateBudgetMs : 0) + SANDBOX_TIMEOUT_BUFFER_MS;
196
208
  }
197
209
  /**
198
- * Effective in-flight lane bound. Default min(laneCount, 3); a declared execution.concurrency
199
- * clamps to [1, laneCount]; the env override may only LOWER it (never raise concurrent paid
200
- * desktops invariant 3). Pure given (config, laneCount, env).
210
+ * Effective in-flight lane bound. Defaults to laneCount every declared seat runs at once,
211
+ * because a throttle nobody asked for silently turns "N actors live" into waves (#350); total
212
+ * session count and spend are the same either way, only wall-clock and simultaneity differ. A
213
+ * declared execution.concurrency is a CAP, clamped to [1, laneCount]; the env override may only
214
+ * LOWER it (never raise concurrent paid desktops — invariant 3), and a lowering is reported via
215
+ * envLoweredFrom so the plan never silently disagrees with the manifest. Pure given
216
+ * (config, laneCount, env).
201
217
  */
202
218
  function resolveCuaConcurrency(config, laneCount, env) {
203
219
  const declared = config.execution?.concurrency;
204
- const base = declared !== undefined
205
- ? Math.min(Math.max(1, declared), laneCount)
206
- : Math.min(laneCount, DEFAULT_CUA_CONCURRENCY);
220
+ const base = Math.max(1, declared !== undefined ? Math.min(Math.max(1, declared), laneCount) : laneCount);
207
221
  const envLower = readPositiveInt(env[CUA_MAX_CONCURRENCY_ENV], 0);
208
- if (envLower > 0) {
209
- return Math.max(1, Math.min(base, envLower, laneCount));
222
+ if (envLower > 0 && envLower < base) {
223
+ return { bound: Math.max(1, Math.min(base, envLower, laneCount)), envLoweredFrom: base };
210
224
  }
211
- return Math.max(1, base);
225
+ return { bound: base };
212
226
  }
213
227
  /** Build the lane specs AND the public plan from a config (pure). countOverride is the CLI
214
228
  * --count for homogeneous fan-out (ignored when a `lanes` roster is declared). */
@@ -250,13 +264,15 @@ function laneSpecsAndPlan(config, opts = {}) {
250
264
  traceArtifactPath: laneCount === 1 ? "actor.json" : `actors/${streamId}.json`
251
265
  });
252
266
  }
253
- const concurrency = resolveCuaConcurrency(config, laneCount, env);
267
+ const resolved = resolveCuaConcurrency(config, laneCount, env);
268
+ const concurrency = resolved.bound;
254
269
  const perLaneSessionBudgetMs = config.execution?.timeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS;
255
270
  const perLaneSandboxMs = resolvePerLaneSandboxMs(config);
256
271
  const plan = {
257
272
  strategy: CUA_FANOUT_STRATEGY,
258
273
  laneCount,
259
274
  concurrency,
275
+ ...(resolved.envLoweredFrom === undefined ? {} : { envLoweredConcurrencyFrom: resolved.envLoweredFrom }),
260
276
  waves: Math.ceil(laneCount / concurrency),
261
277
  perLaneSessionBudgetMs,
262
278
  worstCaseSandboxMinutes: Math.round((laneCount * perLaneSandboxMs) / 60_000),
@@ -390,7 +406,7 @@ export function resolveCuaLanePlan(config, opts = {}) {
390
406
  * digests, and budgets only — no prompt text, no secrets). */
391
407
  function emitPreflightPlan(plan, labId) {
392
408
  const lines = [];
393
- lines.push(`humanish cua fan-out plan (${labId}): ${plan.laneCount} lane(s), strategy ${plan.strategy}, concurrency ${plan.concurrency}, ${plan.waves} wave(s).`);
409
+ lines.push(`humanish cua fan-out plan (${labId}): ${plan.laneCount} lane(s), strategy ${plan.strategy}, concurrency ${plan.concurrency}${plan.envLoweredConcurrencyFrom === undefined ? "" : ` (lowered from ${plan.envLoweredConcurrencyFrom} by ${CUA_MAX_CONCURRENCY_ENV})`}, ${plan.waves} wave(s).`);
394
410
  lines.push(` per-lane session budget ${Math.round(plan.perLaneSessionBudgetMs / 1000)}s; worst-case ~${plan.worstCaseSandboxMinutes} sandbox-minutes total${plan.dryRun ? " (dry-run: $0)" : ""}.`);
395
411
  for (const lane of plan.lanes) {
396
412
  lines.push(` - ${formatLanePlanEntry(lane)}`);
@@ -1145,6 +1161,9 @@ export async function runCuaLane(spec, deps) {
1145
1161
  lifecycle: { onTimeout: "kill" }
1146
1162
  }, config.execution?.desktop?.template);
1147
1163
  sandboxId = desktop.sandboxId;
1164
+ // #358 salvage: journal the id to disk before any work — an interrupted run reclaims by
1165
+ // exact recorded id (`humanish reclaim`), never by enumerating the account.
1166
+ await appendSandboxReceipt(deps.artifactRoot, { at: new Date(deps.now()).toISOString(), laneId: spec.laneId, sandboxId, timeoutMs: deps.perLaneSandboxMs });
1148
1167
  // The billed span starts the instant the sandbox exists.
1149
1168
  sandboxCreatedAtMs = deps.now();
1150
1169
  if (deps.hooks.prepareDesktop) {
@@ -1318,7 +1337,7 @@ export async function runCuaLane(spec, deps) {
1318
1337
  // Tell the persona where its inbox is — but only when comms is live AND this lane has a declared
1319
1338
  // recipient it can actually receive mail into (else it would stall on an inbox that stays empty).
1320
1339
  instructions: commsEmail && commsInboxUrl && deployedComms?.ready && laneHasInboxRecipient(commsEmail, spec.laneId)
1321
- ? withInboxMission(spec, commsInboxUrl).instructions
1340
+ ? withInboxMission(spec, commsInboxUrl, inboxRecipientFor(commsEmail, spec.laneId)?.address).instructions
1322
1341
  : spec.instructions,
1323
1342
  persona: spec.persona,
1324
1343
  timeoutMs: deps.timeoutMs,
@@ -1436,6 +1455,12 @@ export async function runCuaLane(spec, deps) {
1436
1455
  // the address the app actually sends to (e.g. the one the persona surface will sign up with).
1437
1456
  warnings.push(`Comms catch captured ${collected.captured} email send(s) but none matched a declared recipient inbox — no comms evidence written. Declare comms.email.recipients[].address to match the address the app sends to.`);
1438
1457
  }
1458
+ else {
1459
+ // Zero captures is the silent-broken shape (#351): the app never posted to the catch at
1460
+ // all, so the personas stared at an empty inbox. Most common cause: the app does not
1461
+ // actually read the declared injectEnv var for its email API base URL.
1462
+ warnings.push(`Comms catch captured ZERO email sends — the app never delivered mail through the catch. Verify the app reads ${commsEmail.injectEnv} for its email API base URL (an SDK that ignores it sends real mail or throws) and that the flow reached an email step.`);
1463
+ }
1439
1464
  }
1440
1465
  catch (error) {
1441
1466
  warnings.push(`Comms evidence collection failed (run continues; sandbox still torn down): ${redactText(deps.scrubKnownValues(toErrorMessage(error)))}`);
@@ -1468,6 +1493,18 @@ export async function runCuaLane(spec, deps) {
1468
1493
  // Close the billed span for BOTH the killed and kept-for-debug paths (a kept sandbox is
1469
1494
  // still billed until its server-side timeout, so the honest span ends here either way).
1470
1495
  sandboxTornDownAtMs = deps.now();
1496
+ // The lane's live stream is now a dead page whichever teardown path ran (killed, kept, or
1497
+ // kill-failed-awaiting-TTL) — tell the watch overlay so the tile falls back to recorded
1498
+ // evidence instead of "sandbox not found" (#357). Guarded: a viewer callback must never
1499
+ // break teardown.
1500
+ if (streamUrl !== undefined) {
1501
+ try {
1502
+ await deps.hooks.onRuntimeStreamEnded?.({ laneId: spec.laneId, simId: spec.simId, streamId: spec.streamId });
1503
+ }
1504
+ catch {
1505
+ // viewer-side only; nothing to record
1506
+ }
1507
+ }
1471
1508
  }
1472
1509
  }
1473
1510
  // Host-side approximation of the E2B desktop's billed lifetime; feeds the desktop-minute cost
@@ -1589,7 +1626,10 @@ async function runInProcessLane(spec, deps) {
1589
1626
  * pinned reason + a fail-fast event; mission verdicts never trip it). Each lane tears down ITS
1590
1627
  * OWN sandbox by id; nothing here ever enumerates.
1591
1628
  */
1592
- async function runCuaLanes(laneSpecs, deps, concurrency) {
1629
+ /** Exported for the #342 total-runner tests: the injectable runner lets a test make one lane
1630
+ * THROW (the exact class the guard exists for) without a live sandbox. Production always uses
1631
+ * the default. */
1632
+ export async function runCuaLanes(laneSpecs, deps, concurrency, runLane = runCuaLane) {
1593
1633
  const failFast = { tripped: false, reason: "" };
1594
1634
  let resolveGate;
1595
1635
  let rejectGate;
@@ -1612,21 +1652,48 @@ async function runCuaLanes(laneSpecs, deps, concurrency) {
1612
1652
  if (failFast.tripped) {
1613
1653
  return blockedLaneOutcome(spec, `skipped: ${failFast.reason}`);
1614
1654
  }
1615
- const outcome = await runCuaLane(spec, {
1616
- ...deps,
1617
- ...(index === 0
1618
- ? {
1619
- signalProvisioned: (ok) => {
1620
- if (ok) {
1621
- resolveGate?.();
1622
- }
1623
- else {
1624
- rejectGate?.();
1655
+ // The lane runner is TOTAL (#342): every exit path returns a recorded outcome. Without this
1656
+ // guard, one lane's late throw (e.g. its trace write hitting ENOSPC after its own sandbox was
1657
+ // already torn down) rejected the whole map while sibling workers kept launching sandboxes
1658
+ // nobody would ever record — the run spent money and then reported nothing.
1659
+ let outcome;
1660
+ try {
1661
+ outcome = await runLane(spec, {
1662
+ ...deps,
1663
+ ...(index === 0
1664
+ ? {
1665
+ signalProvisioned: (ok) => {
1666
+ if (ok) {
1667
+ resolveGate?.();
1668
+ }
1669
+ else {
1670
+ rejectGate?.();
1671
+ }
1625
1672
  }
1626
1673
  }
1627
- }
1628
- : {})
1629
- });
1674
+ : {})
1675
+ });
1676
+ }
1677
+ catch (error) {
1678
+ // Lane 0 may have thrown before signaling the provisioning gate — release the followers as
1679
+ // blocked rather than leaving them awaiting a gate that will never settle.
1680
+ if (index === 0)
1681
+ rejectGate?.();
1682
+ const detail = redactText(toErrorMessage(error));
1683
+ outcome = {
1684
+ spec,
1685
+ killed: false,
1686
+ streamUrlPresent: false,
1687
+ screenshots: [],
1688
+ stateStepRecords: [],
1689
+ phaseRecords: [],
1690
+ warnings: [],
1691
+ noEngagement: false,
1692
+ selfReportedBlocker: false,
1693
+ harnessError: true,
1694
+ sessionError: `lane runner threw outside the session guard: ${detail}`
1695
+ };
1696
+ }
1630
1697
  if (outcome.harnessError && !failFast.tripped) {
1631
1698
  failFast.tripped = true;
1632
1699
  failFast.reason = `a prior lane (${outcome.spec.laneId}) ended in a harness error (fail-fast)`;
@@ -1794,6 +1861,18 @@ export async function runCuaActorLab(options) {
1794
1861
  if (liveObserver) {
1795
1862
  attachObserverRuntimeStreamUrls(liveObserver, runtimeStreamUrls);
1796
1863
  }
1864
+ },
1865
+ onRuntimeStreamEnded: async (stream) => {
1866
+ await hooks.onRuntimeStreamEnded?.(stream);
1867
+ // Mark, never remove: the tile needs to KNOW the live view ended (and say so) rather than
1868
+ // have the stream silently vanish from the overlay (#357).
1869
+ for (const entry of runtimeStreamUrls) {
1870
+ if (entry.streamId === stream.streamId)
1871
+ entry.ended = true;
1872
+ }
1873
+ if (liveObserver) {
1874
+ attachObserverRuntimeStreamUrls(liveObserver, runtimeStreamUrls);
1875
+ }
1797
1876
  }
1798
1877
  };
1799
1878
  const env = hooks.env ?? process.env;