An agent side project on a spare machine is not finished when the prompt looks clever. It is finished when the worker process holds a lease, writes a heartbeat, and dies on a hard stop. Wall-clock time is a first-class resource, the same way a step counter is a first-class resource for loops.
This weekend recipe is a supervisor plus a worker. The supervisor owns the clock. The worker only proves it is alive. The demo stays small enough to run with the Python standard library and a single terminal.
The failure this cuts
Weekend agents fail in a boring way. They retry a model call. They block on a tool. They wait for a human who already closed the laptop. A token spend meter does not catch a stuck HTTP client. A step ledger does not catch one tool call that never returns.
A process lease covers that gap. The happy path can still be drafted by a model. The lease still ends. That split is the whole point of the weekend: keep generation cheap, keep runtime finite.
Recent discussion around “vibe coding” versus engineering is noisy. This recipe ignores the slogan fight. It adds one engineering object a demo actually needs: an expiry.
Scope cut for Saturday and Sunday
Three files are in scope.
-
lease.json— holder, deadline, last pulse, terminal status -
supervise.py— spawn, poll, kill the process group -
worker.py— do one bounded job and rewrite the lease on a timer
Everything else is skipped on purpose.
- No containers, Kubernetes, or systemd units
- No multi-tenant isolation or public ingress
- No TLS, API keys in source, or secret stores
- No vendor SDK beyond a tiny optional HTTP stub
- No auto-restart storms, backoff graphs, or Slack alerts
A watchdog a reader can run with python3 supervise.py beats a cluster diagram that never boots. The cut is the product.
The lease document
The lease is a JSON file on local disk. File locks are enough for a single-machine weekend. The supervisor is the only writer of status values running, expired, killed, and done. The worker may update heartbeat_unix and note only while status is running.
# lease_schema.py — shape only, no framework
LEASE_EXAMPLE = {
"lease_id": "wknd-20260918",
"holder_pid": 0,
"budget_sec": 180,
"started_unix": 0,
"deadline_unix": 0,
"heartbeat_unix": 0,
"heartbeat_max_gap_sec": 8,
"status": "running",
"note": "boot",
}
A reader who wants a stricter file can add a generation integer later. This weekend does not. One file, one holder, one clock.
Supervisor: spawn, poll, kill
supervise.py creates the lease, starts the worker in a new session, then polls. Missing heartbeats and crossed deadlines both become a kill. The kill targets the process group so child model clients die with the worker.
#!/usr/bin/env python3
"""Weekend process lease. Proposal: run locally, not as a cluster controller."""
from __future__ import annotations
import json
import os
import signal
import subprocess
import sys
import time
from pathlib import Path
LEASE = Path("lease.json")
WORKER = [sys.executable, "worker.py", str(LEASE)]
BUDGET_SEC = 180
GAP_SEC = 8
POLL_SEC = 1.0
def write_lease(doc: dict) -> None:
tmp = LEASE.with_suffix(".tmp")
tmp.write_text(json.dumps(doc, indent=2), encoding="utf-8")
tmp.replace(LEASE)
def read_lease() -> dict:
return json.loads(LEASE.read_text(encoding="utf-8"))
def main() -> int:
now = time.time()
write_lease(
{
"lease_id": "wknd-local",
"holder_pid": 0,
"budget_sec": BUDGET_SEC,
"started_unix": now,
"deadline_unix": now + BUDGET_SEC,
"heartbeat_unix": now,
"heartbeat_max_gap_sec": GAP_SEC,
"status": "running",
"note": "supervisor-boot",
}
)
proc = subprocess.Popen(
WORKER,
start_new_session=True,
stdout=sys.stdout,
stderr=sys.stderr,
)
doc = read_lease()
doc["holder_pid"] = proc.pid
write_lease(doc)
exit_code = 0
while True:
time.sleep(POLL_SEC)
doc = read_lease()
now = time.time()
alive = proc.poll() is None
if not alive:
if doc.get("status") != "done":
doc["status"] = "killed"
doc["note"] = f"worker-exit-{proc.returncode}"
write_lease(doc)
exit_code = proc.returncode or 1
break
if now > float(doc["deadline_unix"]):
doc["status"] = "expired"
doc["note"] = "budget-exceeded"
write_lease(doc)
os.killpg(proc.pid, signal.SIGKILL)
proc.wait()
exit_code = 2
break
gap = now - float(doc["heartbeat_unix"])
if gap > float(doc["heartbeat_max_gap_sec"]):
doc["status"] = "killed"
doc["note"] = f"heartbeat-gap-{gap:.1f}s"
write_lease(doc)
os.killpg(proc.pid, signal.SIGKILL)
proc.wait()
exit_code = 3
break
if doc.get("status") == "done":
proc.wait()
break
print(json.dumps(read_lease(), indent=2))
return exit_code
if __name__ == "__main__":
raise SystemExit(main())
The supervisor never parses model output. That is deliberate. Parsing belongs in the worker, behind the lease.
Worker: pulse, then one bounded job
The worker rewrites heartbeat_unix on a short interval. A daemon thread handles the pulse so a blocking tool call cannot silently freeze the file. The job itself is a stub: read a task file, optionally ask a model, write out/result.json, mark done.
#!/usr/bin/env python3
"""Weekend worker. Proposal: one task, then exit. Not a long-running agent OS."""
from __future__ import annotations
import json
import os
import sys
import threading
import time
import urllib.error
import urllib.request
from pathlib import Path
STOP = threading.Event()
def pulse(lease_path: Path) -> None:
while not STOP.is_set():
doc = json.loads(lease_path.read_text(encoding="utf-8"))
if doc.get("status") != "running":
break
doc["heartbeat_unix"] = time.time()
doc["note"] = f"pid-{os.getpid()}"
tmp = lease_path.with_suffix(".tmp")
tmp.write_text(json.dumps(doc, indent=2), encoding="utf-8")
tmp.replace(lease_path)
STOP.wait(2.0)
def maybe_model(prompt: str) -> str:
"""Optional HTTP stub. Leave MODEL_URL unset to skip the network."""
url = os.environ.get("MODEL_URL", "").strip()
if not url:
return json.dumps({"ok": True, "echo": prompt[:240]})
body = json.dumps({"prompt": prompt}).encode("utf-8")
req = urllib.request.Request(
url,
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=20) as resp:
return resp.read().decode("utf-8")[:4000]
except (urllib.error.URLError, TimeoutError) as exc:
return json.dumps({"ok": False, "error": str(exc)})
def main() -> int:
lease_path = Path(sys.argv[1])
t = threading.Thread(target=pulse, args=(lease_path,), daemon=True)
t.start()
task = Path("task.txt")
prompt = task.read_text(encoding="utf-8") if task.exists() else "summarize lease.json"
Path("out").mkdir(exist_ok=True)
Path("out/result.json").write_text(maybe_model(prompt), encoding="utf-8")
doc = json.loads(lease_path.read_text(encoding="utf-8"))
doc["status"] = "done"
doc["heartbeat_unix"] = time.time()
doc["note"] = "worker-complete"
lease_path.write_text(json.dumps(doc, indent=2), encoding="utf-8")
STOP.set()
return 0
if __name__ == "__main__":
raise SystemExit(main())
The stub keeps the demo honest. A missing MODEL_URL still produces out/result.json. Network is optional. The lease is not.
Where a free model and a free server actually participate
A laptop can run this pair during development. A side project that should keep running after the lid closes needs two other pieces: a model that can draft the next action, and a machine that can host supervise.py.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode offers free model access and a free server option. Those two facts are the only product claims used here. No model names, quotas, hardware sketches, or benchmark numbers are attached, because those details change and do not belong in a weekend recipe.
The mapping is mechanical.
- Free model access sits behind
MODEL_URLwhen a reader wants the worker to draft text instead of echoing the task file. - The free server option is a place to run
supervise.pyso the lease is enforced away from the laptop. - The lease does not care which model answered. It cares that the process still pulses, then exits.
If those hosted pieces are unavailable, the same scripts still run locally. The article remains useful with the product names removed.
Working demo
From a clean directory:
chmod +x supervise.py worker.py
echo "list three risks of an unbounded agent loop" > task.txt
python3 supervise.py
cat lease.json
cat out/result.json
Expected shape after a healthy run: status is done, holder_pid is nonzero, and out/result.json exists. Expected shape after a hang: status is killed or expired, and the process group is gone.
Reproducible hang test
Label this as an unexecuted example until a reader actually runs it. Replace the job body in worker.py with time.sleep(999) and leave the pulse thread in place. The supervisor should still pass, because heartbeats continue. Then comment out the pulse thread and sleep again. The supervisor should exit with code 3 and note starting with heartbeat-gap-.
A second test sets BUDGET_SEC = 5 and sleeps 30 with pulses enabled. The supervisor should exit with code 2 and status expired.
Test plan
1. Happy path: task.txt present, MODEL_URL unset, expect status=done, exit 0.
2. Pulse failure: disable pulse thread, expect status=killed, exit 3.
3. Budget failure: BUDGET_SEC=5, worker sleeps 30, expect status=expired, exit 2.
4. Child cleanup: worker spawns `sleep 120`; after kill, `pgrep -f sleep` is empty.
Decision table for the weekend
| Signal | Supervisor action | Worker duty |
|---|---|---|
| Heartbeat fresh, before deadline | Keep polling | Continue job |
| Heartbeat gap over limit | SIGKILL process group | None; already stuck |
| Deadline crossed | Mark expired, SIGKILL |
None |
Worker sets done
|
Wait, print lease, exit 0 | Stop pulsing |
| Worker exits nonzero | Mark killed
|
Crash or bug |
The table is the demo contract. Anything not in the table is next weekend.
What this weekend skipped
Several tempting features were left on the floor.
- Restart policies. Auto-restart turns a bad tool into a fork bomb. A lease that ends is easier to reason about than a lease that reincarnates.
- Distributed clocks. NTP skew exists. A single machine clock is enough for 180 seconds.
- Patch review. Bounding process lifetime is not bounding diff size. A separate checker would be another article.
- Cassette recording of model HTTP. Call tracing is useful. It is also a different object from a heartbeat.
-
Spend preflight. Cost caps do not stop a hung
recv(). -
Pretty dashboards.
cat lease.jsonis the UI.
Skipping is how a weekend demo stays shippable. Each skipped item is a real problem. None of them is required to prove the lease works.
Limitations, and who should not use this
This watchdog is not a security boundary. A worker that can write lease.json can fake a heartbeat. A worker that can send signals can fight the supervisor. Run it only on a throwaway side-project directory, as the same user, with no secrets in the tree.
JSON on disk will tear if a second supervisor starts. start_new_session=True is Unix-shaped; Windows needs a different kill story. The HTTP stub has no auth. Daemon threads will not save a process that deadlocks inside the interpreter in a way that blocks all Python progress; for that class of hang, an outer timeout from the shell is still required.
Do not use this as:
- a production orchestrator for paying customers
- a multi-tenant sandbox
- a merge gate for generated patches
- a substitute for cgroups, seccomp, or containers
- a billing system
Teams with existing Nomad, systemd, or Kubernetes Jobs should keep those. The weekend lease is for a solo demo that previously ran until the laptop fan became the alert.
Closing shape
The core object is small. A JSON lease, a pulse thread, a supervisor that kills a process group. Model output is optional. Runtime is not. That is enough engineering to keep a free-tier side project from becoming an unbounded process.
Readers who want a hosted model endpoint or a machine that is not a laptop can plug those into MODEL_URL and the server that runs supervise.py. The first proof is still local: one hang test, one expiry test, one lease.json that says the worker stopped.
Top comments (0)