@inflexa-ai/harness 0.46.2 → 0.47.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/README.md +1 -1
- package/dist/agents/conversation-agent.d.ts +3 -8
- package/dist/agents/conversation-agent.js +24 -4
- package/dist/agents/report-session-agent.d.ts +11 -14
- package/dist/agents/report-session-agent.js +30 -17
- package/dist/agents/sandbox/shared.d.ts +18 -13
- package/dist/agents/sandbox/shared.js +42 -27
- package/dist/agents/sandbox/types.d.ts +1 -1
- package/dist/agents/sandbox-catalog.d.ts +2 -0
- package/dist/agents/sandbox-catalog.js +30 -1
- package/dist/app/synthesize-run.d.ts +10 -6
- package/dist/app/synthesize-run.js +4 -2
- package/dist/contracts/analogy-report.d.ts +46 -2
- package/dist/contracts/analogy-report.js +31 -25
- package/dist/contracts/chat-parts.d.ts +3 -14
- package/dist/contracts/report-reference.d.ts +2 -1
- package/dist/contracts/report-reference.js +2 -1
- package/dist/contracts/schemas/chat-parts.d.ts +0 -27
- package/dist/contracts/schemas/chat-parts.js +1 -3
- package/dist/execution/run-canceler.d.ts +2 -0
- package/dist/execution/run-canceler.js +9 -0
- package/dist/execution/run-synthesis.d.ts +36 -8
- package/dist/execution/run-synthesis.js +114 -32
- package/dist/index.d.ts +2 -5
- package/dist/index.js +3 -6
- package/dist/input-scan/enrich.d.ts +5 -10
- package/dist/input-scan/enrich.js +12 -8
- package/dist/lib/markdown-pdf.d.ts +27 -0
- package/dist/lib/markdown-pdf.js +236 -0
- package/dist/lib/metrics.d.ts +31 -0
- package/dist/lib/metrics.js +64 -0
- package/dist/lib/pdf-text.d.ts +23 -0
- package/dist/lib/pdf-text.js +34 -0
- package/dist/lib/regulatory-corpus/fetch-fda-guidance.js +3 -5
- package/dist/lib/regulatory-corpus/fetch-ich.js +3 -5
- package/dist/loop/run-loop.d.ts +5 -0
- package/dist/loop/run-loop.js +30 -2
- package/dist/loop/tool-name-repair.d.ts +11 -0
- package/dist/loop/tool-name-repair.js +23 -0
- package/dist/loop/types.d.ts +14 -0
- package/dist/memory/card-builders.d.ts +1 -1
- package/dist/prompts/SOUL.d.ts +6 -6
- package/dist/prompts/SOUL.js +22 -121
- package/dist/prompts/analogical-reasoner.d.ts +1 -1
- package/dist/prompts/analogical-reasoner.js +35 -101
- package/dist/prompts/briefing.d.ts +20 -11
- package/dist/prompts/briefing.js +36 -19
- package/dist/prompts/conversation.d.ts +1 -1
- package/dist/prompts/conversation.js +103 -383
- package/dist/prompts/planner.d.ts +1 -5
- package/dist/prompts/planner.js +45 -128
- package/dist/prompts/report-session.d.ts +1 -1
- package/dist/prompts/report-session.js +26 -57
- package/dist/prompts/sandbox/bulk-transcriptomics-agent.d.ts +1 -1
- package/dist/prompts/sandbox/bulk-transcriptomics-agent.js +3 -3
- package/dist/prompts/sandbox/cheminformatics-agent.d.ts +1 -1
- package/dist/prompts/sandbox/cheminformatics-agent.js +12 -47
- package/dist/prompts/sandbox/chromatin-agent.d.ts +1 -1
- package/dist/prompts/sandbox/chromatin-agent.js +4 -5
- package/dist/prompts/sandbox/data-profiler.js +11 -34
- package/dist/prompts/sandbox/dna-methylation-agent.d.ts +1 -1
- package/dist/prompts/sandbox/dna-methylation-agent.js +3 -1
- package/dist/prompts/sandbox/drug-repurposing-agent.d.ts +1 -1
- package/dist/prompts/sandbox/drug-repurposing-agent.js +23 -73
- package/dist/prompts/sandbox/enrichment-agent.d.ts +1 -1
- package/dist/prompts/sandbox/enrichment-agent.js +3 -1
- package/dist/prompts/sandbox/genomic-variant-agent.d.ts +1 -1
- package/dist/prompts/sandbox/genomic-variant-agent.js +3 -4
- package/dist/prompts/sandbox/immune-profiling-agent.d.ts +1 -1
- package/dist/prompts/sandbox/immune-profiling-agent.js +5 -4
- package/dist/prompts/sandbox/metabolomics-agent.d.ts +1 -1
- package/dist/prompts/sandbox/metabolomics-agent.js +3 -1
- package/dist/prompts/sandbox/microbiome-agent.d.ts +1 -1
- package/dist/prompts/sandbox/microbiome-agent.js +3 -1
- package/dist/prompts/sandbox/multi-omics-integration-agent.d.ts +1 -1
- package/dist/prompts/sandbox/multi-omics-integration-agent.js +3 -1
- package/dist/prompts/sandbox/multimodal-sc-agent.d.ts +1 -1
- package/dist/prompts/sandbox/multimodal-sc-agent.js +3 -1
- package/dist/prompts/sandbox/network-agent.d.ts +1 -1
- package/dist/prompts/sandbox/network-agent.js +3 -1
- package/dist/prompts/sandbox/pkpd-clinical-response-agent.d.ts +1 -1
- package/dist/prompts/sandbox/pkpd-clinical-response-agent.js +4 -7
- package/dist/prompts/sandbox/proteomics-agent.d.ts +1 -1
- package/dist/prompts/sandbox/proteomics-agent.js +3 -1
- package/dist/prompts/sandbox/scientific-executor.d.ts +1 -1
- package/dist/prompts/sandbox/scientific-executor.js +0 -12
- package/dist/prompts/sandbox/single-cell-agent.d.ts +1 -1
- package/dist/prompts/sandbox/single-cell-agent.js +3 -1
- package/dist/prompts/sandbox/spatial-omics-agent.d.ts +1 -1
- package/dist/prompts/sandbox/spatial-omics-agent.js +3 -1
- package/dist/prompts/sandbox/statistical-modeling-agent.d.ts +1 -1
- package/dist/prompts/sandbox/statistical-modeling-agent.js +4 -4
- package/dist/prompts/sandbox/translational-safety-agent.d.ts +1 -1
- package/dist/prompts/sandbox/translational-safety-agent.js +10 -29
- package/dist/prompts/sandbox-standards.d.ts +15 -18
- package/dist/prompts/sandbox-standards.js +96 -305
- package/dist/prompts/synthesis-agent.d.ts +1 -1
- package/dist/prompts/synthesis-agent.js +24 -90
- package/dist/provenance/collector.d.ts +2 -5
- package/dist/provenance/collector.js +2 -5
- package/dist/provenance/seam.d.ts +36 -1
- package/dist/provenance/workspace-reads.d.ts +36 -0
- package/dist/provenance/workspace-reads.js +33 -0
- package/dist/report-model/production-resolver.d.ts +22 -52
- package/dist/report-model/production-resolver.js +43 -112
- package/dist/report-render/chart-export.d.ts +9 -0
- package/dist/report-render/chart-export.js +1 -1
- package/dist/report-render/views/chart-view.d.ts +1 -0
- package/dist/report-render/views/chart-view.js +1 -0
- package/dist/report-render/views/lineage.d.ts +1 -0
- package/dist/report-render/views/page-view.d.ts +1 -0
- package/dist/report-render/views/page-view.js +1 -0
- package/dist/report-render/views/prose.d.ts +1 -0
- package/dist/report-render/views/prose.js +1 -0
- package/dist/report-render/views/references-view.d.ts +1 -0
- package/dist/report-render/views/values.d.ts +1 -0
- package/dist/report-render/views/values.js +1 -0
- package/dist/runtime/assemble.d.ts +20 -9
- package/dist/runtime/assemble.js +47 -38
- package/dist/sandbox/client.d.ts +5 -3
- package/dist/sandbox/create-sandbox.d.ts +7 -12
- package/dist/sandbox/create-sandbox.js +15 -37
- package/dist/sandbox/docker-client.d.ts +1 -3
- package/dist/sandbox/docker-client.js +3 -5
- package/dist/sandbox/exec-outcome.d.ts +11 -11
- package/dist/sandbox/exec-outcome.js +22 -22
- package/dist/sandbox/exec.d.ts +5 -0
- package/dist/sandbox/exec.js +9 -1
- package/dist/sandbox/identity.d.ts +4 -16
- package/dist/sandbox/identity.js +5 -21
- package/dist/sandbox/k8s-client.d.ts +0 -1
- package/dist/sandbox/k8s-client.js +8 -13
- package/dist/sandbox/lease-names.d.ts +6 -0
- package/dist/sandbox/lease-names.js +12 -0
- package/dist/sandbox/leases.d.ts +105 -0
- package/dist/sandbox/leases.js +519 -0
- package/dist/sandbox/liveness.d.ts +5 -2
- package/dist/sandbox/mount-plan.d.ts +10 -1
- package/dist/sandbox/mount-plan.js +7 -1
- package/dist/sandbox/reconciler.d.ts +58 -0
- package/dist/sandbox/reconciler.js +153 -0
- package/dist/sandbox/sandbox-step-translate.d.ts +1 -1
- package/dist/sandbox/types.d.ts +25 -18
- package/dist/sandbox/types.js +10 -4
- package/dist/schemas/plan-schemas.d.ts +0 -21
- package/dist/schemas/plan-schemas.js +2 -3
- package/dist/schemas/run-synthesis.d.ts +2 -2
- package/dist/schemas/run-synthesis.js +3 -3
- package/dist/schemas/validate-plan.d.ts +1 -14
- package/dist/schemas/validate-plan.js +5 -24
- package/dist/schemas/workflow-state.d.ts +3 -32
- package/dist/schemas/workflow-state.js +4 -13
- package/dist/state/index.d.ts +0 -1
- package/dist/state/index.js +0 -1
- package/dist/state/migrations/20261030120000_lease_the_sandboxes.d.ts +7 -0
- package/dist/state/migrations/20261030120000_lease_the_sandboxes.js +34 -0
- package/dist/state/migrations/20261030120100_drop_the_report_derivations.d.ts +5 -0
- package/dist/state/migrations/20261030120100_drop_the_report_derivations.js +9 -0
- package/dist/state/migrations/20261030120200_keep_the_report_derivations.d.ts +6 -0
- package/dist/state/migrations/20261030120200_keep_the_report_derivations.js +10 -0
- package/dist/state/migrations/index.js +6 -0
- package/dist/state/purge-analysis.d.ts +4 -2
- package/dist/state/purge-analysis.js +22 -11
- package/dist/state/sandbox-leases.d.ts +81 -0
- package/dist/state/sandbox-leases.js +247 -0
- package/dist/state/schema.d.ts +1 -13
- package/dist/state/schema.js +1 -6
- package/dist/state/step-executions.d.ts +0 -2
- package/dist/state/step-executions.js +1 -4
- package/dist/tasks/data-profile.d.ts +40 -2
- package/dist/tasks/data-profile.js +89 -87
- package/dist/tools/ad-hoc-router.d.ts +1 -6
- package/dist/tools/ad-hoc-router.js +5 -48
- package/dist/tools/bio/comptox.js +1 -1
- package/dist/tools/bio/drug-gene-interactions.js +1 -1
- package/dist/tools/bio/gene-disease-evidence.js +1 -1
- package/dist/tools/bio/keys.d.ts +1 -1
- package/dist/tools/bio/pubmed.js +31 -0
- package/dist/tools/bio/search-gene.js +17 -0
- package/dist/tools/bio/search-geo-datasets.js +13 -0
- package/dist/tools/bio/search-regulatory-approvals.js +1 -1
- package/dist/tools/define-tool.d.ts +25 -2
- package/dist/tools/define-tool.js +11 -1
- package/dist/tools/display/show-user.js +1 -1
- package/dist/tools/execute-analysis.d.ts +1 -3
- package/dist/tools/execute-analysis.js +5 -33
- package/dist/tools/report-session/index.d.ts +0 -1
- package/dist/tools/report-session/index.js +0 -1
- package/dist/tools/report-session/script-tools.d.ts +30 -0
- package/dist/tools/report-session/script-tools.js +177 -0
- package/dist/tools/research/generate-analogy-report.d.ts +16 -9
- package/dist/tools/research/generate-analogy-report.js +111 -49
- package/dist/tools/research/generate-plan.d.ts +102 -7
- package/dist/tools/research/generate-plan.js +74 -43
- package/dist/tools/research/index.d.ts +4 -3
- package/dist/tools/research/index.js +4 -3
- package/dist/tools/research/inspect-run.d.ts +0 -1
- package/dist/tools/research/inspect-run.js +5 -10
- package/dist/tools/research/search-arxiv.js +15 -0
- package/dist/tools/research/search-github-repos.js +13 -0
- package/dist/tools/research/search-semantic-scholar.js +16 -0
- package/dist/tools/research/synthesis-lookups.d.ts +21 -0
- package/dist/tools/research/synthesis-lookups.js +29 -0
- package/dist/tools/research/web-fetch.d.ts +1 -1
- package/dist/tools/research/web-fetch.js +14 -12
- package/dist/tools/sandbox/lease-tools.d.ts +32 -0
- package/dist/tools/sandbox/lease-tools.js +60 -0
- package/dist/tools/script/codemode-worker.d.ts +5 -0
- package/dist/tools/script/codemode-worker.js +5 -0
- package/dist/tools/script/file-tools.d.ts +100 -0
- package/dist/tools/script/file-tools.js +377 -0
- package/dist/tools/script/metrics.d.ts +21 -0
- package/dist/tools/script/metrics.js +38 -0
- package/dist/tools/script/quickjs.wasm +0 -0
- package/dist/tools/script/run-script.d.ts +76 -0
- package/dist/tools/script/run-script.js +326 -0
- package/dist/tools/script/scratch.d.ts +81 -0
- package/dist/tools/script/scratch.js +142 -0
- package/dist/tools/start-report-session.js +1 -1
- package/dist/tools/workspace/edit-file.js +2 -1
- package/dist/tools/workspace/execute-command.d.ts +9 -13
- package/dist/tools/workspace/execute-command.js +23 -19
- package/dist/tools/workspace/file-stat.js +1 -1
- package/dist/tools/workspace/grep.d.ts +5 -2
- package/dist/tools/workspace/grep.js +19 -10
- package/dist/tools/workspace/mutator.d.ts +48 -4
- package/dist/tools/workspace/mutator.js +43 -12
- package/dist/tools/workspace/read-file.d.ts +5 -2
- package/dist/tools/workspace/read-file.js +15 -11
- package/dist/tools/workspace/read-surface.d.ts +17 -0
- package/dist/tools/workspace/read-surface.js +17 -0
- package/dist/tools/workspace/result-bounds.d.ts +1 -0
- package/dist/tools/workspace/run-exec.d.ts +12 -13
- package/dist/tools/workspace/run-exec.js +13 -13
- package/dist/tools/workspace/scan-inputs.d.ts +2 -5
- package/dist/tools/workspace/scan-inputs.js +2 -3
- package/dist/tools/workspace/show-file.js +1 -1
- package/dist/tools/workspace/show-plan.js +4 -3
- package/dist/tools/workspace/write-file.js +2 -1
- package/dist/workflows/execute-analysis-scheduler.d.ts +0 -26
- package/dist/workflows/execute-analysis-scheduler.js +12 -32
- package/dist/workflows/execute-analysis.d.ts +17 -35
- package/dist/workflows/execute-analysis.js +13 -107
- package/dist/workflows/pause.js +29 -22
- package/dist/workflows/sandbox-step.d.ts +16 -31
- package/dist/workflows/sandbox-step.js +36 -71
- package/dist/workflows/test/sql-writes.d.ts +0 -1
- package/dist/workflows/test/sql-writes.js +0 -2
- package/dist/workspace/filesystem.d.ts +2 -2
- package/dist/workspace/filesystem.js +10 -5
- package/dist/workspace/paths.d.ts +19 -1
- package/dist/workspace/paths.js +22 -1
- package/package.json +6 -3
- package/dist/prompts/literature-reviewer.d.ts +0 -1
- package/dist/prompts/literature-reviewer.js +0 -114
- package/dist/sandbox/reaper.d.ts +0 -81
- package/dist/sandbox/reaper.js +0 -161
- package/dist/sandbox/step-sandbox.d.ts +0 -47
- package/dist/sandbox/step-sandbox.js +0 -53
- package/dist/state/active-sandboxes.d.ts +0 -33
- package/dist/state/active-sandboxes.js +0 -65
- package/dist/tasks/extract-values-script.d.ts +0 -64
- package/dist/tasks/extract-values-script.js +0 -180
- package/dist/tasks/extract-values.d.ts +0 -77
- package/dist/tasks/extract-values.js +0 -146
- package/dist/tools/report-session/derive-table.d.ts +0 -164
- package/dist/tools/report-session/derive-table.js +0 -419
- package/dist/tools/research/literature-reviewer.d.ts +0 -42
- package/dist/tools/research/literature-reviewer.js +0 -107
- package/dist/workflows/metrics.d.ts +0 -23
- package/dist/workflows/metrics.js +0 -38
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sandbox module (sandbox-leases spec). A consumer holds a sandbox through a lease, and the module owns each
|
|
3
|
+
* machine: the admission, the spawn, the replacement, the idle removal, and the teardown.
|
|
4
|
+
*
|
|
5
|
+
* Each generation of a lease has one lease workflow. A consumer enqueues it through a `DBOSClient`, thus the lease
|
|
6
|
+
* workflow is not a child of the consumer: a cancel, a pause, or a crash of the consumer does not stop it, and its
|
|
7
|
+
* teardown step can always run. The consumer waits for the events of the lease workflow with the same client,
|
|
8
|
+
* because DBOS refuses `recv` and `getEvent` inside a step, and each tool call is a step.
|
|
9
|
+
*
|
|
10
|
+
* A machine of a lease is replaceable. The state of a lease is its files, the exec records in its records
|
|
11
|
+
* directory, and the checkpoints of its consumer. Thus the module removes the machine of an idle lease, and the
|
|
12
|
+
* next exec gets a new one.
|
|
13
|
+
*/
|
|
14
|
+
import { rm } from "node:fs/promises";
|
|
15
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
16
|
+
import { DBOS, Error as DBOSErrors, StatusString } from "@dbos-inc/dbos-sdk";
|
|
17
|
+
import { err, ok } from "neverthrow";
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
import { clampResources } from "../config/resource-limits.js";
|
|
20
|
+
import { createNoopLogger } from "../lib/console-logger.js";
|
|
21
|
+
import { recordLeaseWait, recordMachineReplaced, recordMachineStart, trackLeaseWorkflow } from "../lib/metrics.js";
|
|
22
|
+
import { unwrapOrThrow } from "../lib/result.js";
|
|
23
|
+
import { errorMessageOf } from "../lib/thrown.js";
|
|
24
|
+
import { textOf } from "../lib/unknown-text.js";
|
|
25
|
+
import { queryPausesOfWork } from "../state/paused-work.js";
|
|
26
|
+
import { activeLease, clearIdleMachine, leasesOwnedBy, clearMachine, latestGeneration, oldestWaiting, readLease, releaseLease, requestLease, setMachine, touchLease, tryGrant, } from "../state/sandbox-leases.js";
|
|
27
|
+
import { pauseAtGate, workPause } from "../workflows/pause.js";
|
|
28
|
+
import { tailWritePrefix } from "../workspace/paths.js";
|
|
29
|
+
import { noteExecOutcome, summarizeExec } from "./exec-outcome.js";
|
|
30
|
+
import { saltedStepExecId } from "./exec.js";
|
|
31
|
+
import { keyedSandboxIdentity } from "./identity.js";
|
|
32
|
+
import { leaseRecordTail, leaseWorkflowIdOf } from "./lease-names.js";
|
|
33
|
+
import { registerSandboxReconciler } from "./reconciler.js";
|
|
34
|
+
import { SandboxFailure } from "./sandbox-error.js";
|
|
35
|
+
export const LEASE_WORKFLOW = "sandbox-lease";
|
|
36
|
+
export const LEASE_QUEUE = "sandbox-leases";
|
|
37
|
+
const LEASE_TOPIC = "lease";
|
|
38
|
+
const GRANTED_EVENT = "granted";
|
|
39
|
+
/** The tick of a lease workflow: each tick reads the owner and tests the lease for idleness. */
|
|
40
|
+
const DEFAULT_TICK_SECONDS = 60;
|
|
41
|
+
/** One slice of a wait of a consumer. Between two slices the wait reads the cancel state and mirrors a pause. */
|
|
42
|
+
const WAIT_SLICE_SECONDS = 10;
|
|
43
|
+
/** The poll of a wait for an event. A client outside the engine reads the event table, and no notification wakes it. */
|
|
44
|
+
const EVENT_POLL_MS = 500;
|
|
45
|
+
export const DEFAULT_IDLE_PERIOD_MS = 15 * 60_000;
|
|
46
|
+
/** The timeout of a command that gives none. A step has no deadline of its own, thus each command has one. */
|
|
47
|
+
export const DEFAULT_COMMAND_TIMEOUT_SECONDS = 3600;
|
|
48
|
+
/** The time that the poll of an exec waits past the timeout of its command, for the last result. */
|
|
49
|
+
const POLL_GRACE_MS = 120_000;
|
|
50
|
+
const HEALTH_TIMEOUT_MS = 10_000;
|
|
51
|
+
/**
|
|
52
|
+
* The health answer of sandbox-server. An image that predates the count answers with no count, and the module then
|
|
53
|
+
* reads the machine as busy: an idle removal on a guess could stop a command that runs.
|
|
54
|
+
*/
|
|
55
|
+
const HealthSchema = z.object({ runningExecs: z.number().int().nonnegative().optional() });
|
|
56
|
+
const TERMINAL_STATUSES = new Set([StatusString.SUCCESS, StatusString.ERROR, StatusString.CANCELLED, StatusString.MAX_RECOVERY_ATTEMPTS_EXCEEDED]);
|
|
57
|
+
function machineEventKey(generation) {
|
|
58
|
+
return `machine:${generation}`;
|
|
59
|
+
}
|
|
60
|
+
function sameSize(a, b) {
|
|
61
|
+
return a.cpu === b.cpu && a.memoryGb === b.memoryGb && (a.gpu?.count ?? 0) === (b.gpu?.count ?? 0);
|
|
62
|
+
}
|
|
63
|
+
/** Run `fn` as a step when the caller is a workflow body, and inline when the caller is a step or no workflow. */
|
|
64
|
+
function durably(name, fn) {
|
|
65
|
+
return DBOS.isInWorkflow() ? DBOS.runStep(fn, { name }) : fn();
|
|
66
|
+
}
|
|
67
|
+
/** True once the workflow of the caller is canceled. Outside a workflow, nothing can cancel the caller. */
|
|
68
|
+
async function callerCanceled() {
|
|
69
|
+
const workflowId = DBOS.workflowID;
|
|
70
|
+
if (workflowId === undefined)
|
|
71
|
+
return false;
|
|
72
|
+
const status = await DBOS.getWorkflowStatus(workflowId);
|
|
73
|
+
return status?.status === StatusString.CANCELLED;
|
|
74
|
+
}
|
|
75
|
+
export function createSandboxLeases(config) {
|
|
76
|
+
const logger = (config.logger ?? createNoopLogger()).named("sandbox-leases");
|
|
77
|
+
const { pool, client } = config;
|
|
78
|
+
const idlePeriodMs = config.idlePeriodMs ?? DEFAULT_IDLE_PERIOD_MS;
|
|
79
|
+
const fetchImpl = config.fetch ?? fetch;
|
|
80
|
+
const engine = config.dbosClient;
|
|
81
|
+
const tickSeconds = config.tickSeconds ?? DEFAULT_TICK_SECONDS;
|
|
82
|
+
let queueRegistered;
|
|
83
|
+
const registerQueue = (dbos) => {
|
|
84
|
+
queueRegistered ??= dbos.registerQueue(LEASE_QUEUE, { onConflict: "never_update" }).catch((cause) => {
|
|
85
|
+
queueRegistered = undefined;
|
|
86
|
+
throw cause;
|
|
87
|
+
});
|
|
88
|
+
return queueRegistered;
|
|
89
|
+
};
|
|
90
|
+
registerSandboxReconciler({
|
|
91
|
+
pool,
|
|
92
|
+
sandboxClient: client,
|
|
93
|
+
resolveWorkspaceRoot: config.resolveWorkspaceRoot,
|
|
94
|
+
cancel: async (workflowId) => (await engine()).cancelWorkflow(workflowId),
|
|
95
|
+
logger: config.logger,
|
|
96
|
+
});
|
|
97
|
+
const removeRecords = async (analysisId, leaseKey) => {
|
|
98
|
+
const dir = tailWritePrefix({ workspaceRoot: await config.resolveWorkspaceRoot(analysisId), tail: leaseRecordTail(leaseKey) });
|
|
99
|
+
await rm(dir, { recursive: true, force: true });
|
|
100
|
+
};
|
|
101
|
+
const teardownQuietly = async (ref, cause) => {
|
|
102
|
+
try {
|
|
103
|
+
await client.teardown(ref);
|
|
104
|
+
}
|
|
105
|
+
catch (failure) {
|
|
106
|
+
// The reconciler removes a machine whose lease ended, thus a failed teardown leaks nothing for long.
|
|
107
|
+
logger.warn("the teardown of a machine failed", { sandboxId: ref.sandboxId, ...logger.errorFields(failure) });
|
|
108
|
+
}
|
|
109
|
+
recordMachineReplaced({ cause });
|
|
110
|
+
};
|
|
111
|
+
// ── The lease workflow ────────────────────────────────────────────
|
|
112
|
+
const runLease = async (input) => {
|
|
113
|
+
const log = logger.with({ leaseWorkflowId: input.leaseWorkflowId, ownerWorkflowId: input.ownerWorkflowId });
|
|
114
|
+
const id = input.leaseWorkflowId;
|
|
115
|
+
const pause = workPause({
|
|
116
|
+
pool,
|
|
117
|
+
workId: input.work.id,
|
|
118
|
+
workKind: input.work.kind,
|
|
119
|
+
analysisId: input.analysisId,
|
|
120
|
+
// A host reads the streams of the work, not of the lease workflow. The wait of the consumer mirrors the pause.
|
|
121
|
+
emit: () => Promise.resolve(),
|
|
122
|
+
retryDelaysMs: config.pauseRetryDelaysMs,
|
|
123
|
+
run: { session: input.session, authorizer: config.runAuthorizer },
|
|
124
|
+
logger: log,
|
|
125
|
+
});
|
|
126
|
+
const ownerEnded = () => DBOS.runStep(async () => {
|
|
127
|
+
const status = await DBOS.getWorkflowStatus(input.ownerWorkflowId);
|
|
128
|
+
return status === null || TERMINAL_STATUSES.has(status.status);
|
|
129
|
+
}, { name: "lease.owner" });
|
|
130
|
+
const wakeNext = async () => {
|
|
131
|
+
const next = await DBOS.runStep(async () => unwrapOrThrow(await oldestWaiting(pool)), { name: "lease.next" });
|
|
132
|
+
if (next !== undefined && next !== id)
|
|
133
|
+
await DBOS.send(next, { kind: "capacity" }, LEASE_TOPIC);
|
|
134
|
+
};
|
|
135
|
+
const end = async (reason) => {
|
|
136
|
+
await DBOS.runStep(async () => {
|
|
137
|
+
const released = unwrapOrThrow(await releaseLease(pool, id, reason));
|
|
138
|
+
if (released?.machine !== undefined)
|
|
139
|
+
await teardownQuietly(released.machine, "release");
|
|
140
|
+
await removeRecords(input.analysisId, input.leaseKey);
|
|
141
|
+
}, { name: "lease.release" });
|
|
142
|
+
log.info("lease released", { reason });
|
|
143
|
+
await wakeNext();
|
|
144
|
+
};
|
|
145
|
+
/** `grantedAtMs`: the time of the grant, given only for the first machine, whose start ends the wait of the request. */
|
|
146
|
+
const spawn = (machineGeneration, grantedAtMs) => DBOS.runStep(async () => {
|
|
147
|
+
const identity = keyedSandboxIdentity(input.session.runFrame.runId, `${id}#${machineGeneration}`);
|
|
148
|
+
const startedAt = Date.now();
|
|
149
|
+
const created = await client.createSandbox(input.session, {
|
|
150
|
+
ownerWorkflowId: id,
|
|
151
|
+
recordTail: leaseRecordTail(input.leaseKey),
|
|
152
|
+
resources: input.size,
|
|
153
|
+
image: input.spawn.image,
|
|
154
|
+
extraEnv: input.spawn.extraEnv === undefined ? undefined : { ...input.spawn.extraEnv },
|
|
155
|
+
readOnly: input.spawn.readOnly,
|
|
156
|
+
writableTail: input.spawn.writableTail,
|
|
157
|
+
}, identity);
|
|
158
|
+
if (created.isErr()) {
|
|
159
|
+
const failure = created.error;
|
|
160
|
+
if (failure.type === "labels_refused") {
|
|
161
|
+
return failure.suspend ? { kind: "suspended", reason: failure.reason } : { kind: "refused", reason: failure.reason };
|
|
162
|
+
}
|
|
163
|
+
return { kind: "failed", detail: new SandboxFailure(failure).message };
|
|
164
|
+
}
|
|
165
|
+
unwrapOrThrow(await setMachine(pool, id, created.value, machineGeneration));
|
|
166
|
+
recordMachineStart({ backend: created.value.backend, startMs: Date.now() - startedAt });
|
|
167
|
+
if (grantedAtMs !== undefined)
|
|
168
|
+
recordLeaseWait({ backend: created.value.backend, waitMs: grantedAtMs - input.requestedAtMs });
|
|
169
|
+
return { kind: "ready", ref: created.value };
|
|
170
|
+
}, { name: "lease.spawn" });
|
|
171
|
+
/** Start a machine. A gate refusal with the suspend flag waits in a pause under the work of the consumer. */
|
|
172
|
+
const startMachine = async (machineGeneration, grantedAtMs) => {
|
|
173
|
+
for (;;) {
|
|
174
|
+
const outcome = await spawn(machineGeneration, grantedAtMs);
|
|
175
|
+
if (outcome.kind === "suspended") {
|
|
176
|
+
await pauseAtGate(pause, outcome.reason);
|
|
177
|
+
if (await ownerEnded())
|
|
178
|
+
return { kind: "refused", reason: "owner-ended" };
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const event = outcome.kind === "ready" ? outcome : { kind: "failed", detail: outcome.kind === "refused" ? outcome.reason : outcome.detail };
|
|
182
|
+
await DBOS.setEvent(machineEventKey(machineGeneration), event);
|
|
183
|
+
return outcome;
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
// 1. The grant.
|
|
187
|
+
let size;
|
|
188
|
+
let grantedAtMs = 0;
|
|
189
|
+
while (size === undefined) {
|
|
190
|
+
const grant = await DBOS.runStep(async () => ({ outcome: unwrapOrThrow(await tryGrant(pool, id, config.resourcePolicy?.budget)), atMs: Date.now() }), { name: "lease.grant" });
|
|
191
|
+
if (grant.outcome.kind === "granted") {
|
|
192
|
+
size = grant.outcome.size;
|
|
193
|
+
grantedAtMs = grant.atMs;
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
if (grant.outcome.kind === "released") {
|
|
197
|
+
await DBOS.setEvent(GRANTED_EVENT, { kind: "refused", reason: "released" });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const message = await DBOS.recv(LEASE_TOPIC, tickSeconds);
|
|
201
|
+
if (message?.kind === "release" || (await ownerEnded())) {
|
|
202
|
+
await DBOS.setEvent(GRANTED_EVENT, { kind: "refused", reason: "released" });
|
|
203
|
+
await end(message?.kind === "release" ? message.reason : "owner-ended");
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// A free capacity can admit the next request too.
|
|
208
|
+
await wakeNext();
|
|
209
|
+
// 2. The first machine.
|
|
210
|
+
let machineGeneration = 1;
|
|
211
|
+
const first = await startMachine(machineGeneration, grantedAtMs);
|
|
212
|
+
if (first.kind !== "ready") {
|
|
213
|
+
const outcome = first.kind === "failed"
|
|
214
|
+
? { kind: "failed", detail: first.detail }
|
|
215
|
+
: { kind: "refused", reason: first.kind === "refused" ? first.reason : "suspended" };
|
|
216
|
+
await DBOS.setEvent(GRANTED_EVENT, outcome);
|
|
217
|
+
await end(outcome.kind === "failed" ? "spawn-failed" : `refused:${outcome.reason}`);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
await DBOS.setEvent(GRANTED_EVENT, { kind: "granted", size });
|
|
221
|
+
// 3. The life of the lease.
|
|
222
|
+
let reason = "released";
|
|
223
|
+
for (;;) {
|
|
224
|
+
const message = await DBOS.recv(LEASE_TOPIC, tickSeconds);
|
|
225
|
+
if (message === null) {
|
|
226
|
+
const generation = machineGeneration;
|
|
227
|
+
const tick = await DBOS.runStep(() => tickOnce(id, generation, input.ownerWorkflowId), { name: "lease.tick" });
|
|
228
|
+
if (tick === "owner-ended") {
|
|
229
|
+
reason = "owner-ended";
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (message.kind === "release") {
|
|
235
|
+
reason = message.reason;
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
if (message.kind === "machine") {
|
|
239
|
+
// A request names the generation that its exec found empty. A later generation already answers it.
|
|
240
|
+
if (message.after < machineGeneration)
|
|
241
|
+
continue;
|
|
242
|
+
const next = await startMachine(machineGeneration + 1);
|
|
243
|
+
if (next.kind !== "ready") {
|
|
244
|
+
reason = next.kind === "failed" ? "spawn-failed" : `refused:${next.kind === "refused" ? next.reason : "suspended"}`;
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
machineGeneration += 1;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (message.kind === "machine-lost") {
|
|
251
|
+
// The exec that saw the death cleared the machine from the row. The teardown removes what remains.
|
|
252
|
+
await DBOS.runStep(() => teardownQuietly(message.machine, message.oomKilled ? "oom" : "dead"), { name: "lease.lost" });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
await end(reason);
|
|
256
|
+
};
|
|
257
|
+
const leaseWorkflow = DBOS.registerWorkflow(async (input) => {
|
|
258
|
+
const untrack = trackLeaseWorkflow();
|
|
259
|
+
try {
|
|
260
|
+
await runLease(input);
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
untrack();
|
|
264
|
+
}
|
|
265
|
+
}, { name: LEASE_WORKFLOW });
|
|
266
|
+
/** One tick of a granted lease: the end of the owner, a dead machine, and the idle removal. */
|
|
267
|
+
const tickOnce = async (id, machineGeneration, ownerWorkflowId) => {
|
|
268
|
+
const owner = await DBOS.getWorkflowStatus(ownerWorkflowId);
|
|
269
|
+
if (owner === null || TERMINAL_STATUSES.has(owner.status))
|
|
270
|
+
return "owner-ended";
|
|
271
|
+
const lease = unwrapOrThrow(await readLease(pool, id));
|
|
272
|
+
const machine = lease?.machine;
|
|
273
|
+
if (machine === undefined || lease?.machineGeneration !== machineGeneration)
|
|
274
|
+
return "none";
|
|
275
|
+
const running = await runningExecs(machine);
|
|
276
|
+
if (running === "unreachable") {
|
|
277
|
+
const liveness = await client.isAlive(machine).catch(() => undefined);
|
|
278
|
+
if (liveness?.alive !== false)
|
|
279
|
+
return "none";
|
|
280
|
+
const lost = unwrapOrThrow(await clearMachine(pool, id, machineGeneration));
|
|
281
|
+
if (lost !== undefined)
|
|
282
|
+
await teardownQuietly(lost, liveness.oomKilled ? "oom" : "dead");
|
|
283
|
+
return "dead-removed";
|
|
284
|
+
}
|
|
285
|
+
// A command that runs keeps its machine, also when no host polls it, for example during a restart.
|
|
286
|
+
if (running > 0) {
|
|
287
|
+
unwrapOrThrow(await touchLease(pool, id));
|
|
288
|
+
return "busy";
|
|
289
|
+
}
|
|
290
|
+
const idle = unwrapOrThrow(await clearIdleMachine(pool, id, machineGeneration, idlePeriodMs));
|
|
291
|
+
if (idle === undefined)
|
|
292
|
+
return "none";
|
|
293
|
+
await teardownQuietly(idle, "idle");
|
|
294
|
+
return "idle-removed";
|
|
295
|
+
};
|
|
296
|
+
const runningExecs = async (machine) => {
|
|
297
|
+
try {
|
|
298
|
+
const response = await fetchImpl(`http://${machine.host}:${machine.port}/health`, { signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS) });
|
|
299
|
+
if (!response.ok)
|
|
300
|
+
return "unreachable";
|
|
301
|
+
const health = HealthSchema.safeParse(await response.json());
|
|
302
|
+
return health.success ? (health.data.runningExecs ?? 1) : 1;
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
return "unreachable";
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
// ── The waits of a consumer ───────────────────────────────────────
|
|
309
|
+
/**
|
|
310
|
+
* Wait for an event of a lease workflow. Between two slices, the wait reads the cancel state of its caller and
|
|
311
|
+
* mirrors the pause of the lease workflow into the stream of the consumer. `ended`: the lease workflow ended with
|
|
312
|
+
* no event under the key.
|
|
313
|
+
*/
|
|
314
|
+
const waitForEvent = async (lease, key, mirror) => {
|
|
315
|
+
const dbos = await engine();
|
|
316
|
+
let mirrored;
|
|
317
|
+
for (;;) {
|
|
318
|
+
const value = await dbos.getEvent(lease.leaseWorkflowId, key, { timeoutSeconds: WAIT_SLICE_SECONDS, pollingIntervalMs: EVENT_POLL_MS });
|
|
319
|
+
if (value !== null) {
|
|
320
|
+
if (mirrored !== undefined && mirror !== undefined) {
|
|
321
|
+
await mirror({ type: "data-pause", id: lease.leaseWorkflowId, workId: lease.workId, workKind: lease.workKind, state: "running" });
|
|
322
|
+
}
|
|
323
|
+
return value;
|
|
324
|
+
}
|
|
325
|
+
if (await callerCanceled())
|
|
326
|
+
throw new DBOSErrors.DBOSWorkflowCancelledError(DBOS.workflowID ?? lease.leaseWorkflowId);
|
|
327
|
+
const status = await dbos.getWorkflow(lease.leaseWorkflowId);
|
|
328
|
+
if (status !== undefined && TERMINAL_STATUSES.has(status.status)) {
|
|
329
|
+
const last = await dbos.getEvent(lease.leaseWorkflowId, key, { timeoutSeconds: 0 });
|
|
330
|
+
return last ?? "ended";
|
|
331
|
+
}
|
|
332
|
+
if (mirror !== undefined)
|
|
333
|
+
mirrored = await mirrorPause(lease, mirror, mirrored);
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
const mirrorPause = async (lease, mirror, mirrored) => {
|
|
337
|
+
const pauses = unwrapOrThrow(await queryPausesOfWork(pool, lease.analysisId, lease.workId));
|
|
338
|
+
const pause = pauses.find((row) => row.workflowId === lease.leaseWorkflowId);
|
|
339
|
+
const state = pause === undefined ? undefined : `${pause.state}:${pause.attempt}`;
|
|
340
|
+
if (state === mirrored)
|
|
341
|
+
return mirrored;
|
|
342
|
+
if (pause === undefined) {
|
|
343
|
+
await mirror({ type: "data-pause", id: lease.leaseWorkflowId, workId: lease.workId, workKind: lease.workKind, state: "running" });
|
|
344
|
+
return undefined;
|
|
345
|
+
}
|
|
346
|
+
await mirror({
|
|
347
|
+
type: "data-pause",
|
|
348
|
+
id: lease.leaseWorkflowId,
|
|
349
|
+
workId: lease.workId,
|
|
350
|
+
workKind: lease.workKind,
|
|
351
|
+
state: pause.state,
|
|
352
|
+
cause: pause.cause,
|
|
353
|
+
attempt: pause.attempt,
|
|
354
|
+
retryAt: pause.retryAt,
|
|
355
|
+
});
|
|
356
|
+
return state;
|
|
357
|
+
};
|
|
358
|
+
const enqueue = async (binding, lease, size) => {
|
|
359
|
+
const dbos = await engine();
|
|
360
|
+
await registerQueue(dbos);
|
|
361
|
+
const input = {
|
|
362
|
+
leaseWorkflowId: lease.leaseWorkflowId,
|
|
363
|
+
leaseKey: lease.leaseKey,
|
|
364
|
+
analysisId: lease.analysisId,
|
|
365
|
+
ownerWorkflowId: lease.ownerWorkflowId,
|
|
366
|
+
work: binding.work,
|
|
367
|
+
session: binding.session,
|
|
368
|
+
spawn: binding.spawn ?? {},
|
|
369
|
+
size,
|
|
370
|
+
requestedAtMs: Date.now(),
|
|
371
|
+
};
|
|
372
|
+
// DBOS gives back the existing workflow for an id that it holds, thus a rerun starts nothing new.
|
|
373
|
+
await dbos.enqueue({ workflowName: leaseWorkflow.name, queueName: LEASE_QUEUE, workflowID: lease.leaseWorkflowId, appVersion: DBOS.applicationVersion }, input);
|
|
374
|
+
};
|
|
375
|
+
/** Tell the lease workflow to end. A lease workflow in a pause reads no message, thus this ends it here. */
|
|
376
|
+
const stopLease = async (lease) => {
|
|
377
|
+
const dbos = await engine();
|
|
378
|
+
const pauses = unwrapOrThrow(await queryPausesOfWork(pool, lease.analysisId, lease.workId));
|
|
379
|
+
if (pauses.some((row) => row.workflowId === lease.leaseWorkflowId)) {
|
|
380
|
+
// A lease workflow that waits in a pause holds no machine, and it reads no message of the lease until a
|
|
381
|
+
// resume. Thus the release ends it here.
|
|
382
|
+
await dbos.cancelWorkflow(lease.leaseWorkflowId);
|
|
383
|
+
unwrapOrThrow(await releaseLease(pool, lease.leaseWorkflowId, "released"));
|
|
384
|
+
await removeRecords(lease.analysisId, lease.leaseKey);
|
|
385
|
+
const next = unwrapOrThrow(await oldestWaiting(pool));
|
|
386
|
+
if (next !== undefined)
|
|
387
|
+
await dbos.send(next, { kind: "capacity" }, LEASE_TOPIC);
|
|
388
|
+
return "ended";
|
|
389
|
+
}
|
|
390
|
+
await dbos.send(lease.leaseWorkflowId, { kind: "release", reason: "released" }, LEASE_TOPIC, `${lease.leaseWorkflowId}:release`);
|
|
391
|
+
return "told";
|
|
392
|
+
};
|
|
393
|
+
const release = async (leaseKey) => {
|
|
394
|
+
const lease = unwrapOrThrow(await activeLease(pool, leaseKey));
|
|
395
|
+
if (lease === undefined)
|
|
396
|
+
return;
|
|
397
|
+
if ((await stopLease(lease)) === "ended")
|
|
398
|
+
return;
|
|
399
|
+
const dbos = await engine();
|
|
400
|
+
for (;;) {
|
|
401
|
+
const handle = await dbos.getWorkflow(lease.leaseWorkflowId);
|
|
402
|
+
if (handle === undefined || TERMINAL_STATUSES.has(handle.status))
|
|
403
|
+
return;
|
|
404
|
+
const row = unwrapOrThrow(await readLease(pool, lease.leaseWorkflowId));
|
|
405
|
+
if (row?.state === "released")
|
|
406
|
+
return;
|
|
407
|
+
if (await callerCanceled())
|
|
408
|
+
throw new DBOSErrors.DBOSWorkflowCancelledError(DBOS.workflowID ?? lease.leaseWorkflowId);
|
|
409
|
+
await sleep(1_000);
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
const acquire = async (binding, requested) => {
|
|
413
|
+
const size = clampResources(requested, client.resourceLimits);
|
|
414
|
+
const ownerWorkflowId = DBOS.workflowID;
|
|
415
|
+
if (ownerWorkflowId === undefined)
|
|
416
|
+
throw new Error("a sandbox lease is acquired inside a DBOS workflow, and none is active");
|
|
417
|
+
for (;;) {
|
|
418
|
+
let lease = unwrapOrThrow(await activeLease(pool, binding.leaseKey));
|
|
419
|
+
if (lease !== undefined && !sameSize(lease.requested, size)) {
|
|
420
|
+
await release(binding.leaseKey);
|
|
421
|
+
lease = undefined;
|
|
422
|
+
}
|
|
423
|
+
if (lease === undefined) {
|
|
424
|
+
const generation = unwrapOrThrow(await latestGeneration(pool, binding.leaseKey)) + 1;
|
|
425
|
+
unwrapOrThrow(await requestLease(pool, {
|
|
426
|
+
leaseWorkflowId: leaseWorkflowIdOf(binding.session.scope.analysisId, binding.leaseKey, generation),
|
|
427
|
+
leaseKey: binding.leaseKey,
|
|
428
|
+
generation,
|
|
429
|
+
analysisId: binding.session.scope.analysisId,
|
|
430
|
+
ownerWorkflowId,
|
|
431
|
+
workKind: binding.work.kind,
|
|
432
|
+
workId: binding.work.id,
|
|
433
|
+
requested: size,
|
|
434
|
+
}));
|
|
435
|
+
lease = unwrapOrThrow(await activeLease(pool, binding.leaseKey));
|
|
436
|
+
// A concurrent request of the same key won the insert. The loop reads its lease again.
|
|
437
|
+
if (lease === undefined || !sameSize(lease.requested, size))
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
await enqueue(binding, lease, size);
|
|
441
|
+
const outcome = await waitForEvent(lease, GRANTED_EVENT, binding.emit);
|
|
442
|
+
if (outcome !== "ended")
|
|
443
|
+
return outcome;
|
|
444
|
+
const row = unwrapOrThrow(await readLease(pool, lease.leaseWorkflowId));
|
|
445
|
+
return { kind: "failed", detail: `the lease ended before its grant (${row?.releaseReason ?? "no reason"})` };
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
/** Get a machine for an exec that found none: ask the lease workflow, and wait for the next machine generation. */
|
|
449
|
+
const requestMachine = async (lease, after, mirror) => {
|
|
450
|
+
const dbos = await engine();
|
|
451
|
+
const key = DBOS.workflowID === undefined ? undefined : `${DBOS.workflowID}:${String(DBOS.stepID)}:machine:${after}`;
|
|
452
|
+
await dbos.send(lease.leaseWorkflowId, { kind: "machine", after }, LEASE_TOPIC, key);
|
|
453
|
+
const event = await waitForEvent(lease, machineEventKey(after + 1), mirror);
|
|
454
|
+
if (event === "ended")
|
|
455
|
+
return err({ kind: "no_lease", detail: "the lease ended" });
|
|
456
|
+
if (event.kind === "failed")
|
|
457
|
+
return err({ kind: "no_lease", detail: `the sandbox did not start again: ${event.detail}` });
|
|
458
|
+
return ok(event.ref);
|
|
459
|
+
};
|
|
460
|
+
const exec = (leaseKey, request, emit, mirror) => durably("sandbox.exec", async () => {
|
|
461
|
+
const lease = unwrapOrThrow(await activeLease(pool, leaseKey));
|
|
462
|
+
if (lease?.state !== "granted")
|
|
463
|
+
return err({ kind: "no_lease" });
|
|
464
|
+
// The use is written before the submit, thus the idle removal cannot take the machine of this exec.
|
|
465
|
+
const touched = unwrapOrThrow(await touchLease(pool, lease.leaseWorkflowId));
|
|
466
|
+
if (touched === undefined)
|
|
467
|
+
return err({ kind: "no_lease" });
|
|
468
|
+
let machine = touched.machine;
|
|
469
|
+
let machineGeneration = touched.machineGeneration;
|
|
470
|
+
if (machine === undefined) {
|
|
471
|
+
const started = await requestMachine(lease, machineGeneration, mirror);
|
|
472
|
+
if (started.isErr())
|
|
473
|
+
return err(started.error);
|
|
474
|
+
machine = started.value;
|
|
475
|
+
machineGeneration += 1;
|
|
476
|
+
}
|
|
477
|
+
const timeoutSeconds = request.timeoutSeconds ?? DEFAULT_COMMAND_TIMEOUT_SECONDS;
|
|
478
|
+
const result = await client.exec(machine, { ...request, timeoutSeconds }, emit, Date.now() + timeoutSeconds * 1000 + POLL_GRACE_MS, saltedStepExecId(lease.execSalt));
|
|
479
|
+
noteExecOutcome(leaseKey, summarizeExec(result));
|
|
480
|
+
const failure = result.syntheticFailure;
|
|
481
|
+
if (failure === undefined)
|
|
482
|
+
return ok(result);
|
|
483
|
+
if (failure.reason === "sandbox-dead" || failure.reason === "sandbox-oom-killed") {
|
|
484
|
+
// The row forgets the dead machine before the exec returns, thus the next exec asks for a new one.
|
|
485
|
+
const lost = unwrapOrThrow(await clearMachine(pool, lease.leaseWorkflowId, machineGeneration));
|
|
486
|
+
if (lost !== undefined) {
|
|
487
|
+
const dbos = await engine();
|
|
488
|
+
await dbos.send(lease.leaseWorkflowId, { kind: "machine-lost", machine: lost, oomKilled: failure.reason === "sandbox-oom-killed" }, LEASE_TOPIC, `${result.execId}:lost`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return ok({ ...result, syntheticFailure: { ...failure, memoryLimitGb: lease.granted?.memoryGb } });
|
|
492
|
+
});
|
|
493
|
+
const leases = {
|
|
494
|
+
toolchainSource: client.toolchainSource,
|
|
495
|
+
acquire: (binding, size) => durably("sandbox.acquire", async () => {
|
|
496
|
+
try {
|
|
497
|
+
return await acquire(binding, size);
|
|
498
|
+
}
|
|
499
|
+
catch (failure) {
|
|
500
|
+
if (failure instanceof DBOSErrors.DBOSWorkflowCancelledError)
|
|
501
|
+
throw failure;
|
|
502
|
+
logger.error("an acquire failed", { leaseKey: binding.leaseKey, ...logger.errorFields(failure) });
|
|
503
|
+
return { kind: "failed", detail: errorMessageOf(failure) ?? textOf(failure) };
|
|
504
|
+
}
|
|
505
|
+
}),
|
|
506
|
+
release: (leaseKey) => durably("sandbox.release", () => release(leaseKey)),
|
|
507
|
+
releaseOwnedBy: async (ownerWorkflowIds) => {
|
|
508
|
+
for (const lease of unwrapOrThrow(await leasesOwnedBy(pool, ownerWorkflowIds)))
|
|
509
|
+
await stopLease(lease);
|
|
510
|
+
},
|
|
511
|
+
exec,
|
|
512
|
+
bind: (binding) => ({
|
|
513
|
+
acquire: (size) => leases.acquire(binding, size),
|
|
514
|
+
release: () => leases.release(binding.leaseKey),
|
|
515
|
+
exec: (request, emit) => exec(binding.leaseKey, request, emit, binding.emit),
|
|
516
|
+
}),
|
|
517
|
+
};
|
|
518
|
+
return leases;
|
|
519
|
+
}
|
|
@@ -44,8 +44,11 @@ export declare function createEscalationPolicy(threshold?: number): {
|
|
|
44
44
|
* caller resumes polling.
|
|
45
45
|
*/
|
|
46
46
|
export declare function probeLiveness(isAlive: (ref: SandboxRef) => Promise<SandboxLiveness>, ref: SandboxRef): Promise<ProbeVerdict>;
|
|
47
|
-
/**
|
|
48
|
-
|
|
47
|
+
/**
|
|
48
|
+
* The closed set of reasons a synthetic failure carries. `sandbox-replaced`: a new machine of the lease found the
|
|
49
|
+
* exec cut by the end of the earlier machine.
|
|
50
|
+
*/
|
|
51
|
+
export type SyntheticFailureReason = "sandbox-oom-killed" | "sandbox-dead" | "sandbox-replaced";
|
|
49
52
|
/**
|
|
50
53
|
* An OOM-killed machine gets a distinguishable reason so the step failure
|
|
51
54
|
* reads "exceeded its memory limit", not a mystery death.
|
|
@@ -36,6 +36,7 @@ export declare const STEP_SUBDIRS: readonly ["output", "scripts", "figures", "lo
|
|
|
36
36
|
* store the way the sandbox does configures nothing further.
|
|
37
37
|
*/
|
|
38
38
|
export declare const LIBS_CONTAINER_PATH = "/mnt/libs";
|
|
39
|
+
export declare const EXEC_RECORDS_CONTAINER_PATH = "/mnt/exec-records";
|
|
39
40
|
export declare const REFS_CONTAINER_PATH = "/mnt/refs";
|
|
40
41
|
/** Container path of the per-analysis read-write cache, when a farm resolution carries a cache location. */
|
|
41
42
|
export declare const FARM_CACHE_CONTAINER_PATH = "/mnt/libs/cache";
|
|
@@ -64,8 +65,10 @@ export type MountPlanCoords = {
|
|
|
64
65
|
* contradiction, and the builders refuse it.
|
|
65
66
|
*/
|
|
66
67
|
writableTail?: string;
|
|
68
|
+
/** The workspace-relative directory of the exec records of the lease, mounted read-write in each machine of the lease. */
|
|
69
|
+
recordTail: string;
|
|
67
70
|
};
|
|
68
|
-
export declare function mountCoordsOf(session: SpawnSession, spec: Pick<SandboxSpec, "readOnly" | "writableTail">): MountPlanCoords;
|
|
71
|
+
export declare function mountCoordsOf(session: SpawnSession, spec: Pick<SandboxSpec, "readOnly" | "writableTail" | "recordTail">): MountPlanCoords;
|
|
69
72
|
export type MountPlanStores = {
|
|
70
73
|
/** Lib store is mounted at `/mnt/libs`, with the farm nested inside it, and its env is emitted. */
|
|
71
74
|
libs: boolean;
|
|
@@ -101,6 +104,10 @@ export type MountPlan = {
|
|
|
101
104
|
cachePath?: string;
|
|
102
105
|
/** Container path of the ref store, present only when `refs`. */
|
|
103
106
|
refsPath?: string;
|
|
107
|
+
/** Container path of the exec records of the lease. */
|
|
108
|
+
recordsPath: string;
|
|
109
|
+
/** The workspace-relative source of the records mount. */
|
|
110
|
+
recordTail: string;
|
|
104
111
|
/** Pre-created subdirectories under the writable mount. Empty when read-only
|
|
105
112
|
* (nothing to pre-create) and when a declared tail replaces the step tail. */
|
|
106
113
|
stepSubdirs: readonly string[];
|
|
@@ -113,6 +120,8 @@ export type SessionSubPaths = {
|
|
|
113
120
|
readonly ro: string;
|
|
114
121
|
/** Read-write mount of the step's artifact dir, or of the declared tail; absent when read-only. */
|
|
115
122
|
readonly rw?: string;
|
|
123
|
+
/** Read-write mount of the exec records of the lease. */
|
|
124
|
+
readonly records: string;
|
|
116
125
|
};
|
|
117
126
|
/** The one writable mount of a sandbox: its workspace-relative tail, and what the host pre-makes under it. */
|
|
118
127
|
export type SandboxWriteTail = {
|
|
@@ -35,6 +35,7 @@ export const STEP_SUBDIRS = ["output", "scripts", "figures", "logs", "notebooks"
|
|
|
35
35
|
* store the way the sandbox does configures nothing further.
|
|
36
36
|
*/
|
|
37
37
|
export const LIBS_CONTAINER_PATH = "/mnt/libs";
|
|
38
|
+
export const EXEC_RECORDS_CONTAINER_PATH = "/mnt/exec-records";
|
|
38
39
|
export const REFS_CONTAINER_PATH = "/mnt/refs";
|
|
39
40
|
/** Container path of the per-analysis read-write cache, when a farm resolution carries a cache location. */
|
|
40
41
|
export const FARM_CACHE_CONTAINER_PATH = "/mnt/libs/cache";
|
|
@@ -54,6 +55,7 @@ export function mountCoordsOf(session, spec) {
|
|
|
54
55
|
stepId: session.runFrame.stepId,
|
|
55
56
|
readOnly: spec.readOnly,
|
|
56
57
|
writableTail: spec.writableTail,
|
|
58
|
+
recordTail: spec.recordTail,
|
|
57
59
|
};
|
|
58
60
|
}
|
|
59
61
|
/**
|
|
@@ -111,6 +113,7 @@ export function buildSessionSubPaths(coords, workspaceSubPath) {
|
|
|
111
113
|
return {
|
|
112
114
|
ro: workspaceSubPath,
|
|
113
115
|
rw: write === undefined ? undefined : `${workspaceSubPath}/${write.tail}`,
|
|
116
|
+
records: `${workspaceSubPath}/${assertSafeTail(coords.recordTail, "recordTail").join("/")}`,
|
|
114
117
|
};
|
|
115
118
|
}
|
|
116
119
|
/**
|
|
@@ -155,7 +158,8 @@ export function buildMountPlan(coords, stores) {
|
|
|
155
158
|
const write = sandboxWriteTail(coords);
|
|
156
159
|
const writableStepPath = write === undefined ? undefined : `${readonlyTreePath}/${write.tail}`;
|
|
157
160
|
const cache = stores.libs && stores.cache === true;
|
|
158
|
-
const
|
|
161
|
+
const recordTail = assertSafeTail(coords.recordTail, "recordTail").join("/");
|
|
162
|
+
const env = { PROVENANCE_WATCH_DIRS: readonlyTreePath, EXEC_RECORD_DIR: EXEC_RECORDS_CONTAINER_PATH };
|
|
159
163
|
if (stores.libs)
|
|
160
164
|
Object.assign(env, libStoreEnv(stores.toolchainSource, cache));
|
|
161
165
|
return {
|
|
@@ -166,6 +170,8 @@ export function buildMountPlan(coords, stores) {
|
|
|
166
170
|
farmPath: stores.libs ? farmContainerPath(stores.toolchainSource) : undefined,
|
|
167
171
|
cachePath: cache ? FARM_CACHE_CONTAINER_PATH : undefined,
|
|
168
172
|
refsPath: stores.refs ? REFS_CONTAINER_PATH : undefined,
|
|
173
|
+
recordsPath: EXEC_RECORDS_CONTAINER_PATH,
|
|
174
|
+
recordTail,
|
|
169
175
|
stepSubdirs: write?.subdirs ?? [],
|
|
170
176
|
env,
|
|
171
177
|
};
|