This document walks through the worker agent end-to-end, with a deep dive into the Docker runtime path.
- Source:
process.env, parsed viazod. - Key fields:
SCHEDULER_BASE_URL: Scheduler HTTP API base URL.CPU_LIMIT/MEMORY_LIMIT_MB: Hard caps on how much of the host the worker should advertise and use.HEARTBEAT_INTERVAL_MS: How often to send node heartbeats.MAX_CONCURRENCY: Upper bound on number of concurrent jobs.JOB_TIMEOUT_MS: Default per-job timeout if the job doesn’t specify one.- MinIO (
MINIO_ENDPOINT,MINIO_REGION,MINIO_ACCESS_KEY,MINIO_SECRET_KEY,MINIO_BUCKET): enables log upload.
If the env does not pass validation, the worker fails fast with a clear error.
- On first start, the worker generates an Ed25519 key pair and a random UUID (
nodeId) and persists them in:~/.computebay/identity.json(mode600).
- On subsequent starts, it loads the same identity.
getMachineId()returns this stable UUID and is used when registering with the Scheduler.
detectSystemSpecs()reads:totalCpu=os.cpus().length.totalMemoryMb= total RAM in MB.
computeAllocatable()clamps these byCPU_LIMITandMEMORY_LIMIT_MBif set.- Result:
allocatableCpu,allocatableMemoryMb.
- Result:
ResourceMonitorkeeps track of used vs allocatable CPU & memory across jobs.
- On startup,
src/main.tseither:- Loads bootstrap state from
~/.computebay/bootstrap.json(nodeId, RabbitMQ URL, queue, publish exchange), or - Calls
POST /nodes/registerwith:machineId(stable UUID), physical CPU/RAM,agentVersion.
- Loads bootstrap state from
- Registration returns:
nodeId(session/node ID for this registration),rabbitmq.url,rabbitmq.queue(template containing<nodeId>),rabbitmq.publishExchange.
- The worker expands the queue template with
nodeIdand persists this bootstrap state for next boot. - Registration uses exponential backoff with jitter; it retries indefinitely until success.
createConnection(rabbitmqUrl, publishExchange):- Connects via
amqplib.connect(url). - Attaches error and close listeners to log issues.
- Creates a channel and returns a
RabbitContext:connection,channel,publishExchange.
- Connects via
main.tsconstructs aWorkerPoolwith:nodeId(for logging),resourceMonitor(tracks used CPU/RAM across jobs),maxConcurrency(min of config and allocatable CPU),jobTimeoutMs,logUploadConfig(MinIO client config),onLogChunk(jobId, chunk)→ publishesjob.log.chunkevents,onJobResult(...)→ publishes finaljob.completed/job.failed/job.timeoutevents.
At this point the worker is registered, connected to RabbitMQ, and ready to consume jobs.
consume(channel, queue, prefetch, handler):- Sets
channel.prefetch(prefetch)=maxConcurrencyto limit in-flight jobs. - Calls
channel.consume(queue, onMessage, { noAck: false }). - For each message:
- Parses JSON payload.
- Calls the async
handler(payload). - On success:
channel.ack(msg). - On error:
- If error is
InvalidJobPayloadError→nackwithout requeue (drop bad messages). - Else →
nackwith requeue (transient failure).
- If error is
- Sets
-
Incoming payload type:
ScheduledJobMessage(extends Job ServiceCreateJobInputwithjobId). -
main.tsfirst does a cheap shape check:jobId,jobType,runtime,repoUrl,startCommand,resources,orgIdexistence & basic types.
-
Then it calls
toScheduledJob(msg), which:-
Extracts
cpuandmemoryMBfromresources, enforcing positive numbers. -
Requires
runtime(Docker image string). -
Requires
repoUrlandstartCommandstrings. -
Computes:
const scheduled: ScheduledJob = { jobId: msg.jobId, image: msg.runtime, startCommand: msg.startCommand, cpu, memoryMB, repoUrl: msg.repoUrl, branch: msg.branch ?? "main", networkEnabled: Boolean(msg.networkEnabled), };
-
-
Any validation failure throws
InvalidJobPayloadError, which the consumer treats as non-retriable (nack without requeue).
Before actually running:
WorkerPool.canAccept(job)enforces:- Not draining (
this.draining === false). activeCount < maxConcurrency.resourceMonitor.canAllocate(job.cpu, job.memoryMB).
- Not draining (
- If this fails, the consumer throws an error and nacks with requeue so the job can be picked up later or by another node.
If accepted, main.ts:
- Publishes
job.startedevent. - Calls
await workerPool.run(job).
WorkerPool is responsible for:
- Enforcing concurrency limits and capacity constraints.
- Running jobs via
runJob(the Docker/Git pipeline). - Tracking in-progress jobs to support future cancellation.
- Emitting job result metadata through
onJobResult.
Key fields:
activeCount: number of running jobs.draining: whentrue,canAcceptalways returns false.cancelControllers: Map<jobId, AbortController>: allows external cancellation per job.
-
Admission re-check:
- Re-validates
canAccept(job)(defensive; should match earlier check).
- Re-validates
-
Resource accounting:
activeCount++.resourceMonitor.allocate(job.cpu, job.memoryMB).
-
Abort controller:
- Creates
AbortControllerand stores incancelControllers[jobId]. - Its signal is passed to
runJoband used to kill Docker containers on cancel.
- Creates
-
Invoke
runJob:const result = await runJob(job, { timeoutMs: this.jobTimeoutMs, logUploadConfig: this.logUploadConfig, onLogChunk: this.onLogChunk ? (chunk) => this.onLogChunk!(job.jobId, chunk) : undefined, abortSignal: abortController.signal, });
-
Result handling:
- On success, calls
onJobResultwith:jobId,exitCode,timedOut,output,logObjectKey.
- On error, calls
onJobResultwithexitCode: -1and anErrorinstance.
- On success, calls
-
Cleanup (always):
- Deletes the job’s
AbortController. activeCount--.resourceMonitor.release(job.cpu, job.memoryMB).
- Deletes the job’s
cancelJob(jobId: string):- Looks up the job’s
AbortControllerand callsabort(), which triggers the cancel path insiderunJob(see below). - Currently there’s no active cancel consumer wired from RabbitMQ (the code exists but is not wired in
main.ts), but the mechanism is ready.
- Looks up the job’s
This is where the Git-based job is turned into a real Docker container run.
High-level steps per job:
- Create a unique temp workspace under
/tmp. - Git clone the requested repo + branch into a
reposubdirectory. - Launch a Docker container with:
- Read-only root filesystem.
- Tmpfs-backed
/tmp&/var/tmp. - CPU, memory, DNS, user, and security constraints.
- The cloned repo bind-mounted at
/workspace.
- Stream logs to both disk and RabbitMQ.
- Enforce timeout / cancellation.
- Gather full logs from Docker.
- Upload the log file to MinIO.
- Tear down the container and delete the workspace directory.
const containerId = `computebay-${job.jobId}`;
const currentUid = process.getuid?.() ?? 1000;
const currentGid = process.getgid?.() ?? 1000;
const runAsUser = `${currentUid}:${currentGid}`;
const workDirPath = await mkdtemp(path.join(os.tmpdir(), `computebay-${job.jobId}-`));
const logFilePath = path.join(workDirPath, "full.log");
await writeFile(logFilePath, "");- Each job gets a unique directory like
/tmp/computebay-<jobId>-XXXXXX. - The container is named
computebay-<jobId>. runAsUseris set to match the host user running the worker so that:- Git clones owned by the host user are readable/writable by the container when bind-mounted.
const repoPath = await gitClone(job.repoUrl, job.branch, workDirPath);gitClone(repoUrl, branch, workingDir):-
Ensures
workingDirexists. -
Clones into
<workingDir>/repousing:
-
git clone --depth 1 --branch /repo ```
- Captures
stderrand rejects with a detailed error ifgit clonefails. - Returns
repoPath, the absolute path to the cloned repo.
const mounts: ContainerMount[] = [
{ hostPath: repoPath, containerPath: CONTAINER_WORKSPACE },
];
const command = ["sh", "-c", job.startCommand];
const env = {
HOME: CONTAINER_WORKSPACE,
TMPDIR: "/tmp",
TEMP: "/tmp",
TMP: "/tmp",
PIP_NO_CACHE_DIR: "1",
npm_config_cache: "/tmp/.npm",
...(job.env ?? {}),
};- The cloned repo is bind-mounted into the container at
/workspace. - The working directory inside the container is set to
/workspace(see below). startCommandis run viash -cinside/workspace, so job authors can specify:python scripts/foo.pyornpm install && npm test, etc.
- Environment tweaks:
HOME=/workspace→ many tools write configs/caches under$HOME.TMPDIR,TEMP,TMP→ point to/tmp, which is a tmpfs.PIP_NO_CACHE_DIR=1→ disable pip caches.npm_config_cache=/tmp/.npm→ keep npm cache ephemeral, in tmpfs.
createAndStart(options) builds a plain Docker CLI invocation and spawns it with child_process.spawn.
Effective command:
docker run -d \
--name <containerId> \
--cpus <job.cpu> \
--memory <job.memoryMB>m \
--network bridge \
--dns 8.8.8.8 --dns 8.8.4.4 \
--pids-limit 64 \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=128m \
--tmpfs /var/tmp:rw,noexec,nosuid,size=64m \
--cap-drop ALL \
--security-opt no-new-privileges \
--user <uid:gid> \
-e KEY=VALUE ... \
--mount type=bind,source=<repoPath>,target=/workspace \
-w /workspace \
<job.image> \
sh -c "<startCommand>"--cpus <job.cpu>: fractional CPU limit (e.g.0.5,2).--memory <job.memoryMB>m: hard memory limit in MB.- Combined with
ResourceMonitor, this ensures the node never runs more work than advertised.
- Currently
--network bridgeis always used (DNS + outbound by default). - If you later want hard network isolation, you can wire
job.networkEnabledinto thenetworkEnabledflag and switch betweenbridgeandnone. - DNS is forced to Google’s resolvers for consistency:
8.8.8.8and8.8.4.4.
--read-only: container root filesystem is read-only.- Writes must go to bind mounts (
/workspace) or tmpfs (/tmp,/var/tmp).
- Writes must go to bind mounts (
--tmpfs /tmp&--tmpfs /var/tmp:- Ephemeral, in-memory file systems for temp data and caches.
noexec,nosuidreduce risk from scripts dropped there.
--cap-drop ALL: remove all Linux capabilities from the container.--security-opt no-new-privileges: prevents processes gaining extra privileges (e.g. via setuid binaries).--user <uid:gid>: drop root; process runs as the same UID/GID as the host user running the worker.
Overall this is a locked-down, non-root, read-only container with constrained resources and explicit write targets.
const stopLogStream = streamContainerLogs(containerId, {
onChunk: async (chunk) => {
await appendFile(logFilePath, chunk);
logBuffer += chunk;
flushChunk();
},
onEnd: () => flushChunk(),
onError: (err) => logger.warn({ err, jobId: job.jobId }, "Log stream error"),
});streamContainerLogsrunsdocker logs -f --tail 0 <containerId>.- For each chunk:
- Appends to
full.logon disk. - Buffers in-memory and, once the buffer exceeds 4 KB, flushes via
onLogChunk:- This surfaces to
WorkerPool, which callsonLogChunk(jobId, chunk). main.tsthen publishesjob.log.chunkevents per job.
- This surfaces to
- Appends to
- On natural completion (
docker logs -fexits) it flushes any remaining buffered text. - On error it logs but does not fail the job.
The worker races three things:
waitContainer(containerId)→docker wait, returns the container’s exit code.timeoutPromise→ afterjob.timeoutMsor globalJOB_TIMEOUT_MS, callsdocker killand rejects with"TIMEOUT".abortPromise→ if theAbortControlleris triggered, kills the container and rejects with"CANCELLED".
Result handling:
- Normal exit:
waitContainerwins, exit code is captured. - Timeout:
timeoutPromisewins:- Sets
timedOut = true. - Sets
exitCode = 137(conventional “killed by SIGKILL” style code). - Logs a warning.
- Sets
- Cancelled:
abortPromisewins:- Sets
exitCode = 137. - Logs an info message.
- Sets
In all cases, finally { stopLogStream(); } is called to tear down the streaming process.
After the container is done:
let output = "";
try {
output = await getContainerLogs(containerId);
await writeFile(logFilePath, output || "(no output)");
} catch (logErr) {
logger.warn({ jobId: job.jobId, err: logErr }, "Failed to fetch container logs");
}getContainerLogsrunsdocker logs --tail 10000and captures all logs (stdout + stderr) as a single string.- The worker overwrites
full.logwith this canonical output.- The earlier streaming writes were mostly for real-time log events.
- The final file therefore reflects exactly what Docker reports, not a partial tail from streaming.
await removeContainer(containerId).catch((err) => {
logger.warn({ err, containerId }, "Failed to remove container");
});removeContainerrunsdocker rm -f <containerId>.- Any error is logged but not fatal to the job result.
Then, in the finally block of runJob:
await rm(workDirPath, { recursive: true, force: true });- Deletes the entire workspace directory (the
mkdtemproot), including:- The cloned Git repo.
- Any
node_modules,venv, compiled artifacts, etc. - The
full.logfile (after it’s uploaded, if MinIO is configured).
This ensures no per-job disk usage persists on the host beyond execution.
If logUploadConfig is defined:
logObjectKey = await uploadLogFile(
logUploadConfig.s3Config,
job.jobId,
logFilePath
);- Reads the
full.logfile as a buffer. - Uploads to
logs/<jobId>/full.login the configured bucket. - The resulting
logObjectKeyis included in thejob.completedevent so the job service can link to it.
If upload fails, the job still completes; only a warning is logged.
Published via publishNodeEvent(event, payload) with routing key node.<nodeId>.events:
job.started– when a job is accepted andrun(job)begins.job.log.chunk– streaming log chunks during execution.job.completed– on successful exit, includes:exitCode,output(full logs fromdocker logs),- optional
logObjectKey(MinIO path).
job.failed– on internal errors or non-zero exit code.job.timeout– when the job exceeded its timeout and was killed.
At HEARTBEAT_INTERVAL_MS the worker:
- Reads current
availableCpu/availableMemoryMbfromResourceMonitor. - Posts
NodeHeartbeatRequestto Scheduler with these values. - Logs warnings on failures but keeps running.
This allows the Scheduler to make better placement decisions and detect unhealthy nodes.
When SIGTERM is received:
-
installSigTermHandlerinvokes the registered handler once. -
gracefulShutdownruns with:onSignal:- Sets the worker pool to draining (
startDrain()), so new jobs aren’t accepted. - Cancels the RabbitMQ consumer (
cancelConsumer), stopping new messages. - Clears the heartbeat interval.
- Sets the worker pool to draining (
drainWaiter:- Polls
workerPool.hasActiveJobs()until it returnsfalse(all jobs done).
- Polls
steps:- Final heartbeat send.
closeConnection(RabbitMQ connection close).
-
Once steps complete, the process exits with code
0. -
If anything in the shutdown pipeline throws, the process exits with code
1.
Because per-job cleanup is in runJob’s finally block, all cloned repos and containers are removed as part of normal job completion, even during shutdown.
End-to-end, the worker agent does the following for each job:
- Accepts a validated job from RabbitMQ, subject to resource and concurrency limits.
- Clones the requested Git repo + branch into a temp workspace.
- Spawns a locked-down Docker container named
computebay-<jobId>:- Limited CPU/memory, non-root user.
- Read-only root FS, tmpfs
/tmpand/var/tmp. - Repo bind-mounted at
/workspace; runssh -c <startCommand>.
- Streams logs in real time to both a local file and RabbitMQ
job.log.chunkevents. - Enforces timeout/cancel by racing
docker wait,docker killon timeout, and an abort signal. - Aggregates final logs from
docker logsinto a single canonicalfull.log. - Uploads logs to MinIO (if configured) under
logs/<jobId>/full.logand includes that key injob.completed. - Cleans up the container and deletes the temp workspace, ensuring no job-specific disk state persists.
This architecture keeps the runtime isolated, observable, and self-cleaning, while giving the job service a simple contract: "Git repo + branch + start command + resources" → fully managed container execution with logs and lifecycle events.