|
|
@@ -209,6 +209,7 @@ class FakeSaddle:
|
|
|
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
|
|
|
+ self.suspend_after_start = False # next task parks as suspended
|
|
|
self.pending_output = [] # entries drained by the next /output poll
|
|
|
self.output_seq = 100
|
|
|
|
|
|
@@ -294,8 +295,14 @@ class FakeSaddle:
|
|
|
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.summary = "fake daemon finished"
|
|
|
+ if self.daemon.suspend_after_start:
|
|
|
+ # `saddle suspend`: park until a fresh /start
|
|
|
+ # resumes the workspace (one-shot flag).
|
|
|
+ self.daemon.state = "suspended"
|
|
|
+ self.daemon.suspend_after_start = False
|
|
|
+ else:
|
|
|
+ self.daemon.state = "done"
|
|
|
+ self.daemon.summary = "fake daemon finished"
|
|
|
self._send(200, response)
|
|
|
elif self.path.startswith("/output"):
|
|
|
with self.daemon.lock:
|
|
|
@@ -1125,6 +1132,75 @@ def main():
|
|
|
check(task_state(auth, rejected_task)["status"] == "done"
|
|
|
and "fake daemon finished" in (task_state(auth, rejected_task).get("summary") or ""),
|
|
|
"rejected entries never fail the task")
|
|
|
+ print("== B7: suspended state + per-subtask resume ==")
|
|
|
+ fake.suspend_after_start = True
|
|
|
+ pushes_before = len(fake.start_payloads)
|
|
|
+ act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
|
|
|
+ "project_id": "1",
|
|
|
+ "title": "Suspend parent",
|
|
|
+ "priority": "2",
|
|
|
+ })
|
|
|
+ parent7 = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
|
|
|
+ if t["title"] == "Suspend parent"][0])
|
|
|
+ check(wait_for(lambda: task_state(auth, parent7).get("status") == "suspended"
|
|
|
+ or False, timeout=60, what="parent parks as suspended") is not None,
|
|
|
+ "daemon 'suspended' state parks the task")
|
|
|
+ detail = task_state(auth, parent7)
|
|
|
+ check(detail.get("display") == "waiting on subtasks",
|
|
|
+ "suspended task displays as waiting on subtasks")
|
|
|
+ check(any(s["status"] == "idle"
|
|
|
+ for s in slot_public(auth, "/saddles", "saddles")["saddles"]),
|
|
|
+ "suspension frees the saddle")
|
|
|
+ first_push = fake.start_payloads[pushes_before]
|
|
|
+ check(first_push.get("subtasks") == [],
|
|
|
+ "the /start payload carries the subtasks array (empty here)")
|
|
|
+
|
|
|
+ # Two subtask events land while parked (staged, not dispatched):
|
|
|
+ # both must coalesce into one resume with a full report.
|
|
|
+ conn = db()
|
|
|
+ conn.execute("insert into tasks (project_id, title, status, priority, parent_task_id,"
|
|
|
+ " summary, completed_at, created_at) values (1, 'Child one', 'done', 5, ?,"
|
|
|
+ " 'built and verified the flange', strftime('%s','now','+1 second'),"
|
|
|
+ " strftime('%s','now'))", (parent7,))
|
|
|
+ conn.execute("insert into tasks (project_id, title, status, priority, parent_task_id,"
|
|
|
+ " summary, completed_at, created_at) values (1, 'Child two', 'failed', 5, ?,"
|
|
|
+ " 'the harness refused to start', strftime('%s','now','+1 second'),"
|
|
|
+ " strftime('%s','now'))", (parent7,))
|
|
|
+ conn.commit()
|
|
|
+ conn.close()
|
|
|
+
|
|
|
+ def parent_push_count():
|
|
|
+ return len([p for p in fake.start_payloads
|
|
|
+ if int(p.get("task", {}).get("id") or 0) == parent7])
|
|
|
+
|
|
|
+ check(wait_for(lambda: parent_push_count() >= 2 or False,
|
|
|
+ timeout=45, what="suspended parent resumes") is not None,
|
|
|
+ "subtask events resume the suspended parent")
|
|
|
+ parent_pushes = [p for p in fake.start_payloads
|
|
|
+ if int(p.get("task", {}).get("id") or 0) == parent7]
|
|
|
+ check(len(parent_pushes) >= 2,
|
|
|
+ "the parent's resume is a fresh /start for the same task")
|
|
|
+ resume_push = parent_pushes[-1]
|
|
|
+ notice = resume_push.get("resume_notice") or ""
|
|
|
+ check("Child one" in notice and "completed" in notice
|
|
|
+ and "built and verified the flange" in notice,
|
|
|
+ "resume notice reports the finished subtask with its summary")
|
|
|
+ check("Child two" in notice and "failed" in notice
|
|
|
+ and "the harness refused to start" in notice,
|
|
|
+ "coalesced: the second event rides the same resume notice")
|
|
|
+ subtask_entries = {(s["title"], s["status"]) for s in resume_push.get("subtasks", [])}
|
|
|
+ check(("Child one", "done") in subtask_entries
|
|
|
+ and ("Child two", "failed") in subtask_entries,
|
|
|
+ "subtasks array carries the children's states at dispatch time")
|
|
|
+ check(wait_task_done(auth, parent7, timeout=60) is not None,
|
|
|
+ "resumed parent completes")
|
|
|
+ detail = task_state(auth, parent7)
|
|
|
+ check(any("restored the session context" in p["summary"]
|
|
|
+ for p in detail.get("progress", [])),
|
|
|
+ "resumed flag reported on the first status after the restore")
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
finally:
|
|
|
app.terminate()
|
|
|
try:
|