|
|
@@ -1393,6 +1393,101 @@ def main():
|
|
|
check("daemon idle while the web believes" in app_log,
|
|
|
"the contradiction heal is logged")
|
|
|
|
|
|
+
|
|
|
+ print("== R4 B1: synchronous subtask callback ==")
|
|
|
+ conn = db()
|
|
|
+ callback_rows = conn.execute(
|
|
|
+ "select token from api_tokens where saddle_id = 0"
|
|
|
+ " and token like 'saddle-callback-%'").fetchall()
|
|
|
+ conn.close()
|
|
|
+ r4_token = callback_rows[0][0] if callback_rows else callback_token
|
|
|
+
|
|
|
+ # Parent with a live parked session (suspended) hosts the callback.
|
|
|
+ fake.suspend_after_start = True
|
|
|
+ act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
|
|
|
+ "project_id": "1",
|
|
|
+ "title": "Callback parent",
|
|
|
+ "priority": "2",
|
|
|
+ })
|
|
|
+ cb_parent = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
|
|
|
+ if t["title"] == "Callback parent"][0])
|
|
|
+ check(wait_for(lambda: task_state(auth, cb_parent).get("status") == "suspended"
|
|
|
+ or False, timeout=60, what="callback parent parks") is not None,
|
|
|
+ "callback parent parks")
|
|
|
+
|
|
|
+ status, _ = saddle_api("POST", "/api/saddle/subtask", None,
|
|
|
+ {"parent_task_id": cb_parent, "title": "no token"})
|
|
|
+ check(status == 401, "subtask callback rejects a missing token")
|
|
|
+
|
|
|
+ anchor = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
|
|
|
+ if t["title"] == "Heal dispatch"][0])
|
|
|
+ status, body = saddle_api("POST", "/api/saddle/subtask", r4_token, {
|
|
|
+ "parent_task_id": cb_parent,
|
|
|
+ "title": "Wire the flange",
|
|
|
+ "description": "torque to spec",
|
|
|
+ "depends_on": [anchor],
|
|
|
+ "session_id": 1,
|
|
|
+ })
|
|
|
+ check(status == 200 and body.get("ok") is True and int(body.get("task_id") or 0) > 0,
|
|
|
+ "callback creates the subtask synchronously")
|
|
|
+ subtask_id = int(body.get("task_id"))
|
|
|
+ detail = task_state(auth, subtask_id)
|
|
|
+ check(detail["title"] == "Wire the flange" and int(detail["project_id"]) == 1
|
|
|
+ and int(detail["parent_task_id"]) == cb_parent,
|
|
|
+ "subtask row inherits the parent's project and linkage")
|
|
|
+ check(detail.get("task_branch") == task_state(auth, cb_parent).get("task_branch"),
|
|
|
+ "subtask shares the parent's task branch")
|
|
|
+ conn = db()
|
|
|
+ dep_rows = conn.execute(
|
|
|
+ "select depends_on_task_id from task_dependencies where task_id = ?",
|
|
|
+ (subtask_id,)).fetchall()
|
|
|
+ conn.close()
|
|
|
+ check([r[0] for r in dep_rows] == [anchor],
|
|
|
+ "depends_on wired as task_dependencies")
|
|
|
+
|
|
|
+ # Idempotency + entry-poll fallback dedup.
|
|
|
+ status, body2 = saddle_api("POST", "/api/saddle/subtask", r4_token, {
|
|
|
+ "parent_task_id": cb_parent, "title": "Wire the flange"})
|
|
|
+ check(status == 200 and int(body2.get("task_id") or 0) == subtask_id,
|
|
|
+ "retried callback returns the same task id")
|
|
|
+ conn = db()
|
|
|
+ before = conn.execute("select count(*) from tasks where parent_task_id = ?",
|
|
|
+ (cb_parent,)).fetchone()[0]
|
|
|
+ conn.close()
|
|
|
+ conn = db()
|
|
|
+ cb_session = conn.execute(
|
|
|
+ "select id from sessions where task_id = ? and ended_at is null",
|
|
|
+ (cb_parent,)).fetchone()[0]
|
|
|
+ conn.close()
|
|
|
+ saddle_api("POST", "/api/saddle/%d/update" % fake_id, fake_token,
|
|
|
+ {"type": "subtask", "session_id": cb_session, "data": {
|
|
|
+ "title": "Wire the flange", "description": "again"}})
|
|
|
+ conn = db()
|
|
|
+ after = conn.execute("select count(*) from tasks where parent_task_id = ?",
|
|
|
+ (cb_parent,)).fetchone()[0]
|
|
|
+ timeline = conn.execute(
|
|
|
+ "select count(*) from session_commands where session_id = ?"
|
|
|
+ " and type = 'subtask'", (cb_session,)).fetchone()[0]
|
|
|
+ conn.close()
|
|
|
+ check(after == before,
|
|
|
+ "entry-poll fallback dedupes against the callback's row")
|
|
|
+ check(timeline >= 1, "timeline entry recorded on the parent's session")
|
|
|
+
|
|
|
+ # Validation: unknown parent, unknown depends_on, ancestor cycle.
|
|
|
+ status, body = saddle_api("POST", "/api/saddle/subtask", r4_token,
|
|
|
+ {"parent_task_id": 99999, "title": "ghost"})
|
|
|
+ check(status == 400, "unknown parent rejected")
|
|
|
+ status, body = saddle_api("POST", "/api/saddle/subtask", r4_token,
|
|
|
+ {"parent_task_id": cb_parent, "title": "bad dep",
|
|
|
+ "depends_on": [99999]})
|
|
|
+ check(status == 400 and "unknown task" in body.get("error", ""),
|
|
|
+ "unknown depends_on id rejected with a clear error")
|
|
|
+ status, body = saddle_api("POST", "/api/saddle/subtask", r4_token,
|
|
|
+ {"parent_task_id": cb_parent, "title": "cycle dep",
|
|
|
+ "depends_on": [cb_parent]})
|
|
|
+ check(status == 400 and "cycle" in body.get("error", ""),
|
|
|
+ "depending on an ancestor rejected as a cycle")
|
|
|
+
|
|
|
finally:
|
|
|
# Exit-code diagnostic: distinguishes a crashed app (negative
|
|
|
# returncode, e.g. -11 SIGSEGV) from a clean shutdown. When
|