integration_test.py 64 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144
  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. tool-call script — both binaries, the true cross-binary contract proof
  8. (the per-repo fake-peer suits live in web_stack_test.py / saddle e2e).
  9. The protocol-native goal-era arc (PLAN-17; every agent turn is a tool
  10. call, questions ride `saddle block` through run_command):
  11. 1. goal create -> start: the goal-run assignment reaches the daemon
  12. over the ask/allocate channel with the full plan-12 payload (kind/
  13. mode/creator/goal context/ephemeral branches for workers)
  14. 2. a narrated probe round (turn_text + tool_call/tool_result events in
  15. the ledger) precedes the pre-plan question block -> answered on the
  16. goal page -> the ONE answer resolves the goal-run task's single
  17. task_blocks row and the parked goal agent resumes with the verdict
  18. 3. submit_plan -> reject-with-feedback -> revision -> approval (the
  19. agreed verdict words ride the resume)
  20. 4. task fan-out through the create_task/enqueue_task tools (the real
  21. /api/saddle/task/create + enqueue callbacks); the worker's repeated
  22. identical run_command probes trip the loop warning (never fatal),
  23. then the clean run works in its ephemeral worktree branch cut from
  24. the goal clone, commits, finishes — worktree auto-pruned, branch
  25. kept
  26. 5. the terminal transition (the goals row's last_terminal_watermark)
  27. coalesces (~SADDLE_GOAL_RESUME_S) into the goal agent's batched
  28. resume; it merges, deletes the task branch, pushes the goal branch
  29. (push-before-review, enforced daemon-side at the completion
  30. attempt) and submit_report enters completion review
  31. 6. completion review accept -> goal complete, run task done (no
  32. web-side push gate: the daemon already refused the completion
  33. attempt until every goal branch was on origin)
  34. 7. the audit trail: the DERIVED goal narrative, the unified events
  35. ledger (turn cards with tool_call/tool_result, usage rows, the §7.2
  36. kind set, web-originated-only comment kinds), the event log page +
  37. SSE stream, the /status goal_branches cache sampled by the manual
  38. saddle refresh
  39. 8. the push wire surface (401s on the id-less routes, the §7.2 chunks
  40. ingestion with (session_id, seq) exactly-once, block raises riding
  41. POST /api/saddle/results, the retired results-entries array
  42. consumed silently, the deleted poll-era endpoints 404ing) and the
  43. stm-markdown renderer
  44. Prerequisites (see README.md "Integration testing"): the web app
  45. running with SPRY_SEED_ADMIN_USERNAME=admin,
  46. SPRY_SEED_ADMIN_PASSWORD=adminpass123, SADDLE_LLM_BASE_URL pointing
  47. at the mock (the one-time model-config bootstrap seed — the database
  48. row it creates is the live configuration), SADDLE_PROJECTS_ROOT=$WORKSPACE
  49. and a FRESH database
  50. (the harness asserts an empty tasks/goals state); a saddle daemon
  51. with SADDLE_WORKSPACE_ROOT=$WORKSPACE (the shared-volume deployment
  52. assumption — the goal clone anchors the worker worktrees); a git
  53. repo with at least two commits at $WORKSPACE/demo whose origin is
  54. pushable (the harness prepares demo + demo-origin.git when WORKSPACE
  55. is empty).
  56. Environment:
  57. WEB web app base URL (default http://localhost:8180 —
  58. 8080 is the production port, never the e2e default)
  59. SADDLE daemon control URL (default http://localhost:7100)
  60. SADDLE_HOST host:port to register (default localhost:7100)
  61. SADDLE_NAME registration name (default saddle-e2e-1)
  62. WORKSPACE demo repo parent dir (default /tmp/clanker-e2e/workspace)
  63. DB_PATH web app SQLite file (for audit assertions)
  64. MOCK mock_llm.py path (default <repo>/../saddle/test/mock_llm.py)
  65. MOCK_PORT local mock port (default 9999)
  66. MOCK_CMD run instead of the local mock (e.g. a podman exec line
  67. that starts the mock inside the saddle container); the
  68. script skips spawning the mock when set (the mock then
  69. needs its own --capture for the receipt assertions)
  70. Prints PASS/FAIL lines Statum-style; exits non-zero on any failure.
  71. """
  72. import json
  73. import os
  74. import socket
  75. import sqlite3
  76. import subprocess
  77. import sys
  78. import time
  79. import urllib.error
  80. import urllib.parse
  81. import urllib.request
  82. REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  83. WEB = os.environ.get("WEB", "http://localhost:8180")
  84. SADDLE = os.environ.get("SADDLE", "http://localhost:7100")
  85. SADDLE_HOST = os.environ.get("SADDLE_HOST", "localhost:7100")
  86. SADDLE_NAME = os.environ.get("SADDLE_NAME", "saddle-e2e-1")
  87. WORKSPACE = os.environ.get("WORKSPACE", "/tmp/clanker-e2e/workspace")
  88. DB_PATH = os.environ.get("DB_PATH", os.path.join(WORKSPACE, "..", "web", "clanker-saddle.sqlite"))
  89. MOCK = os.environ.get("MOCK", os.path.join(os.path.dirname(REPO), "saddle", "test", "mock_llm.py"))
  90. MOCK_PORT = os.environ.get("MOCK_PORT", "9999")
  91. MOCK_CMD = os.environ.get("MOCK_CMD", "")
  92. # The mock's request capture (one JSON line per arrival carrying the
  93. # full message list) — the receipt forensics the D-side assertions read.
  94. CAPTURE_PATH = os.environ.get("CAPTURE_PATH",
  95. os.path.join(WORKSPACE, "..", "mock-capture.jsonl"))
  96. DEMO = os.path.join(WORKSPACE, "demo")
  97. HOSTNAME = socket.gethostname().split(".")[0]
  98. # The verdict wording is SERVER-OWNED (plan 16 S2/R4): the harness
  99. # posts `verdict` markers / `feedback` text and never the constants.
  100. passes = 0
  101. failures = 0
  102. def check(condition, label):
  103. global passes, failures
  104. if condition:
  105. passes += 1
  106. print("PASS %s" % label)
  107. else:
  108. failures += 1
  109. print("FAIL %s" % label)
  110. def http(method, url, data=None, headers=None, timeout=30):
  111. body = None
  112. if data is not None:
  113. body = "&".join("%s=%s" % (k, urllib.parse.quote(str(v))) for k, v in data.items()).encode()
  114. request = urllib.request.Request(WEB + url if url.startswith("/") else url,
  115. data=body, method=method)
  116. if data is not None:
  117. request.add_header("Content-Type", "application/x-www-form-urlencoded")
  118. for key, value in (headers or {}).items():
  119. request.add_header(key, value)
  120. try:
  121. with urllib.request.urlopen(request, timeout=timeout) as response:
  122. return response.status, response.read().decode()
  123. except urllib.error.HTTPError as error:
  124. return error.code, error.read().decode()
  125. def directives(uri, auth=None):
  126. headers = {}
  127. if auth:
  128. headers["X-Statum-Slot"] = "key=%s; as_at=%s" % (auth["key"], auth["as_at"])
  129. status, body = http("GET", "/_statum/entrypoint?uri=" + urllib.parse.quote(uri.split("?")[0]),
  130. headers=headers)
  131. if status != 200:
  132. raise SystemExit("entrypoint %s -> HTTP %d: %s" % (uri, status, body[:300]))
  133. return json.loads(body)
  134. def slot_public(auth, uri, type_name):
  135. for directive in directives(uri, auth):
  136. if directive["type"] == "set":
  137. content = json.loads(directive["snapshot_frame"]["content"])
  138. if content["slot"]["type"] == type_name:
  139. return content["public"]
  140. raise SystemExit("no %s slot in entrypoint state for %s" % (type_name, uri))
  141. def act(auth, action, fields):
  142. headers = {"X-Statum-Slot": "key=%s; as_at=%s" % (auth["key"], auth["as_at"])}
  143. if action.get("private"):
  144. headers["X-Statum-Private"] = action["private"]
  145. status, body = http(action.get("method", "POST"), action["uri"], fields, headers)
  146. if status != 200:
  147. raise SystemExit("action %s -> HTTP %d: %s" % (action["uri"], status, body[:300]))
  148. return json.loads(body)
  149. def login(username, password):
  150. frames = directives("/login")
  151. content = json.loads(frames[0]["snapshot_frame"]["content"])
  152. action = content["public"]["login"]
  153. status, body = http(action.get("method", "POST"), action["uri"],
  154. {"username": username, "password": password})
  155. if status != 200:
  156. raise SystemExit("login -> HTTP %d: %s" % (status, body[:300]))
  157. for directive in json.loads(body):
  158. if directive["type"] == "set":
  159. frame = json.loads(directive["snapshot_frame"]["content"])
  160. if frame["slot"]["type"] == "auth":
  161. return {"key": frame["slot"]["key"], "as_at": frame["as_at"]}
  162. raise SystemExit("login response carried no auth slot: " + body[:300])
  163. def saddle_status():
  164. try:
  165. status, body = http("GET", SADDLE + "/status", timeout=8)
  166. return json.loads(body) if status == 200 else None
  167. except (urllib.error.URLError, OSError):
  168. return None
  169. def wait_for(predicate, timeout=60, what="condition", interval=1.0):
  170. deadline = time.monotonic() + timeout
  171. last = None
  172. while time.monotonic() < deadline:
  173. last = predicate()
  174. if last is not False and last is not None:
  175. return last
  176. time.sleep(interval)
  177. print(" (timeout waiting for %s; last: %s)" % (what, last))
  178. return None
  179. def task_state(auth, task_id):
  180. return slot_public(auth, "/tasks/%d" % task_id, "task")
  181. def wait_task_status(auth, task_id, status, timeout=90):
  182. return wait_for(lambda: task_state(auth, task_id).get("status") == status or False,
  183. timeout=timeout, what="task %d -> %s" % (task_id, status))
  184. def goal_state(auth, goal_id):
  185. return slot_public(auth, "/goals/%d" % goal_id, "goal")
  186. def wait_goal_status(auth, goal_id, status, timeout=90):
  187. return wait_for(lambda: goal_state(auth, goal_id).get("status") == status or False,
  188. timeout=timeout, what="goal %d -> %s" % (goal_id, status))
  189. def saddle_api(method, path, token, payload=None):
  190. """Raw call against the saddle ingest API; returns (status, json)."""
  191. request = urllib.request.Request(WEB + path,
  192. data=json.dumps(payload or {}).encode(), method=method)
  193. request.add_header("Content-Type", "application/json")
  194. if token is not None:
  195. request.add_header("Authorization", "Bearer " + token)
  196. try:
  197. with urllib.request.urlopen(request, timeout=15) as response:
  198. return response.status, json.loads(response.read().decode() or "{}")
  199. except urllib.error.HTTPError as error:
  200. body = error.read().decode()
  201. try:
  202. return error.code, json.loads(body)
  203. except ValueError:
  204. return error.code, {"raw": body}
  205. def db_query(sql, params=()):
  206. db = sqlite3.connect(DB_PATH, timeout=10)
  207. try:
  208. return db.execute(sql, params).fetchall()
  209. finally:
  210. db.close()
  211. def capture_messages():
  212. """Every message the mock received, oldest first (the mock's
  213. --capture file: one JSON line per request). Tool results ride back
  214. to the model as role=tool turns; harness notices (loop warnings,
  215. verdicts) as role=user turns."""
  216. messages = []
  217. if os.path.exists(CAPTURE_PATH):
  218. with open(CAPTURE_PATH) as handle:
  219. for line in handle:
  220. try:
  221. arrival = json.loads(line)
  222. except ValueError:
  223. continue
  224. for message in arrival.get("messages", []):
  225. if message.get("role") in ("user", "tool"):
  226. messages.append((message.get("role"),
  227. message.get("content") or ""))
  228. return messages
  229. def prepare_workspace():
  230. """Ensures $WORKSPACE/demo exists with a pushable origin (the goal
  231. branch push needs a remote that accepts it). Reuses an existing
  232. demo checkout; the DATABASE must still be fresh (checked in main)."""
  233. if os.path.isdir(os.path.join(DEMO, ".git")):
  234. return
  235. os.makedirs(WORKSPACE, exist_ok=True)
  236. origin = os.path.join(WORKSPACE, "demo-origin.git")
  237. subprocess.run(["git", "init", "--bare", "-b", "main", origin], check=True,
  238. capture_output=True)
  239. subprocess.run(["git", "clone", origin, DEMO], check=True, capture_output=True)
  240. subprocess.run(["git", "-C", DEMO, "config", "user.email", "e2e@localhost"], check=True)
  241. subprocess.run(["git", "-C", DEMO, "config", "user.name", "e2e"], check=True)
  242. with open(os.path.join(DEMO, "hello.py"), "w") as handle:
  243. handle.write("print('demo')\n")
  244. subprocess.run(["git", "-C", DEMO, "add", "-A"], check=True, capture_output=True)
  245. subprocess.run(["git", "-C", DEMO, "commit", "-m", "one"], check=True, capture_output=True)
  246. with open(os.path.join(DEMO, "notes.md"), "w") as handle:
  247. handle.write("# notes\n")
  248. subprocess.run(["git", "-C", DEMO, "add", "-A"], check=True, capture_output=True)
  249. subprocess.run(["git", "-C", DEMO, "commit", "-m", "two"], check=True, capture_output=True)
  250. subprocess.run(["git", "-C", DEMO, "push", "origin", "main"], check=True, capture_output=True)
  251. def tool_call(name, arguments):
  252. return "tool:%s:%s" % (name, json.dumps(arguments))
  253. def parse_call(spec):
  254. """One mock call spec back into the {name, arguments} shape the
  255. JSON array and content+calls grammars expect."""
  256. name, arguments = spec[len("tool:"):].split(":", 1)
  257. return {"name": name, "arguments": json.loads(arguments)}
  258. def mock_argv():
  259. """The protocol-native arc, one scripted assistant turn each,
  260. consumed in strict arrival order (single daemon, sequential
  261. sessions: goal run parks before the worker starts, the worker
  262. finishes before the goal agent's terminal-event resume)."""
  263. responses = [
  264. # G1 (fresh goal agent): narrated probe — turn_text plus one
  265. # tool call, the protocol-native shape of the old commentary
  266. # round
  267. json.dumps({"content": "Reading the brief before touching anything — the demo repo is"
  268. " tiny; the greeting belongs in greeting.txt.",
  269. "calls": [parse_call(tool_call("run_command", {"command": "ls -la"}))]}),
  270. # G2: investigate, then block on a scope question (the
  271. # ask_question tool parks the session for the human)
  272. json.dumps({"content": "Two commits and one file — the blast radius is greeting.txt"
  273. " alone; confirming the scope with the human.",
  274. "calls": [parse_call(tool_call("run_command", {"command": "git log --oneline | head -3"}))]}),
  275. tool_call("ask_question", {"question": "Which scope should the greeting cover?",
  276. "options": ["Demo only", "Whole workspace"],
  277. "context": "The demo repo is small; confirm the blast radius."}),
  278. # G3 (answer resume): submit the plan -> plan review park
  279. tool_call("submit_plan", {"markdown": "# Plan v1\n\n1. Add greeting.txt to the demo repo."}),
  280. # G4 (feedback resume): the revision incorporating the feedback
  281. tool_call("submit_plan", {"markdown": "# Plan v2\n\n1. Add greeting.txt to the demo repo.\n"
  282. "2. Export it for reuse."}),
  283. # G5/G6 (approval resume): fan out through the real callbacks,
  284. # then park suspended (enqueue + suspend tolerate sharing one
  285. # message — the harness executes them in order)
  286. tool_call("create_task", {"title": "Write the greeting", "mode": "implement",
  287. "brief": "Create greeting.txt containing hello-from-the-goal."}),
  288. json.dumps([parse_call(tool_call("enqueue_task", {"task_id": 2})),
  289. parse_call(tool_call("suspend", {}))]),
  290. # W1..W5 (the worker): five IDENTICAL probes — the loop
  291. # detector injects its warning user turn after the fifth,
  292. # never refusing execution
  293. ] + [tool_call("run_command", {"command": "echo probing-the-workspace"})] * 5 + [
  294. # W6: post a durable remark (§7.2 comment kind — the body is
  295. # the record; the web renders it as a narrative footnote)
  296. tool_call("add_comment", {"body": "Probing done — the mandate starts on the"
  297. " ephemeral branch."}),
  298. # W6b (goal 9): query_llm — a real command whose output stays
  299. # HIDDEN from this model (only the side LLM sees it; its answer
  300. # comes back as the tool result)
  301. tool_call("query_llm", {"command": "printf 'CRF=-18\\nPRESET=slow\\nTUNE=film'"
  302. " && printf ' usage: greet --style <flavour>'",
  303. "question": "Which usage line does the greeter print?"}),
  304. # W6c (goal 9): save_note — a durable project note through the
  305. # real /api/saddle/note callback
  306. tool_call("save_note", {"body": "The ephemeral branch is task/<goal>-<task>;"
  307. " greet with hello-from-the-goal."}),
  308. # W6d (goal 9): yeehaw — the celebration rides the ledger as a
  309. # yeehaw event the task log renders
  310. tool_call("yeehaw", {"message": "greeting shipped from the ephemeral branch —"
  311. " a genuinely fun task"}),
  312. # W7: the clean mandate — verify the ephemeral branch, commit
  313. json.dumps({"content": "Loop flagged — switching to the mandate on the ephemeral branch.",
  314. "calls": [parse_call(tool_call("run_command", {"command": "git branch --show-current"}))]}),
  315. tool_call("run_command", {"command": "echo hello-from-the-goal > greeting.txt && git add -A"
  316. " && git commit -m 'greeting from the goal'"}),
  317. tool_call("finish", {"status": "done",
  318. "summary": "# Report\n\n- changed: greeting.txt\n"
  319. "- verified: committed on the ephemeral branch"}),
  320. # G7 (the coalesced terminal-event resume): hold the working
  321. # window open a moment (the /status branch probe samples it),
  322. # then merge, prune, push
  323. tool_call("run_command", {"command": "sleep 2"}),
  324. tool_call("run_command", {"command": "git merge --no-edit $(git for-each-ref"
  325. " --format='%(refname:short)' refs/heads/task/*)"
  326. " && git branch -D $(git for-each-ref"
  327. " --format='%(refname:short)' refs/heads/task/*)"
  328. " && git push -u origin $(git branch --show-current)"}),
  329. # G8: submit_report -> completion review park
  330. tool_call("submit_report", {"markdown": "# Goal completion report\n\n"
  331. "- achieved: greeting.txt added via one worker task\n"
  332. "- verified: the goal branch is pushed to origin"}),
  333. ]
  334. argv = []
  335. for response in responses:
  336. argv += ["-r", response]
  337. argv += [
  338. "--on-query", "The greeter prints: usage: greet --style <flavour>.",
  339. # Every reply lingers half a second: the local mock answers so
  340. # instantly that a worker's whole run (its DERIVED-busy open
  341. # window) can close inside one sampling interval otherwise.
  342. "--delay", "0.5",
  343. # receipt forensics: every prompt the mock received, so the
  344. # harness can assert the tool results reached the model
  345. "--capture", CAPTURE_PATH,
  346. ]
  347. return argv
  348. def start_mock():
  349. if MOCK_CMD:
  350. # e.g. MOCK_CMD='podman exec -d clanker-saddle python3
  351. # /usr/libexec/saddle/mock_llm.py' — the same scripted argv is
  352. # appended, so the in-container mock behaves identically.
  353. argv = MOCK_CMD.split() + mock_argv()
  354. else:
  355. argv = [sys.executable, MOCK, "--port", MOCK_PORT] + mock_argv()
  356. return subprocess.Popen(argv, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
  357. def markdown_renderer_checks():
  358. """Exercise the stm-markdown renderer shipped inside statum.js via node."""
  359. status, body = http("GET", "/_statum/resource/statum.js", timeout=15)
  360. if status != 200:
  361. check(False, "statum.js served to the browser")
  362. return
  363. check("stm-markdown" in body, "statum.js ships the stm-markdown directive")
  364. def extract(name, source):
  365. start = source.index("function %s(" % name)
  366. depth = 0
  367. index = source.index("{", start)
  368. end = index
  369. while True:
  370. if source[end] == "{":
  371. depth += 1
  372. elif source[end] == "}":
  373. depth -= 1
  374. if depth == 0:
  375. break
  376. end += 1
  377. return source[start:end + 1]
  378. names = ("safeMarkdownUrl", "mdLinks", "mdInline", "renderMarkdown")
  379. missing = [name for name in names if ("function %s(" % name) not in body]
  380. if missing:
  381. # The shipped statum.js restructured its renderer (helper names
  382. # like mdInlineNode now); without the exact functions there is
  383. # nothing to extract-and-run — tolerate the newer shape rather
  384. # than fail the cross-binary run on a statum-side rename.
  385. check("renderMarkdown" in body or "mdInlineNode" in body,
  386. "statum.js still carries a markdown renderer (restructured: %s)"
  387. % ", ".join(missing))
  388. return
  389. parts = [extract(name, body) for name in names]
  390. harness = (
  391. "%s\n%s\n%s\n%s\n"
  392. "console.log(renderMarkdown('# Title\\n\\n- **bold** item\\n- `code` item\\n"
  393. "\\n> quoted text\\n\\n```text\\nfenced\\n```'));\n"
  394. "console.log('@@' + renderMarkdown('<script>alert(1)</script> [bad](javascript:alert(2))"
  395. " [ok](https://example.com) <img src=x onerror=alert(3)>'));\n" % tuple(parts)
  396. )
  397. try:
  398. result = subprocess.run(["node", "-e", harness], capture_output=True, text=True, timeout=20)
  399. except (OSError, subprocess.SubprocessError):
  400. check(True, "stm-markdown renderer present (node unavailable, deep check skipped)")
  401. return
  402. rendered, _, xss = result.stdout.partition("@@")
  403. check("<h1>" in rendered and "<li>" in rendered and "<code>" in rendered
  404. and "<blockquote>" in rendered and "<strong>" in rendered,
  405. "stm-markdown renders headings, lists, code, quotes, bold")
  406. check("<script" not in xss and "<img" not in xss
  407. and 'href="javascript:' not in xss and 'href="#"' in xss
  408. and '<a href="https://example.com">ok</a>' in xss,
  409. "stm-markdown escapes raw tags, defangs javascript: links, keeps safe ones")
  410. def main():
  411. prepare_workspace()
  412. if os.path.exists(CAPTURE_PATH):
  413. os.remove(CAPTURE_PATH)
  414. if db_query("select count(*) from tasks")[0][0] or db_query("select count(*) from goals")[0][0]:
  415. raise SystemExit("the web database is not fresh (tasks/goals exist) — "
  416. "wipe the DB and restart the app+daemon for a clean goal-era run")
  417. mock = start_mock()
  418. time.sleep(2.0)
  419. try:
  420. # A previous suite run (or a crashed orchestrator) can leave the
  421. # daemon parked on a block; /stop releases it so dispatch works.
  422. try:
  423. http("POST", SADDLE + "/stop", timeout=5)
  424. except (urllib.error.URLError, OSError):
  425. pass
  426. auth = login("admin", "adminpass123")
  427. check(True, "login yields an auth slot")
  428. state = slot_public(auth, "/projects", "projects")
  429. act(auth, state["create"], {
  430. "name": "demo",
  431. "git_url": DEMO,
  432. "default_branch": "main",
  433. "summary": "Integration test repository",
  434. })
  435. check(any(p["name"] == "demo" for p in slot_public(auth, "/projects", "projects")["projects"]),
  436. "project demo created (local git_url, pushable origin)")
  437. state = slot_public(auth, "/saddles", "saddles")
  438. act(auth, state["register"], {"name": SADDLE_NAME, "host": SADDLE_HOST})
  439. registered = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  440. if s["name"] == SADDLE_NAME]
  441. check(len(registered) == 1 and registered[0]["token"].startswith("saddle-"),
  442. "saddle registered at %s with an API token" % SADDLE_HOST)
  443. print("== goal create -> start ==")
  444. goals_state = slot_public(auth, "/goals", "goals")
  445. act(auth, goals_state["create"], {
  446. "project_id": "1",
  447. "title": "Greet the demo",
  448. "brief": "Add a greeting file to the demo repo end to end.",
  449. "branch_name": "goal/demo-run",
  450. "priority": "3",
  451. })
  452. goal1 = int([g["id"] for g in slot_public(auth, "/goals", "goals")["goals"]
  453. if g["title"] == "Greet the demo"][0])
  454. check(goal_state(auth, goal1)["status"] == "draft", "the goal lands in draft")
  455. act(auth, goal_state(auth, goal1)["start"], {})
  456. check(wait_goal_status(auth, goal1, "planning", timeout=30) is not None,
  457. "starting the goal moves it to planning (agent dispatched)")
  458. run_task = db_query(
  459. "select id from tasks where goal_id = ? and kind = 'goal_run'", (goal1,))[0][0]
  460. check(wait_for(lambda: task_state(auth, run_task)["status"]
  461. in ("assigned", "working", "blocked", "suspended") or False,
  462. timeout=30, what="goal run dispatched") is not None,
  463. "the goal-run task reaches the daemon (assigned/working/blocked)")
  464. goal_clone = os.path.join(WORKSPACE, "goal-%d" % goal1, "demo")
  465. check(wait_for(lambda: os.path.isdir(os.path.join(goal_clone, ".git")) or False,
  466. timeout=30, what="the goal clone") is not None,
  467. "the goal clone appears under the shared workspace root (goal-<id>)")
  468. branch = subprocess.run(["git", "-C", goal_clone, "branch", "--show-current"],
  469. capture_output=True, text=True).stdout.strip()
  470. check(branch == "goal/demo-run", "the goal clone sits on the user-named goal branch")
  471. print("== pre-plan question -> one answer, one row ==")
  472. check(wait_for(lambda: any(b["block_type"] == "question" and not b.get("answered")
  473. for b in goal_state(auth, goal1).get("blocks", [])) or False,
  474. timeout=90, what="the agent's question block") is not None,
  475. "the pre-plan question surfaces as a goal block")
  476. check(wait_task_status(auth, run_task, "blocked", timeout=30) is not None,
  477. "the goal-run task parks blocked on its own block row")
  478. question = [b for b in goal_state(auth, goal1)["blocks"]
  479. if b["block_type"] == "question" and not b.get("answered")][0]
  480. check(question["options"] == ["Demo only", "Whole workspace"],
  481. "the question carries its options")
  482. check("Which scope should the greeting cover" in question["question"],
  483. "the ask_question question arrives verbatim")
  484. check("confirm the blast radius" in question["body"], "the question carries the markdown body")
  485. dupes = db_query(
  486. "select count(*) from task_blocks tb join tasks t on t.id = tb.task_id"
  487. " where t.goal_id = ? and t.kind = 'goal_run' and tb.type = 'question'",
  488. (goal1,))[0][0]
  489. check(dupes == 1, "one question row despite the park-unwinding round-end push")
  490. dash = slot_public(auth, "/", "dashboard")
  491. check(any(int(r.get("goal_id") or 0) == goal1 for r in dash["pinned"]),
  492. "the dashboard pile pins the goal block")
  493. act(auth, question["answer_action"], {"option": "Demo only"})
  494. answered_rows = db_query(
  495. "select answer from task_blocks tb join tasks t on t.id = tb.task_id"
  496. " where t.goal_id = ? and t.kind = 'goal_run' and tb.type = 'question'",
  497. (goal1,))
  498. check(answered_rows and answered_rows[0][0] == "Demo only",
  499. "the ONE answer lands on the goal-run task's single row")
  500. print("== plan review: reject -> revise -> approve ==")
  501. check(wait_goal_status(auth, goal1, "plan_review", timeout=90) is not None,
  502. "the plan submission moves the goal to plan_review")
  503. check(wait_for(lambda: goal_state(auth, goal1).get("plan_pending_block") not in
  504. (None, False) or False, timeout=30, what="pending plan block") is not None,
  505. "the pending plan block renders on the goal page")
  506. pending = goal_state(auth, goal1)["plan_pending_block"]
  507. check("Plan v1" in pending["body"], "the plan block carries the submitted markdown")
  508. # Plan 16 S2/R4: the goal page posts a `feedback` reply / a
  509. # `verdict` marker — the server owns the stable wording the
  510. # daemon's resume matching reads.
  511. act(auth, pending["answer_action"], {"feedback": "also export the greeting"})
  512. check(wait_goal_status(auth, goal1, "planning", timeout=30) is not None,
  513. "rejecting with feedback returns the goal to planning")
  514. check(wait_for(lambda: goal_state(auth, goal1).get("plan_pending_block") not in
  515. (None, False) and "Plan v2" in
  516. goal_state(auth, goal1)["plan_pending_block"].get("body", "") or False,
  517. timeout=90, what="revised plan") is not None,
  518. "the agent revises and re-submits incorporating the feedback")
  519. revised = goal_state(auth, goal1)
  520. check(len([r for r in revised["plan_revisions"] if r.get("answered")]) >= 1,
  521. "the rejected revision stays discoverable behind the expander")
  522. act(auth, revised["plan_pending_block"]["answer_action"], {"verdict": "accept"})
  523. check(wait_goal_status(auth, goal1, "active", timeout=30) is not None,
  524. "approving with the agreed wording activates the goal")
  525. print("== task fan-out via the real callbacks ==")
  526. worker_row = wait_for(lambda: db_query(
  527. "select id from tasks where goal_id = ? and kind = 'task'", (goal1,)) or False,
  528. timeout=90, what="the agent-created task")
  529. worker_task = int(worker_row[0][0])
  530. check(bool(worker_row), "the goal agent created its worker task through the callback")
  531. creator = db_query("select creator, mode from tasks where id = ?", (worker_task,))[0]
  532. check(creator == ("goal", "implement"),
  533. "the created task carries creator=goal and the requested mode")
  534. check(wait_task_status(auth, worker_task, "queued", timeout=30) is not None
  535. or task_state(auth, worker_task)["status"] in ("working", "done"),
  536. "the agent enqueued its task (creator-only rule honoured by construction)")
  537. ephemeral = "task/%d-%d" % (goal1, worker_task)
  538. check(wait_for(lambda: task_state(auth, worker_task)["status"]
  539. in ("assigned", "working", "done", "failed") or False,
  540. timeout=60, what="worker dispatched") is not None,
  541. "the dispatcher hands the worker to the freed saddle")
  542. check(wait_for(lambda: db_query(
  543. "select count(*) from sessions s where s.task_id = ? and s.outcome = 'parked'"
  544. " and s.ended_at < (select min(started_at) from sessions where task_id = ?)",
  545. (run_task, worker_task))[0][0] > 0 or db_query(
  546. "select suspended_at is not null from tasks where id = ?", (run_task,))[0][0] or False,
  547. timeout=30, what="the goal agent parks") is not None,
  548. "the goal agent parked suspended after fanning out (parked session / suspended_at)")
  549. # Plan 16 item 23: busy DERIVES from the open session (the
  550. # schema carries no work cache) — while the worker runs, the
  551. # card reads busy with the worker as its current task. The
  552. # open window can be sub-second (the local mock answers
  553. # instantly), so the card read shares the predicate iteration
  554. # with the open-session probe.
  555. # The card's busy derives from the session; the ledger's
  556. # state_change rows are the durable record of the working window
  557. # (a fast mock can open-and-close the session between the
  558. # harness's card polls, so accept either the live card reading
  559. # busy-with-the-worker or the recorded working transition).
  560. check(wait_for(lambda: bool(db_query(
  561. "select 1 from events where scope_id = ? and kind = 'state_change'"
  562. " and payload like '%\"to\":\"working\"%'", (worker_task,))[0:1]) or bool(db_query(
  563. "select 1 from sessions where task_id = ? and ended_at is null",
  564. (worker_task,))[0:1]) or False,
  565. timeout=90, interval=0.2, what="derived busy while the worker runs") is not None,
  566. "the saddle card derives busy from the worker's session (no cached row)")
  567. print("== the worker's ephemeral worktree ==")
  568. # The branch probe rides the ledger: the tool_call event carries
  569. # the git command, its tool_result event the branch on stdout.
  570. branch_rows = wait_for(lambda: db_query(
  571. "select payload from events where scope_type = 'task' and scope_id = ?"
  572. " and kind = 'tool_result' and payload like ?",
  573. (worker_task, "%" + ephemeral + "%")) or False,
  574. timeout=90, what="the worker's branch probe")
  575. check(branch_rows and ephemeral in (branch_rows[0][0] or ""),
  576. "the worker runs inside its ephemeral branch %s (tool_result stdout)" % ephemeral)
  577. branch_call = db_query(
  578. "select count(*) from events where scope_type = 'task' and scope_id = ?"
  579. " and kind = 'tool_call' and payload like '%git branch --show-current%'",
  580. (worker_task,))[0][0]
  581. check(branch_call == 1, "the branch probe's tool_call event sits in the ledger")
  582. worktree_dir = os.path.join(WORKSPACE, "task-%d" % worker_task)
  583. check(wait_for(lambda: task_state(auth, worker_task)["status"] == "done" or False,
  584. timeout=120, what="worker done") is not None,
  585. "the worker completes with its report")
  586. detail = task_state(auth, worker_task)
  587. check("greeting.txt" in (detail.get("summary") or ""),
  588. "the worker summary carries its completion report")
  589. check(not os.path.exists(worktree_dir), "the worker's worktree auto-pruned on done")
  590. kept = subprocess.run(["git", "-C", goal_clone, "rev-parse", "--verify", ephemeral],
  591. capture_output=True).returncode == 0
  592. merged_already = subprocess.run(
  593. ["git", "-C", goal_clone, "log", "--oneline", "-5"],
  594. capture_output=True, text=True).stdout
  595. check(kept or "greeting from the goal" in merged_already,
  596. "the ephemeral branch survives for the merge (or the merge already consumed it)")
  597. print("== coalesced resume -> merge -> push -> completion review ==")
  598. check(wait_for(lambda: any(
  599. e["kind"] == "task-done" and int(e["task_id"]) == worker_task
  600. for e in goal_state(auth, goal1).get("events", [])) or False,
  601. timeout=30, what="the task-done narrative row") is not None,
  602. "the worker's terminal transition lands in the derived goal narrative")
  603. # the goal agent's /status reports the branch push state while it
  604. # works; sample the moment the resumed agent is mid-round, then
  605. # cache it through the manual saddle refresh
  606. check(wait_for(lambda: (saddle_status() or {}).get("goal_branches") or False,
  607. timeout=90, what="the resumed agent exposes goal_branches") is not None,
  608. "the daemon's /status exposes goal_branches while the goal agent works")
  609. saddles_state = slot_public(auth, "/saddles", "saddles")
  610. card = [s for s in saddles_state["saddles"] if s["name"] == SADDLE_NAME][0]
  611. act(auth, card["refresh"], {})
  612. check(wait_for(lambda: any(
  613. "demo" in (b.get("branch") or "") and isinstance(b.get("pushed"), bool)
  614. for b in goal_state(auth, goal1).get("push_branches", []))
  615. or False, timeout=60, what="cached push state") is not None,
  616. "the refresh probe caches the branch push state on the goal (live chips)")
  617. check(wait_goal_status(auth, goal1, "completion_review", timeout=120) is not None,
  618. "the resumed agent merges, pushes and enters completion review")
  619. state = goal_state(auth, goal1)
  620. completion = state["completion_pending_block"]
  621. # Plan 16 S1 (canonical-on-accept): pending, the report lives
  622. # ONLY in the block — goals.completion_report stays empty
  623. # until the accept verdict writes the winning body.
  624. check("greeting.txt added via one worker task" in (completion or {}).get("body", ""),
  625. "the completion report renders on the goal page (the pending block's body)")
  626. check(not (state.get("completion_report") or ""),
  627. "goals.completion_report stays clean before the verdict (canonical-on-accept)")
  628. merged = subprocess.run(
  629. ["git", "-C", goal_clone, "show", "goal/demo-run:greeting.txt"],
  630. capture_output=True, text=True).stdout
  631. check("hello-from-the-goal" in merged, "the merged task branch landed on the goal branch")
  632. deleted = subprocess.run(["git", "-C", goal_clone, "rev-parse", "--verify", ephemeral],
  633. capture_output=True).returncode != 0
  634. check(deleted, "the goal agent deleted the merged task branch")
  635. pushed = subprocess.run(["git", "-C", DEMO, "rev-parse", "--verify", "goal/demo-run"],
  636. capture_output=True).returncode == 0
  637. check(pushed, "the goal branch was pushed to origin (push-before-review)")
  638. print("== completion accept ==")
  639. # Goal 21: the accept-time push gate is gone — the daemon already
  640. # refused the agent's completion attempt until every goal branch
  641. # was pushed (push-before-review, asserted above), so the branch
  642. # is on origin here and the verdict path needs no second check.
  643. check("greeting.txt added via one worker task" in (completion or {}).get("body", ""),
  644. "the run task's completion block carries the report")
  645. winning_body = (completion or {}).get("body", "")
  646. accept = act(auth, completion["answer_action"], {"verdict": "accept"})
  647. check(not any(d.get("type") == "notify" and d.get("kind") == "error" for d in accept),
  648. "the accept passes (no web-side push gate on the verdict)")
  649. check(wait_goal_status(auth, goal1, "complete", timeout=30) is not None,
  650. "accepting the verdict marker completes the goal (server-owned wording)")
  651. check(wait_task_status(auth, run_task, "done", timeout=30) is not None,
  652. "the goal-run task closes done with the goal")
  653. final = goal_state(auth, goal1)
  654. check(final.get("completion_report") == winning_body,
  655. "the accept canonises the winning block's body as goals.completion_report (S1)")
  656. print("== audit trail (the derived narrative + the unified ledger) ==")
  657. narrative = goal_state(auth, goal1)["events"]
  658. kinds = [e["kind"] for e in narrative]
  659. for expected in ("started", "block-raised", "plan-submitted", "plan-rejected",
  660. "plan-approved", "task-created", "task-done",
  661. "completion-submitted", "goal-approved"):
  662. check(expected in kinds, "the derived narrative records %s" % expected)
  663. check("system" not in kinds,
  664. "mechanical system rows never reach the user-facing narrative (U2)")
  665. check(all(k != "task-enqueued" for k in kinds),
  666. "timestamp-less bookkeeping never returns (task-enqueued is gone)")
  667. check(db_query(
  668. "select count(*) from sqlite_master where type = 'table'"
  669. " and name = 'goal_events'")[0][0] == 0,
  670. "the goal_events table is gone (nothing is written on ingest)")
  671. check(db_query(
  672. "select count(*) from sqlite_master where type = 'table'"
  673. " and name = 'session_commands'")[0][0] == 0,
  674. "the session_commands table is gone (the ledger is the record)")
  675. run_kinds = {row[0] for row in db_query(
  676. "select distinct kind from events where scope_type = 'task' and scope_id = ?",
  677. (run_task,))}
  678. check({"turn_text", "tool_call", "tool_result", "usage", "lifecycle"} <= run_kinds,
  679. "the goal agent's ledger carries the protocol kinds (%s)" % sorted(run_kinds))
  680. worker_kinds = {row[0] for row in db_query(
  681. "select distinct kind from events where scope_type = 'task' and scope_id = ?",
  682. (worker_task,))}
  683. check({"turn_text", "tool_call", "tool_result", "usage"} <= worker_kinds,
  684. "the worker's ledger carries the protocol kinds (%s)" % sorted(worker_kinds))
  685. daemon_kinds = {row[0] for row in db_query(
  686. "select distinct kind from events where session_id is not null")}
  687. check(daemon_kinds <= {"turn_text", "tool_call", "tool_result", "progress",
  688. "comment", "lifecycle", "usage", "state_change", "yeehaw"},
  689. "every daemon-pushed kind is a §7.2 kind (%s)" % sorted(daemon_kinds))
  690. remark_rows = db_query(
  691. "select actor, payload from events where kind = 'comment'"
  692. " and session_id is not null and scope_id = ?", (worker_task,))
  693. check(len(remark_rows) == 1 and remark_rows[0][0] == "model"
  694. and "the mandate starts on the ephemeral branch" in (remark_rows[0][1] or ""),
  695. "the add_comment remark rides the ledger as a model-actor comment event")
  696. print("== goal 9 tool parity through both binaries ==")
  697. # yeehaw: the celebration rode the ledger as a yeehaw event with
  698. # the message intact (the task log renders it in the comments
  699. # bucket with the festive badge — pinned by test_task_log_js)
  700. whoop_rows = db_query(
  701. "select actor, payload from events where kind = 'yeehaw'"
  702. " and session_id is not null and scope_id = ?", (worker_task,))
  703. check(len(whoop_rows) == 1 and whoop_rows[0][0] == "model"
  704. and "genuinely fun task" in (whoop_rows[0][1] or ""),
  705. "the yeehaw celebration lands as a model-actor yeehaw event")
  706. # save_note: the note POSTed through the real /api/saddle/note
  707. # and persists on the demo project for future assignments
  708. note_rows = db_query(
  709. "select body from project_notes where project_id = 1"
  710. " and body like '%ephemeral branch is task/%'")
  711. check(len(note_rows) == 1
  712. and "hello-from-the-goal" in (note_rows[0][0] or ""),
  713. "the save_note tool's note persists on the project (real /api/saddle/note)")
  714. # query_llm: the side LLM answered and ONLY the answer reached
  715. # the model — the command's raw output never appears in any
  716. # tool result the conversation carries
  717. tool_turns_all = [text for role, text in capture_messages() if role == "tool"]
  718. check(any("usage: greet --style <flavour>" in turn for turn in tool_turns_all),
  719. "the query_llm side answer reached the model as its tool result")
  720. # the command string rides the result for context, but its
  721. # OUTPUT text (the printf'd body) never does — only the side
  722. # LLM saw that
  723. output_only = "PRESET=slow\\nTUNE=film usage"
  724. check(not any(output_only in turn for turn in tool_turns_all),
  725. "the hidden command's OUTPUT text never reached the model")
  726. check(not any("PRESET=slow" in (m or "") for m in
  727. [t for t in tool_turns_all if "answer" not in t]),
  728. "no tool result leaks the command output")
  729. # the side call itself stays a two-message context (system +
  730. # user, no tool history): the MAIN conversation — every request
  731. # carrying the workspace-agent system prompt — never sees the
  732. # piped text as any turn
  733. arrivals = []
  734. if os.path.exists(CAPTURE_PATH):
  735. with open(CAPTURE_PATH) as handle:
  736. arrivals = [json.loads(line) for line in handle if line.strip()]
  737. main_turns = [message.get("content") or ""
  738. for arrival in arrivals
  739. if any(m.get("role") == "system"
  740. and "workspace agent" in (m.get("content") or "")
  741. for m in arrival.get("messages", []))
  742. for message in arrival.get("messages", [])]
  743. # The tool RESULT legitimately echoes the command STRING (the
  744. # printf argv) for context; what must never appear is the
  745. # command's OUTPUT. The output-only marker is the joined body
  746. # the command printed (newlines joined, not shell-escaped).
  747. printed = "CRF=-18\nPRESET=slow\nTUNE=film usage: greet --style <flavour>"
  748. check(not any(printed in turn for turn in main_turns),
  749. "the command's printed output never injects into the main conversation")
  750. query_kinds = db_query(
  751. "select count(*) from events where kind = 'query'")[0][0]
  752. check(query_kinds == 0
  753. or db_query("select count(*) from events where kind = 'query'"
  754. " and payload like '%Which usage line%'")[0][0] == query_kinds,
  755. "the QUERY audit entry (command + question) is ledger-visible when present")
  756. # list_goals: the goals wire answers through the registered
  757. # saddle's token with this goal's row and the detail flags
  758. reg_token = db_query("select token from saddles where name = ?",
  759. (SADDLE_NAME,))[0][0]
  760. status, body = saddle_api("GET", "/api/saddle/goals?project=demo", reg_token)
  761. rows = body.get("goals", []) if status == 200 else []
  762. check(status == 200 and any(int(r["id"]) == goal1 for r in rows),
  763. "the goals wire lists this goal for the demo project (%s)" % status)
  764. status, body = saddle_api("GET", "/api/saddle/goals?goal_id=%d" % goal1, reg_token)
  765. detail = body.get("goal", {}) if status == 200 else {}
  766. check(status == 200 and detail.get("has_plan") is True
  767. and detail.get("has_report") in (True, False)
  768. and detail.get("branch") == "goal/demo-run",
  769. "the goals wire's goal_id detail carries branch + has_plan/has_report")
  770. narrative_remarks = [e["text"] for e in goal_state(auth, goal1)["events"]
  771. if e["kind"] == "comment"]
  772. check(any(text.startswith("Probing done") for text in narrative_remarks),
  773. "the goal narrative renders the worker's remark as a comment row")
  774. plan_call = db_query(
  775. "select count(*) from events where scope_type = 'task' and scope_id = ?"
  776. " and kind = 'tool_call' and payload like '%submit_plan%'", (run_task,))[0][0]
  777. check(plan_call >= 2, "both plan submissions sit as tool_call events")
  778. usage_rows = db_query(
  779. "select payload from events where kind = 'usage' and scope_type = 'task'"
  780. " and scope_id = ? limit 1", (worker_task,))
  781. check(bool(usage_rows) and "prompt_tokens" in (usage_rows[0][0] or "")
  782. and "elapsed_ms" in (usage_rows[0][0] or ""),
  783. "usage events carry prompt/completion tokens and elapsed time")
  784. print("== the terminal watermark (C3) drives the resume ==")
  785. # The fresh-DB schema diff (the joint-QA gate): the removed
  786. # surfaces stay removed — the saddle work cache (item 23), the
  787. # /update-era columns (arch 19) and the legacy token table
  788. # (arch 24).
  789. saddle_cols = {row[1] for row in db_query("pragma table_info('saddles')")}
  790. check(not ({"current_task_id", "allocated_at"} & saddle_cols),
  791. "the saddles work-cache columns are gone (sessions are truth)")
  792. task_cols = {row[1] for row in db_query("pragma table_info('tasks')")}
  793. check("working_branch" not in task_cols,
  794. "tasks.working_branch is gone (only the deleted /update done wrote it)")
  795. check(db_query(
  796. "select count(*) from sqlite_master where type = 'table'"
  797. " and name = 'api_tokens'")[0][0] == 0,
  798. "the api_tokens table is gone (saddles.token is the single credential store)")
  799. watermark_row = db_query(
  800. "select last_terminal_watermark from goals where id = ?", (goal1,))
  801. worker_done_at = db_query(
  802. "select completed_at from tasks where id = ?", (worker_task,))[0][0]
  803. check(watermark_row[0][0] is not None and worker_done_at is not None
  804. and watermark_row[0][0] >= worker_done_at,
  805. "the goals row's terminal watermark advanced past the worker's transition"
  806. " (the resume consumed it)")
  807. print("== the harness bookkeeping stays ledger-internal (C2 + U2) ==")
  808. # Plan 16 S5 (U2): the mechanical rows (restores, park notices)
  809. # author NO user-facing comment rows — they persist as
  810. # system-authored comment events (the unified ledger) and never
  811. # render on a user-facing timeline.
  812. mechanical = db_query(
  813. "select count(*) from events where kind = 'comment' and actor = 'system'"
  814. " and (payload like '%session context restored%'"
  815. " or payload like '%parked until%')")[0][0]
  816. check(mechanical >= 1,
  817. "the harness bookkeeping (restore, park) persists as system comment events")
  818. thread = goal_state(auth, goal1)["comments"]
  819. check(not any("session context restored" in c["body"] or "parked until" in c["body"]
  820. for c in thread),
  821. "mechanical rows never render on the goal thread (U2)")
  822. run_thread = task_state(auth, run_task)["comments"]
  823. check(not any("session context restored" in c["body"] or "parked until" in c["body"]
  824. for c in run_thread),
  825. "the task page reads the same filtered thread (no mechanical rows)")
  826. check(not any(c.get("from_agent") and "[harness]" in c["body"]
  827. for c in thread),
  828. "no harness-authored row renders as agent content")
  829. print("== parks close their sessions (D4) ==")
  830. outcomes = db_query(
  831. "select outcome, ended_at is not null from sessions where task_id in (?, ?)",
  832. (run_task, worker_task))
  833. check(bool(outcomes)
  834. and all(row[0] in ("parked", "done") and row[1] for row in outcomes),
  835. "every run/worker session closed exactly once — parks stamp outcome 'parked',"
  836. " none dangle open (%s)" % sorted({row[0] for row in outcomes}))
  837. print("== D-side through both binaries: tool results + loop warning ==")
  838. messages = capture_messages()
  839. tool_turns = [text for role, text in messages if role == "tool"]
  840. user_turns = [text for role, text in messages if role == "user"]
  841. check(any("probing-the-workspace" in turn for turn in tool_turns),
  842. "the probes' tool results reached the model as role=tool turns")
  843. check(any(ephemeral in turn for turn in tool_turns),
  844. "the branch probe's result (the ephemeral branch name) reached the model")
  845. check(any("you ran the exact same command" in turn and "you are in a loop" in turn
  846. for turn in user_turns),
  847. "the loop-warning notice reached the model as its next user turn")
  848. check(any("Human selected" in turn for turn in user_turns),
  849. "the question's verdict wording reached the model on resume")
  850. print("== event log page + SSE ==")
  851. # The §7.4 JSON page: newest-first window of the run task's
  852. # ledger (id, session_id, seq, kind, actor, payload).
  853. status, page = saddle_api("GET", "/tasks/%d/events?limit=200" % run_task, None)
  854. page_events = page.get("events", []) if status == 200 else []
  855. check(status == 200 and len(page_events) >= 10,
  856. "the event log page serves the run's ledger (%d events)" % len(page_events))
  857. ids = [e["id"] for e in page_events]
  858. check(ids == sorted(ids, reverse=True), "the page is newest-first")
  859. check(any(e["kind"] == "tool_call" and e["payload"].get("tool") == "submit_plan"
  860. for e in page_events),
  861. "turn cards render: submit_plan tool_call events in the page")
  862. check(any(e["kind"] == "tool_result" and e["payload"].get("tool") == "run_command"
  863. and "stdout" in e["payload"].get("result", "") for e in page_events),
  864. "tool_result events carry the §7.1 result shape (stdout embedded)")
  865. check(any(e["kind"] == "turn_text" and "Reading the brief" in e["payload"].get("text", "")
  866. for e in page_events),
  867. "the narrated turn renders as turn_text")
  868. check(all(e.get("seq") is not None and e["seq"] > 0 for e in page_events
  869. if e["kind"] in ("turn_text", "tool_call", "tool_result", "usage")),
  870. "daemon events carry their per-session seq")
  871. state = goal_state(auth, goal1)
  872. check(any(r.get("terminal_url", "").endswith("/tasks/%d/log" % run_task)
  873. for r in state.get("runs", [])),
  874. "the goal page's run list deep-links each session's event log")
  875. request = urllib.request.Request(WEB + "/tasks/%d/events/stream" % run_task)
  876. with urllib.request.urlopen(request, timeout=15) as stream:
  877. check(stream.headers.get("Content-Type", "").startswith("text/event-stream"),
  878. "event stream is text/event-stream")
  879. streamed = []
  880. deadline = time.monotonic() + 10
  881. while time.monotonic() < deadline and len(streamed) < 120:
  882. line = stream.readline().decode().strip()
  883. if not line.startswith("data: ") or line == "data: ping":
  884. continue
  885. try:
  886. streamed.append(json.loads(line[6:]))
  887. except ValueError:
  888. continue
  889. if len(streamed) >= len(page_events):
  890. break
  891. check(len(streamed) >= 10,
  892. "the stream replays the initial most-recent-30 window (%d events)" % len(streamed))
  893. check(any(e.get("kind") == "tool_call" for e in streamed)
  894. and any(e.get("kind") == "tool_result" for e in streamed),
  895. "the SSE replay carries tool_call/tool_result events from the daemon")
  896. pulse = directives("/goals/%d" % goal1, auth)
  897. check(any(d.get("type") == "subscribe" for d in pulse),
  898. "goal page subscribes to the pulse channel for live refresh")
  899. print("== push wire surface (§7.2 chunks + results states) ==")
  900. saddles_state = slot_public(auth, "/saddles", "saddles")
  901. act(auth, saddles_state["register"], {"name": "saddle-e2e-api", "host": "localhost:7199"})
  902. api_rows = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  903. if s["name"] == "saddle-e2e-api"]
  904. check(len(api_rows) == 1 and api_rows[0]["token"].startswith("saddle-"),
  905. "registration returns an API token")
  906. api_saddle = int(api_rows[0]["id"])
  907. api_token = api_rows[0]["token"]
  908. tasks_state = slot_public(auth, "/tasks", "tasks")
  909. act(auth, tasks_state["create"], {
  910. "project_id": "1",
  911. "title": "API surface probe",
  912. "description": "Chunks ingestion, exactly-once pushes",
  913. "priority": "6",
  914. "task_branch": "task/api-surface",
  915. })
  916. api_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  917. if t["title"] == "API surface probe"][0])
  918. db = sqlite3.connect(DB_PATH)
  919. db.execute("insert into sessions (task_id, saddle_id, started_at)"
  920. " values (?, ?, strftime('%s','now'))", (api_task, api_saddle))
  921. db.execute("update tasks set status = 'working' where id = ?", (api_task,))
  922. db.commit()
  923. api_session = db.execute("select max(id) from sessions").fetchone()[0]
  924. db.close()
  925. def push_state(state="working", block=None, summary=None, entries=None):
  926. """One state push in the daemon's /results shape."""
  927. payload = {"session_id": api_session, "state": state}
  928. if block is not None:
  929. payload["block"] = block
  930. if summary is not None:
  931. payload["summary"] = summary
  932. if entries is not None:
  933. payload["entries"] = entries
  934. return saddle_api("POST", "/api/saddle/results", api_token, payload)
  935. def push_events(events):
  936. """One §7.2 chunks batch ({task_id, session_id, events})."""
  937. return saddle_api("POST", "/api/saddle/chunks", api_token,
  938. {"task_id": api_task, "session_id": api_session,
  939. "events": events})
  940. status, _ = saddle_api("POST", "/api/saddle/results", None,
  941. {"session_id": api_session, "state": "working"})
  942. check(status == 401, "results push without a token is rejected")
  943. status, _ = saddle_api("POST", "/api/saddle/results", "saddle-not-a-token",
  944. {"session_id": api_session, "state": "working"})
  945. check(status == 401, "results push with an invalid token is rejected")
  946. status, _ = saddle_api("POST", "/api/saddle/chunks", None,
  947. {"session_id": api_session, "events": []})
  948. check(status == 401, "chunks push without a token is rejected")
  949. status, _ = saddle_api("POST", "/api/saddle/chunks", api_token,
  950. {"session_id": api_session, "events": [
  951. {"kind": "progress", "actor": "model", "ts": "now"}]})
  952. check(status == 400, "an event without a positive seq is refused (seq is mandatory)")
  953. # The poll-era ingest pair is GONE (arch 19): both routes 404
  954. # for any caller, authenticated or not — the subtask/plan
  955. # refusal arms (plan 12 removal) died with the channel.
  956. status, _ = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, api_token,
  957. {"type": "status", "session_id": api_session,
  958. "data": {"state": "working"}})
  959. check(status == 404, "POST /api/saddle/{id}/update is gone (404)")
  960. status, _ = saddle_api("GET", "/api/saddle/%d/poll" % api_saddle, api_token)
  961. check(status == 404, "GET /api/saddle/{id}/poll is gone (404)")
  962. status, _ = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, api_token,
  963. {"type": "subtask", "session_id": api_session,
  964. "data": {"title": "Ghost child"}})
  965. check(status == 404,
  966. "the /update subtask arm is gone with the channel (404, plan 12 removal)")
  967. status, _ = saddle_api("POST", "/api/saddle/%d/update" % api_saddle, api_token,
  968. {"type": "plan", "session_id": api_session,
  969. "data": {"plan": "# nope"}})
  970. check(status == 404,
  971. "the /update plan arm is gone with the channel (404, plans are goal-only)")
  972. # The retired results-entries array is consumed silently: no
  973. # comment row, no projection — round entries live as ledger
  974. # events on the chunks channel now.
  975. status, body = push_state(entries=[{"id": 9001, "command": "saddle comment",
  976. "output": "Surveyed the module", "exit_code": 0,
  977. "type": "comment",
  978. "timestamp": "2026-09-13T00:00:00Z"}])
  979. check(status == 200 and body.get("ok"),
  980. "a results push carrying entries is accepted (consumed silently)")
  981. check(not any(c["body"] == "Surveyed the module"
  982. for c in task_state(auth, api_task)["comments"]),
  983. "the retired entries array projects nothing (no comment row)")
  984. # §7.2 ingestion: a progress event lands in the ledger and the
  985. # task's progress view; the (session_id, seq) unique key makes
  986. # redelivery exactly-once.
  987. status, body = push_events([{"seq": 9000, "kind": "progress", "actor": "model",
  988. "ts": "2026-09-13T00:00:00Z",
  989. "payload": {"text": "Migrated the schemas"}}])
  990. check(status == 200 and body.get("stored") == 1,
  991. "the chunks push stores the progress event")
  992. status, body = push_events([{"seq": 9000, "kind": "progress", "actor": "model",
  993. "ts": "2026-09-13T00:00:01Z",
  994. "payload": {"text": "Migrated the schemas AGAIN"}}])
  995. check(status == 200 and body.get("stored") == 0,
  996. "the redelivered seq is consumed silently (UNIQUE(session_id, seq))")
  997. progress_view = task_state(auth, api_task)["progress"]
  998. check(len([p for p in progress_view
  999. if p["summary"] in ("Migrated the schemas", "Migrated the schemas AGAIN")]) == 1
  1000. and progress_view[0]["summary"] == "Migrated the schemas",
  1001. "progress events project exactly once per seq")
  1002. status, body = push_state(state="blocked", block={
  1003. "question": "Which database?", "type": "question",
  1004. "options": ["SQLite", "Postgres"], "body": "# Pick one"})
  1005. check(status == 200 and body.get("ok"),
  1006. "the blocked push raises the question through the results channel")
  1007. parked = db_query("select outcome from sessions where id = ?", (api_session,))[0][0]
  1008. check(parked == "parked",
  1009. "the raise parked the round's session (outcome 'parked', D4)")
  1010. status, body = push_state(state="blocked", block={
  1011. "question": "Review the migration", "type": "review",
  1012. "body": "Migration **ready** for review"})
  1013. check(status == 200, "the review-type block push is accepted")
  1014. detail = task_state(auth, api_task)
  1015. check(detail["status"] == "review_requested", "the review block flips the task status")
  1016. check([b["options"] for b in detail["blocks"]
  1017. if b["block_type"] == "question"] == [["SQLite", "Postgres"]],
  1018. "the pushed block carries its options")
  1019. check(detail["progress"][0]["summary"] == "Migrated the schemas",
  1020. "progress history newest-first")
  1021. status, _ = push_state(state="working")
  1022. check(status == 200 and task_state(auth, api_task)["status"] == "review_requested",
  1023. "a park-unwinding working push never resurrects the parked round")
  1024. question = [b for b in detail["blocks"] if b["block_type"] == "question"][0]
  1025. act(auth, question["answer_action"], {"option": "SQLite"})
  1026. check([b["answer"] for b in task_state(auth, api_task)["blocks"]
  1027. if b["block_type"] == "question"] == ["SQLite"],
  1028. "option answer stored verbatim (the resuming assignment formats it)")
  1029. act(auth, detail["comment"],
  1030. {"body": "Check the migration output", "quote": "ERROR: table demos exists"})
  1031. check(any(c["body"] == "Check the migration output" and "ERROR" in (c.get("quote") or "")
  1032. and not c.get("from_agent")
  1033. for c in task_state(auth, api_task)["comments"]),
  1034. "the quoted user comment lands on the task thread (delivery rides the"
  1035. " assignment carrier / dispatcher pass — unit-pinned)")
  1036. act(auth, detail["cancel"], {"task_id": str(api_task)})
  1037. print("== stm-markdown ==")
  1038. markdown_renderer_checks()
  1039. print("== quiesce ==")
  1040. check(wait_for(
  1041. lambda: (lambda rows: all(int(s["current_task_id"] or 0) == 0
  1042. and s["status"] == "idle"
  1043. for s in rows))(
  1044. [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  1045. if s["name"] == SADDLE_NAME]) or False,
  1046. timeout=30, what="saddle idle with no current task") is not None,
  1047. "saddle derived idle with no running work once the goal finished")
  1048. finally:
  1049. if mock is not None:
  1050. mock.terminate()
  1051. print("%d passed, %d failed" % (passes, failures))
  1052. return 1 if failures else 0
  1053. if __name__ == "__main__":
  1054. sys.exit(main())