humanish 0.95.0 → 0.96.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.
- package/README.md +4 -2
- package/dist/comms-agentmail.d.ts +25 -0
- package/dist/comms-agentmail.js +418 -0
- package/dist/comms-agentmail.js.map +1 -0
- package/dist/comms-connections.d.ts +4 -2
- package/dist/comms-connections.js +3 -3
- package/dist/comms-connections.js.map +1 -1
- package/dist/comms-lease-store.d.ts +90 -0
- package/dist/comms-lease-store.js +489 -0
- package/dist/comms-lease-store.js.map +1 -0
- package/dist/comms-receiving-evidence.d.ts +46 -0
- package/dist/comms-receiving-evidence.js +35 -0
- package/dist/comms-receiving-evidence.js.map +1 -0
- package/dist/comms-receiving-inbox.d.ts +16 -0
- package/dist/comms-receiving-inbox.js +410 -0
- package/dist/comms-receiving-inbox.js.map +1 -0
- package/dist/comms-receiving-runtime.d.ts +25 -0
- package/dist/comms-receiving-runtime.js +45 -0
- package/dist/comms-receiving-runtime.js.map +1 -0
- package/dist/comms-receiving-types.d.ts +98 -0
- package/dist/comms-receiving-types.js +2 -0
- package/dist/comms-receiving-types.js.map +1 -0
- package/dist/comms-receiving.d.ts +58 -0
- package/dist/comms-receiving.js +554 -0
- package/dist/comms-receiving.js.map +1 -0
- package/dist/comms-setup.d.ts +40 -0
- package/dist/comms-setup.js +121 -0
- package/dist/comms-setup.js.map +1 -0
- package/dist/concurrent-shared-world-lab.js +662 -624
- package/dist/concurrent-shared-world-lab.js.map +1 -1
- package/dist/cua-actor-lab.d.ts +6 -1
- package/dist/cua-actor-lab.js +149 -52
- package/dist/cua-actor-lab.js.map +1 -1
- package/dist/doctor-lab.js +11 -0
- package/dist/doctor-lab.js.map +1 -1
- package/dist/e2b-terminal-lab.js +3 -0
- package/dist/e2b-terminal-lab.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lab-config.d.ts +20 -3
- package/dist/lab-config.js +59 -6
- package/dist/lab-config.js.map +1 -1
- package/dist/lab-engine.js +8 -3
- package/dist/lab-engine.js.map +1 -1
- package/dist/lab-summary.d.ts +1 -0
- package/dist/lab-summary.js +8 -2
- package/dist/lab-summary.js.map +1 -1
- package/dist/observer-app.html +1 -1
- package/dist/observer-data.js +14 -1
- package/dist/observer-data.js.map +1 -1
- package/dist/oss-lab.d.ts +1 -1
- package/dist/oss-lab.js.map +1 -1
- package/dist/oss-meta-lab.d.ts +1 -1
- package/dist/oss-meta-lab.js.map +1 -1
- package/dist/program.d.ts +2 -0
- package/dist/program.js +100 -4
- package/dist/program.js.map +1 -1
- package/dist/run-narration-secrets.d.ts +5 -0
- package/dist/run-narration-secrets.js +68 -0
- package/dist/run-narration-secrets.js.map +1 -0
- package/dist/run.d.ts +8 -3
- package/dist/run.js +26 -4
- package/dist/run.js.map +1 -1
- package/dist/scripted-browser-lab.js +7 -0
- package/dist/scripted-browser-lab.js.map +1 -1
- package/dist/shared-world-lab.js +4 -0
- package/dist/shared-world-lab.js.map +1 -1
- package/dist/study-analysis-engine.js +30 -1
- package/dist/study-analysis-engine.js.map +1 -1
- package/dist/study-analysis-evidence.js +9 -1
- package/dist/study-analysis-evidence.js.map +1 -1
- package/dist/tui-app.js +134 -134
- package/dist/tui-contract.d.ts +13 -0
- package/dist/tui-contract.js.map +1 -1
- package/dist/tui-launch.d.ts +2 -0
- package/dist/tui-launch.js +9 -1
- package/dist/tui-launch.js.map +1 -1
- package/docs/architecture/comms-inbox.md +4 -0
- package/docs/architecture/real-email-receiving.md +135 -0
- package/docs/contracts/run-bundle.md +16 -5
- package/docs/contracts/schemas.md +20 -3
- package/docs/goals/current.md +5 -5
- package/docs/ramp/README.md +13 -5
- package/docs/release/0.96.0-real-email-receiving.md +47 -0
- package/docs/release/0.96.1-browser-navigation.md +22 -0
- package/package.json +2 -1
- package/skills/humanish/SKILL.md +33 -13
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { withTransientCommsSecrets } from "./run-narration-secrets.js";
|
|
2
|
+
import { prepareReceivingRun, receivingPublication } from "./comms-receiving-runtime.js";
|
|
3
|
+
import { receivingEmailValidationReason } from "./lab-config.js";
|
|
1
4
|
// The CONCURRENT shared-world lab backend (#164 phase 2): N persona lanes drive ONE shared,
|
|
2
5
|
// mutable service plane SIMULTANEOUSLY — the actual leverage of a sim. A recomposition of shipped
|
|
3
6
|
// pieces + the getHost wrapper:
|
|
@@ -502,6 +505,9 @@ function observerResultForConcurrentArtifacts(cwd, runId, artifactRoot, warnings
|
|
|
502
505
|
* ticking into a directory something else is deleting, which surfaces as an unrelated ENOTEMPTY.
|
|
503
506
|
*/
|
|
504
507
|
export async function runConcurrentSharedWorld(options) {
|
|
508
|
+
return withTransientCommsSecrets(() => runConcurrentSharedWorldWithSecrets(options));
|
|
509
|
+
}
|
|
510
|
+
async function runConcurrentSharedWorldWithSecrets(options) {
|
|
505
511
|
const analysis = resolveAutomaticAnalysis(options.config.review?.analysis);
|
|
506
512
|
const result = await withRunStatusScope(() => runConcurrentSharedWorldInScope(options));
|
|
507
513
|
return completeAutomaticAnalysis(result, analysis.ok ? analysis.config : undefined, options.automaticAnalysis, options.config.review?.analysis === undefined ? "default" : "explicit", analysis.ok && analysis.preferLargerOutput === true);
|
|
@@ -581,6 +587,9 @@ async function runConcurrentSharedWorldInScope(options) {
|
|
|
581
587
|
// The per-seat vision lobby-code reader (default: the real single-frame OpenAI read). Injectable so the
|
|
582
588
|
// barrier's handoff + convergence proof are testable without a live vision call.
|
|
583
589
|
const readLobbyCode = hooks.readLobbyCodeFromFrame ?? readLobbyCodeFromFrame;
|
|
590
|
+
const receivingReason = receivingEmailValidationReason(config);
|
|
591
|
+
if (receivingReason)
|
|
592
|
+
return fail("HUMANISH_CONCURRENT_SHARED_WORLD_LAB_INVALID", receivingReason, descriptor.id);
|
|
584
593
|
const openaiApiKey = env.OPENAI_API_KEY?.trim() ?? "";
|
|
585
594
|
const e2bApiKey = env.E2B_API_KEY?.trim() ?? "";
|
|
586
595
|
const knownSecretValues = [
|
|
@@ -659,7 +668,7 @@ async function runConcurrentSharedWorldInScope(options) {
|
|
|
659
668
|
// sandbox at create (fixed port known up front); the catch is deployed before serve; the drain + digest
|
|
660
669
|
// evidence run at subject teardown, then register run-level in the bundle. NOT available on the
|
|
661
670
|
// external-public plane (the app is an operator-owned deployment the harness never provisions).
|
|
662
|
-
const commsEmail = planeClass === "provisioned-getHost"
|
|
671
|
+
const commsEmail = planeClass === "provisioned-getHost" && config.comms?.email?.kind === "fake" ? config.comms.email : undefined;
|
|
663
672
|
const commsPort = commsEmail ? (commsEmail.port ?? DEFAULT_SANDBOX_CATCH_PORT) : undefined;
|
|
664
673
|
// injectEnv is absent on an adopter-hosted plane (#328): there is no subject env to inject
|
|
665
674
|
// because the operator points their own app at their own catch.
|
|
@@ -674,7 +683,7 @@ async function runConcurrentSharedWorldInScope(options) {
|
|
|
674
683
|
// the previously-inert block into a working one.
|
|
675
684
|
const externalComms = planeClass === "external-public" ? config.comms?.email?.external : undefined;
|
|
676
685
|
const externalCommsEmail = externalComms ? config.comms?.email : undefined;
|
|
677
|
-
if (config.comms?.email && planeClass === "external-public" && externalComms === undefined) {
|
|
686
|
+
if (config.comms?.email?.kind === "fake" && planeClass === "external-public" && externalComms === undefined) {
|
|
678
687
|
warnings.push("comms.email is declared but this is the external-public plane (the shared plane is an operator-owned public deployment the harness does not provision) — the in-sandbox email catch cannot be deployed and no comms evidence is collected. Declare `comms.email.external` to host the catch yourself (#328).");
|
|
679
688
|
}
|
|
680
689
|
if (externalComms) {
|
|
@@ -741,235 +750,393 @@ async function runConcurrentSharedWorldInScope(options) {
|
|
|
741
750
|
return fail("HUMANISH_CONCURRENT_SHARED_WORLD_LAB_FAILED", `local-tree packing failed: ${redactText(scrubKnownValues(toErrorMessage(error)))}`, descriptor.id);
|
|
742
751
|
}
|
|
743
752
|
}
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
// Defense-in-depth: concurrentSharedWorldValidationReason already required serve above.
|
|
747
|
-
return fail("HUMANISH_CONCURRENT_SHARED_WORLD_LAB_INVALID", "the provisioned-getHost concurrent shared-world route requires `subject.serve`.", descriptor.id);
|
|
748
|
-
}
|
|
749
|
-
let subjectModule;
|
|
750
|
-
let subjectDesktop;
|
|
751
|
-
// The in-sandbox email catch on the ONE subject sandbox (#297); drained at teardown. Undefined
|
|
752
|
-
// unless a comms lab declared it. Hoisted so the finally can drain before the subject is killed.
|
|
753
|
-
let deployedComms;
|
|
754
|
-
// Background prober dispose signal (FIX-9: cleared in finally).
|
|
755
|
-
let proberDisposed = false;
|
|
756
|
-
let releaseDispose = () => { };
|
|
757
|
-
const disposeSignal = new Promise((resolve) => { releaseDispose = resolve; });
|
|
758
|
-
let proberLoop;
|
|
759
|
-
const proberSnapshot = async () => {
|
|
760
|
-
if (!subjectDesktop)
|
|
761
|
-
return;
|
|
762
|
-
const timestamp = now();
|
|
763
|
-
const idx = snapshotIndex;
|
|
764
|
-
snapshotIndex += 1;
|
|
765
|
-
const snapshot = await runCheckpointSnapshot({
|
|
766
|
-
desktop: subjectDesktop,
|
|
767
|
-
snapshotIndex: idx,
|
|
768
|
-
name: `state-${idx}`,
|
|
769
|
-
checkpoints,
|
|
770
|
-
prevDigest: undefined,
|
|
771
|
-
scrub: scrubKnownValues,
|
|
772
|
-
requestTimeoutMs,
|
|
773
|
-
timers
|
|
774
|
-
});
|
|
775
|
-
stateSnapshots.push({ timestamp, digest: snapshot.digest });
|
|
776
|
-
};
|
|
753
|
+
let receiving;
|
|
754
|
+
if (!dryRun && config.comms?.email?.kind === "real") {
|
|
777
755
|
try {
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
// reads the same config); absent keeps the byte-stable Sandbox.create(opts) default.
|
|
783
|
-
subjectDesktop = await createDesktopSandbox(subjectModule, {
|
|
784
|
-
apiKey: e2bApiKey,
|
|
785
|
-
requestTimeoutMs,
|
|
786
|
-
timeoutMs: timeoutMs + SUBJECT_PROVISION_BUDGET_MS
|
|
787
|
-
+ (config.subject.state?.seed ?? []).reduce((sum, step) => sum + (step.timeoutMs ?? DEFAULT_STATE_STEP_TIMEOUT_MS), 0)
|
|
788
|
-
+ SANDBOX_TIMEOUT_BUFFER_MS,
|
|
789
|
-
metadata: {
|
|
790
|
-
...CONCURRENT_SHARED_WORLD_PROVIDER_METADATA,
|
|
791
|
-
labId: config.id,
|
|
792
|
-
topology: "shared-world",
|
|
793
|
-
topologyMode: "concurrent",
|
|
794
|
-
role: "subject",
|
|
795
|
-
roleCount: String(roles.length)
|
|
796
|
-
},
|
|
797
|
-
...(subjectEnvNames.length > 0 || Object.keys(commsEnv).length > 0
|
|
798
|
-
? { envs: { ...Object.fromEntries(subjectEnvNames.map((name) => [name, env[name]])), ...commsEnv } }
|
|
799
|
-
: {}),
|
|
800
|
-
dpi: 96,
|
|
801
|
-
lifecycle: { onTimeout: "kill" }
|
|
802
|
-
}, config.execution?.desktop?.template);
|
|
803
|
-
subjectSandboxId = subjectDesktop.sandboxId;
|
|
804
|
-
// #358 salvage: durable id receipt the moment the subject sandbox exists.
|
|
805
|
-
await appendSandboxReceipt(runPaths, { at: new Date().toISOString(), laneId: "subject", sandboxId: subjectSandboxId });
|
|
806
|
-
if (hooks.prepareDesktop) {
|
|
807
|
-
await hooks.prepareDesktop(subjectDesktop);
|
|
808
|
-
}
|
|
809
|
-
// Start the in-sandbox email catch BEFORE the subject serve, so the app's send-API base URL
|
|
810
|
-
// (injected into its env at create) resolves the moment it boots. Fail closed if the catch can't
|
|
811
|
-
// stand up rather than let a comms-declared app silently send real mail to the internet.
|
|
812
|
-
if (commsEmail && commsPort !== undefined) {
|
|
813
|
-
// A SECOND (0.0.0.0) read-only inbox listener on commsPort+1 so the persona — which lives in a
|
|
814
|
-
// DIFFERENT sandbox here — can reach the inbox surface via getHost; capture stays loopback.
|
|
815
|
-
deployedComms = await deployCommsCatch(subjectDesktop, { port: commsPort, inboxPort: commsPort + 1, requestTimeoutMs, timers });
|
|
816
|
-
if (!deployedComms.ready) {
|
|
817
|
-
throw new Error(`comms email catch did not become ready in the subject sandbox (loopback capture ${commsPort} / inbox ${commsPort + 1})`);
|
|
818
|
-
}
|
|
819
|
-
}
|
|
820
|
-
// Provision the ONE shared plane: clone + install/build + seed + serve on 0.0.0.0 + probe
|
|
821
|
-
// (clone route), or upload/extract the once-per-run packed archive + the SAME shared serve
|
|
822
|
-
// pipeline (local-tree route).
|
|
823
|
-
const onSubjectPhase = hooks.onPhase ?? ((event) => {
|
|
824
|
-
process.stderr.write(`humanish shared-world (concurrent): ${event.message}${event.durationMs === undefined ? "" : ` (${event.durationMs}ms)`}\n`);
|
|
756
|
+
receiving = await prepareReceivingRun({ cwd, runId, config, env, participants: actorSpecs.map(spec => spec.laneId), runPaths,
|
|
757
|
+
registerSecrets: values => { for (const value of values)
|
|
758
|
+
if (value.length >= 4 && !knownSecretValues.includes(value))
|
|
759
|
+
knownSecretValues.push(value); }
|
|
825
760
|
});
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
761
|
+
commsArtifactPath = "comms/receiving.json";
|
|
762
|
+
}
|
|
763
|
+
catch {
|
|
764
|
+
return fail("HUMANISH_CONCURRENT_SHARED_WORLD_LAB_INVALID", "Real email setup failed before desktop allocation. Run humanish comms check --online and humanish comms recover to inspect authentication and pending cleanup.", descriptor.id);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
try {
|
|
768
|
+
if (!dryRun && planeClass === "provisioned-getHost") {
|
|
769
|
+
if (!serve) {
|
|
770
|
+
// Defense-in-depth: concurrentSharedWorldValidationReason already required serve above.
|
|
771
|
+
return fail("HUMANISH_CONCURRENT_SHARED_WORLD_LAB_INVALID", "the provisioned-getHost concurrent shared-world route requires `subject.serve`.", descriptor.id);
|
|
837
772
|
}
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
773
|
+
let subjectModule;
|
|
774
|
+
let subjectDesktop;
|
|
775
|
+
// The in-sandbox email catch on the ONE subject sandbox (#297); drained at teardown. Undefined
|
|
776
|
+
// unless a comms lab declared it. Hoisted so the finally can drain before the subject is killed.
|
|
777
|
+
let deployedComms;
|
|
778
|
+
// Background prober dispose signal (FIX-9: cleared in finally).
|
|
779
|
+
let proberDisposed = false;
|
|
780
|
+
let releaseDispose = () => { };
|
|
781
|
+
const disposeSignal = new Promise((resolve) => { releaseDispose = resolve; });
|
|
782
|
+
let proberLoop;
|
|
783
|
+
const proberSnapshot = async () => {
|
|
784
|
+
if (!subjectDesktop)
|
|
785
|
+
return;
|
|
786
|
+
const timestamp = now();
|
|
787
|
+
const idx = snapshotIndex;
|
|
788
|
+
snapshotIndex += 1;
|
|
789
|
+
const snapshot = await runCheckpointSnapshot({
|
|
790
|
+
desktop: subjectDesktop,
|
|
791
|
+
snapshotIndex: idx,
|
|
792
|
+
name: `state-${idx}`,
|
|
793
|
+
checkpoints,
|
|
794
|
+
prevDigest: undefined,
|
|
846
795
|
scrub: scrubKnownValues,
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
onPhase: onSubjectPhase,
|
|
850
|
-
...timers
|
|
796
|
+
requestTimeoutMs,
|
|
797
|
+
timers
|
|
851
798
|
});
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
799
|
+
stateSnapshots.push({ timestamp, digest: snapshot.digest });
|
|
800
|
+
};
|
|
801
|
+
try {
|
|
802
|
+
subjectModule = await (hooks.loadDesktopModule ?? loadE2BDesktopModule)();
|
|
803
|
+
// The ONE subject sandbox: headless service host (no GUI seat). The SUBJECT env is provisioned
|
|
804
|
+
// HERE; the actor sandboxes get NONE of it (FIX-10). A custom desktop template (image) is
|
|
805
|
+
// honored on BOTH the subject sandbox (here) and every actor sandbox (via runCuaLane, which
|
|
806
|
+
// reads the same config); absent keeps the byte-stable Sandbox.create(opts) default.
|
|
807
|
+
subjectDesktop = await createDesktopSandbox(subjectModule, {
|
|
808
|
+
apiKey: e2bApiKey,
|
|
809
|
+
requestTimeoutMs,
|
|
810
|
+
timeoutMs: timeoutMs + SUBJECT_PROVISION_BUDGET_MS
|
|
811
|
+
+ (config.subject.state?.seed ?? []).reduce((sum, step) => sum + (step.timeoutMs ?? DEFAULT_STATE_STEP_TIMEOUT_MS), 0)
|
|
812
|
+
+ SANDBOX_TIMEOUT_BUFFER_MS,
|
|
813
|
+
metadata: {
|
|
814
|
+
...CONCURRENT_SHARED_WORLD_PROVIDER_METADATA,
|
|
815
|
+
labId: config.id,
|
|
816
|
+
topology: "shared-world",
|
|
817
|
+
topologyMode: "concurrent",
|
|
818
|
+
role: "subject",
|
|
819
|
+
roleCount: String(roles.length)
|
|
820
|
+
},
|
|
821
|
+
...(subjectEnvNames.length > 0 || Object.keys(commsEnv).length > 0
|
|
822
|
+
? { envs: { ...Object.fromEntries(subjectEnvNames.map((name) => [name, env[name]])), ...commsEnv } }
|
|
823
|
+
: {}),
|
|
824
|
+
dpi: 96,
|
|
825
|
+
lifecycle: { onTimeout: "kill" }
|
|
826
|
+
}, config.execution?.desktop?.template);
|
|
827
|
+
subjectSandboxId = subjectDesktop.sandboxId;
|
|
828
|
+
// #358 salvage: durable id receipt the moment the subject sandbox exists.
|
|
829
|
+
await appendSandboxReceipt(runPaths, { at: new Date().toISOString(), laneId: "subject", sandboxId: subjectSandboxId });
|
|
830
|
+
if (hooks.prepareDesktop) {
|
|
831
|
+
await hooks.prepareDesktop(subjectDesktop);
|
|
876
832
|
}
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
833
|
+
// Start the in-sandbox email catch BEFORE the subject serve, so the app's send-API base URL
|
|
834
|
+
// (injected into its env at create) resolves the moment it boots. Fail closed if the catch can't
|
|
835
|
+
// stand up rather than let a comms-declared app silently send real mail to the internet.
|
|
836
|
+
if (commsEmail && commsPort !== undefined) {
|
|
837
|
+
// A SECOND (0.0.0.0) read-only inbox listener on commsPort+1 so the persona — which lives in a
|
|
838
|
+
// DIFFERENT sandbox here — can reach the inbox surface via getHost; capture stays loopback.
|
|
839
|
+
deployedComms = await deployCommsCatch(subjectDesktop, { port: commsPort, inboxPort: commsPort + 1, requestTimeoutMs, timers });
|
|
840
|
+
if (!deployedComms.ready) {
|
|
841
|
+
throw new Error(`comms email catch did not become ready in the subject sandbox (loopback capture ${commsPort} / inbox ${commsPort + 1})`);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
// Provision the ONE shared plane: clone + install/build + seed + serve on 0.0.0.0 + probe
|
|
845
|
+
// (clone route), or upload/extract the once-per-run packed archive + the SAME shared serve
|
|
846
|
+
// pipeline (local-tree route).
|
|
847
|
+
const onSubjectPhase = hooks.onPhase ?? ((event) => {
|
|
848
|
+
process.stderr.write(`humanish shared-world (concurrent): ${event.message}${event.durationMs === undefined ? "" : ` (${event.durationMs}ms)`}\n`);
|
|
882
849
|
});
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
850
|
+
if (localTreeRoute) {
|
|
851
|
+
await provisionLocalTreeSubject(subjectDesktop, {
|
|
852
|
+
archiveBuffer: localTreeArchiveBuffer,
|
|
853
|
+
serve,
|
|
854
|
+
...(config.subject.state === undefined ? {} : { state: config.subject.state }),
|
|
855
|
+
requestTimeoutMs,
|
|
856
|
+
scrub: scrubKnownValues,
|
|
857
|
+
onStateStep: (record) => { stateStepRecords.push(record); },
|
|
858
|
+
onPhase: onSubjectPhase,
|
|
859
|
+
...timers
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
else {
|
|
863
|
+
subjectCommit = await provisionCloneSubject(subjectDesktop, {
|
|
864
|
+
repo: subjectRepo,
|
|
865
|
+
depth: config.subject.clone?.depth ?? 1,
|
|
866
|
+
serve,
|
|
867
|
+
...(config.subject.state === undefined ? {} : { state: config.subject.state }),
|
|
868
|
+
hasGithubToken,
|
|
869
|
+
requestTimeoutMs,
|
|
870
|
+
scrub: scrubKnownValues,
|
|
871
|
+
onCommit: (commit) => { subjectCommit = commit; },
|
|
872
|
+
onStateStep: (record) => { stateStepRecords.push(record); },
|
|
873
|
+
onPhase: onSubjectPhase,
|
|
874
|
+
...timers
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
// Expose the served port via getHost (FIX-2). Fail closed if the SDK lacks it.
|
|
878
|
+
if (typeof subjectDesktop.getHost !== "function") {
|
|
879
|
+
throw new Error("the installed @e2b/desktop SDK does not expose getHost(port); the concurrent shared-world route requires it to reach the subject plane");
|
|
880
|
+
}
|
|
881
|
+
// getHost returns a BARE host (e.g. "3000-<sandboxId>.e2b.app", no scheme); e2b exposes the
|
|
882
|
+
// port over https. Normalize to a full URL before the tokenless check + before persisting.
|
|
883
|
+
const rawHost = subjectDesktop.getHost(servePort(serve.url));
|
|
884
|
+
const hostUrl = /^https?:\/\//i.test(rawHost) ? rawHost : `https://${rawHost}`;
|
|
885
|
+
if (!isTokenlessHost(hostUrl)) {
|
|
886
|
+
throw new Error("getHost returned a non-tokenless URL; refusing to persist a host URL that may carry a credential (invariant 1)");
|
|
887
|
+
}
|
|
888
|
+
getHostUrl = hostUrl;
|
|
889
|
+
// Persona inbox SURFACE (#297 slice B, shared-world): getHost-expose the read-only inbox listener so
|
|
890
|
+
// a persona in a DIFFERENT sandbox can open it; build the serve->getHost origin map (REQUIRED here —
|
|
891
|
+
// the app's loopback verify links must be rewritten to a reachable host); provision the surface
|
|
892
|
+
// channel; write the EMPTY inbox up front (so /inbox never 404s); and start a render loop that drains
|
|
893
|
+
// + re-renders on a cadence. The loop shares the prober's dispose signal (disposed together, before
|
|
894
|
+
// the teardown evidence drain), and uses a DEDICATED FakeInbox + cursor (independent of that drain).
|
|
895
|
+
if (commsEmail && deployedComms?.inboxPort !== undefined) {
|
|
896
|
+
const rawInboxHost = subjectDesktop.getHost(deployedComms.inboxPort);
|
|
897
|
+
const inboxHostUrl = /^https?:\/\//i.test(rawInboxHost) ? rawInboxHost : `https://${rawInboxHost}`;
|
|
898
|
+
if (!isTokenlessHost(inboxHostUrl)) {
|
|
899
|
+
throw new Error("getHost returned a non-tokenless URL for the comms inbox; refusing to advertise it (invariant 1)");
|
|
900
|
+
}
|
|
901
|
+
commsInboxUrl = `${inboxHostUrl}/inbox`;
|
|
902
|
+
commsOriginMap = buildOriginMap({
|
|
903
|
+
internalServeUrl: serve.url,
|
|
904
|
+
reachableBaseUrl: getHostUrl,
|
|
905
|
+
...(commsEmail.linkOrigin === undefined ? {} : { linkOrigin: commsEmail.linkOrigin })
|
|
906
|
+
});
|
|
907
|
+
const surfaceRecipients = (commsEmail.recipients ?? [])
|
|
908
|
+
.filter((recipient) => recipient.address !== undefined)
|
|
909
|
+
.map((recipient) => ({ lane: recipient.lane, address: recipient.address }));
|
|
910
|
+
await writeInboxSurface(subjectDesktop, deployedComms.surfaceDir, [], { originMap: commsOriginMap, requestTimeoutMs });
|
|
911
|
+
const surfaceDeployed = deployedComms;
|
|
912
|
+
const surfaceCadenceMs = 2500;
|
|
913
|
+
surfaceLoop = (async () => {
|
|
914
|
+
// Full, idempotent rebuild each tick; surfaceRenderedCount advances only on a successful render,
|
|
915
|
+
// so a transient failure retries cleanly. Real timer (dispose-interruptible + cleared) — an
|
|
916
|
+
// unbounded loop must not busy-spin on the injected instant clock.
|
|
917
|
+
for (;;) {
|
|
918
|
+
try {
|
|
919
|
+
const refreshed = await refreshInboxSurface({
|
|
920
|
+
desktop: subjectDesktop,
|
|
921
|
+
deployed: surfaceDeployed,
|
|
922
|
+
recipients: surfaceRecipients,
|
|
923
|
+
sinceCount: surfaceRenderedCount,
|
|
924
|
+
originMap: commsOriginMap,
|
|
925
|
+
requestTimeoutMs
|
|
926
|
+
});
|
|
927
|
+
if (refreshed.rendered)
|
|
928
|
+
surfaceRenderedCount = refreshed.count;
|
|
929
|
+
}
|
|
930
|
+
catch {
|
|
931
|
+
// Never throw into the render loop; the teardown drain + by-id teardown must still run.
|
|
932
|
+
}
|
|
933
|
+
if (proberDisposed)
|
|
934
|
+
break;
|
|
935
|
+
await new Promise((resolve) => {
|
|
936
|
+
const timer = setTimeout(resolve, surfaceCadenceMs);
|
|
937
|
+
void disposeSignal.then(() => { clearTimeout(timer); resolve(); });
|
|
902
938
|
});
|
|
903
|
-
if (
|
|
904
|
-
|
|
905
|
-
}
|
|
906
|
-
catch {
|
|
907
|
-
// Never throw into the render loop; the teardown drain + by-id teardown must still run.
|
|
939
|
+
if (proberDisposed)
|
|
940
|
+
break;
|
|
908
941
|
}
|
|
942
|
+
})();
|
|
943
|
+
}
|
|
944
|
+
// Baseline state snapshot, then start the background cadence prober.
|
|
945
|
+
await proberSnapshot();
|
|
946
|
+
if (options.onObserverReady) {
|
|
947
|
+
const inProgressPlaneCommit = localTreeRoute ? localTreeArchive?.git?.commit : subjectCommit;
|
|
948
|
+
const inProgressSubject = buildSubjectProvenance({
|
|
949
|
+
localTreeRoute,
|
|
950
|
+
publicRepo,
|
|
951
|
+
subjectCommit: inProgressPlaneCommit,
|
|
952
|
+
localTreeArchive,
|
|
953
|
+
subjectEnvNames,
|
|
954
|
+
state: resolveSubjectState({ declared: config.subject.state, dryRun: false, executed: stateStepRecords })
|
|
955
|
+
});
|
|
956
|
+
const inProgressBundle = buildConcurrentSharedWorldBundle({
|
|
957
|
+
config,
|
|
958
|
+
descriptor,
|
|
959
|
+
createdAt,
|
|
960
|
+
dryRun: false,
|
|
961
|
+
inProgress: true,
|
|
962
|
+
runId,
|
|
963
|
+
source,
|
|
964
|
+
roles,
|
|
965
|
+
actorSpecs,
|
|
966
|
+
actorResults: [],
|
|
967
|
+
stateSnapshots,
|
|
968
|
+
subject: inProgressSubject,
|
|
969
|
+
seedDigest,
|
|
970
|
+
...(inProgressPlaneCommit === undefined ? {} : { subjectCommit: inProgressPlaneCommit }),
|
|
971
|
+
hostDigest: hostOriginDigest(getHostUrl)
|
|
972
|
+
});
|
|
973
|
+
await writeConcurrentRunArtifacts(inProgressBundle, runPaths);
|
|
974
|
+
liveObserver = observerResultForConcurrentArtifacts(cwd, runId, artifactRoot, [
|
|
975
|
+
"Live concurrent shared-world Observer is attached before final verification; stream auth URLs are runtime-only and are not persisted."
|
|
976
|
+
]);
|
|
977
|
+
await options.onObserverReady(liveObserver);
|
|
978
|
+
}
|
|
979
|
+
proberLoop = (async () => {
|
|
980
|
+
while (!proberDisposed) {
|
|
981
|
+
let timer;
|
|
982
|
+
await Promise.race([
|
|
983
|
+
new Promise((resolve) => { timer = setTimeout(resolve, proberCadenceMs); }),
|
|
984
|
+
disposeSignal
|
|
985
|
+
]);
|
|
986
|
+
if (timer)
|
|
987
|
+
clearTimeout(timer); // FIX-9: no dangling prober timer.
|
|
909
988
|
if (proberDisposed)
|
|
910
989
|
break;
|
|
911
|
-
await
|
|
912
|
-
const timer = setTimeout(resolve, surfaceCadenceMs);
|
|
913
|
-
void disposeSignal.then(() => { clearTimeout(timer); resolve(); });
|
|
914
|
-
});
|
|
915
|
-
if (proberDisposed)
|
|
916
|
-
break;
|
|
990
|
+
await proberSnapshot().catch(() => undefined);
|
|
917
991
|
}
|
|
918
992
|
})();
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
const
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
993
|
+
// Launch N actor sandboxes CONCURRENTLY, INDEPENDENT (FIX-11: runCuaLane + mapWithConcurrency,
|
|
994
|
+
// NOT runCuaLanes — no pipeline gate / fail-fast). Each actor's window is measured on the ONE
|
|
995
|
+
// orchestrator clock (FIX-1). cloneRoute=false + subjectEnvNames=[] keep subject creds out of
|
|
996
|
+
// every actor sandbox (FIX-10).
|
|
997
|
+
const cuaHooks = {
|
|
998
|
+
...(hooks.loadDesktopModule ? { loadDesktopModule: hooks.loadDesktopModule } : {}),
|
|
999
|
+
...(hooks.detachedTimers ? { detachedTimers: hooks.detachedTimers } : {}),
|
|
1000
|
+
...(hooks.env ? { env: hooks.env } : {}),
|
|
1001
|
+
...(hooks.prepareDesktop ? { prepareDesktop: (desktop) => hooks.prepareDesktop(desktop) } : {}),
|
|
1002
|
+
onRuntimeStreamReady: (stream) => {
|
|
1003
|
+
runtimeStreamUrls.push({ streamId: stream.streamId, url: stream.url });
|
|
1004
|
+
if (liveObserver) {
|
|
1005
|
+
attachObserverRuntimeStreamUrls(liveObserver, runtimeStreamUrls);
|
|
1006
|
+
}
|
|
1007
|
+
},
|
|
1008
|
+
onRuntimeStreamEnded: (stream) => {
|
|
1009
|
+
// Mark, never remove (#357): the tile falls back to recorded evidence and says why.
|
|
1010
|
+
for (const entry of runtimeStreamUrls) {
|
|
1011
|
+
if (entry.streamId === stream.streamId)
|
|
1012
|
+
entry.ended = true;
|
|
1013
|
+
}
|
|
1014
|
+
if (liveObserver) {
|
|
1015
|
+
attachObserverRuntimeStreamUrls(liveObserver, runtimeStreamUrls);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
};
|
|
1019
|
+
const baseActorDeps = {
|
|
933
1020
|
config,
|
|
934
1021
|
descriptor,
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
1022
|
+
cloneRoute: false,
|
|
1023
|
+
subjectEnvNames: [],
|
|
1024
|
+
hasGithubToken: false,
|
|
1025
|
+
env,
|
|
1026
|
+
openaiApiKey,
|
|
1027
|
+
e2bApiKey,
|
|
1028
|
+
requestTimeoutMs,
|
|
1029
|
+
perLaneSandboxMs: timeoutMs + SANDBOX_TIMEOUT_BUFFER_MS,
|
|
1030
|
+
timeoutMs,
|
|
1031
|
+
laneCount: roles.length,
|
|
1032
|
+
artifactRoot: runPaths,
|
|
1033
|
+
labCwd: cwd,
|
|
1034
|
+
redactScreenshots,
|
|
1035
|
+
scrubKnownValues,
|
|
1036
|
+
runSession,
|
|
1037
|
+
...(receiving ? { receiving } : {}),
|
|
1038
|
+
now,
|
|
1039
|
+
hooks: cuaHooks,
|
|
1040
|
+
...(runBudget === undefined ? {} : { runBudget }),
|
|
1041
|
+
// Concurrent lanes are independent evidence seats: a requested-vs-verified screen
|
|
1042
|
+
// mismatch is recorded as separate facts + a warning instead of failing the lane's
|
|
1043
|
+
// device claim closed, so one seat's window-manager drift cannot abort the whole
|
|
1044
|
+
// live multi-actor world (the single-lane/fan-out routes keep fail-closed).
|
|
1045
|
+
screenMismatchPolicy: "record-evidence"
|
|
1046
|
+
};
|
|
1047
|
+
actorResults = await mapWithConcurrency(actorSpecs, Math.max(1, concurrency), async (spec, i) => {
|
|
1048
|
+
const route = resolveActorSeatUrl(getHostUrl, roles[i]?.entry);
|
|
1049
|
+
// Tell this persona its (getHost-reachable) inbox URL — but only when comms is live AND this lane
|
|
1050
|
+
// has a declared recipient it can actually receive mail into (else it would stall on an empty
|
|
1051
|
+
// inbox). Only the in-sandbox catch exists on this plane; the adopter-hosted catch is the
|
|
1052
|
+
// external-public plane's, wired in ITS execution block below (#387).
|
|
1053
|
+
const laneSpec = commsEmail && commsInboxUrl && laneHasInboxRecipient(commsEmail, spec.laneId)
|
|
1054
|
+
? withInboxMission(spec, commsInboxUrl, inboxRecipientFor(commsEmail, spec.laneId)?.address)
|
|
1055
|
+
: spec;
|
|
1056
|
+
const startedAt = now();
|
|
1057
|
+
const outcome = await runCuaLane(laneSpec, { ...baseActorDeps, appUrl: route });
|
|
1058
|
+
const endedAt = now();
|
|
1059
|
+
return { spec, outcome, startedAt, endedAt, route };
|
|
948
1060
|
});
|
|
949
|
-
await writeConcurrentRunArtifacts(inProgressBundle, runPaths);
|
|
950
|
-
liveObserver = observerResultForConcurrentArtifacts(cwd, runId, artifactRoot, [
|
|
951
|
-
"Live concurrent shared-world Observer is attached before final verification; stream auth URLs are runtime-only and are not persisted."
|
|
952
|
-
]);
|
|
953
|
-
await options.onObserverReady(liveObserver);
|
|
954
1061
|
}
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
1062
|
+
catch (error) {
|
|
1063
|
+
runError = redactText(scrubKnownValues(toErrorMessage(error)));
|
|
1064
|
+
warnings.push(`Concurrent shared-world run failed before completion: ${runError}`);
|
|
1065
|
+
}
|
|
1066
|
+
finally {
|
|
1067
|
+
// FIX-9: stop the prober, take a final snapshot while the subject is still alive, then tear
|
|
1068
|
+
// down the ONE subject sandbox BY id (the actor sandboxes are torn down inside runCuaLane).
|
|
1069
|
+
proberDisposed = true;
|
|
1070
|
+
releaseDispose();
|
|
1071
|
+
if (proberLoop) {
|
|
1072
|
+
await proberLoop.catch(() => undefined);
|
|
1073
|
+
}
|
|
1074
|
+
// Stop the inbox-surface render loop too (shares the prober's dispose signal), before the teardown
|
|
1075
|
+
// evidence drain below — so the two in-sandbox reads never overlap and the surface state is final.
|
|
1076
|
+
if (surfaceLoop) {
|
|
1077
|
+
await surfaceLoop.catch(() => undefined);
|
|
1078
|
+
}
|
|
1079
|
+
if (subjectDesktop && getHostUrl) {
|
|
966
1080
|
await proberSnapshot().catch(() => undefined);
|
|
967
1081
|
}
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
1082
|
+
// Off-app comms evidence (#297): drain everything the in-sandbox catch captured, route it into a
|
|
1083
|
+
// host fake inbox addressed to the declared recipients, and write the run-level digest-only thread
|
|
1084
|
+
// artifact — while the subject is STILL alive, before it is killed below. Wrapped so a drain error
|
|
1085
|
+
// never blocks teardown (invariant: all sandboxes torn down by id in this finally).
|
|
1086
|
+
if (commsEmail && deployedComms?.ready && subjectDesktop) {
|
|
1087
|
+
try {
|
|
1088
|
+
const commsChannel = new FakeInbox();
|
|
1089
|
+
const commsInboxes = [];
|
|
1090
|
+
for (const recipient of commsEmail.recipients ?? []) {
|
|
1091
|
+
if (recipient.address !== undefined) {
|
|
1092
|
+
commsInboxes.push(await commsChannel.provisionAddress(recipient.lane, recipient.address));
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
const collected = await collectCommsThread({
|
|
1096
|
+
desktop: subjectDesktop,
|
|
1097
|
+
deployed: deployedComms,
|
|
1098
|
+
channel: commsChannel,
|
|
1099
|
+
inboxes: commsInboxes,
|
|
1100
|
+
requestTimeoutMs
|
|
1101
|
+
});
|
|
1102
|
+
if (collected.artifact) {
|
|
1103
|
+
await writeContainedOutputFile(runPaths, "comms/thread.json", `${JSON.stringify(collected.artifact, null, 2)}\n`, "utf8");
|
|
1104
|
+
commsArtifactPath = "comms/thread.json";
|
|
1105
|
+
}
|
|
1106
|
+
else if (collected.captured > 0) {
|
|
1107
|
+
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.`);
|
|
1108
|
+
}
|
|
1109
|
+
else {
|
|
1110
|
+
// Zero captures is the silent-broken shape (#351): the app never posted to the catch.
|
|
1111
|
+
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.`);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
catch (error) {
|
|
1115
|
+
warnings.push(`Comms evidence collection failed (run continues; subject still torn down): ${redactText(scrubKnownValues(toErrorMessage(error)))}`);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
if (subjectDesktop && subjectModule) {
|
|
1119
|
+
if (typeof subjectModule.Sandbox.kill === "function") {
|
|
1120
|
+
try {
|
|
1121
|
+
await subjectModule.Sandbox.kill(subjectDesktop.sandboxId, { requestTimeoutMs: 60_000 });
|
|
1122
|
+
subjectKilled = true;
|
|
1123
|
+
}
|
|
1124
|
+
catch (error) {
|
|
1125
|
+
warnings.push(`Subject sandbox teardown failed (server-side kill-on-timeout will reclaim it): ${redactText(scrubKnownValues(toErrorMessage(error)))}`);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
else {
|
|
1129
|
+
warnings.push("Installed @e2b/desktop SDK does not expose Sandbox.kill; server-side kill-on-timeout will reclaim the subject sandbox.");
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
// EXTERNAL-PUBLIC plane (#164 phase 2): NO subject sandbox, NO getHost, NO prober. The shared plane
|
|
1135
|
+
// is the operator-declared public deployment (publicAppUrl); each seat opens it directly and reaches
|
|
1136
|
+
// the shared session through the real UI. A host-first barrier extracts the /lobby/CODE from the host
|
|
1137
|
+
// seat's CDP-observed URL (onObservedUrl) and threads it into the follower missions; a follower fails
|
|
1138
|
+
// closed WITHOUT opening if the host never yields a code within the handoff deadline.
|
|
1139
|
+
if (!dryRun && planeClass === "external-public") {
|
|
973
1140
|
const cuaHooks = {
|
|
974
1141
|
...(hooks.loadDesktopModule ? { loadDesktopModule: hooks.loadDesktopModule } : {}),
|
|
975
1142
|
...(hooks.detachedTimers ? { detachedTimers: hooks.detachedTimers } : {}),
|
|
@@ -1008,445 +1175,313 @@ async function runConcurrentSharedWorldInScope(options) {
|
|
|
1008
1175
|
artifactRoot: runPaths,
|
|
1009
1176
|
labCwd: cwd,
|
|
1010
1177
|
redactScreenshots,
|
|
1011
|
-
|
|
1178
|
+
// Scrub the latched lobby CODE (known once the host resolves it) from ALL narration.
|
|
1179
|
+
scrubKnownValues: scrubKnownValuesWithLobbyCode,
|
|
1012
1180
|
runSession,
|
|
1181
|
+
...(receiving ? { receiving } : {}),
|
|
1013
1182
|
now,
|
|
1014
1183
|
hooks: cuaHooks,
|
|
1015
1184
|
...(runBudget === undefined ? {} : { runBudget }),
|
|
1016
|
-
// Concurrent lanes are independent evidence seats: a requested-vs-verified screen
|
|
1017
|
-
// mismatch is recorded as separate facts + a warning instead of failing the lane's
|
|
1018
|
-
// device claim closed, so one seat's window-manager drift cannot abort the whole
|
|
1019
|
-
// live multi-actor world (the single-lane/fan-out routes keep fail-closed).
|
|
1020
1185
|
screenMismatchPolicy: "record-evidence"
|
|
1021
1186
|
};
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
await
|
|
1048
|
-
}
|
|
1049
|
-
// Stop the inbox-surface render loop too (shares the prober's dispose signal), before the teardown
|
|
1050
|
-
// evidence drain below — so the two in-sandbox reads never overlap and the surface state is final.
|
|
1051
|
-
if (surfaceLoop) {
|
|
1052
|
-
await surfaceLoop.catch(() => undefined);
|
|
1053
|
-
}
|
|
1054
|
-
if (subjectDesktop && getHostUrl) {
|
|
1055
|
-
await proberSnapshot().catch(() => undefined);
|
|
1187
|
+
// Publish an attached live Observer BEFORE fan-out (mirrors the provisioned path).
|
|
1188
|
+
if (options.onObserverReady) {
|
|
1189
|
+
const inProgressBundle = buildConcurrentSharedWorldBundle({
|
|
1190
|
+
config,
|
|
1191
|
+
descriptor,
|
|
1192
|
+
createdAt,
|
|
1193
|
+
dryRun: false,
|
|
1194
|
+
inProgress: true,
|
|
1195
|
+
runId,
|
|
1196
|
+
source,
|
|
1197
|
+
roles,
|
|
1198
|
+
actorSpecs,
|
|
1199
|
+
actorResults: [],
|
|
1200
|
+
stateSnapshots: [],
|
|
1201
|
+
subject: { source: "app-url", envNames: [], state: { provenance: "external-public" } },
|
|
1202
|
+
seedDigest,
|
|
1203
|
+
planeClass: "external-public",
|
|
1204
|
+
// Pre-fan-out snapshot: no seat has observed an origin yet, so the OBSERVED publicOriginDigest
|
|
1205
|
+
// is not available; surface the DECLARED origin for the live Observer's reference.
|
|
1206
|
+
...(declaredOriginDigest === undefined ? {} : { declaredOriginDigest })
|
|
1207
|
+
});
|
|
1208
|
+
await writeConcurrentRunArtifacts(inProgressBundle, runPaths);
|
|
1209
|
+
liveObserver = observerResultForConcurrentArtifacts(cwd, runId, artifactRoot, [
|
|
1210
|
+
"Live external-public concurrent shared-world Observer is attached before final verification; stream auth URLs are runtime-only and are not persisted."
|
|
1211
|
+
]);
|
|
1212
|
+
await options.onObserverReady(liveObserver);
|
|
1056
1213
|
}
|
|
1057
|
-
//
|
|
1058
|
-
//
|
|
1059
|
-
//
|
|
1060
|
-
//
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1214
|
+
// The host-first handoff barrier.
|
|
1215
|
+
//
|
|
1216
|
+
// TEMPORARY SHIM (tracked by #296): this CDP URL-relay handoff — reading the host's /lobby/CODE off
|
|
1217
|
+
// its own browser and threading it into the follower missions — is a temporary coordination shim.
|
|
1218
|
+
// It is to be augmented/replaced by the actor message bus (fake SMS/email invite) in #297: the
|
|
1219
|
+
// human-realistic version is the HOST SENDING the invite link and followers RECEIVING and tapping
|
|
1220
|
+
// it, rather than the orchestrator relaying the code out-of-band.
|
|
1221
|
+
const lobbyCodeLatch = deferred();
|
|
1222
|
+
const handoffDeadlineMs = hooks.handoffDeadlineMs
|
|
1223
|
+
?? Math.min(timeoutMs, Math.max(DEFAULT_HANDOFF_DEADLINE_MS, Math.floor(timeoutMs * HANDOFF_DEADLINE_BUDGET_FRACTION)));
|
|
1224
|
+
let deadlineTimer;
|
|
1225
|
+
const deadline = new Promise((_resolve, reject) => {
|
|
1226
|
+
deadlineTimer = setTimeout(() => reject(new HandoffTimeoutError(handoffDeadlineMs)), handoffDeadlineMs);
|
|
1227
|
+
});
|
|
1228
|
+
deadline.catch(() => undefined); // never an unhandled rejection
|
|
1229
|
+
// Resolve the host->follower handoff latch from WHICHEVER path sees the code first (CDP url-read,
|
|
1230
|
+
// host narration, or vision-off-frame). Idempotent: only the first code wins, and it is also stashed
|
|
1231
|
+
// as latchedLobbyCode so it gets scrubbed from any later narration. The latched code and observed URLs
|
|
1232
|
+
// are runtime-only and land in persisted METADATA only as digests (origin + convergence). (The code
|
|
1233
|
+
// is a shareable game code, not a secret, and it still renders in the host's screenshots, which are
|
|
1234
|
+
// full-fidelity unless redactScreenshots is set — the digesting is about narration/URL metadata.)
|
|
1235
|
+
const latchLobbyCode = (code, laneIndex) => {
|
|
1236
|
+
if (latchedLobbyCode !== undefined)
|
|
1237
|
+
return;
|
|
1238
|
+
observedLobbyCodes[laneIndex] = code;
|
|
1239
|
+
latchedLobbyCode = code;
|
|
1240
|
+
if (deadlineTimer) {
|
|
1241
|
+
clearTimeout(deadlineTimer);
|
|
1242
|
+
deadlineTimer = undefined;
|
|
1243
|
+
}
|
|
1244
|
+
lobbyCodeLatch.resolve(code);
|
|
1245
|
+
};
|
|
1246
|
+
// Build an onScreenshot handler that vision-reads the lobby code off THIS seat's own frame (the
|
|
1247
|
+
// CDP-independent observation). `done()` short-circuits once this seat has what it needs (the host
|
|
1248
|
+
// once latched; a follower once it has recorded its own observed code), `onCode` records/latches the
|
|
1249
|
+
// result. One read in flight at a time, bounded by MAX_LOBBY_CODE_VISION_READS so a seat that never
|
|
1250
|
+
// reaches a lobby can't rack up unbounded calls (fire-and-forget; the loop never awaits it).
|
|
1251
|
+
const makeLobbyCodeVisionReader = (done, onCode) => {
|
|
1252
|
+
let inFlight = false;
|
|
1253
|
+
let reads = 0;
|
|
1254
|
+
return (frame) => {
|
|
1255
|
+
if (done() || inFlight || reads >= MAX_LOBBY_CODE_VISION_READS)
|
|
1256
|
+
return;
|
|
1257
|
+
inFlight = true;
|
|
1258
|
+
reads += 1;
|
|
1259
|
+
void readLobbyCode(frame, openaiApiKey)
|
|
1260
|
+
.then((code) => {
|
|
1261
|
+
if (code !== undefined && !done())
|
|
1262
|
+
onCode(code);
|
|
1263
|
+
})
|
|
1264
|
+
.catch(() => undefined)
|
|
1265
|
+
.finally(() => {
|
|
1266
|
+
inFlight = false;
|
|
1076
1267
|
});
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1268
|
+
};
|
|
1269
|
+
};
|
|
1270
|
+
const makeLaneObservedUrl = (laneIndex, isHost) => (url) => {
|
|
1271
|
+
if (typeof url !== "string" || url.length === 0)
|
|
1272
|
+
return;
|
|
1273
|
+
observedFinalUrls[laneIndex] = url; // runtime-only; digested to origin, never persisted raw
|
|
1274
|
+
const code = extractLobbyCode(url);
|
|
1275
|
+
if (code !== undefined) {
|
|
1276
|
+
observedLobbyCodes[laneIndex] = code;
|
|
1277
|
+
if (isHost)
|
|
1278
|
+
latchLobbyCode(code, laneIndex);
|
|
1088
1279
|
}
|
|
1089
|
-
|
|
1090
|
-
|
|
1280
|
+
};
|
|
1281
|
+
// The HOST lane (which yields the /lobby/CODE the followers wait on) runs on its OWN dedicated
|
|
1282
|
+
// slot, and the FOLLOWERS run through a bounded pool of size concurrency-1 (blockers 1 & 4):
|
|
1283
|
+
// followers block on `Promise.race([lobbyCodeLatch.promise, deadline])` while holding a worker
|
|
1284
|
+
// slot, so if the host lane were scheduled INSIDE the same bounded pool it could be starved (never
|
|
1285
|
+
// scheduled among the first `concurrency` workers) and the run would die with a spurious
|
|
1286
|
+
// HANDOFF_TIMEOUT (e.g. lanes [p2,p3,host] with concurrency 2). Giving the host its own slot,
|
|
1287
|
+
// started IMMEDIATELY and OUTSIDE the follower pool, guarantees it is ALWAYS schedulable regardless
|
|
1288
|
+
// of its roster position or of concurrency vs lane count — while total in-flight paid desktops stay
|
|
1289
|
+
// ≤ the declared concurrency (host + up to concurrency-1 followers), preserving the spend cap.
|
|
1290
|
+
const runHostLane = async (spec, laneIndex) => {
|
|
1291
|
+
const onObservedUrl = makeLaneObservedUrl(laneIndex, true);
|
|
1292
|
+
// CDP-INDEPENDENT handoff paths (the E2B-desktop CDP url-read the onObservedUrl path relies on is
|
|
1293
|
+
// unreliable in practice). Two backups, both resolving the SAME latch; whichever sees the code first
|
|
1294
|
+
// wins, all digest-only:
|
|
1295
|
+
// (1) onMessage — scan the host's own narration IF it happens to state the lobby URL; and
|
|
1296
|
+
// (2) onScreenshot — vision-read the code straight off the host's waiting-room frame. This is the
|
|
1297
|
+
// robust one: the code is rendered on screen even when CDP fails AND when the host never
|
|
1298
|
+
// narrates it, and — crucially — the host is NOT asked to announce anything, so it keeps
|
|
1299
|
+
// running (create -> wait for players -> Start -> play) instead of ending on a stray message.
|
|
1300
|
+
const onMessage = (text) => {
|
|
1301
|
+
if (latchedLobbyCode !== undefined)
|
|
1302
|
+
return;
|
|
1303
|
+
const code = extractLobbyCodeFromNarration(text);
|
|
1304
|
+
if (code !== undefined)
|
|
1305
|
+
latchLobbyCode(code, laneIndex);
|
|
1306
|
+
};
|
|
1307
|
+
// Vision-read the host's waiting-room frame and LATCH the code for the followers (stops once latched).
|
|
1308
|
+
const onScreenshot = makeLobbyCodeVisionReader(() => latchedLobbyCode !== undefined, (code) => latchLobbyCode(code, laneIndex));
|
|
1309
|
+
// The host's job includes a long LEGITIMATE idle wait — sitting in the waiting room while the
|
|
1310
|
+
// followers provision their own desktops and walk the Join flow (easily 15-30 turns of an
|
|
1311
|
+
// unchanging "waiting for players" screen). At the default idle backstop (6) the host would give up
|
|
1312
|
+
// before anyone arrives, orphaning the lobby (exactly the earlier failure). Raise the host's idle /
|
|
1313
|
+
// no-progress tolerance so it waits patiently; the per-seat timeout still bounds a truly stuck host.
|
|
1314
|
+
// Adopter-hosted inbox (#387): the persona is told its address and inbox URL on THIS plane —
|
|
1315
|
+
// previously only the provisioned plane's seats ever got the instruction, so external comms
|
|
1316
|
+
// ran on no route at all.
|
|
1317
|
+
const hostInboxSpec = externalCommsEmail && commsInboxUrl && laneHasInboxRecipient(externalCommsEmail, spec.laneId)
|
|
1318
|
+
? withInboxMission(spec, commsInboxUrl, inboxRecipientFor(externalCommsEmail, spec.laneId)?.address)
|
|
1319
|
+
: spec;
|
|
1320
|
+
const hostSpec = {
|
|
1321
|
+
...hostInboxSpec,
|
|
1322
|
+
idleSteps: spec.idleSteps ?? HOST_WAIT_IDLE_STEPS,
|
|
1323
|
+
noProgressSteps: spec.noProgressSteps ?? HOST_WAIT_IDLE_STEPS
|
|
1324
|
+
};
|
|
1325
|
+
const startedAt = now();
|
|
1326
|
+
let outcome;
|
|
1327
|
+
try {
|
|
1328
|
+
outcome = await runCuaLane(hostSpec, { ...baseActorDeps, appUrl: publicAppUrl, onObservedUrl, onMessage, onScreenshot });
|
|
1091
1329
|
}
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1330
|
+
finally {
|
|
1331
|
+
// If the host finished without ever surfacing a code, release followers to fail closed
|
|
1332
|
+
// immediately rather than wait the full deadline (a no-op if it already resolved).
|
|
1333
|
+
if (!lobbyCodeLatch.settled()) {
|
|
1334
|
+
const reason = outcome?.sessionError ?? outcome?.session?.reason ?? "no terminal host outcome was recorded";
|
|
1335
|
+
hostHandoffFailure = scrubKnownValuesWithLobbyCode(`Host seat ended before producing a lobby URL: ${reason}`);
|
|
1336
|
+
lobbyCodeLatch.reject(new Error(hostHandoffFailure));
|
|
1098
1337
|
}
|
|
1099
|
-
catch (error) {
|
|
1100
|
-
warnings.push(`Subject sandbox teardown failed (server-side kill-on-timeout will reclaim it): ${redactText(scrubKnownValues(toErrorMessage(error)))}`);
|
|
1101
|
-
}
|
|
1102
|
-
}
|
|
1103
|
-
else {
|
|
1104
|
-
warnings.push("Installed @e2b/desktop SDK does not expose Sandbox.kill; server-side kill-on-timeout will reclaim the subject sandbox.");
|
|
1105
1338
|
}
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
const cuaHooks = {
|
|
1116
|
-
...(hooks.loadDesktopModule ? { loadDesktopModule: hooks.loadDesktopModule } : {}),
|
|
1117
|
-
...(hooks.detachedTimers ? { detachedTimers: hooks.detachedTimers } : {}),
|
|
1118
|
-
...(hooks.env ? { env: hooks.env } : {}),
|
|
1119
|
-
...(hooks.prepareDesktop ? { prepareDesktop: (desktop) => hooks.prepareDesktop(desktop) } : {}),
|
|
1120
|
-
onRuntimeStreamReady: (stream) => {
|
|
1121
|
-
runtimeStreamUrls.push({ streamId: stream.streamId, url: stream.url });
|
|
1122
|
-
if (liveObserver) {
|
|
1123
|
-
attachObserverRuntimeStreamUrls(liveObserver, runtimeStreamUrls);
|
|
1124
|
-
}
|
|
1125
|
-
},
|
|
1126
|
-
onRuntimeStreamEnded: (stream) => {
|
|
1127
|
-
// Mark, never remove (#357): the tile falls back to recorded evidence and says why.
|
|
1128
|
-
for (const entry of runtimeStreamUrls) {
|
|
1129
|
-
if (entry.streamId === stream.streamId)
|
|
1130
|
-
entry.ended = true;
|
|
1339
|
+
const endedAt = now();
|
|
1340
|
+
return { spec, outcome, startedAt, endedAt, route: observedFinalUrls[laneIndex] ?? publicAppUrl };
|
|
1341
|
+
};
|
|
1342
|
+
const runFollowerLane = async (spec, laneIndex) => {
|
|
1343
|
+
const onObservedUrl = makeLaneObservedUrl(laneIndex, false);
|
|
1344
|
+
// FOLLOWER: do NOT compose a mission or open the target until the host yields a lobby code.
|
|
1345
|
+
let code;
|
|
1346
|
+
try {
|
|
1347
|
+
code = await Promise.race([lobbyCodeLatch.promise, deadline]);
|
|
1131
1348
|
}
|
|
1132
|
-
|
|
1133
|
-
|
|
1349
|
+
catch (error) {
|
|
1350
|
+
// An ended host is not a deadline expiry. Preserve its actual failure.
|
|
1351
|
+
const timedOut = error instanceof HandoffTimeoutError;
|
|
1352
|
+
handoffTimedOut ||= timedOut;
|
|
1353
|
+
const reason = scrubKnownValuesWithLobbyCode(toErrorMessage(error));
|
|
1354
|
+
const at = now();
|
|
1355
|
+
return { spec, outcome: makeBlockedFollowerOutcome(spec, reason, timedOut), startedAt: at, endedAt: at, route: publicAppUrl };
|
|
1134
1356
|
}
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
...(runBudget === undefined ? {} : { runBudget }),
|
|
1159
|
-
screenMismatchPolicy: "record-evidence"
|
|
1160
|
-
};
|
|
1161
|
-
// Publish an attached live Observer BEFORE fan-out (mirrors the provisioned path).
|
|
1162
|
-
if (options.onObserverReady) {
|
|
1163
|
-
const inProgressBundle = buildConcurrentSharedWorldBundle({
|
|
1164
|
-
config,
|
|
1165
|
-
descriptor,
|
|
1166
|
-
createdAt,
|
|
1167
|
-
dryRun: false,
|
|
1168
|
-
inProgress: true,
|
|
1169
|
-
runId,
|
|
1170
|
-
source,
|
|
1171
|
-
roles,
|
|
1172
|
-
actorSpecs,
|
|
1173
|
-
actorResults: [],
|
|
1174
|
-
stateSnapshots: [],
|
|
1175
|
-
subject: { source: "app-url", envNames: [], state: { provenance: "external-public" } },
|
|
1176
|
-
seedDigest,
|
|
1177
|
-
planeClass: "external-public",
|
|
1178
|
-
// Pre-fan-out snapshot: no seat has observed an origin yet, so the OBSERVED publicOriginDigest
|
|
1179
|
-
// is not available; surface the DECLARED origin for the live Observer's reference.
|
|
1180
|
-
...(declaredOriginDigest === undefined ? {} : { declaredOriginDigest })
|
|
1181
|
-
});
|
|
1182
|
-
await writeConcurrentRunArtifacts(inProgressBundle, runPaths);
|
|
1183
|
-
liveObserver = observerResultForConcurrentArtifacts(cwd, runId, artifactRoot, [
|
|
1184
|
-
"Live external-public concurrent shared-world Observer is attached before final verification; stream auth URLs are runtime-only and are not persisted."
|
|
1185
|
-
]);
|
|
1186
|
-
await options.onObserverReady(liveObserver);
|
|
1187
|
-
}
|
|
1188
|
-
// The host-first handoff barrier.
|
|
1189
|
-
//
|
|
1190
|
-
// TEMPORARY SHIM (tracked by #296): this CDP URL-relay handoff — reading the host's /lobby/CODE off
|
|
1191
|
-
// its own browser and threading it into the follower missions — is a temporary coordination shim.
|
|
1192
|
-
// It is to be augmented/replaced by the actor message bus (fake SMS/email invite) in #297: the
|
|
1193
|
-
// human-realistic version is the HOST SENDING the invite link and followers RECEIVING and tapping
|
|
1194
|
-
// it, rather than the orchestrator relaying the code out-of-band.
|
|
1195
|
-
const lobbyCodeLatch = deferred();
|
|
1196
|
-
const handoffDeadlineMs = hooks.handoffDeadlineMs
|
|
1197
|
-
?? Math.min(timeoutMs, Math.max(DEFAULT_HANDOFF_DEADLINE_MS, Math.floor(timeoutMs * HANDOFF_DEADLINE_BUDGET_FRACTION)));
|
|
1198
|
-
let deadlineTimer;
|
|
1199
|
-
const deadline = new Promise((_resolve, reject) => {
|
|
1200
|
-
deadlineTimer = setTimeout(() => reject(new HandoffTimeoutError(handoffDeadlineMs)), handoffDeadlineMs);
|
|
1201
|
-
});
|
|
1202
|
-
deadline.catch(() => undefined); // never an unhandled rejection
|
|
1203
|
-
// Resolve the host->follower handoff latch from WHICHEVER path sees the code first (CDP url-read,
|
|
1204
|
-
// host narration, or vision-off-frame). Idempotent: only the first code wins, and it is also stashed
|
|
1205
|
-
// as latchedLobbyCode so it gets scrubbed from any later narration. The latched code and observed URLs
|
|
1206
|
-
// are runtime-only and land in persisted METADATA only as digests (origin + convergence). (The code
|
|
1207
|
-
// is a shareable game code, not a secret, and it still renders in the host's screenshots, which are
|
|
1208
|
-
// full-fidelity unless redactScreenshots is set — the digesting is about narration/URL metadata.)
|
|
1209
|
-
const latchLobbyCode = (code, laneIndex) => {
|
|
1210
|
-
if (latchedLobbyCode !== undefined)
|
|
1211
|
-
return;
|
|
1212
|
-
observedLobbyCodes[laneIndex] = code;
|
|
1213
|
-
latchedLobbyCode = code;
|
|
1214
|
-
if (deadlineTimer) {
|
|
1215
|
-
clearTimeout(deadlineTimer);
|
|
1216
|
-
deadlineTimer = undefined;
|
|
1217
|
-
}
|
|
1218
|
-
lobbyCodeLatch.resolve(code);
|
|
1219
|
-
};
|
|
1220
|
-
// Build an onScreenshot handler that vision-reads the lobby code off THIS seat's own frame (the
|
|
1221
|
-
// CDP-independent observation). `done()` short-circuits once this seat has what it needs (the host
|
|
1222
|
-
// once latched; a follower once it has recorded its own observed code), `onCode` records/latches the
|
|
1223
|
-
// result. One read in flight at a time, bounded by MAX_LOBBY_CODE_VISION_READS so a seat that never
|
|
1224
|
-
// reaches a lobby can't rack up unbounded calls (fire-and-forget; the loop never awaits it).
|
|
1225
|
-
const makeLobbyCodeVisionReader = (done, onCode) => {
|
|
1226
|
-
let inFlight = false;
|
|
1227
|
-
let reads = 0;
|
|
1228
|
-
return (frame) => {
|
|
1229
|
-
if (done() || inFlight || reads >= MAX_LOBBY_CODE_VISION_READS)
|
|
1230
|
-
return;
|
|
1231
|
-
inFlight = true;
|
|
1232
|
-
reads += 1;
|
|
1233
|
-
void readLobbyCode(frame, openaiApiKey)
|
|
1234
|
-
.then((code) => {
|
|
1235
|
-
if (code !== undefined && !done())
|
|
1236
|
-
onCode(code);
|
|
1237
|
-
})
|
|
1238
|
-
.catch(() => undefined)
|
|
1239
|
-
.finally(() => {
|
|
1240
|
-
inFlight = false;
|
|
1241
|
-
});
|
|
1242
|
-
};
|
|
1243
|
-
};
|
|
1244
|
-
const makeLaneObservedUrl = (laneIndex, isHost) => (url) => {
|
|
1245
|
-
if (typeof url !== "string" || url.length === 0)
|
|
1246
|
-
return;
|
|
1247
|
-
observedFinalUrls[laneIndex] = url; // runtime-only; digested to origin, never persisted raw
|
|
1248
|
-
const code = extractLobbyCode(url);
|
|
1249
|
-
if (code !== undefined) {
|
|
1250
|
-
observedLobbyCodes[laneIndex] = code;
|
|
1251
|
-
if (isHost)
|
|
1252
|
-
latchLobbyCode(code, laneIndex);
|
|
1253
|
-
}
|
|
1254
|
-
};
|
|
1255
|
-
// The HOST lane (which yields the /lobby/CODE the followers wait on) runs on its OWN dedicated
|
|
1256
|
-
// slot, and the FOLLOWERS run through a bounded pool of size concurrency-1 (blockers 1 & 4):
|
|
1257
|
-
// followers block on `Promise.race([lobbyCodeLatch.promise, deadline])` while holding a worker
|
|
1258
|
-
// slot, so if the host lane were scheduled INSIDE the same bounded pool it could be starved (never
|
|
1259
|
-
// scheduled among the first `concurrency` workers) and the run would die with a spurious
|
|
1260
|
-
// HANDOFF_TIMEOUT (e.g. lanes [p2,p3,host] with concurrency 2). Giving the host its own slot,
|
|
1261
|
-
// started IMMEDIATELY and OUTSIDE the follower pool, guarantees it is ALWAYS schedulable regardless
|
|
1262
|
-
// of its roster position or of concurrency vs lane count — while total in-flight paid desktops stay
|
|
1263
|
-
// ≤ the declared concurrency (host + up to concurrency-1 followers), preserving the spend cap.
|
|
1264
|
-
const runHostLane = async (spec, laneIndex) => {
|
|
1265
|
-
const onObservedUrl = makeLaneObservedUrl(laneIndex, true);
|
|
1266
|
-
// CDP-INDEPENDENT handoff paths (the E2B-desktop CDP url-read the onObservedUrl path relies on is
|
|
1267
|
-
// unreliable in practice). Two backups, both resolving the SAME latch; whichever sees the code first
|
|
1268
|
-
// wins, all digest-only:
|
|
1269
|
-
// (1) onMessage — scan the host's own narration IF it happens to state the lobby URL; and
|
|
1270
|
-
// (2) onScreenshot — vision-read the code straight off the host's waiting-room frame. This is the
|
|
1271
|
-
// robust one: the code is rendered on screen even when CDP fails AND when the host never
|
|
1272
|
-
// narrates it, and — crucially — the host is NOT asked to announce anything, so it keeps
|
|
1273
|
-
// running (create -> wait for players -> Start -> play) instead of ending on a stray message.
|
|
1274
|
-
const onMessage = (text) => {
|
|
1275
|
-
if (latchedLobbyCode !== undefined)
|
|
1276
|
-
return;
|
|
1277
|
-
const code = extractLobbyCodeFromNarration(text);
|
|
1278
|
-
if (code !== undefined)
|
|
1279
|
-
latchLobbyCode(code, laneIndex);
|
|
1280
|
-
};
|
|
1281
|
-
// Vision-read the host's waiting-room frame and LATCH the code for the followers (stops once latched).
|
|
1282
|
-
const onScreenshot = makeLobbyCodeVisionReader(() => latchedLobbyCode !== undefined, (code) => latchLobbyCode(code, laneIndex));
|
|
1283
|
-
// The host's job includes a long LEGITIMATE idle wait — sitting in the waiting room while the
|
|
1284
|
-
// followers provision their own desktops and walk the Join flow (easily 15-30 turns of an
|
|
1285
|
-
// unchanging "waiting for players" screen). At the default idle backstop (6) the host would give up
|
|
1286
|
-
// before anyone arrives, orphaning the lobby (exactly the earlier failure). Raise the host's idle /
|
|
1287
|
-
// no-progress tolerance so it waits patiently; the per-seat timeout still bounds a truly stuck host.
|
|
1288
|
-
// Adopter-hosted inbox (#387): the persona is told its address and inbox URL on THIS plane —
|
|
1289
|
-
// previously only the provisioned plane's seats ever got the instruction, so external comms
|
|
1290
|
-
// ran on no route at all.
|
|
1291
|
-
const hostInboxSpec = externalCommsEmail && commsInboxUrl && laneHasInboxRecipient(externalCommsEmail, spec.laneId)
|
|
1292
|
-
? withInboxMission(spec, commsInboxUrl, inboxRecipientFor(externalCommsEmail, spec.laneId)?.address)
|
|
1293
|
-
: spec;
|
|
1294
|
-
const hostSpec = {
|
|
1295
|
-
...hostInboxSpec,
|
|
1296
|
-
idleSteps: spec.idleSteps ?? HOST_WAIT_IDLE_STEPS,
|
|
1297
|
-
noProgressSteps: spec.noProgressSteps ?? HOST_WAIT_IDLE_STEPS
|
|
1357
|
+
// Followers also idle-wait — in the waiting room until the host starts, and between rounds. Raise
|
|
1358
|
+
// their idle backstop too (less than the host's: they wait less), so a follower that joins ahead of
|
|
1359
|
+
// the other does not give up before the game begins. Per-seat timeout still bounds a stuck follower.
|
|
1360
|
+
const followerInboxSpec = externalCommsEmail && commsInboxUrl && laneHasInboxRecipient(externalCommsEmail, spec.laneId)
|
|
1361
|
+
? withInboxMission(spec, commsInboxUrl, inboxRecipientFor(externalCommsEmail, spec.laneId)?.address)
|
|
1362
|
+
: spec;
|
|
1363
|
+
const followerSpec = {
|
|
1364
|
+
...withLobbyCodeMission(followerInboxSpec, code),
|
|
1365
|
+
idleSteps: spec.idleSteps ?? FOLLOWER_WAIT_IDLE_STEPS,
|
|
1366
|
+
noProgressSteps: spec.noProgressSteps ?? FOLLOWER_WAIT_IDLE_STEPS
|
|
1367
|
+
};
|
|
1368
|
+
// Independently OBSERVE this follower's own lobby code by vision-reading its waiting-room frame,
|
|
1369
|
+
// and record it for the cross-seat convergence proof. This does NOT latch anything (followers gate
|
|
1370
|
+
// on the HOST's code, not their own) — it just fills this seat's observedLobbyCodes slot from a
|
|
1371
|
+
// reliable signal instead of the flaky CDP url-read, so lobbyConvergenceDigest can prove all seats
|
|
1372
|
+
// reached the SAME /lobby/CODE. If a follower somehow joined a DIFFERENT lobby, it reads a different
|
|
1373
|
+
// code and convergence correctly fails (no false proof); if it never reads one, the seat stays a
|
|
1374
|
+
// hole and convergence is honestly "not observed" for that seat.
|
|
1375
|
+
const onScreenshot = makeLobbyCodeVisionReader(() => observedLobbyCodes[laneIndex] !== undefined, (observed) => { observedLobbyCodes[laneIndex] = observed; });
|
|
1376
|
+
const startedAt = now();
|
|
1377
|
+
const outcome = await runCuaLane(followerSpec, { ...baseActorDeps, appUrl: publicAppUrl, onObservedUrl, onScreenshot });
|
|
1378
|
+
const endedAt = now();
|
|
1379
|
+
return { spec, outcome, startedAt, endedAt, route: observedFinalUrls[laneIndex] ?? publicAppUrl };
|
|
1298
1380
|
};
|
|
1299
|
-
|
|
1300
|
-
|
|
1381
|
+
// Split the roster into the designated host lane and the followers, preserving each follower's
|
|
1382
|
+
// ORIGINAL lane index so results land back in lane order (validation guarantees EXACTLY ONE host).
|
|
1383
|
+
const hostLaneIndex = roles.findIndex((role) => role.host === true);
|
|
1384
|
+
const followerEntries = actorSpecs
|
|
1385
|
+
.map((spec, index) => ({ spec, index }))
|
|
1386
|
+
.filter(({ index }) => index !== hostLaneIndex);
|
|
1387
|
+
const laneResults = new Array(actorSpecs.length);
|
|
1301
1388
|
try {
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
if (
|
|
1308
|
-
|
|
1309
|
-
hostHandoffFailure = scrubKnownValuesWithLobbyCode(`Host seat ended before producing a lobby URL: ${reason}`);
|
|
1310
|
-
lobbyCodeLatch.reject(new Error(hostHandoffFailure));
|
|
1389
|
+
const hostPromise = hostLaneIndex >= 0 && actorSpecs[hostLaneIndex] !== undefined
|
|
1390
|
+
? runHostLane(actorSpecs[hostLaneIndex], hostLaneIndex)
|
|
1391
|
+
: undefined;
|
|
1392
|
+
const followerResultsPromise = mapWithConcurrency(followerEntries, Math.max(1, concurrency - 1), ({ spec, index }) => runFollowerLane(spec, index));
|
|
1393
|
+
const [hostResult, followerResults] = await Promise.all([hostPromise, followerResultsPromise]);
|
|
1394
|
+
if (hostResult !== undefined && hostLaneIndex >= 0) {
|
|
1395
|
+
laneResults[hostLaneIndex] = hostResult;
|
|
1311
1396
|
}
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
return { spec, outcome, startedAt, endedAt, route: observedFinalUrls[laneIndex] ?? publicAppUrl };
|
|
1315
|
-
};
|
|
1316
|
-
const runFollowerLane = async (spec, laneIndex) => {
|
|
1317
|
-
const onObservedUrl = makeLaneObservedUrl(laneIndex, false);
|
|
1318
|
-
// FOLLOWER: do NOT compose a mission or open the target until the host yields a lobby code.
|
|
1319
|
-
let code;
|
|
1320
|
-
try {
|
|
1321
|
-
code = await Promise.race([lobbyCodeLatch.promise, deadline]);
|
|
1397
|
+
followerEntries.forEach((entry, i) => { laneResults[entry.index] = followerResults[i]; });
|
|
1398
|
+
actorResults = laneResults;
|
|
1322
1399
|
}
|
|
1323
1400
|
catch (error) {
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
handoffTimedOut ||= timedOut;
|
|
1327
|
-
const reason = scrubKnownValuesWithLobbyCode(toErrorMessage(error));
|
|
1328
|
-
const at = now();
|
|
1329
|
-
return { spec, outcome: makeBlockedFollowerOutcome(spec, reason, timedOut), startedAt: at, endedAt: at, route: publicAppUrl };
|
|
1401
|
+
runError = redactText(scrubKnownValuesWithLobbyCode(toErrorMessage(error)));
|
|
1402
|
+
warnings.push(`External-public concurrent shared-world run failed before completion: ${runError}`);
|
|
1330
1403
|
}
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
:
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
: undefined;
|
|
1366
|
-
const followerResultsPromise = mapWithConcurrency(followerEntries, Math.max(1, concurrency - 1), ({ spec, index }) => runFollowerLane(spec, index));
|
|
1367
|
-
const [hostResult, followerResults] = await Promise.all([hostPromise, followerResultsPromise]);
|
|
1368
|
-
if (hostResult !== undefined && hostLaneIndex >= 0) {
|
|
1369
|
-
laneResults[hostLaneIndex] = hostResult;
|
|
1370
|
-
}
|
|
1371
|
-
followerEntries.forEach((entry, i) => { laneResults[entry.index] = followerResults[i]; });
|
|
1372
|
-
actorResults = laneResults;
|
|
1373
|
-
}
|
|
1374
|
-
catch (error) {
|
|
1375
|
-
runError = redactText(scrubKnownValuesWithLobbyCode(toErrorMessage(error)));
|
|
1376
|
-
warnings.push(`External-public concurrent shared-world run failed before completion: ${runError}`);
|
|
1377
|
-
}
|
|
1378
|
-
finally {
|
|
1379
|
-
if (deadlineTimer) {
|
|
1380
|
-
clearTimeout(deadlineTimer);
|
|
1381
|
-
deadlineTimer = undefined;
|
|
1382
|
-
}
|
|
1383
|
-
// Adopter-hosted drain (#328/#387): same routing and digest-only artifact as the in-sandbox
|
|
1384
|
-
// catch — only the transport differs (HTTP GET /deliveries against the catch the operator
|
|
1385
|
-
// runs). In the finally so the evidence survives a failed run; a drain error never masks
|
|
1386
|
-
// the run's own outcome.
|
|
1387
|
-
if (externalComms && externalCommsEmail) {
|
|
1388
|
-
try {
|
|
1389
|
-
const commsChannel = new FakeInbox();
|
|
1390
|
-
const commsInboxes = [];
|
|
1391
|
-
for (const recipient of externalCommsEmail.recipients ?? []) {
|
|
1392
|
-
if (recipient.address !== undefined) {
|
|
1393
|
-
commsInboxes.push(await commsChannel.provisionAddress(recipient.lane, recipient.address));
|
|
1404
|
+
finally {
|
|
1405
|
+
if (deadlineTimer) {
|
|
1406
|
+
clearTimeout(deadlineTimer);
|
|
1407
|
+
deadlineTimer = undefined;
|
|
1408
|
+
}
|
|
1409
|
+
// Adopter-hosted drain (#328/#387): same routing and digest-only artifact as the in-sandbox
|
|
1410
|
+
// catch — only the transport differs (HTTP GET /deliveries against the catch the operator
|
|
1411
|
+
// runs). In the finally so the evidence survives a failed run; a drain error never masks
|
|
1412
|
+
// the run's own outcome.
|
|
1413
|
+
if (externalComms && externalCommsEmail) {
|
|
1414
|
+
try {
|
|
1415
|
+
const commsChannel = new FakeInbox();
|
|
1416
|
+
const commsInboxes = [];
|
|
1417
|
+
for (const recipient of externalCommsEmail.recipients ?? []) {
|
|
1418
|
+
if (recipient.address !== undefined) {
|
|
1419
|
+
commsInboxes.push(await commsChannel.provisionAddress(recipient.lane, recipient.address));
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
const authToken = externalComms.authTokenEnv === undefined ? undefined : env[externalComms.authTokenEnv];
|
|
1423
|
+
const collected = await collectExternalCommsThread({
|
|
1424
|
+
external: { ...externalComms, ...(authToken === undefined ? {} : { authToken }) },
|
|
1425
|
+
channel: commsChannel,
|
|
1426
|
+
inboxes: commsInboxes
|
|
1427
|
+
});
|
|
1428
|
+
if (collected.artifact) {
|
|
1429
|
+
const path = "comms/thread.json";
|
|
1430
|
+
await writeContainedOutputFile(runPaths, path, `${JSON.stringify(collected.artifact, null, 2)}\n`, "utf8");
|
|
1431
|
+
commsArtifactPath = path;
|
|
1432
|
+
}
|
|
1433
|
+
else if (collected.captured > 0) {
|
|
1434
|
+
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.`);
|
|
1435
|
+
}
|
|
1436
|
+
else {
|
|
1437
|
+
warnings.push(`Comms catch captured ZERO email sends — your app never delivered mail through the catch at ${externalComms.catchBaseUrl}. Verify the app's email-API base URL points at it and that the flow reached an email step.`);
|
|
1394
1438
|
}
|
|
1395
1439
|
}
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
external: { ...externalComms, ...(authToken === undefined ? {} : { authToken }) },
|
|
1399
|
-
channel: commsChannel,
|
|
1400
|
-
inboxes: commsInboxes
|
|
1401
|
-
});
|
|
1402
|
-
if (collected.artifact) {
|
|
1403
|
-
const path = "comms/thread.json";
|
|
1404
|
-
await writeContainedOutputFile(runPaths, path, `${JSON.stringify(collected.artifact, null, 2)}\n`, "utf8");
|
|
1405
|
-
commsArtifactPath = path;
|
|
1406
|
-
}
|
|
1407
|
-
else if (collected.captured > 0) {
|
|
1408
|
-
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.`);
|
|
1409
|
-
}
|
|
1410
|
-
else {
|
|
1411
|
-
warnings.push(`Comms catch captured ZERO email sends — your app never delivered mail through the catch at ${externalComms.catchBaseUrl}. Verify the app's email-API base URL points at it and that the flow reached an email step.`);
|
|
1440
|
+
catch (error) {
|
|
1441
|
+
warnings.push(`Comms evidence collection failed against the adopter-hosted catch (run continues): ${redactText(toErrorMessage(error))}`);
|
|
1412
1442
|
}
|
|
1413
1443
|
}
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1444
|
+
}
|
|
1445
|
+
// Observed-origin convergence proof (blocker 2): the convergence claim is about what the seats
|
|
1446
|
+
// OBSERVED, not what was DECLARED. Digest each observing seat's origin and require they AGREE on
|
|
1447
|
+
// ONE — that agreement IS the convergence proof and becomes plane.publicOriginDigest. A normal
|
|
1448
|
+
// cross-origin redirect (declared apex -> observed www) is therefore tolerated: the seats still
|
|
1449
|
+
// converge on ONE observed origin. Leave it undefined (verify fails closed) only if the seats did
|
|
1450
|
+
// not converge on a single observed origin (or none observed one).
|
|
1451
|
+
const observedOriginDigests = observedFinalUrls
|
|
1452
|
+
.filter((url) => typeof url === "string" && url.length > 0)
|
|
1453
|
+
.map((url) => hostOriginDigest(url));
|
|
1454
|
+
const distinctObservedOrigins = new Set(observedOriginDigests);
|
|
1455
|
+
publicOriginDigest = distinctObservedOrigins.size === 1
|
|
1456
|
+
? [...distinctObservedOrigins][0]
|
|
1457
|
+
// NOTHING observed (e.g. a handoff-timeout run where no seat ever navigated): fall back to the
|
|
1458
|
+
// DECLARED origin so a FAILED run's bundle stays structurally valid (every seat's route then
|
|
1459
|
+
// digests to the declared origin too). The run still fails closed for its own reason (HANDOFF_
|
|
1460
|
+
// TIMEOUT / no lobby convergence / no overlap-on-pass). GENUINE divergence (≥2 distinct observed
|
|
1461
|
+
// origins) leaves it undefined so verify fails closed on the non-convergence.
|
|
1462
|
+
: distinctObservedOrigins.size === 0
|
|
1463
|
+
? declaredOriginDigest
|
|
1464
|
+
: undefined;
|
|
1465
|
+
// Lobby-convergence proof: a digest of the shared /lobby/CODE path iff EVERY seat converged on the
|
|
1466
|
+
// SAME code (a follower stuck on "/" yields no code → no false convergence). Digest-only. NOTE:
|
|
1467
|
+
// observedLobbyCodes may be a SPARSE array (a seat that never observed a code leaves a hole), and
|
|
1468
|
+
// Array.prototype.every SKIPS holes — so count the DEFINED codes explicitly, never rely on every().
|
|
1469
|
+
const definedCodes = observedLobbyCodes.filter((code) => code !== undefined);
|
|
1470
|
+
const distinctCodes = new Set(definedCodes);
|
|
1471
|
+
if (distinctCodes.size === 1 && definedCodes.length === roles.length) {
|
|
1472
|
+
lobbyConvergenceDigest = commandDigestOf(`/lobby/${[...distinctCodes][0]}`);
|
|
1473
|
+
}
|
|
1474
|
+
if (handoffTimedOut && runError === undefined) {
|
|
1475
|
+
runError = `The host seat never produced a /lobby/CODE URL within the ${handoffDeadlineMs}ms handoff deadline; follower seats failed closed without opening.`;
|
|
1417
1476
|
}
|
|
1418
1477
|
}
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
// converge on ONE observed origin. Leave it undefined (verify fails closed) only if the seats did
|
|
1424
|
-
// not converge on a single observed origin (or none observed one).
|
|
1425
|
-
const observedOriginDigests = observedFinalUrls
|
|
1426
|
-
.filter((url) => typeof url === "string" && url.length > 0)
|
|
1427
|
-
.map((url) => hostOriginDigest(url));
|
|
1428
|
-
const distinctObservedOrigins = new Set(observedOriginDigests);
|
|
1429
|
-
publicOriginDigest = distinctObservedOrigins.size === 1
|
|
1430
|
-
? [...distinctObservedOrigins][0]
|
|
1431
|
-
// NOTHING observed (e.g. a handoff-timeout run where no seat ever navigated): fall back to the
|
|
1432
|
-
// DECLARED origin so a FAILED run's bundle stays structurally valid (every seat's route then
|
|
1433
|
-
// digests to the declared origin too). The run still fails closed for its own reason (HANDOFF_
|
|
1434
|
-
// TIMEOUT / no lobby convergence / no overlap-on-pass). GENUINE divergence (≥2 distinct observed
|
|
1435
|
-
// origins) leaves it undefined so verify fails closed on the non-convergence.
|
|
1436
|
-
: distinctObservedOrigins.size === 0
|
|
1437
|
-
? declaredOriginDigest
|
|
1438
|
-
: undefined;
|
|
1439
|
-
// Lobby-convergence proof: a digest of the shared /lobby/CODE path iff EVERY seat converged on the
|
|
1440
|
-
// SAME code (a follower stuck on "/" yields no code → no false convergence). Digest-only. NOTE:
|
|
1441
|
-
// observedLobbyCodes may be a SPARSE array (a seat that never observed a code leaves a hole), and
|
|
1442
|
-
// Array.prototype.every SKIPS holes — so count the DEFINED codes explicitly, never rely on every().
|
|
1443
|
-
const definedCodes = observedLobbyCodes.filter((code) => code !== undefined);
|
|
1444
|
-
const distinctCodes = new Set(definedCodes);
|
|
1445
|
-
if (distinctCodes.size === 1 && definedCodes.length === roles.length) {
|
|
1446
|
-
lobbyConvergenceDigest = commandDigestOf(`/lobby/${[...distinctCodes][0]}`);
|
|
1478
|
+
}
|
|
1479
|
+
finally {
|
|
1480
|
+
try {
|
|
1481
|
+
await receiving?.finish();
|
|
1447
1482
|
}
|
|
1448
|
-
|
|
1449
|
-
|
|
1483
|
+
catch {
|
|
1484
|
+
warnings.push("Email finalization could not complete. Inspect humanish comms recover; provider cleanup remains unresolved.");
|
|
1450
1485
|
}
|
|
1451
1486
|
}
|
|
1452
1487
|
// Subject provenance: external-public is the operator-declared, operator-owned public deployment
|
|
@@ -1508,6 +1543,8 @@ async function runConcurrentSharedWorldInScope(options) {
|
|
|
1508
1543
|
hookLabel: "sharedWorldHooks",
|
|
1509
1544
|
...(options.scorerProvenance === undefined ? {} : { scorerProvenance: options.scorerProvenance })
|
|
1510
1545
|
});
|
|
1546
|
+
if (receiving)
|
|
1547
|
+
bundle.commsReceiving = receiving.snapshot();
|
|
1511
1548
|
await writeConcurrentRunArtifacts(bundle, runPaths);
|
|
1512
1549
|
// Finalize identity+liveness from the bundle just written. Deliberately here and not inside
|
|
1513
1550
|
// writeConcurrentRunArtifacts — that writer is shared with the mid-run in-progress flushes, and
|
|
@@ -2055,6 +2092,7 @@ export function buildConcurrentSharedWorldBundle(args) {
|
|
|
2055
2092
|
const ranLive = actorResults.some((result) => result.outcome.session !== undefined || result.outcome.sessionError !== undefined);
|
|
2056
2093
|
return {
|
|
2057
2094
|
schema: RUN_BUNDLE_SCHEMA,
|
|
2095
|
+
...receivingPublication(args.config, args.dryRun),
|
|
2058
2096
|
runId: args.runId,
|
|
2059
2097
|
mode: dryRun ? "dry-run" : "live",
|
|
2060
2098
|
simCount: actorSpecs.length,
|