The freeze was never the remote model at all. Your coding spinner just needed a simple villain. I kept a stall graph instead of vibes.
You ever watch an AI pane freeze hard? You blame tokens because the badge says thinking. I blamed tokens too, then I measured spans.
Git status won the bar fight cleanly. Decode time looked almost polite on the graph. This note is about stalls, not model quality.
I keep a waterfall of the coding loop. You can run the probe on tonight's repo. No extra cluster has to join this.
A spinner has only one facial expression. Slow still looks like slow from here. So we invent a story about tokens.
The free server must be cold tonight, right? The model must be thinking very hard? Those stories sound neat and still fail.
I treat the assistant like any other worker. I give every wait a stubborn name. Then I keep the graph beside the patch.
If decode owns only a thin slice, I stop yelling at tokens for sport. The loop looks clever in product demos. It is still a small queue.
Read files, build a prompt, call a server. Stream text, apply a patch, then repeat. Which step owns the freeze on your machine? Ask the graph. Do not ask the spinner.
I record five spans on every probe run. Workspace walk, git status, prompt build. Request wait, first token, then decode.
Then I print them as a single bar chart. Think of a subway delay board at night. One train is late down the line.
The board still shows every station in order. You want the red station, not the line. That is the whole method in one picture.
Here is the recorder I keep as a scratch file. Treat this as a local probe only. It does not praise a vendor yet. The timings it prints are illustrative sample output, not a lab claim.
# stall_graph.py — local probe, labeled sample harness
from __future__ import annotations
import json
import os
import subprocess
import time
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import Iterator, List
@dataclass
class Span:
name: str
start: float
end: float = 0.0
@property
def ms(self) -> float:
return (self.end - self.start) * 1000.0
@dataclass
class StallGraph:
spans: List[Span] = field(default_factory=list)
@contextmanager
def span(self, name: str) -> Iterator[None]:
s = Span(name=name, start=time.perf_counter())
try:
yield
finally:
s.end = time.perf_counter()
self.spans.append(s)
def dump(self) -> str:
total = sum(s.ms for s in self.spans) or 1.0
lines = []
for s in self.spans:
width = max(1, int(s.ms / total * 40))
bar = "#" * width
lines.append(f"{s.name:16} {s.ms:8.1f} ms {bar}")
return "\n".join(lines)
def walk_workspace(root: str, limit: int = 400) -> list[str]:
out: list[str] = []
skip = {".git", "node_modules", ".venv", "dist", "target"}
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in skip]
for name in filenames:
if name.endswith((".py", ".ts", ".go", ".rs")):
out.append(os.path.join(dirpath, name))
if len(out) >= limit:
return out
return out
def git_status(root: str) -> str:
try:
p = subprocess.run(
["git", "status", "--porcelain", "-uall"],
cwd=root,
capture_output=True,
text=True,
timeout=8,
)
return p.stdout
except (FileNotFoundError, subprocess.TimeoutExpired):
return ""
def assemble_prompt(files: list[str], status: str, question: str) -> str:
parts = [f"STATUS:\n{status}\n", f"Q:\n{question}\n"]
for path in files[:12]:
try:
with open(path, "r", encoding="utf-8", errors="ignore") as handle:
parts.append(f"FILE {path}\n{handle.read()[:2000]}\n")
except OSError:
continue
return "\n".join(parts)
def fake_remote(prompt: str) -> str:
# Stand-in only. Swap this for a real HTTP call later.
time.sleep(0.05)
_ = hash(prompt) % 97
time.sleep(0.12)
return "ok"
def run_probe(root: str) -> None:
g = StallGraph()
with g.span("workspace_walk"):
files = walk_workspace(root)
with g.span("git_status"):
status = git_status(root)
with g.span("prompt_build"):
prompt = assemble_prompt(
files, status, "Where is the N+1 query?"
)
with g.span("request_wait"):
time.sleep(0.01) # DNS / connect stand-in
with g.span("first_token"):
_ = fake_remote(prompt)[:1]
with g.span("decode"):
_ = fake_remote(prompt)
print(g.dump())
print("---")
print(json.dumps({s.name: round(s.ms, 1) for s in g.spans}, indent=2))
if __name__ == "__main__":
run_probe(os.getcwd())
Run it on a dirty repo first tonight. Then run it on a tiny clean tree. Compare the bars like a crime scene.
python stall_graph.py
The block below is fake shape, not my laptop. Your numbers will move with disk heat. A dirty monorepo often shows this kind of cartoon.
workspace_walk 180.0 ms ############
git_status 420.0 ms ############################
prompt_build 90.0 ms ######
request_wait 25.0 ms #
first_token 40.0 ms ##
decode 80.0 ms #####
See the joke hiding in those bars? Tokens look shy while git looks loud. The spinner still writes thinking on its face. Who taught that badge such a sloppy word?
I needed a remote that skipped the credit card. I also needed that remote to stay boring. Boring helps a probe stay honest.
You want a stable far side for contrast. You want the near side to confess first.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
I pointed the far spans at MonkeyCode for that contrast. Free model access and a free server option. That is why the project appears in this note. I am not ranking vendors here at all. I am holding the network still on purpose.
Point request_wait and decode at that server. Keep the local spans brutally honest. If you need a quiet far side, that free server option is enough to run the probe.
Do not paste secrets into the prompt body. Do not ship production keys with a probe. This is a stall graph, not a load test.
A thin client can look like the sketch below. Wire your own URL and your own auth. Do not treat the snippet as production code.
import json
import urllib.request
def remote_complete(prompt: str, url: str) -> str:
body = json.dumps({"prompt": prompt, "stream": False}).encode()
req = urllib.request.Request(
url,
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.read().decode("utf-8", errors="replace")
Wrap the call until headers actually arrive. Then wrap the body read as decode. If you only store one blob, split it. A single span hides the handshake every time.
Why use a free server for the far side? Because the live question is local I/O. Pay for a huge box and the story bends.
You start hunting glamorous model wins again. The graph goes quiet and polite. I wanted the opposite mood for once. Cheap remote. Loud local. Let the stall speak.
If git status dominates the printed bars, stop asking porcelain on every keystroke. Cache it. Watch the index instead of polling. Skip -uall on huge trees.
That flag walks untracked files like a tourist. It is a trap inside a monorepo. You already knew that in your bones. The spinner made you forget the lesson.
If workspace walk dominates the same graph, stop crawling from the repo root. Pass the open file and the diff. Pass a tight pathspec and nothing else.
The model cannot read a forest faster than disk. It never could, even on a good day. A glob from root is not intelligence. It is just a cold walk.
If prompt build dominates, cap the file bytes. Hash the includes and refuse rereads. Prompt build is CPU plus disk in a costume. It still wears a model badge.
If request wait finally dominates, then talk network. DNS, TLS, idle connections, cold start. Then the free server is on the hook. Until that bar grows, leave the remote alone.
If decode dominates, you may have a model wait. Or you asked the stream for a novel. Still measure it. Do not guess it.
I rerun the probe three boring ways. Same question. Same laptop. I refuse one lucky run.
First comes a clean git worktree with no junk. Second comes a dirty tree with untracked build output. Third comes that dirty tree with node_modules still visible.
That third run is the horror movie version. You should watch it once and keep the printout. I keep bars. I do not keep vibes.
If the bars do not move after a fix, the fix is theater and you know it. Ship the graph with the patch. Future you will thank present you.
Here is a pytest sketch that guards the probe. It asserts shape, not a magic speed. Label this unexecuted until you run it. These tests will not prove production latency. They only keep the recorder from rotting.
# test_stall_shape.py
import os
from stall_graph import StallGraph, git_status, walk_workspace
def test_spans_are_named():
g = StallGraph()
with g.span("git_status"):
git_status(os.getcwd())
assert g.spans[0].name == "git_status"
assert g.spans[0].ms >= 0.0
def test_walk_respects_limit(tmp_path):
for i in range(50):
(tmp_path / f"f{i}.py").write_text("x=1\n")
files = walk_workspace(str(tmp_path), limit=10)
assert len(files) == 10
This probe lies when the assistant is a black box. You cannot span what you cannot wrap. If file crawl lives inside a closed binary, you only see one blob.
Then you need OS tools, not this script. fs_usage, perf, procmon, whatever you trust. That is a different sport with heavier shoes.
This probe also lies under tiny toy repos. Everything looks like decode in a sandbox. Of course it does with nothing else to eat. Do not publish that graph as gospel.
Do not use this method to rank models. Free access is not a bake-off on quality. Token taste is another axis I ignored. If you need evals, walk away.
Do not use this for SLA theater either. There are no percentiles hiding in the script. There is no jitter model and no region story. One laptop. One waterfall. That is scope.
Skip this if you do not own the client. You cannot instrument a closed pane with Python. You would be guessing, and guessing feeds the spinner.
Skip it if your freeze is local GPU inference. That needs another graph and other clocks. I am talking about a coding loop that calls a server. That is the only scene.
I paste the last waterfall into the ticket. Not a screenshot of a lying spinner. The bars, the names, one blunt sentence.
Name the span that dwarfed decode, then the patch. Then a second graph after the change. If the red station moves, we learned something. If it stays, we stop arguing with folklore.
AI coding talk loves agents and long loops this week. A loop is still a wait with extra makeup. If-statements in a trench coat still call git status.
They still walk trees and pack bloated prompts. Measure that part before the costume parade. I did not check whether the model got smarter.
I checked whether the spinner lied to me. The stall graph did not lie back. Bring a dirty repo and run the recorder. Keep the graph.
Top comments (0)