Draft
Conversation
Addresses code review findings: - yapf -ir on client.py and test_client.py to satisfy CI - 3 new tests for download_code (happy path, 4xx, network error)
… inline PUT) Replace on_submission_complete's inline HTTP PUT with a push to result_queue so the agent's result_sender thread handles delivery. Remove now-unused imports (requests, tempfile). Add two TDD tests verifying queue push and state cleanup behaviour.
- delete app.py and gunicorn.conf.py - Dockerfile CMD: python main.py (+ mkdir -p logs) - requirements.txt: drop flask + gunicorn (no longer needed) - dispatcher/utils.py: update logger fallback from 'gunicorn.error' to 'dispatcher' Sandbox is now an active runner agent that polls Backend, not a passive HTTP server. The pull-loop modules in agent/ replace the old Flask routes.
After Plan B's removal of SANDBOX_TOKEN, dispatcher/testdata.py needs to authenticate to backend's testdata endpoints with the runner's own rk_token (set after registration). - Module-level _RK_TOKEN with set_runner_token() setter - fetch_problem_meta, fetch_testdata, get_checksum use it - main.py calls dispatcher_testdata.set_runner_token(creds.token) after registration
main.py is no longer a Flask app — current_app fallback is dead code. Caused ModuleNotFoundError when running the sandbox container.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Pull-Based Job Dispatch — Runner / Infra Implementation Plan (Plan B)
Goal: Refactor the Normal-OJ Sandbox process from "passive HTTP receiver" to "active GH-style runner that polls Backend for jobs". Plus all infra changes (docker-compose Redis AOF, new env vars) needed for the big-bang cutover.
Architecture: Add
Sandbox/agent/with 4 daemon-thread modules (registration, heartbeat, poller, result_sender). ModifySandbox/dispatcher/dispatcher.pyto accept ajob_idper submission and push results to an in-memory queue (instead of POSTing inline). Replace FlaskSandbox/app.pyentrypoint with a newSandbox/main.pythat wires all threads together. Update Dockerfile + docker-compose.Tech Stack: Python 3.13 (runtime), Python 3.10 (CI tests), pip (no Poetry), pytest, requests, Docker.
Working directories:
Sandbox/— most changesNormal-OJ/) —docker-compose.yml,.secret.example/*.envPrerequisite: Plan A (
docs/superpowers/plans/2026-04-28-pull-based-job-dispatch-backend.md) must be merged into Back-End first. Plan B's smoke test depends on Backend exposing the new/runners/*endpoints.Spec Reference
Source design:
docs/superpowers/specs/2026-04-28-pull-based-job-dispatch-design.mdThis Plan B covers Sections 10 (Runner internal structure), 11 (env vars — runner side), 12 (infra), 14 (Migration steps).
Naming clarification:
runner/vsagent/The existing
Sandbox/runner/directory contains code that runs submissions inside Docker containers (SubmissionRunnerclass) — it's the execution engine. This is unrelated to "GitHub-style runner" terminology.To avoid name collision, all new pull-loop modules go in
Sandbox/agent/(agent = the worker process that polls + executes). The existingSandbox/runner/stays untouched.File Structure
New files (Sandbox)
Modified files
Deleted files
Phases
Phase 1: agent/ scaffold + HTTP client
Task 1: Create agent/ package + config
Files:
Sandbox/agent/__init__.pySandbox/agent/config.pySandbox/tests/agent/__init__.pyWorking dir for git commands:
/Users/as535364/Downloads/Project/Normal-OJ/Sandbox# Sandbox/tests/agent/__init__.pyRun:
cd Sandbox && python -c "from agent import config; print(config.BACKEND_URL)"Expected: prints
http://web:8080(or whatever BACKEND_URL is in env)Task 2: Implement agent/client.py — thin HTTP client to backend
Files:
Sandbox/tests/agent/test_client.pySandbox/agent/client.pyA small wrapper around
requeststhat handles auth headers, base URL, JSON encoding, and exception mapping. All other agent modules use it (avoids each module re-implementing HTTP boilerplate).Run:
cd Sandbox && pip install responses && pytest tests/agent/test_client.py -vExpected: FAIL with import error (
agent.clientdoesn't exist yet)responsesto test requirementsEdit
Sandbox/tests/requirements.txt(create if missing) to add:If
tests/requirements.txtexists, append the line. If not, create it (the existing CI workflow installs from this path per CLAUDE.md).Run:
cd Sandbox && pytest tests/agent/test_client.py -vExpected: All 9 tests PASS
Phase 2: Per-component TDD
Task 3: registration.py — register on startup
Files:
Test:
Sandbox/tests/agent/test_registration.pyCreate:
Sandbox/agent/registration.pyStep 1: Write failing tests
Run:
cd Sandbox && pytest tests/agent/test_registration.py -vExpected: FAIL —
agent.registrationnot foundRun:
cd Sandbox && pytest tests/agent/test_registration.py -vExpected: 2 tests PASS
Task 4: heartbeat.py — daemon thread sending heartbeat at interval
Files:
Test:
Sandbox/tests/agent/test_heartbeat.pyCreate:
Sandbox/agent/heartbeat.pyStep 1: Write failing tests
Run:
cd Sandbox && pytest tests/agent/test_heartbeat.py -vExpected: FAIL —
HeartbeatThreadnot foundRun:
cd Sandbox && pytest tests/agent/test_heartbeat.py -vExpected: 3 tests PASS
Task 5: result_sender.py — daemon thread delivering results with retry
Files:
Test:
Sandbox/tests/agent/test_result_sender.pyCreate:
Sandbox/agent/result_sender.pyStep 1: Write failing tests
Run:
cd Sandbox && pytest tests/agent/test_result_sender.py -vExpected: FAIL
Run:
cd Sandbox && pytest tests/agent/test_result_sender.py -vExpected: 5 tests PASS
Task 6: poller.py — daemon thread polling backend for jobs
Files:
Test:
Sandbox/tests/agent/test_poller.pyCreate:
Sandbox/agent/poller.pyStep 1: Write failing tests
Run:
cd Sandbox && pytest tests/agent/test_poller.py -vExpected: FAIL
The poller needs a helper
prepare_submission_dir_for_jobthat:dispatcher.prepare_submission_dir(...)with the right argsLook at existing
Sandbox/dispatcher/dispatcher.py::prepare_submission_dir(already there) andSandbox/dispatcher/testdata.pyforget_problem_root/get_problem_metato understand testdata fetching. The new helper wires these together with the downloaded code.Run:
cd Sandbox && pytest tests/agent/test_poller.py -vExpected: 4 tests PASS
Phase 3: Dispatcher modifications
Task 7: Add
has_capacity()andresult_queueto DispatcherFiles:
Modify:
Sandbox/dispatcher/dispatcher.pyModify:
Sandbox/tests/test_dispatcher.pyStep 1: Write failing test
Add to
Sandbox/tests/test_dispatcher.py:Run:
cd Sandbox && pytest tests/test_dispatcher.py::test_dispatcher_has_capacity_returns_true_when_queue_empty tests/test_dispatcher.py::test_dispatcher_exposes_result_queue -vExpected: FAIL — methods don't exist
In
Sandbox/dispatcher/dispatcher.py::Dispatcher.__init__, add:has_capacitymethod to DispatcherRun:
cd Sandbox && pytest tests/test_dispatcher.py -v -k "has_capacity or result_queue"Expected: PASS
Task 8: Modify
handle()to acceptjob_idFiles:
Modify:
Sandbox/dispatcher/dispatcher.pyModify:
Sandbox/tests/test_dispatcher.pyStep 1: Write failing test
(If the existing test fixture pattern differs, adapt to whatever
submission_generator.pyprovides for setting up a submission dir.)Expected: FAIL —
handle()doesn't acceptjob_idIn
Sandbox/dispatcher/dispatcher.py::Dispatcher.handle:job_id=Nonedefault keeps any existing internal callers working (the in-process test fixtures don't go through poller).Run:
cd Sandbox && pytest tests/test_dispatcher.py -v -k "job_id"Expected: PASS
Task 9: Replace direct PUT in
on_submission_completewith result_queue pushFiles:
Modify:
Sandbox/dispatcher/dispatcher.pyModify:
Sandbox/tests/test_dispatcher.pyStep 1: Write failing test
Expected: FAIL
on_submission_completebodyFind existing
on_submission_completeinSandbox/dispatcher/dispatcher.py(~lines 352-402). Replace its body:This:
Removes direct
requests.putcall (now done by result_sender)Removes the
with tempfile.NamedTemporaryFiledanceRemoves the SANDBOX_TOKEN reference
Removes
file_manager.backup_data(rare failure mode; user has rejudge as fallback)Step 4: Remove now-unused imports
In
Sandbox/dispatcher/dispatcher.pytop:Remove
import requests(or check it's still used elsewhere)Remove
import tempfile(same check)Remove
from .config import SANDBOX_TOKEN, BACKEND_API(no longer used)Step 5: Run tests
Run:
cd Sandbox && pytest tests/test_dispatcher.py -vExpected: All pass (or document any pre-existing failures)
Phase 4: Entrypoint swap
Task 10: Create
Sandbox/main.py— new entrypointFiles:
Create:
Sandbox/main.pyStep 1: Write the entrypoint
Run:
cd Sandbox && python -c "import main; print('ok')"Expected: prints
okTask 11: Delete
Sandbox/app.pyandSandbox/gunicorn.conf.pyFiles:
Delete:
Sandbox/app.pyDelete:
Sandbox/gunicorn.conf.pyStep 1: Verify nothing else imports from app.py
Run:
cd Sandbox && grep -rn "from app\|import app" --include="*.py" .Expected: No results (or only in
main.pyif you accidentally referenced it — fix that)cd Sandbox git rm app.py gunicorn.conf.pyRun:
cd Sandbox && pytest -vExpected: All non-Docker-requiring tests pass. (Some tests need
./build.shto have run — those would skip or fail in a clean env; check existing CI workflow for what's expected.)Task 12: Update Dockerfiles to use new entrypoint
Files:
Modify:
Sandbox/DockerfileModify:
Sandbox/Dockerfile.prod(if exists)Modify:
Sandbox/requirements.txt(drop flask + gunicorn)Step 1: Update Dockerfile CMD
Replace
Sandbox/Dockerfile:Check if
Sandbox/Dockerfile.prodexists; if so apply the same CMD change.Edit
Sandbox/requirements.txt— remove these two lines:Keep:
docker,requests,yapf,pydantic,redis(verify which are actually still imported anywhere; usegrep -rn "import flask\|from flask").Run from meta-repo root:
docker compose build sandboxExpected: Image builds successfully
Phase 5: Infra changes (meta-repo)
Task 13: Update docker-compose.yml — Redis AOF + new env vars
Files:
docker-compose.yml(meta-repo root).secret.example/sandbox.env.secret.example/web.envWorking dir for git commands: meta-repo root
/Users/as535364/Downloads/Project/Normal-OJ. This commits to the meta-repo, not a submodule.Append (or modify if existing):
Append:
Run:
docker compose config | grep -A5 redisExpected: Shows the appendonly command
Task 14: Submodule pointer bumps in meta-repo
Files:
Back-Endsubmodule pointerSandboxsubmodule pointerAfter Plan A and Plan B are merged into their respective submodule's
mainbranch, the meta-repo needs its pointer bumped.Phase 6: Smoke test
Task 15: End-to-end smoke test (manual, with checklist)
No code changes — verification only.
This task verifies the whole stack works after both plans. Run in dev environment.
Expected: 200 with
{"mongo": true, "redis": true}Expected: log line
registered as rn_...docker compose exec redis redis-cli SMEMBERS runners:registeredExpected: At least one
rn_...IDLogin as
first_admin/firstpasswordforadminvia the Vue app athttp://localhost:8080. Create a problem with one simple testcase. Submit a trivial AC solution.Watch its status:
Initial:
PendingWithin ~10s: should transition to
AC(or whatever is appropriate)Step 6: Verify the job flow in logs
Expected:
Sandbox log shows
dispatched submission=...thendelivered jb_...Backend log shows the complete endpoint being hit
Step 7: Failure injection — kill sandbox mid-submission
In one terminal:
docker compose kill sandboxIn another, submit a long-running submission. Wait 30 seconds. Restart:
Within ~60s, the submission should reclaim and complete (status moves from Pending to a final status).
If anything fails, file specific issues with logs. Otherwise, mark Plan B complete.
Plan B Done — Verification Checklist
Sandbox/:pytest -vpasses (excluding tests requiring Docker)Sandbox/:yapf -rd .shows no diffflask/gunicornimports in SandboxPost-Plan Tasks (out of scope here)