Skip to content

Commit 75e4f4a

Browse files
rayketchamclaude
andcommitted
fix(mechanic): isolate status file in tests + Force override for stuck runs (#100)
The real bug behind 'Run does nothing': the orchestration tests call the real run_mechanic_cycle, which wrote progress to the PRODUCTION status file (a test item 'Blow up mid-run', left non-terminal by the exception test) — the run guard read that ghost and refused every click. Isolate mechanic_status._STATUS_FILE per test so the suite never touches the file the server's panel reads. Also add an escape hatch: POST /api/mechanic/run?force=true overrides the one-at-a-time guard (an agent is silent for minutes, so age can't prove a run is dead). The panel offers 'force a new run?' on already_running. mechanic.js -> ?v=4. +tests: status isolated, force overrides the guard. Full suite 1801 passing; ruff clean. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent e7c9ad9 commit 75e4f4a

5 files changed

Lines changed: 66 additions & 21 deletions

File tree

src/project_forge/web/routes.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1683,24 +1683,30 @@ async def api_mechanic_status():
16831683

16841684

16851685
@router.post("/api/mechanic/run")
1686-
async def api_mechanic_run(request: Request):
1686+
async def api_mechanic_run(request: Request, force: bool = False):
16871687
"""Human-triggered single mechanic cycle (validation / on-demand). Launches
16881688
a detached one-shot process so the server never blocks on the agent; the
1689-
resulting PR appears in the panel for review. Rate-limited."""
1689+
resulting PR appears in the panel for review. Rate-limited.
1690+
1691+
`force=true` overrides the one-at-a-time guard — for when a run looks stuck
1692+
(the agent is silent for minutes, so age alone can't prove it's dead)."""
16901693
client_ip = request.client.host if request.client else "unknown"
16911694
_check_rate_limit(f"mechanic-run:{client_ip}")
16921695
from project_forge.cron.mechanic_runner import spawn_mechanic_run
16931696
from project_forge.engine.mechanic_status import read_status, write_status
16941697

1695-
# Guard: one run at a time. A second concurrent run would double the
1696-
# subscription spend and race on the same branch.
1698+
# Guard: one run at a time (a second concurrent run doubles the spend and
1699+
# races on the branch) — unless the operator forces it.
16971700
status = read_status()
1698-
if not status.get("terminal"):
1699-
return {"status": "already_running", "detail": status.get("message", "A mechanic run is already in progress.")}
1701+
if not force and not status.get("terminal"):
1702+
return {
1703+
"status": "already_running",
1704+
"detail": status.get("message", "A mechanic run is already in progress."),
1705+
"item": status.get("item", ""),
1706+
}
17001707

17011708
# Write an immediate non-terminal status BEFORE spawning, so the panel's
1702-
# first poll sees progress instead of the previous run's stale/idle state
1703-
# (that race is what made 'Run now' look like nothing happened).
1709+
# first poll sees progress instead of the previous run's stale state.
17041710
write_status("selecting")
17051711
spawn_mechanic_run()
17061712
return {"status": "started"}

src/project_forge/web/static/mechanic.js

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -104,20 +104,33 @@
104104
pollOnce();
105105
}
106106

107+
async function doRun(force) {
108+
runBtn.disabled = true;
109+
startedAt = Date.now();
110+
setStatus('⏱ starting — this takes a few minutes (clone → Claude implements → full test suite → PR). Leave this open; the PR appears below when done.', 'loading');
111+
try {
112+
var resp = await fetch('/api/mechanic/run' + (force ? '?force=true' : ''), { method: 'POST', headers: headers() });
113+
if (!resp.ok) throw new Error('HTTP ' + resp.status);
114+
var data = await resp.json();
115+
if (data.status === 'already_running') {
116+
var item = data.item ? ' (item: ' + data.item + ')' : '';
117+
if (confirm('A mechanic run is already in progress' + item + '. Agents run silently for several minutes, so it may still be working. Force a NEW run anyway?')) {
118+
return doRun(true);
119+
}
120+
startPolling(); // just show the in-progress run's live status
121+
return;
122+
}
123+
startPolling();
124+
} catch (e) {
125+
setStatus('failed to start: ' + e.message, 'error');
126+
runBtn.disabled = false;
127+
}
128+
}
129+
107130
if (runBtn && statusEl) {
108-
runBtn.addEventListener('click', async function () {
131+
runBtn.addEventListener('click', function () {
109132
if (!confirm('Run one mechanic cycle now? It implements the top Think Tank item on your subscription and opens a PR here. Takes several minutes — leave the page open.')) return;
110-
runBtn.disabled = true;
111-
startedAt = Date.now();
112-
setStatus('⏱ starting — this takes a few minutes (clone → Claude implements → full test suite → PR). Leave this open; the PR appears below when done.', 'loading');
113-
try {
114-
var resp = await fetch('/api/mechanic/run', { method: 'POST', headers: headers() });
115-
if (!resp.ok) throw new Error('HTTP ' + resp.status);
116-
startPolling();
117-
} catch (e) {
118-
setStatus('failed to start: ' + e.message, 'error');
119-
runBtn.disabled = false;
120-
}
133+
doRun(false);
121134
});
122135
}
123136

src/project_forge/web/templates/mechanic.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,5 +56,5 @@ <h1>&#128295; Forge Mechanic</h1>
5656
</div>
5757
</section>
5858

59-
<script src="/static/mechanic.js?v=3"></script>
59+
<script src="/static/mechanic.js?v=4"></script>
6060
{% endblock %}

tests/test_mechanic_arm.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,19 @@ async def test_run_endpoint_refuses_when_already_running(self, client):
127127
resp = await client.post("/api/mechanic/run")
128128
assert resp.json()["status"] == "already_running"
129129
sp.assert_not_called()
130+
131+
@pytest.mark.asyncio
132+
async def test_run_endpoint_force_overrides_guard(self, client):
133+
"""force=true starts a run even if one looks in-progress (the escape
134+
hatch for a stuck run)."""
135+
with (
136+
patch(
137+
"project_forge.engine.mechanic_status.read_status",
138+
return_value={"terminal": False, "message": "busy"},
139+
),
140+
patch("project_forge.engine.mechanic_status.write_status"),
141+
patch("project_forge.cron.mechanic_runner.spawn_mechanic_run") as sp,
142+
):
143+
resp = await client.post("/api/mechanic/run?force=true")
144+
assert resp.json()["status"] == "started"
145+
sp.assert_called_once()

tests/test_mechanic_engine.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,16 @@ def _no_open_prs(monkeypatch):
3838
monkeypatch.setattr("project_forge.engine.mechanic_review.list_open_prs", lambda: [])
3939

4040

41+
@pytest.fixture(autouse=True)
42+
def _isolate_status(tmp_path, monkeypatch):
43+
# run_mechanic_cycle writes progress via mechanic_status; isolate that file
44+
# so tests NEVER touch the real one the running server's panel reads (a
45+
# test's mid-run 'implementing' status was ghost-locking the run guard).
46+
import project_forge.engine.mechanic_status as ms
47+
48+
monkeypatch.setattr(ms, "_STATUS_FILE", tmp_path / "mech-status.json")
49+
50+
4151
def _si(name: str, **over) -> Idea:
4252
base = dict(
4353
name=name,

0 commit comments

Comments
 (0)