This document describes how jobs are dispatched to EC2 workers and how workers return results. Everything goes through S3 — no message queues needed.
Operator (laptop or server) EC2 worker
│ │
│ 1. write manifest.json to S3 │
│ ────────────────────────────────►│
│ │ 2. poll S3, find manifest
│ │ 3. download weights (if any)
│ │ 4. run train.py
│ │ 5. upload checkpoint + log
│ 6. read result.json from S3 │
│◄──────────────────────────────── │
│ │
│ (use checkpoint_s3_uri │
│ to get new weights) │
For federated runs, a sync_server.py process (running on any machine with S3 access) sits between workers and handles weight aggregation:
Worker 1 ──► upload ep1 checkpoint ──► S3
Worker 2 ──► upload ep1 checkpoint ──► S3
│
sync_server.py
(FedAvg → global_weights.pt)
│
Worker 1 ◄── download global_weights ───┘
Worker 2 ◄── download global_weights ───┘
(both continue with ep2 from shared averaged policy)
All objects live in one bucket (e.g. diya-simarena-jobs-664926621123-us-east-2-an).
jobs/
pending/<job_id>/manifest.json ← dispatcher writes here
in_progress/<job_id>/claimed_by ← worker writes while running (claim marker)
results/
<job_id>/result.json ← worker writes when done
<job_id>/checkpoint_final.pt ← trained weights (DQN)
<job_id>/checkpoint_final.json ← trained weights (greedy/random)
<job_id>/train.log ← full stdout from train.py
_federation/<group_id>/
from_worker/after_ep_XXXX/<worker_id>/checkpoint.pt ← per-worker upload
from_worker/after_ep_XXXX/<worker_id>/done.json
to_worker/before_ep_XXXX/global_weights.pt ← FedAvg output
Traces remain in the separate bucket: s3://diya-simarena-traces/.
Written by the dispatcher; read by the worker.
{
"job_id": "job_20260325_120000_a1b2c3",
"trace_s3_uri": "s3://diya-simarena-traces/demo/trace-mem-slight.msgpack",
"agent": "dqn",
"episodes": 2,
"steps": 3,
"duration": 40,
"namespace": "default",
"deploy": "web",
"target": 3,
"weights_s3_uri": null,
"timeout_seconds": 7200,
"per_episode_s3_sync": true,
"federation_group_id": "fedrun-20260407-1656",
"federation_size": 2,
"created_at": "2026-03-25T12:00:00Z"
}| Field | Description |
|---|---|
job_id |
Unique identifier; also used as the result prefix in S3 |
trace_s3_uri |
Full S3 path to the .msgpack trace |
agent |
dqn, greedy, or random |
episodes / steps / duration |
Passed directly to train.py |
weights_s3_uri |
S3 URI of a previous checkpoint to resume from — null = fresh start |
timeout_seconds |
Worker kills train.py if it runs longer than this (default 3600; use 7200+ for longer runs) |
per_episode_s3_sync |
When true, worker runs one episode at a time and synchronises weights via S3 between each |
federation_group_id |
Non-empty means federated run; all jobs with same ID share one global model |
federation_size |
Number of workers that must finish an episode before FedAvg runs |
Written by the worker after the job finishes (success, failure, or timeout).
{
"job_id": "job_20260325_120000_a1b2c3",
"worker_id": "i-0abc1234567890def",
"status": "success",
"started_at": "2026-03-25T12:01:00Z",
"finished_at": "2026-03-25T12:31:00Z",
"elapsed_seconds": 1800.0,
"episodes_completed": 2,
"total_reward": -2.70,
"final_reward": -1.35,
"error": null,
"checkpoint_s3_uri": "s3://diya-simarena-jobs-.../results/job_.../checkpoint_final.pt",
"log_s3_uri": "s3://diya-simarena-jobs-.../results/job_.../train.log"
}| Field | Description |
|---|---|
status |
"success", "failed", or "timeout" |
episodes_completed |
Episodes actually run (read from the saved checkpoint) |
total_reward |
Sum of all episode rewards |
final_reward |
Reward of the last episode |
checkpoint_s3_uri |
Where the central server should download new weights from |
error |
Error message for non-success statuses; null otherwise |
# 0. Health check — do this EVERY session before starting the worker
kubectl get pods -A | grep kwok # must be Running, not CrashLoopBackOff
kubectl get nodes # all needed nodes must be Ready
kubectl delete simulations.simkube.io --all -n default 2>/dev/null || true
pkill -f "train.py" 2>/dev/null || true
# 1. Activate env
source ~/work/sim-arena/.venv/bin/activate
cd ~/work/sim-arena
# 2. Export env vars
export AWS_ACCESS_KEY_ID=<your_key>
export AWS_SECRET_ACCESS_KEY=<your_secret>
export AWS_DEFAULT_REGION=us-east-2
export SIM_ARENA_DRIVER_TIMEOUT=150
export SIM_ARENA_DEPLOY_TIMEOUT=90
export SIM_ARENA_NODE_DATA_DIR=/var/kind/cluster
export PYTHONPATH=/home/ubuntu/work/sim-arena
export JOBS_BUCKET=diya-simarena-jobs-664926621123-us-east-2-an
# 3. Refresh K8s secret
kubectl create secret generic simkube -n simkube \
--from-literal=AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID \
--from-literal=AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY \
--from-literal=AWS_DEFAULT_REGION=us-east-2 \
--dry-run=client -o yaml | kubectl apply -f -
# 4. Run worker (process one job then exit)
python protocol/worker.py --bucket "$JOBS_BUCKET" --run-once --log-level INFO
# Or run continuously (loops and polls every 30s when idle):
python protocol/worker.py --bucket "$JOBS_BUCKET" --log-level INFOThe worker polls jobs/pending/ every --poll-interval seconds, claims the first unclaimed job, runs train.py, uploads results, writes result.json, then loops.
Why the health check matters: If KWOK is crashing or nodes are NotReady, train.py will hang waiting for pods that never get scheduled, and the job will timeout after timeout_seconds. Ghost simulations.simkube.io CRDs left over from killed/timed-out runs have the same effect — delete them first.
cd ~/clinic_ACRL/sim-arena
source .venv/bin/activate
export JOBS_BUCKET=diya-simarena-jobs-664926621123-us-east-2-an
# Single worker, fresh start
python protocol/dispatch.py submit \
--bucket "$JOBS_BUCKET" \
--trace s3://diya-simarena-traces/demo/trace-mem-slight.msgpack \
--agent dqn --episodes 2 --steps 3 --duration 40 --timeout 7200
# Federated run (submit once per worker, same GROUP)
GROUP="fedrun-$(date +%Y%m%d-%H%M)"
python protocol/dispatch.py submit \
--bucket "$JOBS_BUCKET" \
--trace s3://diya-simarena-traces/demo/trace-mem-slight.msgpack \
--agent dqn --episodes 2 --steps 3 --duration 40 --timeout 7200 \
--federation-group "$GROUP" --federation-size 2
# run the above command TWICE (once per worker)python protocol/dispatch.py list --bucket "$JOBS_BUCKET"Output:
Job ID Status Episodes Total Reward
-------------------------------------------------------------------------------------
job_20260407_235626_f1d114 success 2 -2.70
job_20260407_235626_f922f1 success 2 -2.70
job_20260409_051223_5263ff pending - -
# Remove from pending so workers don't pick it up
aws s3 rm "s3://$JOBS_BUCKET/jobs/pending/JOB_ID/manifest.json"
# Remove claim marker if one exists
aws s3 rm "s3://$JOBS_BUCKET/jobs/in_progress/JOB_ID/claimed_by" 2>/dev/null || trueSet per_episode_s3_sync: true on the manifest (CLI: dispatch.py submit --per-episode-sync …) when weights must be refreshed between every episode on the same job.
Worker behaviour:
- Run
train.pywith--episodes 1for each episode (separate subprocess per episode). - After episode
e, upload the checkpoint toresults/<job_id>/sync/from_worker/after_ep_XXXX/checkpoint.{pt|json}and writedone.jsonnext to it. - Before episode
e+1, wait until the server object exists:results/<job_id>/sync/to_worker/before_ep_XXXX/weights.{pt|json}then download it and pass it totrain.pyas--load … --transfer. - After the last episode, the worker still writes
checkpoint_final.*,train.log, andresult.json.
Use the same --federation-group on every manifest and set --federation-size to the number of workers that must finish an episode before the round advances.
- Dispatch: submit
federation_sizejobs all with the same--federation-groupand--federation-size. - Workers run episode 1 independently, upload checkpoints under
results/_federation/<group>/from_worker/after_ep_0001/<worker_id>/. sync_server.pywaits untilfederation_sizedistinctworker_ids have uploaded for that episode, then runs FedAvg (mean ofq_net_state_dictandtarget_net_state_dict), and writes one file for everyone:results/_federation/<group>/to_worker/before_ep_0002/global_weights.pt.- Workers download that same object and continue episode 2 from the shared averaged policy.
Run this anywhere with S3 credentials (laptop, small EC2, etc.) and keep it running for the duration of the federated job:
cd ~/clinic_ACRL/sim-arena
source .venv/bin/activate
export JOBS_BUCKET=diya-simarena-jobs-664926621123-us-east-2-an
python protocol/sync_server.py --bucket "$JOBS_BUCKET" --poll-interval 10 --log-level INFOOrder of operations: submit jobs → start sync_server → start all workers. Workers block at each episode barrier until sync_server publishes global_weights.pt.
- Submit exactly
federation_sizejobs with the same group ID; workers block until the barrier fills. - Agent must be
dqnfor FedAvg (.ptcheckpoints). Greedy/random agents use identity copy. - Do not use
--sync-identity-serverwith--federation-group— usesync_server.pyfor FedAvg. sync_server_weights_timeout_seconds(default 7200) caps how long each worker waits at a barrier.timeout_secondson the manifest caps how long each individualtrain.pysubprocess runs.
Round 1: dispatch submit --trace ... (no --weights → fresh start)
worker runs → writes checkpoint_final.pt
Round 2: dispatch submit --trace ... \
--weights s3://.../results/<round1_job>/checkpoint_final.pt
worker downloads weights → runs train.py --load ... --transfer
worker uploads new checkpoint_final.pt
Round N: repeat
The --transfer flag (automatically added by the worker when weights are provided) resets the agent's replay buffer and exploration schedule so learning continues cleanly from the new weights.
| Scenario | Worker behaviour | result.json status |
|---|---|---|
train.py exits non-zero |
Log uploaded if available; no checkpoint | "failed" |
Job exceeds timeout_seconds |
subprocess.run raises TimeoutExpired; process killed |
"timeout" |
| Weights download fails | Exception caught; job marked failed immediately | "failed" |
| Two workers claim same job | Both may run it; deduplication happens via claimed_by check |
first writer wins |
| KWOK CrashLoopBackOff | train.py hangs (pods never schedule) → timeout | "timeout" |
| Ghost simulations in namespace | train.py hangs immediately → timeout | "timeout" |
The worker inherits these from the shell (same as running train.py directly):
| Variable | Required | Notes |
|---|---|---|
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY |
Yes | For S3 reads/writes |
AWS_DEFAULT_REGION |
Yes | Use us-east-2 |
SIM_ARENA_DRIVER_TIMEOUT |
Recommended | Set to 150 on EC2 |
SIM_ARENA_DEPLOY_TIMEOUT |
Recommended | Set to 90 on EC2 |
SIM_ARENA_NODE_DATA_DIR |
Yes | /var/kind/cluster on EC2 |
PYTHONPATH |
Yes | /home/ubuntu/work/sim-arena on EC2 |
JOBS_BUCKET |
Optional | Overrides --bucket default |
protocol/
schemas.py — JobManifest and JobResult dataclasses
s3_helpers.py — thin boto3 wrappers (upload, download, list, put/get JSON)
sync_paths.py — canonical S3 key helpers for per-episode sync and federation
sync_server.py — polls S3: identity barriers + FedAvg for federation groups
federated_avg.py — mean of DQN q_net / target_net checkpoints
worker.py — EC2 worker polling loop
dispatch.py — submit jobs and check status from a laptop or central server
tests/
test_protocol.py — worker/dispatch/schema tests (mocked, no AWS needed)
test_sync_server.py — sync server and path tests (mocked)
test_federated_avg.py — FedAvg tensor math