DEV Community

Alex Zhu
Alex Zhu

Posted on

Name a Stop-Condition Owner: A One-Page Wiki SOP for Runaway Shared Agent Jobs

You kick a Friday agent job onto a shared box because the prompt already feels close enough to merge. The first loop looks harmless until files keep changing after dinner and nobody can name who may kill the process. Monday morning the queue is jammed, two teammates have duplicated the same repair, and the originating thread is gone. Shared free runtimes usually fail as operations problems long before they fail as model-quality or prompt-quality problems.

What a runaway job actually is

A runaway job is not only an infinite loop in generated code or a retry storm against a flaky tool. It is also a social failure because the submitter went offline and the runtime still has no named killer. Every bystander then hesitates to send SIGTERM, so you lose wall-clock, disk, and the next person's slot. If your team treats vibe sessions as engineering output, you still need a halt policy a stranger can run at 01:00.

Cheap retries make that social failure sharper, because starting another loop costs almost nothing until the shared queue collapses. A free server option then concentrates those loops onto one machine that several people believe they kind of own. MonkeyCode is one place this pattern shows up, through free model access and a free server option for shared draft runs.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

You do not need a new vendor workflow to repair the gap when Monday arrives. You need a stop-condition owner, a written halt contract, and a watchdog that dies louder than the agent process. The rest of this page is the run you paste into the wiki before anyone launches again.

Roles you name before anyone launches

Stop-Condition Owner. This person is the only teammate who may raise max wall-clock, max steps, or retry depth during the current week. They also reject jobs whose done-sentence cannot be checked without rereading a private chat. If the owner is unclear, you do not have a shared engineering runtime yet.

Job Submitter. This is whoever starts the process, and they must paste a halt contract into the wiki before launch. They do not get to argue that the agent will probably finish after one more tool call. Launch without a contract is treated as an unowned process, not as enthusiasm.

On-call Killer. This backup may send SIGTERM without waiting for the submitter's phone, including overnight. Their job is to stop an unowned process first and reconstruct intent later from the receipt file. Politeness is not a substitute for a kill path on a shared box.

Reviewer. This person checks that the halt contract matches the branch, the allowed write roots, and the intended test command. They are not there to admire the prompt; they are there to confirm the job can end. A review that starts after three hours of writes is a postmortem, not a review.

Handoffs stay explicit on purpose, because informal ownership is how Friday jobs survive into Monday. The submitter cannot also be the only killer overnight when the box is shared. The owner cannot approve an unbounded run with an emoji reaction in chat. The reviewer cannot look later after the job has already been writing files for three hours.

Seven steps you can run this afternoon

  1. Freeze the done-sentence. Write one sentence that a teammate could mark complete without opening the original thread. If you cannot write that sentence, you do not have a stop condition, and the work stays on a laptop. Shared hardware is for jobs with an observable end, not for open-ended browsing.
  2. Fill the halt contract before launch. Require four numbers: max wall-clock seconds, max agent steps, max write paths, and a heartbeat interval. Keep that contract beside the command so the killer does not hunt through chat history. Numbers that live only in someone's head are not numbers.
  3. Name the killer in the same block. Record a person, a backup, and a timezone rather than a group mention nobody reads. If the owner is offline, the backup kills first and files the receipt second. A missing name means the job is already unowned.
  4. Launch under a wrapper, never a raw long-lived shell. The wrapper must exit non-zero when any halt number trips, and it must leave a receipt JSON file. Receipts are the only honest memory Monday-you will have of Friday-you. A tmux session with no receipt is not an operations story you can replay.
  5. Broadcast PID, receipt path, and kill command. Post them in the team channel at launch, not after the first anomaly. Missing broadcast means the on-call killer treats the job as unowned and stops it. Silence is not consent to keep the CPU.
  6. Snapshot, then stop. On halt, copy the receipt, git status --short, and the last 200 log lines into the ticket. Do not restart until the owner edits a number in the contract and initials the change. Almost done is not a halt condition you can hand to a stranger.
  7. Hold a ten-minute post-halt. The only agenda is which number was wrong and whether the done-sentence was fake. A fake done-sentence sends the next attempt back to a personal machine, not the shared server. Repeat failures against the same unbounded prompt are a process bug, not bad luck.

Artifact: halt contract, watchdog, and kill decision table

Treat the following as a proposed local wrapper, not as a platform API and not as a measured benchmark. You can keep it next to any agent CLI you already run on a shared box. Label the script unexecuted until you run it against a dummy sleep job on your own machine.

Halt contract template

# Halt contract — paste above every shared run

- job_id: 2026-09-18-fix-retry-storm
- submitter: riley
- stop_condition_owner: mei
- oncall_killer: sam (UTC+8)
- reviewer: jordan
- done_sentence: Receipt JSON exists and pytest tests/test_retry.py passes.
- max_wall_seconds: 1200
- max_steps: 40
- max_write_paths: 12
- heartbeat_seconds: 30
- allowed_write_roots:
  - /tmp/agent-jobs/2026-09-18-fix-retry-storm
  - /home/shared/work/retry-storm
- launch_command: python3 watchdog.py --config halt.env -- python3 run_agent.py
- kill_command: kill -TERM $(cat /tmp/agent-jobs/2026-09-18-fix-retry-storm/job.pid)
Enter fullscreen mode Exit fullscreen mode

halt.env for the wrapper

# halt.env
JOB_ID=2026-09-18-fix-retry-storm
JOB_DIR=/tmp/agent-jobs/2026-09-18-fix-retry-storm
MAX_WALL_SECONDS=1200
MAX_STEPS=40
HEARTBEAT_SECONDS=30
Enter fullscreen mode Exit fullscreen mode

Proposed watchdog.py (unexecuted example)

#!/usr/bin/env python3
"""Proposed watchdog for shared agent jobs. Label: unexecuted example."""
from __future__ import annotations

import json
import os
import signal
import subprocess
import sys
import time
from pathlib import Path


def load_env(path: str) -> None:
    for raw in Path(path).read_text().splitlines():
        line = raw.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        key, value = line.split("=", 1)
        os.environ.setdefault(key.strip(), value.strip())


def receipt_path(job_dir: Path) -> Path:
    return job_dir / "receipt.json"


def write_receipt(job_dir: Path, payload: dict) -> None:
    job_dir.mkdir(parents=True, exist_ok=True)
    receipt_path(job_dir).write_text(json.dumps(payload, indent=2) + "\n")


def main(argv: list[str]) -> int:
    if "--config" not in argv or "--" not in argv:
        print("usage: watchdog.py --config halt.env -- <command>", file=sys.stderr)
        return 2
    cfg = argv[argv.index("--config") + 1]
    cmd = argv[argv.index("--") + 1 :]
    load_env(cfg)

    job_id = os.environ["JOB_ID"]
    job_dir = Path(os.environ["JOB_DIR"])
    max_wall = int(os.environ["MAX_WALL_SECONDS"])
    max_steps = int(os.environ["MAX_STEPS"])
    heartbeat = int(os.environ["HEARTBEAT_SECONDS"])
    job_dir.mkdir(parents=True, exist_ok=True)

    proc = subprocess.Popen(cmd, cwd=job_dir)
    (job_dir / "job.pid").write_text(str(proc.pid))
    started = time.time()
    steps = 0
    halt_reason = "completed"

    try:
        while True:
            code = proc.poll()
            if code is not None:
                halt_reason = "child_exited"
                break
            elapsed = time.time() - started
            if elapsed > max_wall:
                halt_reason = "max_wall_seconds"
                proc.send_signal(signal.SIGTERM)
                try:
                    proc.wait(timeout=15)
                except subprocess.TimeoutExpired:
                    proc.kill()
                    halt_reason = "max_wall_seconds_sigkill"
                break
            step_file = job_dir / "steps.count"
            if step_file.exists():
                steps = int(step_file.read_text().strip() or "0")
            if steps > max_steps:
                halt_reason = "max_steps"
                proc.send_signal(signal.SIGTERM)
                break
            write_receipt(
                job_dir,
                {
                    "job_id": job_id,
                    "pid": proc.pid,
                    "elapsed_seconds": int(elapsed),
                    "steps": steps,
                    "status": "running",
                },
            )
            time.sleep(heartbeat)
    finally:
        elapsed = int(time.time() - started)
        final_code = proc.poll()
        write_receipt(
            job_dir,
            {
                "job_id": job_id,
                "pid": proc.pid,
                "elapsed_seconds": elapsed,
                "steps": steps,
                "status": halt_reason,
                "exit_code": final_code,
            },
        )
        print(f"halt_reason={halt_reason} receipt={receipt_path(job_dir)}")
    return 1 if halt_reason != "child_exited" else int(final_code or 0)


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))
Enter fullscreen mode Exit fullscreen mode

Your agent loop should increment steps.count after every tool call so the wrapper can halt on policy, not vibes. That single file is the difference between a killable job and a mystery CPU spike. Dry-run the wrapper with python3 watchdog.py --config halt.env -- sleep 9999 and confirm receipt.json appears before you wrap a real agent.

Commands the on-call killer actually runs

JOB_DIR=/tmp/agent-jobs/2026-09-18-fix-retry-storm
cat "$JOB_DIR/receipt.json"
ps -p "$(cat "$JOB_DIR/job.pid")" -o pid,etime,cmd
kill -TERM "$(cat "$JOB_DIR/job.pid")"
sleep 5
ps -p "$(cat "$JOB_DIR/job.pid")" >/dev/null && kill -KILL "$(cat "$JOB_DIR/job.pid")"
git -C /home/shared/work/retry-storm status --short
tail -n 200 "$JOB_DIR/agent.log"
Enter fullscreen mode Exit fullscreen mode

Decision table for wait, kill, or refuse restart

Observation Owner action Killer action Restart allowed?
Heartbeat older than two intervals Read receipt only SIGTERM if the PID is still live No, until a contract edit
Steps exceed max_steps Narrow scope, or raise the cap with initials Stop immediately No
Writes outside allowed roots Treat the run as an incident SIGKILL, then snapshot No
Done-sentence already true Close the job in the wiki Do not wait for more agent talk Not applicable
Submitter offline, no broadcast Mark the job unowned Stop, then file the ticket No

The table is the handoff. Chat debate after a halt is how unbounded jobs get a second life they did not earn.

One-page wiki block to paste

# Stop-condition run (shared agent server)

Purpose: every shared job has a done-sentence, a halt number, and a named killer.

This week
- Stop-condition owner:
- On-call killer:
- Reviewer:

Before launch
1. Paste halt contract with job_id, done-sentence, four numbers, write roots.
2. Run watchdog.py; never start a raw long-lived agent shell.
3. Post PID, receipt path, and kill_command in #agent-jobs.

During run
1. Killer checks receipt.json every heartbeat * 2.
2. Any missing broadcast is an unowned job and gets SIGTERM.
3. Nobody raises limits in chat; only the owner edits the contract.

After halt
1. Attach receipt, git status, and tail -n 200 of the log.
2. Ten-minute post-halt: which number failed, was the done-sentence fake?
3. Fake done-sentence => next attempt stays off the shared server.

Do not
- Leave Friday jobs running without a killer timezone.
- Restart because it was almost done.
- Store secrets in the job directory that the watchdog snapshots.
Enter fullscreen mode Exit fullscreen mode

Fill the three names before the next launch, then refuse jobs that arrive with blank lines. A wiki page without a killer is documentation of hope, not an operations run.

Limitations and who should skip this

This SOP assumes two humans and a box you are allowed to share; it is not a production control plane. The watchdog only sees wall-clock, a step counter file, and the child process PID you launched. It will not catch silent data leaks or a grandchild process that daemonizes in the background. It also will not replace code review, eval ownership, or a real queue when you outgrow one shared server.

Skip this approach when you are a solo hobbyist on a personal laptop with nothing to hand off. Skip it when the workload is regulated production traffic, customer data, or anything that needs change tickets rather than a wiki paste. Skip it when your agent must fork daemons the wrapper cannot see, because a PID file would give you false confidence. In those cases you want isolation first, not a halt contract on a shared working tree.

Cheap shared inference is useful for draft loops, not for unbounded autonomy on a box other people need. If you already share a free server for draft agent loops, paste the wiki block before the next Friday launch rather than hoping the process feels done.

Top comments (0)