|
|
@@ -212,6 +212,8 @@ class FakeSaddle:
|
|
|
self.suspend_after_start = False # next task parks as suspended
|
|
|
self.pending_output = [] # entries drained by the next /output poll
|
|
|
self.output_seq = 100
|
|
|
+ self.subtask_status_events = [] # recorded POST /subtask-status payloads
|
|
|
+ self.fail_subtask_status = False # exercise the web's retry path
|
|
|
|
|
|
def queue_output(self, entry_type, command, output):
|
|
|
"""Queues a typed entry for the dispatcher's next /output poll."""
|
|
|
@@ -280,6 +282,17 @@ class FakeSaddle:
|
|
|
self._send(200, {})
|
|
|
elif self.path == "/answer":
|
|
|
self._send(200, {})
|
|
|
+ elif self.path == "/subtask-status":
|
|
|
+ try:
|
|
|
+ payload = json.loads(raw)
|
|
|
+ except ValueError:
|
|
|
+ self._send(400, {"error": "bad json"})
|
|
|
+ return
|
|
|
+ if self.daemon.fail_subtask_status:
|
|
|
+ self._send(500, {"error": "simulated failure"})
|
|
|
+ return
|
|
|
+ self.daemon.subtask_status_events.append(payload)
|
|
|
+ self._send(200, {"ok": True})
|
|
|
else:
|
|
|
self._send(404, {"error": "no such route"})
|
|
|
|
|
|
@@ -1201,6 +1214,70 @@ def main():
|
|
|
|
|
|
|
|
|
|
|
|
+
|
|
|
+ print("== R3 B1: subtask status push ==")
|
|
|
+ # A parked (suspended) parent gives delivery a stable open session.
|
|
|
+ fake.suspend_after_start = True
|
|
|
+ act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
|
|
|
+ "project_id": "1",
|
|
|
+ "title": "Push parent",
|
|
|
+ "priority": "2",
|
|
|
+ })
|
|
|
+ push_parent = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
|
|
|
+ if t["title"] == "Push parent"][0])
|
|
|
+ check(wait_for(lambda: task_state(auth, push_parent).get("status") == "suspended"
|
|
|
+ or False, timeout=60, what="push parent parks") is not None,
|
|
|
+ "push parent parks with an open session")
|
|
|
+
|
|
|
+ act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
|
|
|
+ "project_id": "1",
|
|
|
+ "title": "Push child",
|
|
|
+ "parent_task_id": str(push_parent),
|
|
|
+ })
|
|
|
+ push_child = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
|
|
|
+ if t["title"] == "Push child"][0])
|
|
|
+
|
|
|
+ def events_for(subtask_id):
|
|
|
+ return [e for e in fake.subtask_status_events
|
|
|
+ if int(e.get("subtask_id") or 0) == subtask_id]
|
|
|
+
|
|
|
+ check(wait_for(lambda: len(events_for(push_child)) > 0 or False,
|
|
|
+ timeout=30, what="queued subtask event") is not None,
|
|
|
+ "subtask creation pushes a queued status event")
|
|
|
+ first_event = events_for(push_child)[0]
|
|
|
+ check(int(first_event.get("parent_task_id") or 0) == push_parent
|
|
|
+ and first_event.get("title") == "Push child"
|
|
|
+ and first_event.get("status") in ("queued", "working"),
|
|
|
+ "status event carries parent id, title and a wire status (%s)"
|
|
|
+ % first_event.get("status"))
|
|
|
+
|
|
|
+ # Real lifecycle: the child dispatches (working) and completes (done
|
|
|
+ # with summary) — the parent's daemon hears every transition.
|
|
|
+ check(wait_for(lambda: any(e.get("status") == "done" and "finished" in (e.get("summary") or "")
|
|
|
+ for e in events_for(push_child)) or False,
|
|
|
+ timeout=90, what="done subtask event") is not None,
|
|
|
+ "subtask completion pushes done with the summary")
|
|
|
+ check(any(e.get("status") == "working" for e in events_for(push_child)),
|
|
|
+ "dispatch pushes a working status event")
|
|
|
+
|
|
|
+ # Best-effort retry: while the daemon 500s, events stay queued and
|
|
|
+ # land once it recovers.
|
|
|
+ fake.fail_subtask_status = True
|
|
|
+ act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
|
|
|
+ "project_id": "1",
|
|
|
+ "title": "Push child two",
|
|
|
+ "parent_task_id": str(push_parent),
|
|
|
+ })
|
|
|
+ push_child_two = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
|
|
|
+ if t["title"] == "Push child two"][0])
|
|
|
+ time.sleep(7)
|
|
|
+ check(len(events_for(push_child_two)) == 0,
|
|
|
+ "failed pushes stay queued (no event delivered)")
|
|
|
+ fake.fail_subtask_status = False
|
|
|
+ check(wait_for(lambda: len(events_for(push_child_two)) > 0 or False,
|
|
|
+ timeout=30, what="retried subtask event") is not None,
|
|
|
+ "recovered pushes deliver on a later tick")
|
|
|
+
|
|
|
finally:
|
|
|
app.terminate()
|
|
|
try:
|