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.
- package/AGENTS.md +8 -0
- package/README.md +11 -7
- package/dist/concurrent-shared-world-lab.d.ts +3 -0
- package/dist/concurrent-shared-world-lab.js +56 -5
- package/dist/concurrent-shared-world-lab.js.map +1 -1
- package/dist/cua-actor-lab.d.ts +32 -3
- package/dist/cua-actor-lab.js +110 -31
- package/dist/cua-actor-lab.js.map +1 -1
- package/dist/e2b-terminal-lab.js +3 -0
- package/dist/e2b-terminal-lab.js.map +1 -1
- package/dist/init-templates.js +1 -1
- package/dist/lab-config.d.ts +18 -6
- package/dist/lab-config.js +84 -2
- package/dist/lab-config.js.map +1 -1
- package/dist/observer-assets.js +36 -2
- package/dist/observer-assets.js.map +1 -1
- package/dist/observer.d.ts +7 -0
- package/dist/observer.js +13 -6
- package/dist/observer.js.map +1 -1
- package/dist/program.js +28 -0
- package/dist/program.js.map +1 -1
- package/dist/reclaim.d.ts +31 -0
- package/dist/reclaim.js +90 -0
- package/dist/reclaim.js.map +1 -0
- package/dist/run.d.ts +3 -0
- package/dist/run.js +3 -1
- package/dist/run.js.map +1 -1
- package/dist/sandbox-receipts.d.ts +19 -0
- package/dist/sandbox-receipts.js +51 -0
- package/dist/sandbox-receipts.js.map +1 -0
- package/dist/shared-world-lab.js +3 -0
- package/dist/shared-world-lab.js.map +1 -1
- package/docs/contracts/schemas.md +37 -15
- package/docs/goals/current.md +1 -1
- package/docs/principles/invariants-and-defaults.md +1 -1
- package/docs/ramp/README.md +5 -4
- package/package.json +1 -1
- package/skills/humanish/SKILL.md +53 -9
package/dist/cua-actor-lab.js
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
199
|
-
*
|
|
200
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
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;
|