integration_test.py 48 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902
  1. #!/usr/bin/env python3
  2. """End-to-end integration test: ClankerSaddle web app + saddle daemon.
  3. Drives the web app the way statum.js does (entrypoint state loads plus
  4. action POSTs carrying the auth slot and sealed private blobs) while a
  5. REAL saddle daemon runs a REAL goal against a real git checkout under
  6. WORKSPACE, powered by saddle/test/mock_llm.py with a deterministic
  7. script — both binaries, the true cross-binary contract proof (the
  8. per-repo fake-peer suits live in web_stack_test.py / saddle e2e).
  9. The consolidated goal-era arc (plan 15, re-baselined for C+D):
  10. 1. goal create -> start: the goal-run assignment reaches the daemon
  11. over the ask/allocate channel with the full plan-12 payload (kind/
  12. mode/creator/goal context/ephemeral branches for workers)
  13. 2. a commentary-only round and a trailing-comment round feed VERBATIM
  14. (comment-policy-free inputs, plan 15 D1) before the pre-plan
  15. question block -> answered on the goal page -> the ONE answer
  16. resolves the goal-run task's single task_blocks row and the parked
  17. goal agent resumes with the verdict wording
  18. 3. plan submit -> reject-with-feedback -> revision -> approval (the
  19. agreed verdict words ride the resume)
  20. 4. task fan-out through the real /api/saddle/task/create + enqueue
  21. callbacks; the worker's DEGENERATE first response is
  22. validator-caught (plan 15 D2 — never executed, one receipt, the
  23. session continues); the clean resend runs in its ephemeral
  24. worktree branch cut from the goal clone, commits, completes —
  25. worktree auto-pruned, branch kept
  26. 5. the terminal transition (the goals row's last_terminal_watermark,
  27. plan 15 C3 — goal_events is gone) coalesces (~SADDLE_GOAL_RESUME_S)
  28. into the goal agent's batched resume; it merges, deletes the task
  29. branch, pushes the goal branch (push-before-review) and submits
  30. the completion report
  31. 6. completion review accept — the live per-repo push flags gate it
  32. (plan 15 C4): the accept REFUSES while origin is behind, passes
  33. once the branch is re-pushed and the probe re-cached -> goal
  34. complete, run task done
  35. 7. the goal agent's terminal sessions (SSE replay), the audit trail
  36. (the DERIVED goal narrative, typed session_commands, the
  37. harness-authored system rows), the /status goal_branches cache
  38. sampled by the manual saddle refresh
  39. 8. the legacy ingest API surface (401s, typed updates, exactly-once
  40. poll handover) and the stm-markdown renderer
  41. Prerequisites (see README.md "Integration testing"): the web app
  42. running with SPRY_SEED_ADMIN_USERNAME=admin,
  43. SPRY_SEED_ADMIN_PASSWORD=adminpass123, SADDLE_LLM_BASE_URL pointing
  44. at the mock, SADDLE_PROJECTS_ROOT=$WORKSPACE and a FRESH database
  45. (the harness asserts an empty tasks/goals state); a saddle daemon
  46. with SADDLE_WORKSPACE_ROOT=$WORKSPACE (the shared-volume deployment
  47. assumption — the goal clone anchors the worker worktrees); a git
  48. repo with at least two commits at $WORKSPACE/demo whose origin is
  49. pushable (the harness prepares demo + demo-origin.git when WORKSPACE
  50. is empty).
  51. Environment:
  52. WEB web app base URL (default http://localhost:8080)
  53. SADDLE daemon control URL (default http://localhost:7100)
  54. SADDLE_HOST host:port to register (default localhost:7100)
  55. SADDLE_NAME registration name (default saddle-e2e-1)
  56. WORKSPACE demo repo parent dir (default /tmp/clanker-e2e/workspace)
  57. DB_PATH web app SQLite file (for audit assertions)
  58. MOCK mock_llm.py path (default <repo>/../saddle/test/mock_llm.py)
  59. MOCK_PORT local mock port (default 9999)
  60. MOCK_CMD run instead of the local mock (e.g. a podman exec line
  61. that starts the mock inside the saddle container); the
  62. script skips spawning the mock when set (the mock then
  63. needs its own --capture for the receipt assertions)
  64. Prints PASS/FAIL lines Statum-style; exits non-zero on any failure.
  65. """
  66. import json
  67. import os
  68. import socket
  69. import sqlite3
  70. import subprocess
  71. import sys
  72. import time
  73. import urllib.error
  74. import urllib.parse
  75. import urllib.request
  76. REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  77. WEB = os.environ.get("WEB", "http://localhost:8080")
  78. SADDLE = os.environ.get("SADDLE", "http://localhost:7100")
  79. SADDLE_HOST = os.environ.get("SADDLE_HOST", "localhost:7100")
  80. SADDLE_NAME = os.environ.get("SADDLE_NAME", "saddle-e2e-1")
  81. WORKSPACE = os.environ.get("WORKSPACE", "/tmp/clanker-e2e/workspace")
  82. DB_PATH = os.environ.get("DB_PATH", os.path.join(WORKSPACE, "..", "web", "clanker-saddle.sqlite"))
  83. MOCK = os.environ.get("MOCK", os.path.join(os.path.dirname(REPO), "saddle", "test", "mock_llm.py"))
  84. MOCK_PORT = os.environ.get("MOCK_PORT", "9999")
  85. MOCK_CMD = os.environ.get("MOCK_CMD", "")
  86. # The mock's request capture (one JSON line per arrival carrying the
  87. # full message list) — the receipt forensics the D-side assertions read.
  88. CAPTURE_PATH = os.environ.get("CAPTURE_PATH",
  89. os.path.join(WORKSPACE, "..", "mock-capture.jsonl"))
  90. DEMO = os.path.join(WORKSPACE, "demo")
  91. HOSTNAME = socket.gethostname().split(".")[0]
  92. PLAN_APPROVED = "Plan approved. Proceed with implementation."
  93. REVIEW_APPROVED = "Review approved. Continue with the task."
  94. passes = 0
  95. failures = 0
  96. def check(condition, label):
  97. global passes, failures
  98. if condition:
  99. passes += 1
  100. print("PASS %s" % label)
  101. else:
  102. failures += 1
  103. print("FAIL %s" % label)
  104. def http(method, url, data=None, headers=None, timeout=30):
  105. body = None
  106. if data is not None:
  107. body = "&".join("%s=%s" % (k, urllib.parse.quote(str(v))) for k, v in data.items()).encode()
  108. request = urllib.request.Request(WEB + url if url.startswith("/") else url,
  109. data=body, method=method)
  110. if data is not None:
  111. request.add_header("Content-Type", "application/x-www-form-urlencoded")
  112. for key, value in (headers or {}).items():
  113. request.add_header(key, value)
  114. try:
  115. with urllib.request.urlopen(request, timeout=timeout) as response:
  116. return response.status, response.read().decode()
  117. except urllib.error.HTTPError as error:
  118. return error.code, error.read().decode()
  119. def directives(uri, auth=None):
  120. headers = {}
  121. if auth:
  122. headers["X-Statum-Slot"] = "key=%s; as_at=%s" % (auth["key"], auth["as_at"])
  123. status, body = http("GET", "/_statum/entrypoint?uri=" + urllib.parse.quote(uri.split("?")[0]),
  124. headers=headers)
  125. if status != 200:
  126. raise SystemExit("entrypoint %s -> HTTP %d: %s" % (uri, status, body[:300]))
  127. return json.loads(body)
  128. def slot_public(auth, uri, type_name):
  129. for directive in directives(uri, auth):
  130. if directive["type"] == "set":
  131. content = json.loads(directive["snapshot_frame"]["content"])
  132. if content["slot"]["type"] == type_name:
  133. return content["public"]
  134. raise SystemExit("no %s slot in entrypoint state for %s" % (type_name, uri))
  135. def act(auth, action, fields):
  136. headers = {"X-Statum-Slot": "key=%s; as_at=%s" % (auth["key"], auth["as_at"])}
  137. if action.get("private"):
  138. headers["X-Statum-Private"] = action["private"]
  139. status, body = http(action.get("method", "POST"), action["uri"], fields, headers)
  140. if status != 200:
  141. raise SystemExit("action %s -> HTTP %d: %s" % (action["uri"], status, body[:300]))
  142. return json.loads(body)
  143. def login(username, password):
  144. frames = directives("/login")
  145. content = json.loads(frames[0]["snapshot_frame"]["content"])
  146. action = content["public"]["login"]
  147. status, body = http(action.get("method", "POST"), action["uri"],
  148. {"username": username, "password": password})
  149. if status != 200:
  150. raise SystemExit("login -> HTTP %d: %s" % (status, body[:300]))
  151. for directive in json.loads(body):
  152. if directive["type"] == "set":
  153. frame = json.loads(directive["snapshot_frame"]["content"])
  154. if frame["slot"]["type"] == "auth":
  155. return {"key": frame["slot"]["key"], "as_at": frame["as_at"]}
  156. raise SystemExit("login response carried no auth slot: " + body[:300])
  157. def saddle_status():
  158. try:
  159. status, body = http("GET", SADDLE + "/status", timeout=8)
  160. return json.loads(body) if status == 200 else None
  161. except (urllib.error.URLError, OSError):
  162. return None
  163. def wait_for(predicate, timeout=60, what="condition", interval=1.0):
  164. deadline = time.monotonic() + timeout
  165. last = None
  166. while time.monotonic() < deadline:
  167. last = predicate()
  168. if last is not False and last is not None:
  169. return last
  170. time.sleep(interval)
  171. print(" (timeout waiting for %s; last: %s)" % (what, last))
  172. return None
  173. def task_state(auth, task_id):
  174. return slot_public(auth, "/tasks/%d" % task_id, "task")
  175. def wait_task_status(auth, task_id, status, timeout=90):
  176. return wait_for(lambda: task_state(auth, task_id).get("status") == status or False,
  177. timeout=timeout, what="task %d -> %s" % (task_id, status))
  178. def goal_state(auth, goal_id):
  179. return slot_public(auth, "/goals/%d" % goal_id, "goal")
  180. def wait_goal_status(auth, goal_id, status, timeout=90):
  181. return wait_for(lambda: goal_state(auth, goal_id).get("status") == status or False,
  182. timeout=timeout, what="goal %d -> %s" % (goal_id, status))
  183. def saddle_api(method, path, token, payload=None):
  184. """Raw call against the saddle ingest API; returns (status, json)."""
  185. request = urllib.request.Request(WEB + path,
  186. data=json.dumps(payload or {}).encode(), method=method)
  187. request.add_header("Content-Type", "application/json")
  188. if token is not None:
  189. request.add_header("Authorization", "Bearer " + token)
  190. try:
  191. with urllib.request.urlopen(request, timeout=15) as response:
  192. return response.status, json.loads(response.read().decode() or "{}")
  193. except urllib.error.HTTPError as error:
  194. body = error.read().decode()
  195. try:
  196. return error.code, json.loads(body)
  197. except ValueError:
  198. return error.code, {"raw": body}
  199. def db_query(sql, params=()):
  200. db = sqlite3.connect(DB_PATH, timeout=10)
  201. try:
  202. return db.execute(sql, params).fetchall()
  203. finally:
  204. db.close()
  205. def capture_user_turns():
  206. """Every user turn the mock received, oldest first (the mock's
  207. --capture file: one JSON line per request). The D-side receipts
  208. (round outputs) ride these turns back to the model."""
  209. turns = []
  210. if os.path.exists(CAPTURE_PATH):
  211. with open(CAPTURE_PATH) as handle:
  212. for line in handle:
  213. try:
  214. arrival = json.loads(line)
  215. except ValueError:
  216. continue
  217. for message in arrival.get("messages", []):
  218. if message.get("role") == "user":
  219. turns.append(message.get("content") or "")
  220. return turns
  221. def prepare_workspace():
  222. """Ensures $WORKSPACE/demo exists with a pushable origin (the goal
  223. branch push needs a remote that accepts it). Reuses an existing
  224. demo checkout; the DATABASE must still be fresh (checked in main)."""
  225. if os.path.isdir(os.path.join(DEMO, ".git")):
  226. return
  227. os.makedirs(WORKSPACE, exist_ok=True)
  228. origin = os.path.join(WORKSPACE, "demo-origin.git")
  229. subprocess.run(["git", "init", "--bare", "-b", "main", origin], check=True,
  230. capture_output=True)
  231. subprocess.run(["git", "clone", origin, DEMO], check=True, capture_output=True)
  232. subprocess.run(["git", "-C", DEMO, "config", "user.email", "e2e@localhost"], check=True)
  233. subprocess.run(["git", "-C", DEMO, "config", "user.name", "e2e"], check=True)
  234. with open(os.path.join(DEMO, "hello.py"), "w") as handle:
  235. handle.write("print('demo')\n")
  236. subprocess.run(["git", "-C", DEMO, "add", "-A"], check=True, capture_output=True)
  237. subprocess.run(["git", "-C", DEMO, "commit", "-m", "one"], check=True, capture_output=True)
  238. with open(os.path.join(DEMO, "notes.md"), "w") as handle:
  239. handle.write("# notes\n")
  240. subprocess.run(["git", "-C", DEMO, "add", "-A"], check=True, capture_output=True)
  241. subprocess.run(["git", "-C", DEMO, "commit", "-m", "two"], check=True, capture_output=True)
  242. subprocess.run(["git", "-C", DEMO, "push", "origin", "main"], check=True, capture_output=True)
  243. def mock_argv():
  244. heredoc = "cat << 'SADDLE_EOF' | %s\n%%s\nSADDLE_EOF"
  245. fanout = (
  246. "# Fanning the plan out\n"
  247. "out=$(cat << 'SADDLE_EOF' | saddle task create --title 'Write the greeting' --mode implement\n"
  248. "Create greeting.txt containing hello-from-the-goal.\nSADDLE_EOF)\n"
  249. "tid=$(echo \"$out\" | grep -oE '[0-9]+' | head -1)\n"
  250. "saddle task enqueue \"$tid\"\n"
  251. "saddle suspend"
  252. )
  253. responses = [
  254. # R1 (fresh goal agent): commentary-ONLY (plan 15 D1) — fed
  255. # verbatim, receipt [0 command lines] plus the factual
  256. # no-commands line, conversation continues
  257. "# Reading the brief before touching anything\n"
  258. "# the demo repo is tiny; the greeting belongs in greeting.txt\n"
  259. "# nothing worth running yet — probing comes next",
  260. # R2: investigate with TRAILING comments after the commands
  261. # (comments-after-commands feeds exactly like leading ones),
  262. # then block on a scope question
  263. "# Investigating before planning\nls -la\ngit log --oneline | head -3\n"
  264. "# two commits and one file — the blast radius is greeting.txt alone\n"
  265. + (heredoc % 'saddle block question "Demo only" "Whole workspace"') % (
  266. "Which scope should the greeting cover? The demo repo is small; confirm the blast radius."),
  267. # R3 (plan revision after feedback): the rejected plan, re-submitted
  268. (heredoc % "saddle plan") % (
  269. "# Plan v2\n\n1. Add greeting.txt to the demo repo.\n2. Export it for reuse."),
  270. # W1 (the worker's first round): a DEGENERATE repetition (plan
  271. # 15 D2) — the validator catches it before feeding; nothing
  272. # executes, one rejection receipt, the round is consumed
  273. "# Starting the mandate\n" + "echo probing-the-workspace\n" * 12,
  274. # W2 (the clean resend): verify the ephemeral branch, commit, done
  275. "# Working the mandate\ngit branch --show-current\n"
  276. "echo hello-from-the-goal > greeting.txt\ngit add -A\n"
  277. "git commit -m 'greeting from the goal'\n"
  278. + (heredoc % "saddle done") % (
  279. "# Completion report\n\n- changed: greeting.txt\n"
  280. "- verified: committed on the ephemeral branch"),
  281. ]
  282. argv = []
  283. for response in responses:
  284. argv += ["-r", response]
  285. argv += [
  286. # the question's verdict ("Human selected: Demo only") -> plan v1
  287. "--on-answer", (heredoc % "saddle plan") % (
  288. "# Plan v1\n\n1. Add greeting.txt to the demo repo."),
  289. # the approval verdict -> task fan-out + suspend
  290. "--on-plan-approved", fanout,
  291. # the batched "Goal update" resume -> merge, prune, push, complete
  292. "--answer-contains", "Goal update",
  293. "--on-answer-seen",
  294. "# Merging the finished task, then completing (the sleep holds the\n"
  295. "# working state open so /status can sample the branch state)\n"
  296. "sleep 4\n"
  297. "git merge --no-edit $(git for-each-ref --format='%(refname:short)' refs/heads/task/*)\n"
  298. "git branch -D $(git for-each-ref --format='%(refname:short)' refs/heads/task/*)\n"
  299. "git push -u origin $(git branch --show-current)\n"
  300. + (heredoc % "saddle done") % (
  301. "# Goal completion report\n\n- achieved: greeting.txt added via one worker task\n"
  302. "- verified: the goal branch is pushed to origin"),
  303. "--on-query", "The hostname is %s." % HOSTNAME,
  304. # receipt forensics: every prompt the mock received, so the
  305. # harness can assert the D-side receipts reached the model
  306. "--capture", CAPTURE_PATH,
  307. ]
  308. return argv
  309. def start_mock():
  310. if MOCK_CMD:
  311. # e.g. MOCK_CMD='podman exec -d clanker-saddle python3
  312. # /usr/libexec/saddle/mock_llm.py' — the same scripted argv is
  313. # appended, so the in-container mock behaves identically.
  314. argv = MOCK_CMD.split() + mock_argv()
  315. else:
  316. argv = [sys.executable, MOCK, "--port", MOCK_PORT] + mock_argv()
  317. return subprocess.Popen(argv, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
  318. def markdown_renderer_checks():
  319. """Exercise the stm-markdown renderer shipped inside statum.js via node."""
  320. status, body = http("GET", "/_statum/resource/statum.js", timeout=15)
  321. if status != 200:
  322. check(False, "statum.js served to the browser")
  323. return
  324. check("stm-markdown" in body, "statum.js ships the stm-markdown directive")
  325. def extract(name, source):
  326. start = source.index("function %s(" % name)
  327. depth = 0
  328. index = source.index("{", start)
  329. end = index
  330. while True:
  331. if source[end] == "{":
  332. depth += 1
  333. elif source[end] == "}":
  334. depth -= 1
  335. if depth == 0:
  336. break
  337. end += 1
  338. return source[start:end + 1]
  339. try:
  340. parts = [extract(name, body)
  341. for name in ("safeMarkdownUrl", "mdLinks", "mdInline", "renderMarkdown")]
  342. except ValueError:
  343. check(False, "statum.js carries the markdown renderer functions")
  344. return
  345. harness = (
  346. "%s\n%s\n%s\n%s\n"
  347. "console.log(renderMarkdown('# Title\\n\\n- **bold** item\\n- `code` item\\n"
  348. "\\n> quoted text\\n\\n```text\\nfenced\\n```'));\n"
  349. "console.log('@@' + renderMarkdown('<script>alert(1)</script> [bad](javascript:alert(2))"
  350. " [ok](https://example.com) <img src=x onerror=alert(3)>'));\n" % tuple(parts)
  351. )
  352. try:
  353. result = subprocess.run(["node", "-e", harness], capture_output=True, text=True, timeout=20)
  354. except (OSError, subprocess.SubprocessError):
  355. check(True, "stm-markdown renderer present (node unavailable, deep check skipped)")
  356. return
  357. rendered, _, xss = result.stdout.partition("@@")
  358. check("<h1>" in rendered and "<li>" in rendered and "<code>" in rendered
  359. and "<blockquote>" in rendered and "<strong>" in rendered,
  360. "stm-markdown renders headings, lists, code, quotes, bold")
  361. check("<script" not in xss and "<img" not in xss
  362. and 'href="javascript:' not in xss and 'href="#"' in xss
  363. and '<a href="https://example.com">ok</a>' in xss,
  364. "stm-markdown escapes raw tags, defangs javascript: links, keeps safe ones")
  365. def main():
  366. prepare_workspace()
  367. if os.path.exists(CAPTURE_PATH):
  368. os.remove(CAPTURE_PATH)
  369. if db_query("select count(*) from tasks")[0][0] or db_query("select count(*) from goals")[0][0]:
  370. raise SystemExit("the web database is not fresh (tasks/goals exist) — "
  371. "wipe the DB and restart the app+daemon for a clean goal-era run")
  372. mock = start_mock()
  373. time.sleep(2.0)
  374. try:
  375. # A previous suite run (or a crashed orchestrator) can leave the
  376. # daemon parked on a block; /stop releases it so dispatch works.
  377. try:
  378. http("POST", SADDLE + "/stop", timeout=5)
  379. except (urllib.error.URLError, OSError):
  380. pass
  381. auth = login("admin", "adminpass123")
  382. check(True, "login yields an auth slot")
  383. state = slot_public(auth, "/projects", "projects")
  384. act(auth, state["create"], {
  385. "name": "demo",
  386. "git_url": DEMO,
  387. "default_branch": "main",
  388. "summary": "Integration test repository",
  389. })
  390. check(any(p["name"] == "demo" for p in slot_public(auth, "/projects", "projects")["projects"]),
  391. "project demo created (local git_url, pushable origin)")
  392. state = slot_public(auth, "/saddles", "saddles")
  393. act(auth, state["register"], {"name": SADDLE_NAME, "host": SADDLE_HOST})
  394. registered = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  395. if s["name"] == SADDLE_NAME]
  396. check(len(registered) == 1 and registered[0]["token"].startswith("saddle-"),
  397. "saddle registered at %s with an API token" % SADDLE_HOST)
  398. print("== goal create -> start ==")
  399. goals_state = slot_public(auth, "/goals", "goals")
  400. act(auth, goals_state["create"], {
  401. "project_id": "1",
  402. "title": "Greet the demo",
  403. "brief": "Add a greeting file to the demo repo end to end.",
  404. "branch_name": "goal/demo-run",
  405. "priority": "3",
  406. })
  407. goal1 = int([g["id"] for g in slot_public(auth, "/goals", "goals")["goals"]
  408. if g["title"] == "Greet the demo"][0])
  409. check(goal_state(auth, goal1)["status"] == "draft", "the goal lands in draft")
  410. act(auth, goal_state(auth, goal1)["start"], {})
  411. check(wait_goal_status(auth, goal1, "planning", timeout=30) is not None,
  412. "starting the goal moves it to planning (agent dispatched)")
  413. run_task = db_query(
  414. "select id from tasks where goal_id = ? and kind = 'goal_run'", (goal1,))[0][0]
  415. check(wait_for(lambda: task_state(auth, run_task)["status"]
  416. in ("assigned", "working", "blocked", "suspended") or False,
  417. timeout=30, what="goal run dispatched") is not None,
  418. "the goal-run task reaches the daemon (assigned/working/blocked)")
  419. goal_clone = os.path.join(WORKSPACE, "goal-%d" % goal1, "demo")
  420. check(wait_for(lambda: os.path.isdir(os.path.join(goal_clone, ".git")) or False,
  421. timeout=30, what="the goal clone") is not None,
  422. "the goal clone appears under the shared workspace root (goal-<id>)")
  423. branch = subprocess.run(["git", "-C", goal_clone, "branch", "--show-current"],
  424. capture_output=True, text=True).stdout.strip()
  425. check(branch == "goal/demo-run", "the goal clone sits on the user-named goal branch")
  426. print("== pre-plan question -> one answer, one row ==")
  427. check(wait_for(lambda: any(b["block_type"] == "question" and not b.get("answered")
  428. for b in goal_state(auth, goal1).get("blocks", [])) or False,
  429. timeout=90, what="the agent's question block") is not None,
  430. "the pre-plan question surfaces as a goal block")
  431. check(wait_task_status(auth, run_task, "blocked", timeout=30) is not None,
  432. "the goal-run task parks blocked on its own block row")
  433. question = [b for b in goal_state(auth, goal1)["blocks"]
  434. if b["block_type"] == "question" and not b.get("answered")][0]
  435. check(question["options"] == ["Demo only", "Whole workspace"],
  436. "the question carries its options")
  437. check("Which scope should the greeting cover" in question["question"],
  438. "the question title derives from the body's first line")
  439. check("confirm the blast radius" in question["body"], "the question carries the markdown body")
  440. dupes = db_query(
  441. "select count(*) from task_blocks tb join tasks t on t.id = tb.task_id"
  442. " where t.goal_id = ? and t.kind = 'goal_run' and tb.type = 'question'",
  443. (goal1,))[0][0]
  444. check(dupes == 1, "one question row despite the park-unwinding round-end push")
  445. dash = slot_public(auth, "/", "dashboard")
  446. check(any(int(r.get("goal_id") or 0) == goal1 for r in dash["pinned"]),
  447. "the dashboard pile pins the goal block")
  448. act(auth, question["answer_action"], {"option": "Demo only"})
  449. answered_rows = db_query(
  450. "select answer from task_blocks tb join tasks t on t.id = tb.task_id"
  451. " where t.goal_id = ? and t.kind = 'goal_run' and tb.type = 'question'",
  452. (goal1,))
  453. check(answered_rows and answered_rows[0][0] == "Demo only",
  454. "the ONE answer lands on the goal-run task's single row")
  455. print("== plan review: reject -> revise -> approve ==")
  456. check(wait_goal_status(auth, goal1, "plan_review", timeout=90) is not None,
  457. "the plan submission moves the goal to plan_review")
  458. check(wait_for(lambda: goal_state(auth, goal1).get("plan_pending_block") not in
  459. (None, False) or False, timeout=30, what="pending plan block") is not None,
  460. "the pending plan block renders on the goal page")
  461. pending = goal_state(auth, goal1)["plan_pending_block"]
  462. check("Plan v1" in pending["body"], "the plan block carries the submitted markdown")
  463. act(auth, pending["answer_action"], {"answer": "Feedback: also export the greeting"})
  464. check(wait_goal_status(auth, goal1, "planning", timeout=30) is not None,
  465. "rejecting with feedback returns the goal to planning")
  466. check(wait_for(lambda: goal_state(auth, goal1).get("plan_pending_block") not in
  467. (None, False) and "Plan v2" in
  468. goal_state(auth, goal1)["plan_pending_block"].get("body", "") or False,
  469. timeout=90, what="revised plan") is not None,
  470. "the agent revises and re-submits incorporating the feedback")
  471. revised = goal_state(auth, goal1)
  472. check(len([r for r in revised["plan_revisions"] if r.get("answered")]) >= 1,
  473. "the rejected revision stays discoverable behind the expander")
  474. act(auth, revised["plan_pending_block"]["answer_action"], {"answer": PLAN_APPROVED})
  475. check(wait_goal_status(auth, goal1, "active", timeout=30) is not None,
  476. "approving with the agreed wording activates the goal")
  477. print("== task fan-out via the real callbacks ==")
  478. worker_row = wait_for(lambda: db_query(
  479. "select id from tasks where goal_id = ? and kind = 'task'", (goal1,)) or False,
  480. timeout=90, what="the agent-created task")
  481. worker_task = int(worker_row[0][0])
  482. check(bool(worker_row), "the goal agent created its worker task through the callback")
  483. creator = db_query("select creator, mode from tasks where id = ?", (worker_task,))[0]
  484. check(creator == ("goal", "implement"),
  485. "the created task carries creator=goal and the requested mode")
  486. check(wait_task_status(auth, worker_task, "queued", timeout=30) is not None
  487. or task_state(auth, worker_task)["status"] in ("working", "done"),
  488. "the agent enqueued its task (creator-only rule honoured by construction)")
  489. ephemeral = "task/%d-%d" % (goal1, worker_task)
  490. check(wait_for(lambda: task_state(auth, worker_task)["status"]
  491. in ("assigned", "working", "done", "failed") or False,
  492. timeout=60, what="worker dispatched") is not None,
  493. "the dispatcher hands the worker to the freed saddle")
  494. check(wait_for(lambda: db_query(
  495. "select suspended_at is not null from tasks where id = ?", (run_task,))[0][0] or False,
  496. timeout=30, what="the goal agent parks") is not None,
  497. "the goal agent parked suspended after fanning out (suspended_at stamped)")
  498. print("== the worker's ephemeral worktree ==")
  499. branch_rows = wait_for(lambda: db_query(
  500. "select sc.output_text from session_commands sc join sessions s"
  501. " on s.id = sc.session_id where s.task_id = ?"
  502. " and sc.command_text like '%git branch --show-current%'", (worker_task,)) or False,
  503. timeout=90, what="the worker's branch probe")
  504. check(branch_rows and ephemeral in (branch_rows[0][0] or ""),
  505. "the worker runs inside its ephemeral branch %s" % ephemeral)
  506. worktree_dir = os.path.join(WORKSPACE, "task-%d" % worker_task)
  507. check(wait_for(lambda: task_state(auth, worker_task)["status"] == "done" or False,
  508. timeout=120, what="worker done") is not None,
  509. "the worker completes with its report")
  510. detail = task_state(auth, worker_task)
  511. check("greeting.txt" in (detail.get("summary") or ""),
  512. "the worker summary carries its completion report")
  513. check(not os.path.exists(worktree_dir), "the worker's worktree auto-pruned on done")
  514. kept = subprocess.run(["git", "-C", goal_clone, "rev-parse", "--verify", ephemeral],
  515. capture_output=True).returncode == 0
  516. check(kept, "the ephemeral branch survives in the goal clone for the merge")
  517. print("== coalesced resume -> merge -> push -> completion review ==")
  518. check(wait_for(lambda: any(
  519. e["kind"] == "task-done" and int(e["task_id"]) == worker_task
  520. for e in goal_state(auth, goal1).get("events", [])) or False,
  521. timeout=30, what="the task-done narrative row") is not None,
  522. "the worker's terminal transition lands in the derived goal narrative")
  523. # the goal agent's /status reports the branch push state while it
  524. # works; sample the moment the resumed agent is mid-round, then
  525. # cache it through the manual saddle refresh
  526. check(wait_for(lambda: (saddle_status() or {}).get("goal_branches") or False,
  527. timeout=90, what="the resumed agent exposes goal_branches") is not None,
  528. "the daemon's /status exposes goal_branches while the goal agent works")
  529. saddles_state = slot_public(auth, "/saddles", "saddles")
  530. card = [s for s in saddles_state["saddles"] if s["name"] == SADDLE_NAME][0]
  531. act(auth, card["refresh"], {})
  532. check(wait_for(lambda: any(
  533. "demo" in (b.get("branch") or "") and isinstance(b.get("pushed"), bool)
  534. for b in goal_state(auth, goal1).get("push_branches", []))
  535. or False, timeout=60, what="cached push state") is not None,
  536. "the refresh probe caches the branch push state on the goal (live chips)")
  537. check(wait_goal_status(auth, goal1, "completion_review", timeout=120) is not None,
  538. "the resumed agent merges, pushes and enters completion review")
  539. state = goal_state(auth, goal1)
  540. check("greeting.txt added via one worker task" in state.get("completion_report", ""),
  541. "the completion report renders on the goal page")
  542. merged = subprocess.run(
  543. ["git", "-C", goal_clone, "show", "goal/demo-run:greeting.txt"],
  544. capture_output=True, text=True).stdout
  545. check("hello-from-the-goal" in merged, "the merged task branch landed on the goal branch")
  546. deleted = subprocess.run(["git", "-C", goal_clone, "rev-parse", "--verify", ephemeral],
  547. capture_output=True).returncode != 0
  548. check(deleted, "the goal agent deleted the merged task branch")
  549. pushed = subprocess.run(["git", "-C", DEMO, "rev-parse", "--verify", "goal/demo-run"],
  550. capture_output=True).returncode == 0
  551. check(pushed, "the goal branch was pushed to origin (push-before-review)")
  552. print("== accept refuses while origin is behind (the live push gate) ==")
  553. # Rewind origin's goal branch to the pre-merge base and refresh
  554. # the daemon probe: the live per-repo flags flip to unpushed and
  555. # the completion accept must REFUSE (plan 15 C4 / F9) until the
  556. # branch is pushed again — enforcing push-after-each-merge at
  557. # the human gate, read from probe state (never a snapshot).
  558. completion = state["completion_pending_block"]
  559. subprocess.run(["git", "-C", goal_clone, "push", "-f", "origin",
  560. "main:refs/heads/goal/demo-run"], check=True, capture_output=True)
  561. subprocess.run(["git", "-C", goal_clone, "fetch", "origin"],
  562. check=True, capture_output=True)
  563. act(auth, card["refresh"], {})
  564. check(wait_for(lambda: any(
  565. "demo" in (b.get("branch") or "") and b.get("pushed") is False
  566. for b in goal_state(auth, goal1).get("push_branches", [])) or False,
  567. timeout=60, what="unpushed chip") is not None,
  568. "the rewound origin flips the live push chip to unpushed")
  569. refusal = act(auth, completion["answer_action"], {"answer": REVIEW_APPROVED})
  570. check(any(d.get("type") == "notify" and d.get("kind") == "error"
  571. and "Origin is behind" in (d.get("message") or "")
  572. for d in refusal),
  573. "the accept refuses with a clear message while origin is behind")
  574. check(goal_state(auth, goal1)["status"] == "completion_review",
  575. "the refused accept leaves the goal in completion review")
  576. check(goal_state(auth, goal1)["completion_pending_block"]
  577. not in (None, False),
  578. "the refused accept leaves the completion block unanswered")
  579. subprocess.run(["git", "-C", goal_clone, "push", "origin", "goal/demo-run"],
  580. check=True, capture_output=True)
  581. act(auth, card["refresh"], {})
  582. check(wait_for(lambda: any(
  583. "demo" in (b.get("branch") or "") and b.get("pushed") is True
  584. for b in goal_state(auth, goal1).get("push_branches", [])) or False,
  585. timeout=60, what="pushed chip restored") is not None,
  586. "re-pushing the branch restores the live push chip (after push)")
  587. completion = goal_state(auth, goal1)["completion_pending_block"]
  588. print("== completion accept ==")
  589. check(completion and completion.get("body", "") == state.get("completion_report", ""),
  590. "the run task's completion block carries the report")
  591. accept = act(auth, completion["answer_action"], {"answer": REVIEW_APPROVED})
  592. check(not any(d.get("type") == "notify" and d.get("kind") == "error" for d in accept),
  593. "the accept passes once the push flags read pushed")
  594. check(wait_goal_status(auth, goal1, "complete", timeout=30) is not None,
  595. "accepting with the agreed wording completes the goal")
  596. check(wait_task_status(auth, run_task, "done", timeout=30) is not None,
  597. "the goal-run task closes done with the goal")
  598. print("== audit trail (the derived narrative) ==")
  599. narrative = goal_state(auth, goal1)["events"]
  600. kinds = [e["kind"] for e in narrative]
  601. for expected in ("started", "block-raised", "plan-submitted", "plan-rejected",
  602. "plan-approved", "task-created", "task-done",
  603. "completion-submitted", "goal-approved", "system"):
  604. check(expected in kinds, "the derived narrative records %s" % expected)
  605. check(all(k != "task-enqueued" for k in kinds),
  606. "timestamp-less bookkeeping never returns (task-enqueued is gone)")
  607. check(db_query(
  608. "select count(*) from sqlite_master where type = 'table'"
  609. " and name = 'goal_events'")[0][0] == 0,
  610. "the goal_events table is gone (nothing is written on ingest)")
  611. run_types = {row[0] for row in db_query(
  612. "select distinct sc.type from session_commands sc join sessions s"
  613. " on s.id = sc.session_id where s.task_id = ?", (run_task,))}
  614. check({"bash", "block", "plan"} <= run_types,
  615. "the goal agent's terminal carries typed entries (%s)" % sorted(run_types))
  616. worker_types = {row[0] for row in db_query(
  617. "select distinct sc.type from session_commands sc join sessions s"
  618. " on s.id = sc.session_id where s.task_id = ?", (worker_task,))}
  619. check({"bash", "done"} <= worker_types,
  620. "the worker's terminal carries typed entries (%s)" % sorted(worker_types))
  621. print("== the terminal watermark (C3) drives the resume ==")
  622. watermark_row = db_query(
  623. "select last_terminal_watermark from goals where id = ?", (goal1,))
  624. worker_done_at = db_query(
  625. "select completed_at from tasks where id = ?", (worker_task,))[0][0]
  626. check(watermark_row[0][0] is not None and worker_done_at is not None
  627. and watermark_row[0][0] >= worker_done_at,
  628. "the goals row's terminal watermark advanced past the worker's transition"
  629. " (the resume consumed it)")
  630. print("== system rows on the ONE thread (C2) ==")
  631. thread = goal_state(auth, goal1)["comments"]
  632. system_rows = [c for c in thread if c.get("is_system")]
  633. check(system_rows and all(c["author"] == "system" for c in system_rows)
  634. and not any(c.get("from_agent") for c in system_rows)
  635. and any("session context restored" in c["body"] for c in system_rows)
  636. and any("parked until an owned task" in c["body"] for c in system_rows),
  637. "the harness bookkeeping (restore, park) rides the thread as system rows,"
  638. " never agent rows")
  639. run_thread = task_state(auth, run_task)["comments"]
  640. check(any(c.get("is_system") and "session context restored" in c["body"]
  641. for c in run_thread),
  642. "the task page reads the same thread (the system rows included)")
  643. check(not any(c.get("from_agent") and "[harness]" in c["body"]
  644. for c in thread),
  645. "no harness-authored row renders as agent content")
  646. print("== D-side through both binaries: commentary + degenerate ==")
  647. run_bash = db_query(
  648. "select sc.command_text from session_commands sc join sessions s"
  649. " on s.id = sc.session_id where s.task_id = ? and sc.type = 'bash'",
  650. (run_task,))
  651. check(any("nothing worth running yet" in (row[0] or "")
  652. and "Reading the brief" in (row[0] or "") for row in run_bash),
  653. "the narration-only response was fed verbatim (comments are style, not policy)")
  654. check(any("git log --oneline" in (row[0] or "")
  655. and "blast radius is greeting.txt alone" in (row[0] or "")
  656. for row in run_bash),
  657. "trailing comments after commands rode the same round (no rejection)")
  658. rejected = db_query(
  659. "select sc.command_text, sc.output_text from session_commands sc"
  660. " join sessions s on s.id = sc.session_id"
  661. " where sc.type = 'rejected' and s.task_id in (?, ?)",
  662. (run_task, worker_task))
  663. check(len(rejected) == 1 and rejected[0][0] == "degenerate response"
  664. and "degenerate signature" in (rejected[0][1] or "")
  665. and "echo probing-the-workspace" in (rejected[0][1] or "")
  666. and "Resend the intended action only" in (rejected[0][1] or ""),
  667. "exactly ONE rejected entry — the degenerate repetition, caught with its receipt")
  668. worker_bash = db_query(
  669. "select sc.command_text from session_commands sc join sessions s"
  670. " on s.id = sc.session_id where s.task_id = ? and sc.type = 'bash'",
  671. (worker_task,))
  672. check(not any("probing-the-workspace" in (row[0] or "") for row in worker_bash),
  673. "the degenerate script never executed (validator-caught before feeding)")
  674. turns = capture_user_turns()
  675. check(any(turn.startswith("[0 command lines executed")
  676. and "no commands ran — narrate in the same response as your next command" in turn
  677. for turn in turns),
  678. "the narration round's receipt reached the model (no-commands line)")
  679. check(any("degenerate signature" in turn and "Resend the intended action only" in turn
  680. for turn in turns),
  681. "the degenerate rejection receipt reached the model as its next turn")
  682. print("== SSE ==")
  683. run_session = db_query(
  684. "select sc.session_id from session_commands sc join sessions s on s.id = sc.session_id"
  685. " where s.task_id = ? and sc.type = 'plan' order by sc.id limit 1",
  686. (run_task,))[0][0]
  687. state = goal_state(auth, goal1)
  688. check(any(r.get("terminal_url", "").endswith("/terminal?session_id=%d" % run_session)
  689. for r in state.get("runs", [])),
  690. "the goal page's run list deep-links each session terminal")
  691. request = urllib.request.Request(WEB + "/terminal-stream/%d" % run_session)
  692. with urllib.request.urlopen(request, timeout=15) as stream:
  693. check(stream.headers.get("Content-Type", "").startswith("text/event-stream"),
  694. "terminal stream is text/event-stream")
  695. entries = []
  696. deadline = time.monotonic() + 10
  697. while time.monotonic() < deadline and len(entries) < 120:
  698. if any(e.get("type") == "done" for e in entries):
  699. break
  700. line = stream.readline().decode().strip()
  701. if not line.startswith("data: ") or line == "data: ping":
  702. continue
  703. entries.append(json.loads(line[6:]))
  704. check(len(entries) >= 4, "terminal stream replays the run's backlog (%d entries)" % len(entries))
  705. # push-era sessions stream their raw PTY log (the typed
  706. # commands live in session_commands) — the plan submission
  707. # shows as its echoed heredoc in the raw replay
  708. check(any("saddle plan" in json.dumps(e) for e in entries),
  709. "terminal stream replay carries the plan submission round")
  710. pulse = directives("/goals/%d" % goal1, auth)
  711. check(any(d.get("type") == "subscribe" for d in pulse),
  712. "goal page subscribes to the pulse channel for live refresh")
  713. print("== legacy ingest API surface ==")
  714. saddles_state = slot_public(auth, "/saddles", "saddles")
  715. act(auth, saddles_state["register"], {"name": "saddle-e2e-api", "host": "localhost:7199"})
  716. api_rows = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  717. if s["name"] == "saddle-e2e-api"]
  718. check(len(api_rows) == 1 and api_rows[0]["token"].startswith("saddle-"),
  719. "registration returns an API token")
  720. api_saddle = int(api_rows[0]["id"])
  721. api_token = api_rows[0]["token"]
  722. tasks_state = slot_public(auth, "/tasks", "tasks")
  723. act(auth, tasks_state["create"], {
  724. "project_id": "1",
  725. "title": "API surface probe",
  726. "description": "Typed updates, exactly-once poll",
  727. "priority": "6",
  728. "task_branch": "task/api-surface",
  729. })
  730. api_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  731. if t["title"] == "API surface probe"][0])
  732. db = sqlite3.connect(DB_PATH)
  733. db.execute("insert into sessions (task_id, saddle_id, started_at)"
  734. " values (?, ?, strftime('%s','now'))", (api_task, api_saddle))
  735. db.execute("update saddles set current_task_id = ?, status = 'idle' where id = ?",
  736. (api_task, api_saddle))
  737. db.execute("update tasks set status = 'working' where id = ?", (api_task,))
  738. db.commit()
  739. api_session = db.execute("select max(id) from sessions").fetchone()[0]
  740. db.close()
  741. status, _ = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, None,
  742. {"type": "comment", "session_id": api_session, "data": {"body": "x"}})
  743. check(status == 401, "update without a token is rejected")
  744. status, _ = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, "saddle-not-a-token",
  745. {"type": "comment", "session_id": api_session, "data": {"body": "x"}})
  746. check(status == 401, "update with an invalid token is rejected")
  747. status, body = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, api_token,
  748. {"type": "comment", "session_id": api_session,
  749. "data": {"body": "Surveyed the module"}})
  750. check(status == 200 and body.get("ok"), "comment update accepted with token")
  751. status, body = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, api_token,
  752. {"type": "block", "session_id": api_session, "data": {
  753. "question": "Which database?",
  754. "type": "question",
  755. "options": ["SQLite", "Postgres"],
  756. "body": "# Pick one"}})
  757. check(status == 200 and body.get("block_id"), "typed block returns block_id")
  758. status, _ = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, api_token,
  759. {"type": "output", "session_id": api_session, "data": {
  760. "command": "cat notes.md | saddle query 'meaning?'",
  761. "output": "42",
  762. "type": "query",
  763. "input": "# notes\nmeaning of life"}})
  764. check(status == 200, "query output update accepted")
  765. status, _ = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, api_token,
  766. {"type": "progress", "session_id": api_session,
  767. "data": {"summary": "Migrated the schemas"}})
  768. check(status == 200, "progress update accepted")
  769. status, _ = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, api_token,
  770. {"type": "review", "session_id": api_session,
  771. "data": {"summary": "Migration **ready** for review"}})
  772. check(status == 200, "review update accepted")
  773. status, body = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, api_token,
  774. {"type": "plan", "session_id": api_session,
  775. "data": {"plan": "# Plan\n- migrate schemas"}})
  776. check(status == 400 and "gone" in (body.get("error") or ""),
  777. "the task-plan update type fails loudly (plan 12 removal)")
  778. status, body = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, api_token,
  779. {"type": "subtask", "session_id": api_session, "data": {}})
  780. check(status == 400 and "gone" in (body.get("error") or ""),
  781. "the subtask update type fails loudly (plan 12 removal)")
  782. detail = task_state(auth, api_task)
  783. check(detail["status"] == "review_requested", "review flips the task status")
  784. check([b["options"] for b in detail["blocks"]
  785. if b["block_type"] == "question"] == [["SQLite", "Postgres"]],
  786. "question block carries its options")
  787. check(detail["progress"][0]["summary"] == "Migrated the schemas",
  788. "progress history newest-first")
  789. question = [b for b in detail["blocks"] if b["block_type"] == "question"][0]
  790. act(auth, question["answer_action"], {"option": "SQLite"})
  791. check([b["answer"] for b in task_state(auth, api_task)["blocks"]
  792. if b["block_type"] == "question"] == ["SQLite"],
  793. "option answer stored verbatim (the resume notice formats it)")
  794. act(auth, detail["comment"],
  795. {"body": "Check the migration output", "quote": "ERROR: table demos exists"})
  796. status, body = saddle_api("GET", "/api/saddle/%d/poll" % api_saddle, api_token)
  797. check(any(a["answer"] == "SQLite" for a in body.get("answers", [])),
  798. "poll hands over the block answer")
  799. check(any(c["body"] == "Check the migration output" and "ERROR" in c["quote"]
  800. for c in body.get("comments", [])),
  801. "poll hands over the quoted user comment")
  802. status, body = saddle_api("GET", "/api/saddle/%d/poll" % api_saddle, api_token)
  803. check(body.get("answers") == [] and body.get("comments") == [],
  804. "poll delivers answers and comments exactly once")
  805. act(auth, detail["cancel"], {"task_id": str(api_task)})
  806. print("== stm-markdown ==")
  807. markdown_renderer_checks()
  808. print("== quiesce ==")
  809. check(wait_for(
  810. lambda: (lambda rows: all(int(s["current_task_id"] or 0) == 0 and s["status"] == "idle"
  811. for s in rows))(
  812. [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  813. if s["name"] == SADDLE_NAME]) or False,
  814. timeout=30, what="saddle idle with no current task") is not None,
  815. "saddle freed and idle once the goal finished")
  816. finally:
  817. if mock is not None:
  818. mock.terminate()
  819. print("%d passed, %d failed" % (passes, failures))
  820. return 1 if failures else 0
  821. if __name__ == "__main__":
  822. sys.exit(main())