DEV Community

Emery Yang
Emery Yang

Posted on

Your 90-Minute Spike Needs a Kill Switch in Code, Not Willpower

A 90-minute spike rarely dies from a hard problem.
It dies from scope creep at minute 70.
The fix is not discipline. The fix is a harness that refuses to continue.

This post gives you that harness: three small files, one deadline, one verdict check.
It works with any model endpoint, including free ones.

The failure mode is not the model

Watch a time-boxed spike fail and you will see the same three moves.

  • Clock drift. "One more prompt" costs eight minutes each time.
  • Scope creep. The failing test becomes a refactor, then a dependency upgrade.
  • Memory rewrite. At T+120 you remember the evidence as better than it was.

None of these are model problems. They are bookkeeping problems.
Bookkeeping belongs in code.

Write the spike contract before the first prompt

A spike without a pre-registered kill condition is just unstructured work with a timer.
Write four fields down first:

  1. Hypothesis — one sentence, falsifiable.
  2. Kill condition — the observable result that ends the spike.
  3. Scope fence — the paths the spike may touch.
  4. Verdict rule — what counts as ship, kill, or one extension.

Example hypothesis: "The flaky test in tests/test_orders.py fails because of shared fixture state, not network timing."
Example kill condition: "If the test still fails with fixtures isolated at T+90, kill it."

Notice both are falsifiable in one sitting. That is the whole point.

The harness: three files, one deadline

1. spike.sh — the only way to run a command

#!/usr/bin/env bash
# spike.sh - wall-clock enforcement for a time-boxed spike.
# Usage: ./spike.sh spike-out/ 90 -- pytest -q tests/test_orders.py
set -euo pipefail

OUT="$1"
MINUTES="${2:-90}"
shift 2
[ "${1:-}" = "--" ] && shift

mkdir -p "$OUT/logs"
DEADLINE=$(( $(date +%s) + MINUTES * 60 ))
printf '%s\n' "$DEADLINE" > "$OUT/deadline.epoch"
git rev-parse HEAD > "$OUT/base_commit.txt" 2>/dev/null \
  || echo "no-git" > "$OUT/base_commit.txt"
git status --porcelain > "$OUT/logs/dirty_tree_before.txt" 2>/dev/null || true

spike_run() {
  local left=$(( DEADLINE - $(date +%s) ))
  if (( left <= 0 )); then
    printf 'KILL: deadline passed at %s. Stop and write verdict.json.\n' \
      "$(date -u +%H:%M:%SZ)" >&2
    return 90
  fi
  local cap=$(( left < 300 ? left : 300 ))
  printf 't+%ss left=%ss cap=%ss :: %s\n' \
    "$(( MINUTES * 60 - left ))" "$left" "$cap" "$*" >> "$OUT/logs/calls.log"
  timeout "$cap" "$@"
}

spike_run "$@"
Enter fullscreen mode Exit fullscreen mode

Why timeout per call instead of one big timeout around everything?
Because a single outer timeout kills the process and leaves no trace.
Per-call caps leave a ledger of what happened when.

2. verdict.json — the artifact a spike produces

{
  "hypothesis": "flaky test comes from shared fixture state, not network timing",
  "kill_condition": "still failing at T+90 with fixtures isolated",
  "observed": "FILL IN: command, exit code, and the exact assertion text you saw",
  "elapsed_minutes": 0,
  "requests_made": 0,
  "files_touched": [],
  "verdict": "ship | kill | extend-once",
  "extend_reason": ""
}
Enter fullscreen mode Exit fullscreen mode

observed is the load-bearing field. Command, exit code, assertion text.
If you cannot fill it, you did not learn anything measurable.

3. spike_verdict.py — the gate before you tell anyone

#!/usr/bin/env python3
"""spike_verdict.py - reject a thin spike verdict before it leaves your desk."""
import json
import pathlib
import sys

REQUIRED = ("hypothesis", "kill_condition", "observed", "elapsed_minutes", "verdict")
ALLOWED = {"ship", "kill", "extend-once"}


def main(path: str) -> int:
    p = pathlib.Path(path)
    if not p.exists():
        print(f"missing {p}: write the verdict before you talk about the spike")
        return 2
    v = json.loads(p.read_text())
    problems = [k for k in REQUIRED if not v.get(k) and v.get(k) != 0]
    if v.get("verdict") not in ALLOWED:
        problems.append("verdict")
    if v.get("verdict") == "extend-once" and not v.get("extend_reason"):
        problems.append("extend_reason")
    if len(str(v.get("observed", ""))) < 40:
        problems.append("observed (too short to be evidence)")
    if problems:
        print("REJECT:", ", ".join(problems))
        return 1
    print("ACCEPT:", v["verdict"], f"after {v['elapsed_minutes']} min")
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "verdict.json"))
Enter fullscreen mode Exit fullscreen mode

Run it in CI on the spike branch. A rejected verdict fails the job.
That is the difference between a deadline and a suggestion.

Pre-register the decision table

Decide the mapping in advance, not at T+89 when you are tired.

Signal at T+90 Verdict Next action
Target test passes, files touched within the fence ship Open a PR, add a regression test
Test passes but diff escapes the fence kill Split into two narrower spikes
No pass, but the failure narrowed to one module extend-once Max 30 more minutes, same hypothesis
No pass, no narrowing kill Write down what you ruled out

Two rules make the table work: a fixed file fence, and no second extension.

Running it somewhere that is not your laptop

A deadline means little if a laptop sleep or a flaky Wi-Fi kills the run.
Run the harness on a machine that stays up.

This is where the free availability claims matter. MonkeyCode advertises free model access and a free server option. The free token allowance is published as a token count rather than a hardcoded number here, on purpose: a stale constant in a script is a bug, not a budget. Read the project's current terms before you plan around it.

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

Wire it in as the model endpoint behind your wrapper, so every call still passes through spike_run. The wrapper does not care which model answers. It cares that the call is logged and capped.

One caveat worth checking in the terms: whether automated call volume is acceptable under the free tier. If it is not, run the spike with fewer calls and a stricter fence.

Read the ledger, not your memory

The spike output directory is the only thing you should trust afterward.
A single ledger line looks like this template, not like a claim:

t+412s left=4988s cap=300s :: pytest -q tests/test_orders.py
Enter fullscreen mode Exit fullscreen mode

At the end, record four numbers and nothing else:

  • Wall-clock elapsed, in minutes.
  • Commands attempted, from logs/calls.log.
  • Files touched, from git status --porcelain.
  • Target test exit codes, in order, e.g. 1,1,1,0.

If your endpoint returns usage metadata, log the token count per call.
If it does not, log request count only. Do not estimate. Estimated tokens are fiction.

Limitations and who should skip this

  • Exploratory research. If the goal is to learn, not to decide, a 90-minute box is wrong.
  • Cross-service spikes. Anything needing two deployed systems will not fit.
  • Teams without pre-registration. The harness enforces time, not judgment.
  • Anything touching production data. A time-boxed panic is still a panic.

If you cannot write a kill condition in one sentence, you do not have a spike yet.
You have a project, and it deserves a project plan.

The only question at T+90

The question is not "is the model good?" It never was.
The question is: did this spike produce a decision someone else can audit?

Three files answer it. Deadline in epoch seconds, verdict in JSON, validator in CI.
If you want a free endpoint and a free place to run the harness, MonkeyCode's free model access and free server option are one place to start — read their terms first, then let the kill switch do its job.

Run the next spike under the harness. Kill it on time. Publish the verdict either way.

Top comments (0)