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:
- Hypothesis — one sentence, falsifiable.
- Kill condition — the observable result that ends the spike.
- Scope fence — the paths the spike may touch.
- 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 "$@"
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": ""
}
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"))
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
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)