|
@@ -201,6 +201,9 @@ class FakeSaddle:
|
|
|
self.state = "idle"
|
|
self.state = "idle"
|
|
|
self.summary = ""
|
|
self.summary = ""
|
|
|
self.status_polls_after_start = 0
|
|
self.status_polls_after_start = 0
|
|
|
|
|
+ self.fail_next = False # next /start drives the task to failed
|
|
|
|
|
+ self.start_counts = {} # task id -> /start count
|
|
|
|
|
+ self.resumed_pending = False # crash-resume flag on the next /status
|
|
|
|
|
|
|
|
def append_chunk(self, text):
|
|
def append_chunk(self, text):
|
|
|
with self.lock:
|
|
with self.lock:
|
|
@@ -239,10 +242,19 @@ class FakeSaddle:
|
|
|
except ValueError:
|
|
except ValueError:
|
|
|
payload = {"raw": raw}
|
|
payload = {"raw": raw}
|
|
|
self.daemon.start_payloads.append(payload)
|
|
self.daemon.start_payloads.append(payload)
|
|
|
|
|
+ task_id = (payload.get("task") or {}).get("id")
|
|
|
|
|
+ count = self.daemon.start_counts.get(task_id, 0) + 1
|
|
|
|
|
+ self.daemon.start_counts[task_id] = count
|
|
|
|
|
+ if count > 1:
|
|
|
|
|
+ # Crash-resume contract: re-pushing a task whose
|
|
|
|
|
+ # workspace still exists restores it; the first
|
|
|
|
|
+ # /status after the restore reports "resumed": true.
|
|
|
|
|
+ self.daemon.resumed_pending = True
|
|
|
self.daemon.append_chunk(self.daemon.PROMPT)
|
|
self.daemon.append_chunk(self.daemon.PROMPT)
|
|
|
self.daemon.append_chunk("echo hello\r\n")
|
|
self.daemon.append_chunk("echo hello\r\n")
|
|
|
self.daemon.append_chunk("hello\r\n")
|
|
self.daemon.append_chunk("hello\r\n")
|
|
|
- self.daemon.state = "working"
|
|
|
|
|
|
|
+ self.daemon.state = "failing" if self.daemon.fail_next else "working"
|
|
|
|
|
+ self.daemon.fail_next = False
|
|
|
self.daemon.status_polls_after_start = 0
|
|
self.daemon.status_polls_after_start = 0
|
|
|
self._send(200, {})
|
|
self._send(200, {})
|
|
|
elif self.path == "/answer":
|
|
elif self.path == "/answer":
|
|
@@ -252,13 +264,19 @@ class FakeSaddle:
|
|
|
|
|
|
|
|
def do_GET(self):
|
|
def do_GET(self):
|
|
|
if self.path == "/status":
|
|
if self.path == "/status":
|
|
|
- if self.daemon.state == "working":
|
|
|
|
|
|
|
+ response = {"state": self.daemon.state, "summary": self.daemon.summary}
|
|
|
|
|
+ if self.daemon.resumed_pending:
|
|
|
|
|
+ response["resumed"] = True
|
|
|
|
|
+ self.daemon.resumed_pending = False
|
|
|
|
|
+ if self.daemon.state in ("working", "failing"):
|
|
|
self.daemon.status_polls_after_start += 1
|
|
self.daemon.status_polls_after_start += 1
|
|
|
- if self.daemon.status_polls_after_start >= 2:
|
|
|
|
|
|
|
+ if self.daemon.state == "failing" and self.daemon.status_polls_after_start >= 1:
|
|
|
|
|
+ self.daemon.state = "failed"
|
|
|
|
|
+ self.daemon.summary = "bash -n rejected the script (simulated)"
|
|
|
|
|
+ elif self.daemon.state == "working" and self.daemon.status_polls_after_start >= 2:
|
|
|
self.daemon.state = "done"
|
|
self.daemon.state = "done"
|
|
|
self.daemon.summary = "fake daemon finished"
|
|
self.daemon.summary = "fake daemon finished"
|
|
|
- self._send(200, {"state": self.daemon.state,
|
|
|
|
|
- "summary": self.daemon.summary})
|
|
|
|
|
|
|
+ self._send(200, response)
|
|
|
elif self.path.startswith("/output"):
|
|
elif self.path.startswith("/output"):
|
|
|
self._send(200, [])
|
|
self._send(200, [])
|
|
|
elif self.path.startswith("/log"):
|
|
elif self.path.startswith("/log"):
|
|
@@ -741,6 +759,86 @@ def main():
|
|
|
check(all(c["name"] != "bigmem"
|
|
check(all(c["name"] != "bigmem"
|
|
|
for c in saddle_card("fake-saddle")["capabilities"]),
|
|
for c in saddle_card("fake-saddle")["capabilities"]),
|
|
|
"deleting a capability drops it from saddles")
|
|
"deleting a capability drops it from saddles")
|
|
|
|
|
+
|
|
|
|
|
+ print("== retry + resume ==")
|
|
|
|
|
+ # A daemon-side failure fails the task with its explanation.
|
|
|
|
|
+ fake.fail_next = True
|
|
|
|
|
+ act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
|
|
|
|
|
+ "project_id": "1",
|
|
|
|
|
+ "title": "Flaky task",
|
|
|
|
|
+ "priority": "2",
|
|
|
|
|
+ })
|
|
|
|
|
+ flaky = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
|
|
|
|
|
+ if t["title"] == "Flaky task"][0])
|
|
|
|
|
+ check(wait_for(lambda: task_state(auth, flaky).get("status") == "failed" or False,
|
|
|
|
|
+ timeout=60, what="flaky task fails") is not None,
|
|
|
|
|
+ "daemon failure fails the task")
|
|
|
|
|
+ detail = task_state(auth, flaky)
|
|
|
|
|
+ check("bash -n rejected" in (detail.get("summary") or ""),
|
|
|
|
|
+ "failure summary shows the daemon's explanation")
|
|
|
|
|
+
|
|
|
|
|
+ # Retry: summary moves into a comment and clears, task requeues,
|
|
|
|
|
+ # history stays, and the re-dispatch restores the workspace.
|
|
|
|
|
+ comments_before = len(detail.get("comments", []))
|
|
|
|
|
+ progress_before = len(detail.get("progress", []))
|
|
|
|
|
+ act(auth, detail["retry"], {})
|
|
|
|
|
+ detail = task_state(auth, flaky)
|
|
|
|
|
+ check(detail["status"] == "queued" and (detail.get("summary") or "") == "",
|
|
|
|
|
+ "retry requeues the task and clears the stale summary")
|
|
|
|
|
+ check(any("bash -n rejected" in c["body"] for c in detail["comments"]),
|
|
|
|
|
+ "failure explanation kept as a bot comment")
|
|
|
|
|
+ check(any(c["body"] == "Task retried by user" for c in detail["comments"]),
|
|
|
|
|
+ "retry lands its audit comment")
|
|
|
|
|
+ check(len(detail["comments"]) >= comments_before + 1,
|
|
|
|
|
+ "retry keeps the comment history")
|
|
|
|
|
+ check(wait_task_done(auth, flaky, timeout=60) is not None,
|
|
|
|
|
+ "retried task dispatches again and completes")
|
|
|
|
|
+ detail = task_state(auth, flaky)
|
|
|
|
|
+ check(any("restored the session context" in p["summary"]
|
|
|
|
|
+ for p in detail.get("progress", [])),
|
|
|
|
|
+ "daemon resume reported on the first status after the restore")
|
|
|
|
|
+ check(len(detail["progress"]) > progress_before,
|
|
|
|
|
+ "resume note appended to the progress timeline")
|
|
|
|
|
+
|
|
|
|
|
+ # Manual resume: a task stuck working on an offline saddle.
|
|
|
|
|
+ act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
|
|
|
|
|
+ "project_id": "1",
|
|
|
|
|
+ "title": "Stuck task",
|
|
|
|
|
+ "priority": "2",
|
|
|
|
|
+ })
|
|
|
|
|
+ stuck = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
|
|
|
|
|
+ if t["title"] == "Stuck task"][0])
|
|
|
|
|
+ check(wait_for(lambda: task_state(auth, stuck).get("status") == "working" or False,
|
|
|
|
|
+ timeout=60, what="stuck task starts working") is not None,
|
|
|
|
|
+ "stuck task reaches working")
|
|
|
|
|
+ conn = db()
|
|
|
|
|
+ conn.execute("update saddles set status = 'offline' where name = 'fake-saddle'")
|
|
|
|
|
+ conn.commit()
|
|
|
|
|
+ conn.close()
|
|
|
|
|
+ detail = task_state(auth, stuck)
|
|
|
|
|
+ check(detail.get("resume_available") is True,
|
|
|
|
|
+ "task page offers resume while its saddle is offline")
|
|
|
|
|
+ act(auth, detail["resume"], {})
|
|
|
|
|
+ detail = task_state(auth, stuck)
|
|
|
|
|
+ check(detail["status"] == "queued",
|
|
|
|
|
+ "manual resume requeues the stuck task")
|
|
|
|
|
+ check(any(c["body"] == "Saddle became unreachable; task requeued"
|
|
|
|
|
+ for c in detail["comments"]),
|
|
|
|
|
+ "manual resume lands the interruption comment")
|
|
|
|
|
+ check(all(s["status"] == "offline"
|
|
|
|
|
+ for s in slot_public(auth, "/saddles", "saddles")["saddles"]),
|
|
|
|
|
+ "saddle stays offline after the manual requeue")
|
|
|
|
|
+
|
|
|
|
|
+ # Bringing the saddle back lets the requeued task resume and finish.
|
|
|
|
|
+ fake.state = "idle"
|
|
|
|
|
+ fake_card = saddle_card("fake-saddle")
|
|
|
|
|
+ act(auth, fake_card["refresh"], {})
|
|
|
|
|
+ check(any(s["status"] == "idle"
|
|
|
|
|
+ for s in slot_public(auth, "/saddles", "saddles")["saddles"]
|
|
|
|
|
+ if s["name"] == "fake-saddle"),
|
|
|
|
|
+ "refreshed saddle returns idle")
|
|
|
|
|
+ check(wait_task_done(auth, stuck, timeout=60) is not None,
|
|
|
|
|
+ "requeued task resumes on the refreshed saddle and completes")
|
|
|
finally:
|
|
finally:
|
|
|
app.terminate()
|
|
app.terminate()
|
|
|
try:
|
|
try:
|