humanish 0.143.0 → 0.144.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/CHANGELOG.md +11 -0
- package/dist/cli/commands/run-command.js +2 -1
- package/dist/cli/commands/study-remote.d.ts +34 -2
- package/dist/cli/commands/study-remote.js +103 -52
- package/dist/cli/commands/study-route-run.d.ts +3 -2
- package/dist/cli/commands/study-route-run.js +7 -2
- package/dist/cli/commands/study-run.js +19 -9
- package/dist/cli/io.d.ts +2 -0
- package/dist/remote/launch.d.ts +13 -1
- package/dist/remote/launch.js +55 -21
- package/dist/remote/plan.d.ts +2 -0
- package/dist/remote/plan.js +12 -0
- package/dist/remote/runner.d.ts +5 -0
- package/dist/remote/runner.js +13 -1
- package/dist/run/results.d.ts +9 -1
- package/dist/study/keys.d.ts +1 -0
- package/dist/study/keys.js +1 -0
- package/dist/study/parse/execution.js +5 -0
- package/dist/study/types.d.ts +6 -0
- package/dist/tui/launch.js +3 -0
- package/package.json +1 -1
- package/skills/humanish/SKILL.md +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,17 @@ The Unreleased section holds the full notes for the next version until it is tag
|
|
|
8
8
|
|
|
9
9
|
## Unreleased
|
|
10
10
|
|
|
11
|
+
## 0.144.0: Hosted studies run remotely by default (2026-10-22)
|
|
12
|
+
|
|
13
|
+
humanish 0.144.0 runs a hosted computer-use study remotely by default. A study on
|
|
14
|
+
`e2b-desktop` or `modal-desktop` with a public `app-url` runs its own process in a sandbox in your
|
|
15
|
+
E2B or Modal account, so it keeps going when this machine sleeps; the command follows it, serves
|
|
16
|
+
the live Observer here and copies the evidence back. Before the runner starts, the command names
|
|
17
|
+
the keys it sends. Pass `--local` for one run, or set `execution.remote: false` in a study, to keep
|
|
18
|
+
it on this machine.
|
|
19
|
+
|
|
20
|
+
[Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.144.0)
|
|
21
|
+
|
|
11
22
|
## 0.143.0: Analysis costs about one seventh as much: gpt-6.1-sol at medium effort is the default (2026-10-22)
|
|
12
23
|
|
|
13
24
|
humanish 0.143.0 analyzes studies with `gpt-6.1-sol` at medium reasoning effort by default, in
|
|
@@ -20,7 +20,8 @@ export function addRunOptions(command) {
|
|
|
20
20
|
.option("--dotenv <path>", DOTENV_OPTION_DESCRIPTION)
|
|
21
21
|
.addOption(envFileAliasOption())
|
|
22
22
|
.option("--run-id <id>", "Explicit run id for deterministic fixture tests; refused when that run already exists.")
|
|
23
|
-
.
|
|
23
|
+
.addOption(new Option("--remote", "Require a remote run: the study's own process runs in a sandbox on its participants' provider, so it keeps going when this machine sleeps. A study that can is run this way by default; this refuses one that cannot, with the reason.").conflicts("local"))
|
|
24
|
+
.option("--local", "Run the study on this machine even when it could run remotely. It then stops if this machine sleeps.")
|
|
24
25
|
.addOption(ownerKeyOption())
|
|
25
26
|
.option("--json", JSON_OPTION_DESCRIPTION);
|
|
26
27
|
}
|
|
@@ -1,13 +1,45 @@
|
|
|
1
1
|
import type { Command } from "commander";
|
|
2
2
|
import { type RemoteOutcome } from "../../remote/launch.js";
|
|
3
|
+
import { type RemotePlan } from "../../remote/plan.js";
|
|
4
|
+
import type { RunResult } from "../../run/results.js";
|
|
3
5
|
import type { StudyConfig } from "../../study/types.js";
|
|
4
6
|
import { type CliIo, type StudyCommandOptions } from "../io.js";
|
|
7
|
+
type RemoteErrorCode = "HUMANISH_REMOTE_REFUSED" | "HUMANISH_REMOTE_FAILED";
|
|
8
|
+
/** Where a study's run executes, decided before anything is created. */
|
|
9
|
+
export type RunPlacement = {
|
|
10
|
+
kind: "local";
|
|
11
|
+
note?: string;
|
|
12
|
+
} | {
|
|
13
|
+
kind: "remote";
|
|
14
|
+
plan: RemotePlan;
|
|
15
|
+
} | {
|
|
16
|
+
kind: "refused";
|
|
17
|
+
message: string;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Whether this run executes here or in a runner sandbox. A hosted study runs remotely by default
|
|
21
|
+
* when it can; `--local` or `execution.remote: false` keeps it here, and `--remote` or
|
|
22
|
+
* `execution.remote: true` requires a remote run, refused with the reason when the study cannot
|
|
23
|
+
* have one. `--local` and `--remote` win over the study's field.
|
|
24
|
+
*/
|
|
25
|
+
export declare function placeRun(args: {
|
|
26
|
+
options: StudyCommandOptions;
|
|
27
|
+
config: StudyConfig;
|
|
28
|
+
studyPath: string;
|
|
29
|
+
env?: Readonly<Record<string, string | undefined>>;
|
|
30
|
+
/** Whether this humanish is a built package a runner can install; read from the module when absent. */
|
|
31
|
+
packaged?: boolean;
|
|
32
|
+
}): RunPlacement;
|
|
33
|
+
/** A refused placement as the command's result. */
|
|
34
|
+
export declare function refuseRemote(command: Command, io: CliIo, cwd: string, message: string, code?: RemoteErrorCode, forwarded?: NonNullable<RunResult["remote"]>): void;
|
|
35
|
+
/** Launch `remote` in a runner sandbox and report how it ended. */
|
|
5
36
|
export declare function runRemoteStudy(args: {
|
|
6
37
|
command: Command;
|
|
7
38
|
io: CliIo;
|
|
8
39
|
options: StudyCommandOptions;
|
|
9
|
-
config: StudyConfig;
|
|
10
40
|
studyPath: string;
|
|
41
|
+
remote: RemotePlan;
|
|
11
42
|
}): Promise<void>;
|
|
12
43
|
/** The command's result and exit code for a remote run that ended, stopped or detached. */
|
|
13
|
-
export declare function reportRemoteOutcome(command: Command, io: CliIo, cwd: string, outcome: RemoteOutcome): void;
|
|
44
|
+
export declare function reportRemoteOutcome(command: Command, io: CliIo, cwd: string, outcome: RemoteOutcome, forwarded?: NonNullable<RunResult["remote"]>): void;
|
|
45
|
+
export {};
|
|
@@ -1,81 +1,125 @@
|
|
|
1
1
|
// `--remote` on run and watch: plan here, refuse what cannot run remotely, then launch and follow
|
|
2
2
|
// the run in a runner sandbox (src/remote/launch.ts). The result and exit code are the run's own.
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { launchRemoteRun } from "../../remote/launch.js";
|
|
4
|
+
import { forwardedEnv, launchRemoteRun, runsFromPackage, } from "../../remote/launch.js";
|
|
5
5
|
import { planRemoteRun } from "../../remote/plan.js";
|
|
6
|
-
import {
|
|
6
|
+
import { INSIDE_RUNNER_ENV } from "../../remote/runner.js";
|
|
7
|
+
import { planStudy, resolveStudyDryRun } from "../../study/plan.js";
|
|
7
8
|
import { concurrentSandboxes, sandboxCeiling } from "../../substrates/e2b/lifetime.js";
|
|
8
9
|
import { MODAL_MAX_SANDBOX_MS } from "../../substrates/modal/sdk.js";
|
|
9
10
|
import { formatRunHuman, parseObserverPort, wantsJson, writeResult, } from "../io.js";
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Whether this run executes here or in a runner sandbox. A hosted study runs remotely by default
|
|
13
|
+
* when it can; `--local` or `execution.remote: false` keeps it here, and `--remote` or
|
|
14
|
+
* `execution.remote: true` requires a remote run, refused with the reason when the study cannot
|
|
15
|
+
* have one. `--local` and `--remote` win over the study's field.
|
|
16
|
+
*/
|
|
17
|
+
export function placeRun(args) {
|
|
18
|
+
const { options, config } = args;
|
|
19
|
+
if ((args.env ?? process.env)[INSIDE_RUNNER_ENV] === "1")
|
|
20
|
+
return options.remote === true
|
|
21
|
+
? {
|
|
22
|
+
kind: "refused",
|
|
23
|
+
message: "This run is already inside a runner sandbox, so it runs here.",
|
|
24
|
+
}
|
|
25
|
+
: { kind: "local" };
|
|
26
|
+
const declared = config.execution?.remote;
|
|
27
|
+
if (options.local === true || (options.remote !== true && declared === false))
|
|
28
|
+
return { kind: "local" };
|
|
29
|
+
const required = options.remote === true || declared === true;
|
|
30
|
+
// A dry run creates no sandbox, so it never needs a runner, whatever asked for one.
|
|
31
|
+
if (resolveStudyDryRun(config, options.dryRun, true) !== false)
|
|
32
|
+
return required
|
|
33
|
+
? { kind: "local", note: "A dry run creates no sandbox, so it runs here, not remotely." }
|
|
34
|
+
: { kind: "local" };
|
|
35
|
+
const local = (reason) => required
|
|
36
|
+
? { kind: "refused", message: `This study cannot run remotely: ${reason}` }
|
|
37
|
+
: movable(config)
|
|
38
|
+
? {
|
|
39
|
+
kind: "local",
|
|
40
|
+
note: `Not running remotely: ${reason} A run on this machine stops if it sleeps.`,
|
|
41
|
+
}
|
|
42
|
+
: { kind: "local" };
|
|
43
|
+
const flags = localOnlyFlags(options);
|
|
44
|
+
if (flags.length > 0)
|
|
45
|
+
return local(`${flags.join(", ")} ${flags.length === 1 ? "does" : "do"} not run remotely yet.`);
|
|
12
46
|
const cwd = path.resolve(options.cwd);
|
|
13
|
-
const refuse = (code, message, runId) => {
|
|
14
|
-
const result = {
|
|
15
|
-
schema: "humanish.run-result.v1",
|
|
16
|
-
ok: false,
|
|
17
|
-
cwd,
|
|
18
|
-
...(runId === undefined ? {} : { runId }),
|
|
19
|
-
warnings: [],
|
|
20
|
-
error: { code, message },
|
|
21
|
-
};
|
|
22
|
-
writeResult(args.command, io, result, formatRunHuman);
|
|
23
|
-
io.setExitCode(2);
|
|
24
|
-
};
|
|
25
|
-
if (options.dryRun === true) {
|
|
26
|
-
refuse("HUMANISH_REMOTE_REFUSED", "--remote runs live studies; a dry run needs no runner.");
|
|
27
|
-
return;
|
|
28
|
-
}
|
|
29
|
-
const local = localOnlyFlags(options);
|
|
30
|
-
if (local.length > 0) {
|
|
31
|
-
refuse("HUMANISH_REMOTE_REFUSED", `${local.join(", ")} ${local.length === 1 ? "does" : "do"} not run remotely yet; run without --remote to use ${local.length === 1 ? "it" : "them"}.`);
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
47
|
const planned = planStudy(config, { cwd, dryRun: false });
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
return;
|
|
38
|
-
}
|
|
48
|
+
// A study that does not plan is refused by its route with the plan's own message.
|
|
49
|
+
if (!planned.ok)
|
|
50
|
+
return required ? { kind: "refused", message: planned.refusal.message } : { kind: "local" };
|
|
39
51
|
const plan = planned.planned.plan;
|
|
40
|
-
const e2bCeiling = sandboxCeiling(process.env);
|
|
41
52
|
const provider = plan.route === "computer-use" && plan.runner.desktop === "e2b-desktop"
|
|
42
53
|
? plan.runner.provider
|
|
43
54
|
: "e2b";
|
|
44
|
-
const
|
|
55
|
+
const e2bCeiling = sandboxCeiling(process.env);
|
|
45
56
|
const sandboxes = concurrentSandboxes(process.env);
|
|
46
57
|
const remote = planRemoteRun({
|
|
47
58
|
config,
|
|
48
59
|
plan,
|
|
49
60
|
cwd,
|
|
50
61
|
studyPath: args.studyPath,
|
|
51
|
-
ceilingMs,
|
|
62
|
+
ceilingMs: provider === "modal" ? MODAL_MAX_SANDBOX_MS : e2bCeiling.ok ? e2bCeiling.ms : 0,
|
|
52
63
|
concurrentSandboxes: sandboxes.ok ? sandboxes.count : 1,
|
|
53
64
|
});
|
|
54
|
-
if (!remote.ok)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
65
|
+
if (!remote.ok)
|
|
66
|
+
return local(remote.reason);
|
|
67
|
+
// Checked last, so a study that could not run remotely anyway gives its own reason.
|
|
68
|
+
if (!(args.packaged ?? runsFromPackage()))
|
|
69
|
+
return local("this humanish runs from source, which a runner cannot install. Build it with pnpm build and run node dist/cli.js to run remotely.");
|
|
70
|
+
return { kind: "remote", plan: remote.plan };
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Whether the study is of a kind that runs remotely, so a reason it does not this time is worth
|
|
74
|
+
* saying: a computer-use study on hosted desktops. Other routes never run remotely yet.
|
|
75
|
+
*/
|
|
76
|
+
function movable(config) {
|
|
77
|
+
const target = config.execution?.target;
|
|
78
|
+
return (config.route === "computer-use" && (target === "e2b-desktop" || target === "modal-desktop"));
|
|
79
|
+
}
|
|
80
|
+
/** A refused placement as the command's result. */
|
|
81
|
+
export function refuseRemote(command, io, cwd, message, code = "HUMANISH_REMOTE_REFUSED", forwarded) {
|
|
82
|
+
const result = {
|
|
83
|
+
schema: "humanish.run-result.v1",
|
|
84
|
+
ok: false,
|
|
85
|
+
cwd: path.resolve(cwd),
|
|
86
|
+
warnings: [],
|
|
87
|
+
...(forwarded === undefined ? {} : { remote: forwarded }),
|
|
88
|
+
error: { code, message },
|
|
89
|
+
};
|
|
90
|
+
writeResult(command, io, result, formatRunHuman);
|
|
91
|
+
io.setExitCode(2);
|
|
92
|
+
}
|
|
93
|
+
/** Launch `remote` in a runner sandbox and report how it ended. */
|
|
94
|
+
export async function runRemoteStudy(args) {
|
|
95
|
+
const { io, options } = args;
|
|
96
|
+
const cwd = path.resolve(options.cwd);
|
|
58
97
|
const port = parseObserverPort(options.port ?? "0");
|
|
59
98
|
if (port === null) {
|
|
60
|
-
|
|
99
|
+
refuseRemote(args.command, io, cwd, "--port must be an integer between 0 and 65535.");
|
|
61
100
|
return;
|
|
62
101
|
}
|
|
102
|
+
const forwarded = {
|
|
103
|
+
provider: args.remote.provider,
|
|
104
|
+
keys: Object.keys(forwardedEnv(args.remote, process.env)).sort(),
|
|
105
|
+
};
|
|
106
|
+
io.writeErr(`The runner receives ${forwarded.keys.join(", ") || "no keys"} from this machine, in your own ${forwarded.provider === "modal" ? "Modal" : "E2B"} account. Run with --local to keep the study on this machine.\n`);
|
|
63
107
|
let outcome;
|
|
64
108
|
try {
|
|
65
109
|
outcome = await launchRemoteRun({
|
|
66
110
|
cwd,
|
|
67
111
|
study: path.relative(cwd, path.resolve(cwd, args.studyPath)).split(path.sep).join("/"),
|
|
68
|
-
remote: remote
|
|
112
|
+
remote: args.remote,
|
|
69
113
|
env: process.env,
|
|
70
114
|
port,
|
|
71
115
|
say: (line) => io.writeErr(`${line}\n`),
|
|
72
116
|
});
|
|
73
117
|
}
|
|
74
118
|
catch (error) {
|
|
75
|
-
|
|
119
|
+
refuseRemote(args.command, io, cwd, error instanceof Error ? error.message : String(error), "HUMANISH_REMOTE_FAILED", forwarded);
|
|
76
120
|
return;
|
|
77
121
|
}
|
|
78
|
-
reportRemoteOutcome(args.command, io, cwd, outcome);
|
|
122
|
+
reportRemoteOutcome(args.command, io, cwd, outcome, forwarded);
|
|
79
123
|
}
|
|
80
124
|
/** The run options a remote run does not carry to the runner yet. */
|
|
81
125
|
function localOnlyFlags(options) {
|
|
@@ -90,28 +134,35 @@ function localOnlyFlags(options) {
|
|
|
90
134
|
].flat();
|
|
91
135
|
}
|
|
92
136
|
/** The command's result and exit code for a remote run that ended, stopped or detached. */
|
|
93
|
-
export function reportRemoteOutcome(command, io, cwd, outcome) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
137
|
+
export function reportRemoteOutcome(command, io, cwd, outcome, forwarded) {
|
|
138
|
+
const remote = forwarded === undefined ? {} : { remote: forwarded };
|
|
139
|
+
if (outcome.detached === true ||
|
|
140
|
+
outcome.result === undefined ||
|
|
141
|
+
typeof outcome.result !== "object") {
|
|
142
|
+
const detached = outcome.detached === true;
|
|
99
143
|
const failed = {
|
|
100
144
|
schema: "humanish.run-result.v1",
|
|
101
145
|
ok: false,
|
|
102
146
|
cwd,
|
|
103
147
|
runId: outcome.runId,
|
|
104
148
|
warnings: [],
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
149
|
+
...remote,
|
|
150
|
+
error: detached
|
|
151
|
+
? {
|
|
152
|
+
code: "HUMANISH_REMOTE_DETACHED",
|
|
153
|
+
message: `The terminal closed, so run ${outcome.runId} goes on in its runner without this machine. Follow it and copy it back with humanish observe --run ${outcome.runId}, or stop it and its sandboxes with humanish reclaim --run ${outcome.runId}.`,
|
|
154
|
+
}
|
|
155
|
+
: {
|
|
156
|
+
code: "HUMANISH_REMOTE_FAILED",
|
|
157
|
+
message: `Run ${outcome.runId} ${outcome.error ?? "ended without a result"}.`,
|
|
158
|
+
},
|
|
109
159
|
};
|
|
110
160
|
writeResult(command, io, failed, formatRunHuman);
|
|
111
|
-
|
|
161
|
+
// A detached run exits as a hung-up local command does; it is not finished, so not 0.
|
|
162
|
+
io.setExitCode(detached ? 129 : outcome.exitCode === 0 ? 2 : outcome.exitCode);
|
|
112
163
|
return;
|
|
113
164
|
}
|
|
114
|
-
const result = { ...outcome.result, cwd };
|
|
165
|
+
const result = { ...outcome.result, cwd, ...remote };
|
|
115
166
|
if (outcome.error !== undefined)
|
|
116
167
|
result.warnings = [...(result.warnings ?? []), outcome.error];
|
|
117
168
|
if (wantsJson(command))
|
|
@@ -12,8 +12,9 @@ export interface RouteRun {
|
|
|
12
12
|
* only for a plan that will run: it loads the scorer, or writes its own refusal and returns
|
|
13
13
|
* undefined, so a refused study never imports the scorer's host code. `afterRun` runs once runStudyWith
|
|
14
14
|
* has returned or thrown, before presentation, which may hold its own signal handlers (watch's
|
|
15
|
-
* Observer and tunnel).
|
|
15
|
+
* Observer and tunnel). `elsewhere`, when given, runs an admitted study somewhere else instead of
|
|
16
|
+
* here: the route's own checks have refused anything this machine would refuse first.
|
|
16
17
|
*/
|
|
17
18
|
export declare function runRoute(config: StudyConfig, run: RouteRun, beforeRun?: () => Promise<{
|
|
18
19
|
scorer?: LoadedAdapterScorer;
|
|
19
|
-
} | undefined>, afterRun?: () => void): Promise<void>;
|
|
20
|
+
} | undefined>, afterRun?: () => void, elsewhere?: () => Promise<void>): Promise<void>;
|
|
@@ -6,12 +6,17 @@ import { prepareStudy } from "../../run-study.js";
|
|
|
6
6
|
* only for a plan that will run: it loads the scorer, or writes its own refusal and returns
|
|
7
7
|
* undefined, so a refused study never imports the scorer's host code. `afterRun` runs once runStudyWith
|
|
8
8
|
* has returned or thrown, before presentation, which may hold its own signal handlers (watch's
|
|
9
|
-
* Observer and tunnel).
|
|
9
|
+
* Observer and tunnel). `elsewhere`, when given, runs an admitted study somewhere else instead of
|
|
10
|
+
* here: the route's own checks have refused anything this machine would refuse first.
|
|
10
11
|
*/
|
|
11
|
-
export async function runRoute(config, run, beforeRun = () => Promise.resolve({}), afterRun = () => undefined) {
|
|
12
|
+
export async function runRoute(config, run, beforeRun = () => Promise.resolve({}), afterRun = () => undefined, elsewhere) {
|
|
12
13
|
let outcome;
|
|
13
14
|
try {
|
|
14
15
|
const prepared = await prepareStudy(config, run.options);
|
|
16
|
+
if (prepared.ok && elsewhere !== undefined) {
|
|
17
|
+
await elsewhere();
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
15
20
|
if (prepared.ok) {
|
|
16
21
|
const ready = await beforeRun();
|
|
17
22
|
if (ready === undefined)
|
|
@@ -15,7 +15,7 @@ import { sharedWorldRouteRun } from "./study-route-shared-world.js";
|
|
|
15
15
|
import { previewRouteRun } from "./study-route-preview.js";
|
|
16
16
|
import { terminalRouteRun } from "./study-route-terminal.js";
|
|
17
17
|
import { maybeLoadAdapterScorer } from "./study-scorer.js";
|
|
18
|
-
import { runRemoteStudy } from "./study-remote.js";
|
|
18
|
+
import { placeRun, refuseRemote, runRemoteStudy } from "./study-remote.js";
|
|
19
19
|
import { discoverCliKeys, formatRunHuman, noteRunFacts, writeResult, } from "../io.js";
|
|
20
20
|
import { watchExposeRequested } from "../observer-follow.js";
|
|
21
21
|
import { beginRunSignalPhase } from "./run-signals.js";
|
|
@@ -112,16 +112,13 @@ export async function runStudyCommand(args) {
|
|
|
112
112
|
...(announced === undefined ? {} : { announced }),
|
|
113
113
|
});
|
|
114
114
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
io: args.io,
|
|
119
|
-
options: args.options,
|
|
120
|
-
config,
|
|
121
|
-
studyPath: resolved.path,
|
|
122
|
-
});
|
|
115
|
+
const placement = placeRun({ options: args.options, config, studyPath: resolved.path });
|
|
116
|
+
if (placement.kind === "refused") {
|
|
117
|
+
refuseRemote(args.command, args.io, args.options.cwd, placement.message);
|
|
123
118
|
return;
|
|
124
119
|
}
|
|
120
|
+
if (placement.kind === "local" && placement.note !== undefined)
|
|
121
|
+
args.io.writeErr(`${placement.note}\n`);
|
|
125
122
|
// The route's CLI setup refuses bad options, runStudyWith's plan refuses bad labs, and the route's local
|
|
126
123
|
// checks (keys, runtime auth, subject env, the local agent, caps) refuse this machine, all before
|
|
127
124
|
// the scorer loads, so none imports the scorer's host code. The scripted route's checks and the
|
|
@@ -131,6 +128,19 @@ export async function runStudyCommand(args) {
|
|
|
131
128
|
return;
|
|
132
129
|
// Every route's runStudyWith options carry the study's provenance, from here only.
|
|
133
130
|
const run = { ...routeRun, options: { ...routeRun.options, study } };
|
|
131
|
+
// A remote run passes the same checks here first, so a study this machine would refuse is refused
|
|
132
|
+
// before any runner exists. The launcher holds its own signal handlers.
|
|
133
|
+
if (placement.kind === "remote") {
|
|
134
|
+
const remote = placement.plan;
|
|
135
|
+
await runRoute(config, run, undefined, undefined, () => runRemoteStudy({
|
|
136
|
+
command: args.command,
|
|
137
|
+
io: args.io,
|
|
138
|
+
options: args.options,
|
|
139
|
+
studyPath: resolved.path,
|
|
140
|
+
remote,
|
|
141
|
+
}));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
134
144
|
// From here a signal marks the run interrupted and reclaims its sandboxes (run-signals.ts).
|
|
135
145
|
const signals = beginRunSignalPhase(args.io);
|
|
136
146
|
try {
|
package/dist/cli/io.d.ts
CHANGED
|
@@ -47,6 +47,8 @@ export interface StudyCommandOptions {
|
|
|
47
47
|
runId?: string | undefined;
|
|
48
48
|
/** Run the study in a runner sandbox on its participants' provider. */
|
|
49
49
|
remote?: boolean | undefined;
|
|
50
|
+
/** Run the study on this machine even when it could run remotely. */
|
|
51
|
+
local?: boolean | undefined;
|
|
50
52
|
/** Hidden: the run key the run's sandboxes carry, chosen by a remote run's launcher. */
|
|
51
53
|
ownerKey?: string | undefined;
|
|
52
54
|
/** Hidden, watch only: bind the served Observer on every interface (a remote run's runner). */
|
package/dist/remote/launch.d.ts
CHANGED
|
@@ -45,8 +45,13 @@ export declare function followRemoteRun(args: {
|
|
|
45
45
|
export declare function joinJournals(...journals: string[]): string;
|
|
46
46
|
export interface Signals {
|
|
47
47
|
readonly requested: "stop" | "detach" | undefined;
|
|
48
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* The exit code for the first signal received: 130 for SIGINT and 143 for SIGTERM, as a local
|
|
50
|
+
* run gives, and 129 for a closed terminal.
|
|
51
|
+
*/
|
|
49
52
|
readonly exitCode: number;
|
|
53
|
+
/** Settles when the first signal arrives, so a long step can stop waiting for itself. */
|
|
54
|
+
readonly received: Promise<void>;
|
|
50
55
|
dispose(): void;
|
|
51
56
|
}
|
|
52
57
|
/**
|
|
@@ -54,3 +59,10 @@ export interface Signals {
|
|
|
54
59
|
* running; the runner ends it at its deadline.
|
|
55
60
|
*/
|
|
56
61
|
export declare function watchSignals(): Signals;
|
|
62
|
+
/**
|
|
63
|
+
* The names and values the runner's run receives: each of the plan's names that is set here, and of
|
|
64
|
+
* names the run reads only the first of, only the first one set.
|
|
65
|
+
*/
|
|
66
|
+
export declare function forwardedEnv(remote: Pick<RemotePlan, "envNames" | "alternatives">, env: Readonly<Record<string, string | undefined>>): Record<string, string>;
|
|
67
|
+
/** Whether this humanish runs from a built package, which a runner can install. */
|
|
68
|
+
export declare function runsFromPackage(): boolean;
|
package/dist/remote/launch.js
CHANGED
|
@@ -56,6 +56,8 @@ export async function launchRemoteRun(launch) {
|
|
|
56
56
|
try {
|
|
57
57
|
const tarball = await packThisHumanish();
|
|
58
58
|
tarballDir = tarball.dir;
|
|
59
|
+
if (signals.requested !== undefined)
|
|
60
|
+
return { runId, exitCode: signals.exitCode, error: "stopped before the runner was created" };
|
|
59
61
|
const where = remote.provider === "modal" ? "a Modal sandbox" : "an E2B sandbox";
|
|
60
62
|
say(`Running this study on ${where} as run ${runId}; creating the runner.`);
|
|
61
63
|
const runner = runnerOf((await acquireRunner(launch, paths)).sandbox);
|
|
@@ -71,7 +73,17 @@ export async function launchRemoteRun(launch) {
|
|
|
71
73
|
});
|
|
72
74
|
// Bound before the run starts, so a taken --port fails before any participant exists.
|
|
73
75
|
proxy = await serveObserverProxy(await runner.sandbox.portAccess(RUNNER_OBSERVER_PORT), launch.port);
|
|
74
|
-
|
|
76
|
+
// Installing can take minutes; a signal stops waiting for it, and the reclaim below stops the
|
|
77
|
+
// runner, and the install with it.
|
|
78
|
+
const installing = installHumanish(runner, tarball.bytes, [await peerSpec(remote.provider)]);
|
|
79
|
+
// An install left behind by a signal fails once its runner is gone; that is expected.
|
|
80
|
+
installing.catch(() => undefined);
|
|
81
|
+
const version = await Promise.race([installing, signals.received.then(() => "")]);
|
|
82
|
+
if (signals.requested !== undefined)
|
|
83
|
+
return await endRun(launch, runId, {
|
|
84
|
+
exitCode: signals.exitCode,
|
|
85
|
+
error: "stopped before the run started",
|
|
86
|
+
});
|
|
75
87
|
say(`Installed humanish ${version} in the runner; uploading the study.`);
|
|
76
88
|
await uploadProject(runner, cwd, remote.uploads, remote.uploadDirectories);
|
|
77
89
|
if (signals.requested !== undefined)
|
|
@@ -83,16 +95,15 @@ export async function launchRemoteRun(launch) {
|
|
|
83
95
|
study: launch.study,
|
|
84
96
|
runId,
|
|
85
97
|
ownerKey,
|
|
86
|
-
env:
|
|
87
|
-
const value = launch.env[name];
|
|
88
|
-
return value === undefined || value === "" ? [] : [[name, value]];
|
|
89
|
-
})),
|
|
98
|
+
env: forwardedEnv(remote, launch.env),
|
|
90
99
|
});
|
|
91
100
|
return await followRemoteRun({ launch, runner, paths, runId, signals, proxyUrl: proxy.url });
|
|
92
101
|
}
|
|
93
102
|
catch (error) {
|
|
94
|
-
await reclaimRunSandboxes(cwd, runId, { env: launch.env }).
|
|
95
|
-
|
|
103
|
+
const reclaimed = await reclaimRunSandboxes(cwd, runId, { env: launch.env }).then((result) => result.ok, () => false);
|
|
104
|
+
if (reclaimed)
|
|
105
|
+
throw error;
|
|
106
|
+
throw new Error(`${message(error)}; its sandboxes were not confirmed stopped, so run humanish reclaim --run ${runId}`, { cause: error });
|
|
96
107
|
}
|
|
97
108
|
finally {
|
|
98
109
|
signals.dispose();
|
|
@@ -152,8 +163,10 @@ export async function followRemoteRun(args) {
|
|
|
152
163
|
say("The run ended; copying its evidence back.");
|
|
153
164
|
const copied = await copy().then((files) => ({ files }), (error) => ({ error }));
|
|
154
165
|
// The run reclaimed its participants as it ended; only the runner is left, and it holds the
|
|
155
|
-
// only whole copy of the evidence until a copy succeeds.
|
|
156
|
-
|
|
166
|
+
// only whole copy of the evidence until a copy succeeds. It is kept only then: a run that
|
|
167
|
+
// refused before its run directory existed has nothing to keep, and its own result says why.
|
|
168
|
+
// Its result is not reported as the run's own, since the evidence is not here.
|
|
169
|
+
if ("error" in copied && stopAt === undefined && run.state !== undefined)
|
|
157
170
|
return {
|
|
158
171
|
runId,
|
|
159
172
|
exitCode: 2,
|
|
@@ -165,7 +178,9 @@ export async function followRemoteRun(args) {
|
|
|
165
178
|
result: run.result.result,
|
|
166
179
|
...("files" in copied
|
|
167
180
|
? { files: copied.files }
|
|
168
|
-
:
|
|
181
|
+
: run.state === undefined
|
|
182
|
+
? {}
|
|
183
|
+
: { error: `copying the evidence back failed: ${message(copied.error)}` }),
|
|
169
184
|
});
|
|
170
185
|
}
|
|
171
186
|
if (signals.requested === "detach")
|
|
@@ -302,17 +317,20 @@ async function writeRemoteRecord(paths, record) {
|
|
|
302
317
|
export function watchSignals() {
|
|
303
318
|
let requested;
|
|
304
319
|
let exitCode = 130;
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
320
|
+
let signalled;
|
|
321
|
+
const received = new Promise((resolve) => {
|
|
322
|
+
signalled = resolve;
|
|
323
|
+
});
|
|
324
|
+
const first = (kind, code) => {
|
|
325
|
+
if (requested !== undefined)
|
|
326
|
+
return;
|
|
327
|
+
requested = kind;
|
|
328
|
+
exitCode = code;
|
|
329
|
+
signalled();
|
|
315
330
|
};
|
|
331
|
+
const interrupt = () => first("stop", 130);
|
|
332
|
+
const terminate = () => first("stop", 143);
|
|
333
|
+
const detach = () => first("detach", 129);
|
|
316
334
|
process.on("SIGINT", interrupt);
|
|
317
335
|
process.on("SIGTERM", terminate);
|
|
318
336
|
process.on("SIGHUP", detach);
|
|
@@ -323,6 +341,7 @@ export function watchSignals() {
|
|
|
323
341
|
get exitCode() {
|
|
324
342
|
return exitCode;
|
|
325
343
|
},
|
|
344
|
+
received,
|
|
326
345
|
dispose() {
|
|
327
346
|
process.off("SIGINT", interrupt);
|
|
328
347
|
process.off("SIGTERM", terminate);
|
|
@@ -330,6 +349,21 @@ export function watchSignals() {
|
|
|
330
349
|
},
|
|
331
350
|
};
|
|
332
351
|
}
|
|
352
|
+
/**
|
|
353
|
+
* The names and values the runner's run receives: each of the plan's names that is set here, and of
|
|
354
|
+
* names the run reads only the first of, only the first one set.
|
|
355
|
+
*/
|
|
356
|
+
export function forwardedEnv(remote, env) {
|
|
357
|
+
const set = (name) => (env[name]?.trim() ?? "") !== "";
|
|
358
|
+
const unread = new Set(remote.alternatives.flatMap((names) => names.filter(set).slice(1)));
|
|
359
|
+
return Object.fromEntries(remote.envNames.flatMap((name) => (set(name) && !unread.has(name) ? [[name, env[name]]] : [])));
|
|
360
|
+
}
|
|
361
|
+
/** Whether this humanish runs from a built package, which a runner can install. */
|
|
362
|
+
export function runsFromPackage() {
|
|
363
|
+
const here = fileURLToPath(import.meta.url);
|
|
364
|
+
const root = path.resolve(path.dirname(here), "..", "..");
|
|
365
|
+
return path.relative(root, here).split(path.sep)[0] === "dist";
|
|
366
|
+
}
|
|
333
367
|
/**
|
|
334
368
|
* This machine's humanish as an npm tarball, so the runner runs exactly the same code. A run from
|
|
335
369
|
* source has no built package to pack.
|
|
@@ -337,7 +371,7 @@ export function watchSignals() {
|
|
|
337
371
|
async function packThisHumanish() {
|
|
338
372
|
const here = fileURLToPath(import.meta.url);
|
|
339
373
|
const root = path.resolve(path.dirname(here), "..", "..");
|
|
340
|
-
if (
|
|
374
|
+
if (!runsFromPackage())
|
|
341
375
|
throw new Error("A remote run installs this humanish in the runner, and a run from source has no built package. Run `pnpm build`, then `node dist/cli.js`, or use an installed humanish.");
|
|
342
376
|
const dir = await mkdtemp(path.join(tmpdir(), "humanish-pack-"));
|
|
343
377
|
const { stdout } = await promisify(execFile)("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", dir, root], { maxBuffer: 16 * 1024 * 1024 });
|
package/dist/remote/plan.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ export interface RemotePlan {
|
|
|
11
11
|
readonly uploadDirectories: readonly string[];
|
|
12
12
|
/** The environment names the runner's run may receive, and nothing else. */
|
|
13
13
|
readonly envNames: readonly string[];
|
|
14
|
+
/** Names of which only the first one set is sent, as the run reads only that one. */
|
|
15
|
+
readonly alternatives: readonly (readonly string[])[];
|
|
14
16
|
}
|
|
15
17
|
export type RemotePlanResult = {
|
|
16
18
|
ok: true;
|
package/dist/remote/plan.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// long it must live, which files it must receive and which keys it may hold. A pure function of the
|
|
3
3
|
// study and its plan, so a refusal happens before anything is created.
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { analysisKeyNames } from "../keys/key-resolution.js";
|
|
5
6
|
import { keyNamesOf } from "../study/requirements.js";
|
|
6
7
|
import { participantList } from "../study/study-fields.js";
|
|
7
8
|
/** Time the runner needs before the study starts: create, Node and humanish install, uploads. */
|
|
@@ -46,6 +47,8 @@ export function planRemoteRun(args) {
|
|
|
46
47
|
return refuse("it loads a scorer module, which does not run remotely yet.");
|
|
47
48
|
if (plan.rerun !== undefined)
|
|
48
49
|
return refuse("a rerun reads a run on this machine.");
|
|
50
|
+
if (config.comms !== undefined)
|
|
51
|
+
return refuse("its email setup reads files and connections on this machine.");
|
|
49
52
|
const study = inside(cwd, args.studyPath);
|
|
50
53
|
if (study === undefined)
|
|
51
54
|
return refuse("the study file is outside the project directory.");
|
|
@@ -72,6 +75,7 @@ export function planRemoteRun(args) {
|
|
|
72
75
|
envNames: [
|
|
73
76
|
...new Set([...keyNamesOf(plan), ...PROVIDER_KEYS[runner.provider], ...PLAN_SETTINGS]),
|
|
74
77
|
].sort(),
|
|
78
|
+
alternatives: analystKeyNames(plan),
|
|
75
79
|
},
|
|
76
80
|
};
|
|
77
81
|
}
|
|
@@ -112,3 +116,11 @@ function servedHere(appUrl) {
|
|
|
112
116
|
(a === 192 && b === 168) ||
|
|
113
117
|
(a === 169 && b === 254));
|
|
114
118
|
}
|
|
119
|
+
/** The analyst's key names, when it reads the first one set of several. */
|
|
120
|
+
function analystKeyNames(plan) {
|
|
121
|
+
const analyst = plan.analysis?.config.provider;
|
|
122
|
+
if (analyst === undefined || analyst === "codex")
|
|
123
|
+
return [];
|
|
124
|
+
const names = analysisKeyNames(analyst);
|
|
125
|
+
return names.length > 1 ? [names] : [];
|
|
126
|
+
}
|
package/dist/remote/runner.d.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { E2BDesktopSandbox } from "../substrates/e2b/sdk.js";
|
|
2
2
|
import { type Shell } from "../substrates/shell.js";
|
|
3
|
+
/**
|
|
4
|
+
* Set in the runner's run, so any humanish there runs locally even when its study could run
|
|
5
|
+
* remotely: a runner that launched a runner would repeat without end.
|
|
6
|
+
*/
|
|
7
|
+
export declare const INSIDE_RUNNER_ENV = "HUMANISH_INSIDE_RUNNER";
|
|
3
8
|
/** The port the runner's Observer listens on. */
|
|
4
9
|
export declare const RUNNER_OBSERVER_PORT = 7000;
|
|
5
10
|
/** A runner sandbox and the shell into it. */
|
package/dist/remote/runner.js
CHANGED
|
@@ -11,6 +11,11 @@ import { tailOf } from "../substrates/shell.js";
|
|
|
11
11
|
const RUNNER_PROJECT = "project";
|
|
12
12
|
/** Where the runner keeps the launcher's own files: the result file and the run's output. */
|
|
13
13
|
const RUNNER_STATE = ".humanish-remote";
|
|
14
|
+
/**
|
|
15
|
+
* Set in the runner's run, so any humanish there runs locally even when its study could run
|
|
16
|
+
* remotely: a runner that launched a runner would repeat without end.
|
|
17
|
+
*/
|
|
18
|
+
export const INSIDE_RUNNER_ENV = "HUMANISH_INSIDE_RUNNER";
|
|
14
19
|
/** The port the runner's Observer listens on. */
|
|
15
20
|
export const RUNNER_OBSERVER_PORT = 7000;
|
|
16
21
|
/**
|
|
@@ -115,13 +120,20 @@ export async function startRemoteRun(runner, start) {
|
|
|
115
120
|
start.runId,
|
|
116
121
|
"--owner-key",
|
|
117
122
|
start.ownerKey,
|
|
123
|
+
// The runner's run is the remote run: it must not launch a runner of its own.
|
|
124
|
+
"--local",
|
|
118
125
|
];
|
|
119
126
|
const line = [
|
|
120
127
|
`cd "$HOME/${RUNNER_PROJECT}"`,
|
|
121
128
|
`export PATH="$HOME/${RUNNER_STATE}/node/bin:$PATH"`,
|
|
122
129
|
`exec ${args.map((arg) => (arg.startsWith("$HOME") ? `"${arg}"` : shellQuote(arg))).join(" ")} > "$HOME/${RUNNER_STATE}/watch.out" 2> "$HOME/${RUNNER_STATE}/watch.err"`,
|
|
123
130
|
].join("; ");
|
|
124
|
-
const env = {
|
|
131
|
+
const env = {
|
|
132
|
+
...start.env,
|
|
133
|
+
DO_NOT_TRACK: "1",
|
|
134
|
+
HUMANISH_TELEMETRY_DISABLED: "1",
|
|
135
|
+
[INSIDE_RUNNER_ENV]: "1",
|
|
136
|
+
};
|
|
125
137
|
const result = await runner.shell.start(`bash -c ${shellQuote(line)}`, {
|
|
126
138
|
env,
|
|
127
139
|
timeoutMs: 60_000,
|
package/dist/run/results.d.ts
CHANGED
|
@@ -31,8 +31,16 @@ export interface RunResult {
|
|
|
31
31
|
/** The Observer rendered for this run, when `RunOptions.observer` asked for one. */
|
|
32
32
|
observer?: ObserverResult;
|
|
33
33
|
warnings: string[];
|
|
34
|
+
/**
|
|
35
|
+
* Set when the run executed in a runner sandbox: the provider, and the names of the keys this
|
|
36
|
+
* machine sent to the runner, in the operator's own account.
|
|
37
|
+
*/
|
|
38
|
+
remote?: {
|
|
39
|
+
provider: string;
|
|
40
|
+
keys: string[];
|
|
41
|
+
};
|
|
34
42
|
error?: {
|
|
35
|
-
code: "HUMANISH_STUDY_ANALYSIS_INVALID" | "HUMANISH_STUDY_ANALYSIS_UNSUPPORTED" | "HUMANISH_STUDY_TASKS_UNSUPPORTED" | "HUMANISH_STUDY_OPTION_UNSUPPORTED" | "HUMANISH_STUDY_V2_UNSUPPORTED" | "HUMANISH_STUDY_INVALID" | "HUMANISH_STUDY_COMMS_UNSUPPORTED" | "HUMANISH_LIVE_RUN_UNIMPLEMENTED" | "HUMANISH_INVALID_CWD" | "HUMANISH_RUN_ID_IN_USE" | "HUMANISH_INVALID_PARTICIPANT_COUNT" | "HUMANISH_INVALID_PORT" | "HUMANISH_UNSUPPORTED_RERUN_FLAGS" | "HUMANISH_WATCH_OPTION_CONFLICT" | "HUMANISH_WATCH_SAFE_NOT_APPLICABLE" | "HUMANISH_RUN_OPTION_CONFLICT" | "HUMANISH_OBSERVE_OPTION_CONFLICT" | "HUMANISH_REMOTE_REFUSED" | "HUMANISH_REMOTE_FAILED" | "HUMANISH_STUDY_SCORER_BAD_REF" | "HUMANISH_STUDY_SCORER_NOT_FOUND" | "HUMANISH_STUDY_SCORER_LOAD_FAILED" | "HUMANISH_STUDY_SCORER_NO_HOOKS" | "HUMANISH_STUDY_SCORER_UNSUPPORTED_BACKEND";
|
|
43
|
+
code: "HUMANISH_STUDY_ANALYSIS_INVALID" | "HUMANISH_STUDY_ANALYSIS_UNSUPPORTED" | "HUMANISH_STUDY_TASKS_UNSUPPORTED" | "HUMANISH_STUDY_OPTION_UNSUPPORTED" | "HUMANISH_STUDY_V2_UNSUPPORTED" | "HUMANISH_STUDY_INVALID" | "HUMANISH_STUDY_COMMS_UNSUPPORTED" | "HUMANISH_LIVE_RUN_UNIMPLEMENTED" | "HUMANISH_INVALID_CWD" | "HUMANISH_RUN_ID_IN_USE" | "HUMANISH_INVALID_PARTICIPANT_COUNT" | "HUMANISH_INVALID_PORT" | "HUMANISH_UNSUPPORTED_RERUN_FLAGS" | "HUMANISH_WATCH_OPTION_CONFLICT" | "HUMANISH_WATCH_SAFE_NOT_APPLICABLE" | "HUMANISH_RUN_OPTION_CONFLICT" | "HUMANISH_OBSERVE_OPTION_CONFLICT" | "HUMANISH_REMOTE_REFUSED" | "HUMANISH_REMOTE_FAILED" | "HUMANISH_REMOTE_DETACHED" | "HUMANISH_STUDY_SCORER_BAD_REF" | "HUMANISH_STUDY_SCORER_NOT_FOUND" | "HUMANISH_STUDY_SCORER_LOAD_FAILED" | "HUMANISH_STUDY_SCORER_NO_HOOKS" | "HUMANISH_STUDY_SCORER_UNSUPPORTED_BACKEND";
|
|
36
44
|
message: string;
|
|
37
45
|
};
|
|
38
46
|
}
|
package/dist/study/keys.d.ts
CHANGED
package/dist/study/keys.js
CHANGED
|
@@ -23,6 +23,11 @@ export function parseExecution(raw) {
|
|
|
23
23
|
}
|
|
24
24
|
execution.target = target;
|
|
25
25
|
}
|
|
26
|
+
if (raw.remote !== undefined) {
|
|
27
|
+
if (typeof raw.remote !== "boolean")
|
|
28
|
+
return invalid("`execution.remote` must be true or false.");
|
|
29
|
+
execution.remote = raw.remote;
|
|
30
|
+
}
|
|
26
31
|
const timeoutMs = posInt(raw.timeoutMs);
|
|
27
32
|
if (timeoutMs !== undefined)
|
|
28
33
|
execution.timeoutMs = timeoutMs;
|
package/dist/study/types.d.ts
CHANGED
|
@@ -581,6 +581,12 @@ export type StudyNetworkCapture = boolean | {
|
|
|
581
581
|
};
|
|
582
582
|
export interface StudyExecution {
|
|
583
583
|
target?: StudyExecutionTarget;
|
|
584
|
+
/**
|
|
585
|
+
* Where the study's own process runs. Unset: in a runner sandbox on the participants' provider
|
|
586
|
+
* when the study can, so it outlives this machine; false: always on this machine; true: only
|
|
587
|
+
* remotely, refused when the study cannot. `--local` and `--remote` override it for one run.
|
|
588
|
+
*/
|
|
589
|
+
remote?: boolean;
|
|
584
590
|
/** Actor session wall-clock budget. Consumed on the app-url route. */
|
|
585
591
|
timeoutMs?: number;
|
|
586
592
|
/** Forward-declared: no route reads it yet, so a set value warns. */
|
package/dist/tui/launch.js
CHANGED
|
@@ -104,6 +104,9 @@ export async function launchRun(options) {
|
|
|
104
104
|
cwd,
|
|
105
105
|
"--json",
|
|
106
106
|
"--no-open",
|
|
107
|
+
// The TUI follows and stops a run through its local status record, which a remote run does
|
|
108
|
+
// not have until it is copied back.
|
|
109
|
+
"--local",
|
|
107
110
|
...(options.mode === "dry-run" ? ["--dry-run"] : []),
|
|
108
111
|
// `--` ends option parsing, so the handle can only ever be read as the positional argument.
|
|
109
112
|
"--",
|
package/package.json
CHANGED
package/skills/humanish/SKILL.md
CHANGED
|
@@ -553,6 +553,14 @@ has finished. A participant with `endAfterMs` can keep a study running for up to
|
|
|
553
553
|
analysis usually adds 1 to 4 minutes after the last participant ends. A shell timeout that sends
|
|
554
554
|
SIGTERM stops the run: humanish marks it interrupted and stops its sandboxes.
|
|
555
555
|
|
|
556
|
+
- A computer-use study on `e2b-desktop` or `modal-desktop` with a public `app-url` runs remotely
|
|
557
|
+
by default: its own process runs in a sandbox on that provider, so a sleeping machine does not
|
|
558
|
+
stop it. `run` still blocks and exits with the run's code; SIGTERM still stops the run. Until its
|
|
559
|
+
bundle is copied back it is not in `runs --json`; `npx humanish observe --run <id>` follows it.
|
|
560
|
+
If its terminal closes, the command exits 129 with `HUMANISH_REMOTE_DETACHED` and the run goes
|
|
561
|
+
on; `observe --run <id>` copies it back. `--local` keeps one run on this machine,
|
|
562
|
+
`execution.remote: false` every run of a study. The JSON result's `remote.keys` names the keys
|
|
563
|
+
the runner received.
|
|
556
564
|
- Start a study that can outlive your shell call in tmux, for example
|
|
557
565
|
`tmux new -d -s study 'npx humanish run <study> --json --no-open > run-result.json 2> run.log'`.
|
|
558
566
|
`--detach` only skips the Observer server at the end; it does not move the run to the background.
|