web_stack_test.py 75 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511
  1. #!/usr/bin/env python3
  2. """Self-contained web-stack feature tests: app + fake saddle daemon.
  3. Unlike integration_test.py (which needs a real saddle daemon and a mock
  4. LLM), this suite spawns everything it needs:
  5. - the clanker-saddle app binary (path from argv[1], or builddir default)
  6. in a scratch directory with its own web-config.json (statum keys copied
  7. from the repo, plus `saddle_callback_base_url`) and SQLite database
  8. - an in-process fake saddle daemon speaking the wire contract: POST
  9. /start (captures the payload), GET /status (scripted state machine),
  10. GET /output (empty), GET /log?since= (raw PTY chunks, one new chunk
  11. per request so since-paging is exercised)
  12. It then drives the app the way statum.js does (entrypoint loads + action
  13. POSTs) and asserts the Workstream B features against the wire contract:
  14. 3. plan-review blocks hide their duplicate body preview
  15. 5. rules CRUD/attach + the effective `rules` array in /start
  16. 6. project notes, POST /api/saddle/note, `project_notes` + `callback`
  17. in /start
  18. plus the raw-log side: session_raw_log polling + terminal raw view
  19. Prints PASS/FAIL lines Statum-style; exits non-zero on any failure.
  20. """
  21. import json
  22. import os
  23. import shutil
  24. import socket
  25. import sqlite3
  26. import subprocess
  27. import sys
  28. import threading
  29. import time
  30. import urllib.error
  31. import urllib.parse
  32. import urllib.request
  33. from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
  34. REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  35. APP = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else os.path.join(REPO, "builddir", "clanker-saddle")
  36. WORK = os.environ.get("WEBSTACK_WORK", "/tmp/kilo/webstack-e2e")
  37. DB_PATH = os.path.join(WORK, "web.sqlite")
  38. passes = 0
  39. failures = 0
  40. def check(condition, label):
  41. global passes, failures
  42. if condition:
  43. passes += 1
  44. print("PASS %s" % label)
  45. else:
  46. failures += 1
  47. print("FAIL %s" % label)
  48. def free_port():
  49. with socket.socket() as sock:
  50. sock.bind(("127.0.0.1", 0))
  51. return sock.getsockname()[1]
  52. APP_PORT = free_port()
  53. DAEMON_PORT = free_port()
  54. WEB = "http://127.0.0.1:%d" % APP_PORT
  55. def http(method, url, data=None, headers=None, timeout=30):
  56. body = None
  57. if data is not None:
  58. pairs = []
  59. for k, v in data.items():
  60. for item in (v if isinstance(v, (list, tuple)) else [v]):
  61. pairs.append((k, item))
  62. body = "&".join("%s=%s" % (k, urllib.parse.quote(str(item))) for k, item in pairs).encode()
  63. request = urllib.request.Request(WEB + url if url.startswith("/") else url,
  64. data=body, method=method)
  65. if data is not None:
  66. request.add_header("Content-Type", "application/x-www-form-urlencoded")
  67. for key, value in (headers or {}).items():
  68. request.add_header(key, value)
  69. # Retry dropped/refused connections: the freshly started app can
  70. # close a very early keep-alive connection once while warming up,
  71. # and its first seconds can wobble under load.
  72. for attempt in range(3):
  73. try:
  74. with urllib.request.urlopen(request, timeout=timeout) as response:
  75. return response.status, response.read().decode()
  76. except urllib.error.HTTPError as error:
  77. return error.code, error.read().decode()
  78. except (urllib.error.URLError, OSError):
  79. if attempt >= 2:
  80. raise
  81. time.sleep(0.75)
  82. def directives(uri, auth=None):
  83. headers = {}
  84. if auth:
  85. headers["X-Statum-Slot"] = "key=%s; as_at=%s" % (auth["key"], auth["as_at"])
  86. status, body = http("GET", "/_statum/entrypoint?uri=" + urllib.parse.quote(uri.split("?")[0]),
  87. headers=headers)
  88. if status != 200:
  89. raise SystemExit("entrypoint %s -> HTTP %d: %s" % (uri, status, body[:300]))
  90. return json.loads(body)
  91. def slot_public(auth, uri, type_name):
  92. for directive in directives(uri, auth):
  93. if directive["type"] == "set":
  94. content = json.loads(directive["snapshot_frame"]["content"])
  95. if content["slot"]["type"] == type_name:
  96. return content["public"]
  97. raise SystemExit("no %s slot in entrypoint state for %s" % (type_name, uri))
  98. def act(auth, action, fields):
  99. headers = {"X-Statum-Slot": "key=%s; as_at=%s" % (auth["key"], auth["as_at"])}
  100. if action.get("private"):
  101. headers["X-Statum-Private"] = action["private"]
  102. status, body = http(action.get("method", "POST"), action["uri"], fields, headers)
  103. if status != 200:
  104. raise SystemExit("action %s -> HTTP %d: %s" % (action["uri"], status, body[:300]))
  105. return json.loads(body)
  106. def login(username, password):
  107. frames = directives("/login")
  108. content = json.loads(frames[0]["snapshot_frame"]["content"])
  109. action = content["public"]["login"]
  110. status, body = http(action.get("method", "POST"), action["uri"],
  111. {"username": username, "password": password})
  112. if status != 200:
  113. raise SystemExit("login -> HTTP %d: %s" % (status, body[:300]))
  114. for directive in json.loads(body):
  115. if directive["type"] == "set":
  116. frame = json.loads(directive["snapshot_frame"]["content"])
  117. if frame["slot"]["type"] == "auth":
  118. return {"key": frame["slot"]["key"], "as_at": frame["as_at"]}
  119. raise SystemExit("login response carried no auth slot: " + body[:300])
  120. def wait_for(predicate, timeout=60, what="condition", interval=0.5):
  121. deadline = time.monotonic() + timeout
  122. last = None
  123. while time.monotonic() < deadline:
  124. last = predicate()
  125. if last is not False and last is not None:
  126. return last
  127. time.sleep(interval)
  128. print(" (timeout waiting for %s; last: %s)" % (what, last))
  129. return None
  130. def task_state(auth, task_id):
  131. return slot_public(auth, "/tasks/%d" % task_id, "task")
  132. def wait_task_done(auth, task_id, timeout=90):
  133. return wait_for(lambda: task_state(auth, task_id).get("status") == "done" or False,
  134. timeout=timeout, what="task %d -> done" % task_id)
  135. def task_id_of(title):
  136. conn = db()
  137. try:
  138. return conn.execute("select id from tasks where title = ?", (title,)).fetchone()[0]
  139. finally:
  140. conn.close()
  141. def saddle_api(method, path, token, payload=None):
  142. """Raw call against the saddle ingest API; returns (status, json)."""
  143. request = urllib.request.Request(WEB + path,
  144. data=json.dumps(payload or {}).encode(), method=method)
  145. request.add_header("Content-Type", "application/json")
  146. if token is not None:
  147. request.add_header("Authorization", "Bearer " + token)
  148. try:
  149. with urllib.request.urlopen(request, timeout=15) as response:
  150. return response.status, json.loads(response.read().decode() or "{}")
  151. except urllib.error.HTTPError as error:
  152. body = error.read().decode()
  153. try:
  154. return error.code, json.loads(body)
  155. except ValueError:
  156. return error.code, {"raw": body}
  157. def db():
  158. return sqlite3.connect(DB_PATH)
  159. class FakeSaddle:
  160. """In-process saddle daemon implementing the wire contract."""
  161. PROMPT = "bbarrow@fedora:/workspace/task-1$ "
  162. def __init__(self):
  163. self.lock = threading.Lock()
  164. self.start_payloads = [] # captured POST /start bodies
  165. self.chunks = [] # [{seq, text}] — the raw log ring
  166. self.next_seq = 1
  167. self.state = "idle"
  168. self.summary = ""
  169. self.state_entered = time.monotonic()
  170. self.fail_next = False # next /start drives the task to failed
  171. self.start_counts = {} # task id -> /start count
  172. self.resumed_pending = False # crash-resume flag on the next /status
  173. self.suspend_after_start = False # next task parks as suspended
  174. self.pending_output = [] # entries drained by the next /output poll
  175. self.output_seq = 100
  176. self.subtask_status_events = [] # recorded POST /subtask-status payloads
  177. self.fail_subtask_status = False # exercise the web's retry path
  178. def set_state(self, state, summary=None):
  179. """Advances the scripted lifecycle.
  180. Time-based on purpose: the web app may probe /status more than
  181. once per tick (status polls, stale-busy healing, manual
  182. refreshes), and a poll-counted machine would conflate them and
  183. race dependent sections.
  184. """
  185. self.state = state
  186. if summary is not None:
  187. self.summary = summary
  188. self.state_entered = time.monotonic()
  189. def queue_output(self, entry_type, command, output):
  190. """Queues a typed entry for the dispatcher's next /output poll."""
  191. with self.lock:
  192. self.pending_output.append({
  193. "id": self.output_seq,
  194. "command": command,
  195. "output": output,
  196. "exit_code": 1,
  197. "type": entry_type,
  198. "timestamp": "2026-09-06T00:00:00Z",
  199. })
  200. self.output_seq += 1
  201. def append_chunk(self, text):
  202. with self.lock:
  203. self.chunks.append({"seq": self.next_seq, "text": text})
  204. self.next_seq += 1
  205. def log_since(self, since):
  206. with self.lock:
  207. # `since` is the inclusive resume point (wire contract: a
  208. # response ending at seq 12 carries next=13, and polling
  209. # with since=13 must still deliver the chunk numbered 13).
  210. chunks = [c for c in self.chunks if c["seq"] >= since]
  211. return chunks, self.next_seq
  212. class Handler(BaseHTTPRequestHandler):
  213. daemon = None # set on the class before serving
  214. protocol_version = "HTTP/1.1"
  215. def log_message(self, fmt, *args):
  216. pass
  217. def _send(self, status, obj):
  218. body = json.dumps(obj).encode()
  219. self.send_response(status)
  220. self.send_header("Content-Type", "application/json")
  221. self.send_header("Content-Length", str(len(body)))
  222. self.end_headers()
  223. self.wfile.write(body)
  224. def do_POST(self):
  225. length = int(self.headers.get("Content-Length") or 0)
  226. raw = self.rfile.read(length).decode() if length else "{}"
  227. if self.path == "/start":
  228. try:
  229. payload = json.loads(raw)
  230. except ValueError:
  231. payload = {"raw": raw}
  232. self.daemon.start_payloads.append(payload)
  233. task_id = (payload.get("task") or {}).get("id")
  234. count = self.daemon.start_counts.get(task_id, 0) + 1
  235. self.daemon.start_counts[task_id] = count
  236. if count > 1:
  237. # Crash-resume contract: re-pushing a task whose
  238. # workspace still exists restores it; the first
  239. # /status after the restore reports "resumed": true.
  240. self.daemon.resumed_pending = True
  241. self.daemon.append_chunk(self.daemon.PROMPT)
  242. self.daemon.append_chunk("echo hello\r\n")
  243. self.daemon.append_chunk("hello\r\n")
  244. self.daemon.set_state("failing" if self.daemon.fail_next else "working")
  245. self.daemon.fail_next = False
  246. self._send(200, {})
  247. elif self.path == "/answer":
  248. self._send(200, {})
  249. elif self.path == "/subtask-status":
  250. try:
  251. payload = json.loads(raw)
  252. except ValueError:
  253. self._send(400, {"error": "bad json"})
  254. return
  255. if self.daemon.fail_subtask_status:
  256. self._send(500, {"error": "simulated failure"})
  257. return
  258. self.daemon.subtask_status_events.append(payload)
  259. self._send(200, {"ok": True})
  260. else:
  261. self._send(404, {"error": "no such route"})
  262. def do_GET(self):
  263. if self.path == "/status":
  264. response = {"state": self.daemon.state, "summary": self.daemon.summary}
  265. if self.daemon.resumed_pending:
  266. response["resumed"] = True
  267. self.daemon.resumed_pending = False
  268. if self.daemon.state == "failing":
  269. # Fails fast (a rejected /start script).
  270. if time.monotonic() - self.daemon.state_entered >= 1:
  271. self.daemon.set_state("failed",
  272. "bash -n rejected the script (simulated)")
  273. elif self.daemon.state == "working":
  274. # Time-based, not poll-based: whoever probes /status
  275. # (dispatcher polls, stale-busy healing, refreshes)
  276. # must not advance the lifecycle.
  277. if time.monotonic() - self.daemon.state_entered >= 8:
  278. if self.daemon.suspend_after_start:
  279. # `saddle suspend`: park until a fresh /start
  280. # resumes the workspace (one-shot flag).
  281. self.daemon.set_state("suspended")
  282. self.daemon.suspend_after_start = False
  283. else:
  284. self.daemon.set_state("done", "fake daemon finished")
  285. self._send(200, response)
  286. elif self.path.startswith("/output"):
  287. with self.daemon.lock:
  288. entries = self.daemon.pending_output
  289. self.daemon.pending_output = []
  290. self._send(200, entries)
  291. elif self.path.startswith("/log"):
  292. query = urllib.parse.urlparse(self.path).query
  293. since = int(urllib.parse.parse_qs(query).get("since", ["0"])[0])
  294. # Simulate the live PTY: each poll produces one more chunk
  295. # (before slicing, so the poller actually sees it).
  296. self.daemon.append_chunk("stream-line-%d\r\n" % self.daemon.next_seq)
  297. chunks, nxt = self.daemon.log_since(since)
  298. self._send(200, {"chunks": chunks, "next": nxt})
  299. else:
  300. self._send(404, {"error": "no such route"})
  301. self.close_connection = True
  302. def serve(self):
  303. handler = type("BoundHandler", (FakeSaddle.Handler,), {"daemon": self})
  304. server = ThreadingHTTPServer(("127.0.0.1", DAEMON_PORT), handler)
  305. threading.Thread(target=server.serve_forever, daemon=True).start()
  306. return server
  307. def start_app():
  308. config = json.load(open(os.path.join(REPO, "web-config.json")))
  309. config["saddle_callback_base_url"] = WEB
  310. with open(os.path.join(WORK, "web-config.json"), "w") as handle:
  311. json.dump(config, handle)
  312. env = dict(os.environ)
  313. env.update({
  314. "SPRY_DB_PATH": DB_PATH,
  315. "SPRY_SEED_ADMIN_USERNAME": "admin",
  316. "SPRY_SEED_ADMIN_PASSWORD": "adminpass123",
  317. "SADDLE_LLM_BASE_URL": "http://127.0.0.1:%d" % DAEMON_PORT,
  318. "SADDLE_PROJECTS_ROOT": os.path.join(WORK, "workspace"),
  319. })
  320. log = open(os.path.join(WORK, "app.log"), "w")
  321. command = [APP, str(APP_PORT)]
  322. if os.environ.get("WEBSTACK_GDB"):
  323. # Debugging aid: run the app under gdb; a crash backtrace lands in
  324. # app.log. WEBSTACK_GDB=1 meson test ... to enable.
  325. command = ["gdb", "-batch", "-quiet",
  326. "-ex", "handle SIGPIPE nostop noprint pass",
  327. "-ex", "run", "-ex", "bt", "--args", APP, str(APP_PORT)]
  328. process = subprocess.Popen(command, cwd=WORK, env=env,
  329. stdout=log, stderr=subprocess.STDOUT)
  330. outcome = wait_for(lambda: (app_ready() and "ready")
  331. or (process.poll() is not None and "app-exited-%d" % process.poll())
  332. or False, timeout=30, what="app ready")
  333. if outcome is None:
  334. raise SystemExit("app did not come up; see %s/app.log" % WORK)
  335. if str(outcome).startswith("app-exited-"):
  336. raise SystemExit("app exited during startup (%s); see %s/app.log"
  337. % outcome, WORK)
  338. return process
  339. def app_ready():
  340. try:
  341. status, _ = http("GET", "/login", timeout=3)
  342. return status == 200
  343. except (urllib.error.URLError, OSError):
  344. return False
  345. def dump_app_stacks(pid):
  346. """Best-effort thread backtrace of the (possibly wedged) app."""
  347. try:
  348. fds = os.listdir("/proc/%d/fd" % pid)
  349. print("(app open fds at dump: %d)" % len(fds))
  350. kinds = {}
  351. for fd in fds[:1200]:
  352. try:
  353. target = os.readlink("/proc/%d/fd/%s" % (pid, fd))
  354. except OSError:
  355. continue
  356. key = target.split(":", 1)[0] if ":" in target else target
  357. kinds[key] = kinds.get(key, 0) + 1
  358. print("(fd kinds: %s)" % sorted(kinds.items(), key=lambda kv: -kv[1])[:6])
  359. except OSError:
  360. pass
  361. for command in (["eu-stack", "-p", str(pid)],
  362. ["gdb", "-batch", "-quiet", "-p", str(pid),
  363. "-ex", "thread apply all bt"]):
  364. try:
  365. out = subprocess.run(command, capture_output=True, timeout=60)
  366. text = out.stdout.decode(errors="replace") + out.stderr.decode(errors="replace")
  367. if text.strip():
  368. print("(app stacks via %s)\n%s" % (command[0], text[:12000]))
  369. return
  370. except (OSError, subprocess.SubprocessError):
  371. continue
  372. def main():
  373. if not os.path.exists(APP):
  374. raise SystemExit("app binary not found at %s (build first)" % APP)
  375. fake = FakeSaddle()
  376. fake.serve()
  377. # A rare framework-level crash (stack corruption in Statum slot
  378. # signing during the FIRST login — shared-library codegen bug, moved
  379. # twice already) kills the app before any test runs; a clean restart
  380. # sidesteps it. Only tolerated while nothing has been checked yet.
  381. auth = None
  382. app = None
  383. for attempt in range(3):
  384. if os.path.exists(WORK):
  385. shutil.rmtree(WORK)
  386. os.makedirs(os.path.join(WORK, "workspace"))
  387. try:
  388. app = start_app()
  389. auth = login("admin", "adminpass123")
  390. break
  391. except (SystemExit, OSError, urllib.error.URLError):
  392. if passes > 0:
  393. raise
  394. if app is not None and app.poll() is None:
  395. app.terminate()
  396. try:
  397. app.wait(timeout=5)
  398. except subprocess.TimeoutExpired:
  399. app.kill()
  400. app = None
  401. print("(app died before login; restarting cleanly, attempt %d)" % (attempt + 1))
  402. if app is None or auth is None:
  403. raise SystemExit("app never came up; see %s/app.log" % WORK)
  404. try:
  405. check(True, "login yields an auth slot")
  406. print("== feature 3: plan review hides duplicate body ==")
  407. state = slot_public(auth, "/projects", "projects")
  408. act(auth, state["create"], {
  409. "name": "demo",
  410. "git_url": "https://example.com/demo.git",
  411. "default_branch": "main",
  412. })
  413. conn = db()
  414. conn.execute("insert into tasks (project_id, title, status, priority,"
  415. " is_design_task, plan, created_at)"
  416. " values (1, 'Plan review task', 'working', 5, 1, '# The plan\n\n1. Do it',"
  417. " strftime('%s','now'))")
  418. conn.commit()
  419. plan_task = conn.execute("select max(id) from tasks").fetchone()[0]
  420. saddles_state = slot_public(auth, "/saddles", "saddles")
  421. act(auth, saddles_state["register"], {"name": "fake-saddle",
  422. "host": "127.0.0.1:%d" % DAEMON_PORT})
  423. registered = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  424. if s["name"] == "fake-saddle"]
  425. check(len(registered) == 1 and registered[0]["token"].startswith("saddle-"),
  426. "fake saddle registered with an API token")
  427. fake_id = int(registered[0]["id"])
  428. fake_token = registered[0]["token"]
  429. conn.execute("insert into sessions (task_id, saddle_id, started_at, ended_at, outcome)"
  430. " values (?, ?, strftime('%s','now'), strftime('%s','now'), 'done')",
  431. (plan_task, fake_id))
  432. conn.commit()
  433. session_id = conn.execute("select max(id) from sessions").fetchone()[0]
  434. conn.close()
  435. status, body = saddle_api("POST", "/api/saddle/%d/update" % fake_id, fake_token,
  436. {"type": "block", "session_id": session_id, "data": {
  437. "question": "Plan submitted for review",
  438. "type": "rfc",
  439. "body": "# The plan\n\n1. Do it"}})
  440. check(status == 200 and body.get("block_id"), "plan-review block raised")
  441. status, _ = saddle_api("POST", "/api/saddle/%d/update" % fake_id, fake_token,
  442. {"type": "block", "session_id": session_id, "data": {
  443. "question": "Which database?",
  444. "type": "question",
  445. "options": ["SQLite", "Postgres"],
  446. "body": "# Storage choice"}})
  447. check(status == 200, "question block raised")
  448. detail = task_state(auth, plan_task)
  449. blocks = {b["block_type"]: b for b in detail["blocks"]}
  450. check(blocks["rfc"].get("is_plan_review") is True,
  451. "rfc block flagged as the plan review")
  452. check(blocks["rfc"].get("show_body") is False,
  453. "pending plan review hides the duplicate body preview")
  454. check(blocks["question"].get("show_body") is True,
  455. "question block keeps its body preview")
  456. act(auth, blocks["rfc"]["answer_action"], {"answer": "Plan approved. Proceed."})
  457. detail = task_state(auth, plan_task)
  458. rfc = [b for b in detail["blocks"] if b["block_type"] == "rfc"][0]
  459. check(rfc.get("show_body") is True,
  460. "answered plan review shows the body again (review section gone)")
  461. print("== B1: highlight-to-comment quote dialog ==")
  462. status, page = http("GET", "/tasks/%d" % plan_task, timeout=15)
  463. check(status == 200 and "quote-comment-dialog" in page and "quote-text" in page,
  464. "task page carries the quote-comment dialog the popover opens")
  465. check('name="quote"' in page and 'name="return_to"' in page,
  466. "quote dialog posts the quote and return-to fields")
  467. status, page = http("GET", "/tasks/%d/terminal" % plan_task, timeout=15)
  468. check(status == 200 and "quote-comment-dialog" in page,
  469. "terminal page keeps its quote dialog")
  470. print("== B3: review section hides the duplicate body ==")
  471. status, body = saddle_api("POST", "/api/saddle/%d/update" % fake_id, fake_token,
  472. {"type": "block", "session_id": session_id, "data": {
  473. "question": "Review the diff",
  474. "type": "review",
  475. "body": "# The change\n\nReworks the flange module."}})
  476. check(status == 200 and body.get("block_id"), "review block raised")
  477. detail = task_state(auth, plan_task)
  478. review_block = [b for b in detail["blocks"] if b["block_type"] == "review"][0]
  479. check(detail.get("review_pending") is True,
  480. "review section shows while the review is unanswered")
  481. check(review_block.get("show_body") is False,
  482. "pending review block hides its duplicate body preview")
  483. check(review_block.get("body", "").startswith("# The change"),
  484. "review body itself still arrives in state (B2 counterpart)")
  485. act(auth, review_block["answer_action"], {"answer": "Human approved the review"})
  486. detail = task_state(auth, plan_task)
  487. review_block = [b for b in detail["blocks"] if b["block_type"] == "review"][0]
  488. check(review_block.get("show_body") is True,
  489. "answered review shows the body again (banner gone)")
  490. print("== B2: question block body above options ==")
  491. # Contract side (A2 will restore bodies end-to-end): a question
  492. # block raised with a heredoc body carries it; the body preview
  493. # rides above the options (template order) and survives answering
  494. # alongside the selected answer.
  495. detail = task_state(auth, plan_task)
  496. question = [b for b in detail["blocks"] if b["block_type"] == "question"][0]
  497. check(question.get("body") == "# Storage choice"
  498. and question.get("show_body") is True,
  499. "question block keeps its body markdown with the preview on")
  500. act(auth, question["answer_action"], {"option": "SQLite"})
  501. detail = task_state(auth, plan_task)
  502. question = [b for b in detail["blocks"] if b["block_type"] == "question"][0]
  503. check(question.get("answered") is True and question.get("answer") == "SQLite"
  504. and question.get("body") == "# Storage choice"
  505. and question.get("show_body") is True,
  506. "answered question shows body + selected answer")
  507. print("== B5: compare-changes SSH -> HTTPS ==")
  508. def diff_url_for_project(git_url, suffix):
  509. state = slot_public(auth, "/projects", "projects")
  510. act(auth, state["create"], {"name": "diff-%s" % suffix, "git_url": git_url})
  511. project_id = int([p["id"] for p in slot_public(auth, "/projects", "projects")["projects"]
  512. if p["name"] == "diff-%s" % suffix][0])
  513. conn = db()
  514. conn.execute("insert into tasks (project_id, title, status, task_branch,"
  515. " branch_from, created_at) values (?, ?, 'working', 'feature/x',"
  516. " 'main', strftime('%s','now'))", (project_id, "Diff %s" % suffix))
  517. conn.commit()
  518. task_id = conn.execute("select max(id) from tasks").fetchone()[0]
  519. conn.execute("insert into sessions (task_id, saddle_id, started_at, ended_at,"
  520. " outcome) values (?, ?, strftime('%s','now'), strftime('%s','now'),"
  521. " 'done')", (task_id, fake_id))
  522. conn.commit()
  523. session = conn.execute("select max(id) from sessions").fetchone()[0]
  524. conn.close()
  525. saddle_api("POST", "/api/saddle/%d/update" % fake_id, fake_token,
  526. {"type": "block", "session_id": session, "data": {
  527. "question": "Review the diff", "type": "review"}})
  528. return task_state(auth, task_id).get("review_diff_url") or ""
  529. check(diff_url_for_project("ssh://fabrica.unitatem.net:222/clanker/clanker-saddle.git",
  530. "fabrica")
  531. == "https://fabrica.unitatem.net/clanker/clanker-saddle/compare/main...feature/x",
  532. "ssh://git@host:port/owner/repo.git translates (port dropped)")
  533. check(diff_url_for_project("git@github.com:acme/widgets.git", "scp")
  534. == "https://github.com/acme/widgets/compare/main...feature/x",
  535. "git@host:owner/repo.git translates to the browse host")
  536. check(diff_url_for_project("https://example.com/plain/repo", "plain")
  537. == "https://example.com/plain/repo/compare/main...feature/x",
  538. "HTTPS URLs pass through unchanged")
  539. print("== feature 5: rules ==")
  540. rules_state = slot_public(auth, "/rules", "rules")
  541. for name, body in [
  542. ("Use Invercargill", "Never use libgee; use Invercargill enumerables."),
  543. ("Write Vala docs", "Every public class carries a doc comment."),
  544. ("Prefer meson", "Builds go through meson; no makefiles."),
  545. ("Co-dev rule", "Keep the wire contract exactly."),
  546. ("Reference rule", "Must never ride along."),
  547. ]:
  548. act(auth, rules_state["create"], {"name": name, "body": body})
  549. listed = {r["name"]: r for r in slot_public(auth, "/rules", "rules")["rules"]}
  550. check(len(listed) == 5, "five rules created and listed on /rules")
  551. def rule_id(name):
  552. rows = [r for r in slot_public(auth, "/rules", "rules")["rules"] if r["name"] == name]
  553. return int(rows[0]["id"])
  554. state = slot_public(auth, "/projects", "projects")
  555. act(auth, state["create"], {"name": "co", "git_url": "https://example.com/co.git"})
  556. act(auth, state["create"], {"name": "ref", "git_url": "https://example.com/ref.git"})
  557. co_id = int([p["id"] for p in slot_public(auth, "/projects", "projects")["projects"]
  558. if p["name"] == "co"][0])
  559. ref_id = int([p["id"] for p in slot_public(auth, "/projects", "projects")["projects"]
  560. if p["name"] == "ref"][0])
  561. project = slot_public(auth, "/projects/1", "project")
  562. act(auth, project["add_link"], {"linked_project_id": str(co_id),
  563. "relationship": "co-developed"})
  564. act(auth, project["add_link"], {"linked_project_id": str(ref_id),
  565. "relationship": "reference"})
  566. act(auth, project["attach_rule"], {"rule_id": str(rule_id("Use Invercargill"))})
  567. act(auth, project["attach_rule"], {"rule_id": str(rule_id("Prefer meson"))})
  568. project = slot_public(auth, "/projects/1", "project")
  569. check(sorted(r["name"] for r in project["rules"]) == ["Prefer meson", "Use Invercargill"],
  570. "project page lists its attached rules")
  571. co_project = slot_public(auth, "/projects/%d" % co_id, "project")
  572. act(auth, co_project["attach_rule"], {"rule_id": str(rule_id("Co-dev rule"))})
  573. ref_project = slot_public(auth, "/projects/%d" % ref_id, "project")
  574. act(auth, ref_project["attach_rule"], {"rule_id": str(rule_id("Reference rule"))})
  575. tasks_state = slot_public(auth, "/tasks", "tasks")
  576. act(auth, tasks_state["create"], {
  577. "project_id": "1",
  578. "title": "Rules dispatch",
  579. "description": "Effective rule set rides the /start payload.",
  580. "priority": "2",
  581. })
  582. rule_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  583. if t["title"] == "Rules dispatch"][0])
  584. detail = task_state(auth, rule_task)
  585. act(auth, detail["attach_rule"], {"rule_id": str(rule_id("Write Vala docs"))})
  586. act(auth, detail["attach_rule"], {"rule_id": str(rule_id("Use Invercargill"))})
  587. detail = task_state(auth, rule_task)
  588. check(sorted(r["name"] for r in detail["rules"]) == ["Use Invercargill", "Write Vala docs"],
  589. "task page lists its attached rules")
  590. check(wait_for(lambda: len(fake.start_payloads) > 0 or False,
  591. timeout=30, what="/start push") is not None,
  592. "dispatcher pushes the task to the fake saddle")
  593. payload = fake.start_payloads[-1]
  594. rule_names = [r["name"] for r in payload.get("rules", [])]
  595. check(rule_names == ["Write Vala docs", "Use Invercargill", "Prefer meson", "Co-dev rule"],
  596. "effective rules in /start: task, primary, co-developed order (%s)" % rule_names)
  597. check(all(r.get("body") for r in payload.get("rules", [])),
  598. "rules carry their bodies")
  599. check("Reference rule" not in rule_names,
  600. "reference project rules excluded")
  601. detail = task_state(auth, rule_task)
  602. act(auth, detail["rules"][0]["detach"], {})
  603. check([r["name"] for r in task_state(auth, rule_task)["rules"]] == ["Use Invercargill"],
  604. "detached task rule disappears")
  605. rules_rows = slot_public(auth, "/rules", "rules")["rules"]
  606. docs_row = [r for r in rules_rows if r["name"] == "Write Vala docs"][0]
  607. act(auth, docs_row["update"], {"name": "Write Vala docs", "body": "Doc comments or bust."})
  608. check(any(r["body"] == "Doc comments or bust."
  609. for r in slot_public(auth, "/rules", "rules")["rules"]),
  610. "rule edit saves the body")
  611. check(wait_task_done(auth, rule_task, timeout=60) is not None,
  612. "dispatched task completes against the fake daemon")
  613. print("== feature 6: project notes + callback ==")
  614. conn = db()
  615. callback_rows = conn.execute(
  616. "select token from api_tokens where saddle_id = 0").fetchall()
  617. conn.close()
  618. check(len(callback_rows) == 1 and callback_rows[0][0].startswith("saddle-callback-"),
  619. "dedicated saddle-callback token minted at startup")
  620. callback_token = callback_rows[0][0]
  621. status, _ = saddle_api("POST", "/api/saddle/note", None,
  622. {"project": "demo", "body": "x"})
  623. check(status == 401, "note endpoint rejects a missing token")
  624. status, _ = saddle_api("POST", "/api/saddle/note", "saddle-callback-not-real",
  625. {"project": "demo", "body": "x"})
  626. check(status == 401, "note endpoint rejects a wrong token")
  627. status, body = saddle_api("POST", "/api/saddle/note", callback_token,
  628. {"task_id": rule_task, "project": "demo",
  629. "body": "style.css tokens live in :root"})
  630. check(status == 200 and body.get("ok") is True, "note accepted with the callback token")
  631. status, _ = saddle_api("POST", "/api/saddle/note", callback_token,
  632. {"project": "no-such-project", "body": "x"})
  633. check(status == 400, "unknown project rejected")
  634. status, _ = saddle_api("POST", "/api/saddle/note", callback_token,
  635. {"project": "demo", "body": " "})
  636. check(status == 400, "empty note body rejected")
  637. project = slot_public(auth, "/projects/1", "project")
  638. check(any("style.css tokens" in n["body"] and int(n["source_task_id"]) == rule_task
  639. for n in project["notes"]),
  640. "project page lists the note with its source task")
  641. act(auth, [n for n in project["notes"] if "style.css tokens" in n["body"]][0]["delete"], {})
  642. check(all("style.css tokens" not in n["body"]
  643. for n in slot_public(auth, "/projects/1", "project")["notes"]),
  644. "note deleted from the project page")
  645. # /start payload: notes of primary + co-developed (not reference),
  646. # oldest first; callback carries the configured base URL + token.
  647. saddle_api("POST", "/api/saddle/note", callback_token,
  648. {"project": "demo", "body": "note-one"})
  649. saddle_api("POST", "/api/saddle/note", callback_token,
  650. {"project": "demo", "body": "note-two"})
  651. saddle_api("POST", "/api/saddle/note", callback_token,
  652. {"project": "co", "body": "co-note"})
  653. conn = db()
  654. conn.execute("insert into project_notes (project_id, body, created_at)"
  655. " values (?, 'ref-note-must-not-ship', strftime('%s','now'))", (ref_id,))
  656. conn.commit()
  657. conn.close()
  658. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  659. "project_id": "1",
  660. "title": "Notes dispatch",
  661. "description": "Project notes ride the /start payload.",
  662. "priority": "2",
  663. })
  664. check(wait_for(lambda: len(fake.start_payloads) >= 2 or False,
  665. timeout=30, what="second /start push") is not None,
  666. "dispatcher pushes the notes task")
  667. payload = fake.start_payloads[-1]
  668. notes = payload.get("project_notes", [])
  669. check([n["body"] for n in notes] == ["note-one", "note-two", "co-note"],
  670. "project_notes: primary + co-developed, oldest first (%s)" % [n["body"] for n in notes])
  671. check(all(n.get("project") for n in notes)
  672. and notes[2]["project"] == "co",
  673. "project_notes carry the project name")
  674. check("ref-note-must-not-ship" not in [n["body"] for n in notes],
  675. "reference project notes excluded")
  676. callback = payload.get("callback", {})
  677. check(callback.get("base_url") == WEB and callback.get("token") == callback_token,
  678. "callback carries the configured base URL and the minted token")
  679. # entry cap: 30 of 33 candidates
  680. conn = db()
  681. for i in range(33):
  682. conn.execute("insert into project_notes (project_id, body, created_at)"
  683. " values (?, ?, strftime('%s','now'))", (1, "bulk-%02d" % i))
  684. conn.commit()
  685. conn.close()
  686. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  687. "project_id": "1",
  688. "title": "Entry cap dispatch",
  689. "priority": "2",
  690. })
  691. check(wait_for(lambda: len(fake.start_payloads) >= 3 or False,
  692. timeout=30, what="third /start push") is not None,
  693. "dispatcher pushes the entry-cap task")
  694. notes = fake.start_payloads[-1].get("project_notes", [])
  695. check(len(notes) == 30 and notes[0]["body"] == "note-one" and notes[1]["body"] == "note-two",
  696. "project_notes capped at 30 entries, oldest kept (%d)" % len(notes))
  697. # char cap: the giant note is truncated so the total stays <= 20000
  698. conn = db()
  699. conn.execute("delete from project_notes where body like 'bulk-%' or body = 'co-note'")
  700. conn.execute("insert into project_notes (project_id, body, created_at)"
  701. " values (?, ?, strftime('%s','now'))", (1, "giant!" + "x" * 25000,))
  702. conn.commit()
  703. conn.close()
  704. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  705. "project_id": "1",
  706. "title": "Char cap dispatch",
  707. "priority": "2",
  708. })
  709. check(wait_for(lambda: len(fake.start_payloads) >= 4 or False,
  710. timeout=30, what="fourth /start push") is not None,
  711. "dispatcher pushes the char-cap task")
  712. notes = fake.start_payloads[-1].get("project_notes", [])
  713. total = sum(len(n["body"]) for n in notes)
  714. check(len(notes) == 3 and total == 20000 and notes[2]["body"].startswith("giant!"),
  715. "project_notes char cap: head kept, giant truncated (entries=%d, chars=%d)"
  716. % (len(notes), total))
  717. print("== raw log polling + terminal raw view ==")
  718. # Wait for the last dispatch to finish so its final polls ran.
  719. check(wait_task_done(auth, task_id_of("Char cap dispatch"), timeout=60) is not None,
  720. "char-cap task completes")
  721. conn = db()
  722. raw_session = conn.execute(
  723. "select s.id from sessions s join tasks t on t.id = s.task_id"
  724. " where t.title = 'Char cap dispatch' order by s.id desc limit 1").fetchone()[0]
  725. rows = conn.execute("select seq, chunk from session_raw_log where session_id = ?"
  726. " order by seq", (raw_session,)).fetchall()
  727. conn.close()
  728. check(len(rows) >= 3, "session_raw_log captured the daemon's chunks (%d)" % len(rows))
  729. seqs = [r[0] for r in rows]
  730. check(seqs == sorted(seqs) and len(set(seqs)) == len(seqs),
  731. "raw chunks stored once, seq strictly increasing")
  732. text = "".join(r[1] for r in rows)
  733. check(fake.PROMPT in text and "echo hello" in text and "hello" in text,
  734. "raw log keeps the prompt and echoed output byte-exact")
  735. check("stream-line-" in text, "incremental /log polls append later chunks")
  736. terminal = slot_public(auth, "/tasks/%d/terminal" % task_id_of("Char cap dispatch"),
  737. "terminal")
  738. check(terminal.get("has_raw_log") is True and int(terminal["session_id"]) == raw_session,
  739. "terminal view selects raw rendering for the raw session")
  740. # Old sessions (typed entries, no raw rows) keep the fallback view.
  741. conn = db()
  742. conn.execute("insert into session_commands (session_id, command_text, output_text,"
  743. " type, started_at) values (?, 'ls', 'old-output', 'command',"
  744. " strftime('%s','now'))", (session_id,))
  745. conn.commit()
  746. conn.close()
  747. legacy = slot_public(auth, "/tasks/%d/terminal" % plan_task, "terminal")
  748. check(legacy.get("has_raw_log") is False,
  749. "legacy session falls back to the entry-type terminal view")
  750. request = urllib.request.Request(WEB + "/terminal-stream/%d" % raw_session)
  751. with urllib.request.urlopen(request, timeout=15) as stream:
  752. raw_events = []
  753. deadline = time.monotonic() + 8
  754. while time.monotonic() < deadline and len(raw_events) < 3:
  755. line = stream.readline().decode().strip()
  756. if not line.startswith("data: ") or line == "data: ping":
  757. continue
  758. event = json.loads(line[6:])
  759. if event.get("type") == "raw":
  760. raw_events.append(event)
  761. check(len(raw_events) >= 3 and raw_events[0]["text"] == fake.PROMPT
  762. and raw_events[0]["seq"] < raw_events[1]["seq"],
  763. "terminal stream serves raw chunks in seq order (prompt first)")
  764. print("== capabilities: tag-based routing ==")
  765. saddles_state = slot_public(auth, "/saddles", "saddles")
  766. act(auth, saddles_state["create_capability"], {"name": "gpu"})
  767. act(auth, saddles_state["create_capability"], {"name": "bigmem"})
  768. saddles_state = slot_public(auth, "/saddles", "saddles")
  769. caps = {c["name"]: c for c in saddles_state["capabilities"]}
  770. check(len(caps) == 2 and all(int(c["saddle_count"]) == 0 and int(c["task_count"]) == 0
  771. for c in caps.values()),
  772. "capabilities created and listed with zero usage")
  773. def saddle_card(name):
  774. rows = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  775. if s["name"] == name]
  776. return rows[0]
  777. fake_card = saddle_card("fake-saddle")
  778. check(fake_card["capabilities"] == [],
  779. "saddle card starts with no capability chips")
  780. # A task requiring 'gpu' waits while the only idle saddle lacks it;
  781. # a later task without requirements still dispatches (skip-and-continue).
  782. tasks_state = slot_public(auth, "/tasks", "tasks")
  783. act(auth, tasks_state["create"], {
  784. "project_id": "1",
  785. "title": "GPU task",
  786. "priority": "2",
  787. })
  788. gpu_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  789. if t["title"] == "GPU task"][0])
  790. detail = task_state(auth, gpu_task)
  791. act(auth, detail["attach_capability"], {"capability_id": str(int(caps["gpu"]["id"]))})
  792. detail = task_state(auth, gpu_task)
  793. check([c["name"] for c in detail["capabilities"]] == ["gpu"],
  794. "task page lists the required capability")
  795. # The only idle saddle lacks the tag, so the queue flags the wait
  796. # (read before any other task claims the idle saddle).
  797. queue = [t for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  798. if int(t["id"]) == gpu_task][0]
  799. check(queue["status"] == "queued" and queue["capabilities"] == ["gpu"]
  800. and queue.get("waiting_for_saddle") is True,
  801. "queue row shows the waiting task with its tag and wait flag")
  802. act(auth, tasks_state["create"], {
  803. "project_id": "1",
  804. "title": "Any-saddle task",
  805. "priority": "3",
  806. })
  807. any_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  808. if t["title"] == "Any-saddle task"][0])
  809. check(wait_for(lambda: task_state(auth, any_task).get("status") != "queued" or False,
  810. timeout=30, what="uncapable task dispatches") is not None,
  811. "task without requirements dispatches past the waiting task")
  812. with open(os.path.join(WORK, "app.log")) as handle:
  813. app_log = handle.read()
  814. check("waits for a capable saddle" in app_log and "requires: gpu" in app_log,
  815. "dispatcher logs the capability wait")
  816. # Attaching the tag to the saddle releases the task.
  817. fake_card = saddle_card("fake-saddle")
  818. act(auth, fake_card["attach_capability"], {"capability_id": str(int(caps["gpu"]["id"]))})
  819. fake_card = saddle_card("fake-saddle")
  820. check([c["name"] for c in fake_card["capabilities"]] == ["gpu"],
  821. "saddle card shows the attached chip")
  822. check(wait_task_done(auth, gpu_task, timeout=60) is not None,
  823. "task dispatches once the saddle carries the required tag")
  824. queue = [t for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  825. if int(t["id"]) == gpu_task][0]
  826. check(queue.get("waiting_for_saddle") is not True,
  827. "wait flag clears once dispatched")
  828. # Multi-tag: gpu + bigmem required, only gpu carried → waits; adding
  829. # bigmem completes the superset.
  830. act(auth, tasks_state["create"], {
  831. "project_id": "1",
  832. "title": "Big task",
  833. "priority": "2",
  834. })
  835. big_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  836. if t["title"] == "Big task"][0])
  837. detail = task_state(auth, big_task)
  838. act(auth, detail["attach_capability"], {"capability_id": str(int(caps["gpu"]["id"]))})
  839. act(auth, detail["attach_capability"], {"capability_id": str(int(caps["bigmem"]["id"]))})
  840. check(wait_for(lambda: ([t for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  841. if int(t["id"]) == big_task][0].get("waiting_for_saddle")
  842. is True) or False, timeout=30, what="big task waits") is not None,
  843. "task waits when the saddle's tags are not a superset")
  844. fake_card = saddle_card("fake-saddle")
  845. act(auth, fake_card["attach_capability"], {"capability_id": str(int(caps["bigmem"]["id"]))})
  846. check(wait_task_done(auth, big_task, timeout=60) is not None,
  847. "task dispatches once the superset is complete")
  848. # Detach a requirement; delete a capability everywhere.
  849. detail = task_state(auth, big_task)
  850. act(auth, [c for c in detail["capabilities"] if c["name"] == "bigmem"][0]["detach"], {})
  851. check([c["name"] for c in task_state(auth, big_task)["capabilities"]] == ["gpu"],
  852. "detached requirement disappears from the task")
  853. bigmem_row = [c for c in slot_public(auth, "/saddles", "saddles")["capabilities"]
  854. if c["name"] == "bigmem"][0]
  855. act(auth, bigmem_row["delete"], {})
  856. saddles_state = slot_public(auth, "/saddles", "saddles")
  857. check(all(c["name"] != "bigmem" for c in saddles_state["capabilities"]),
  858. "deleted capability gone from the catalogue")
  859. check([c["name"] for c in task_state(auth, big_task)["capabilities"]] == ["gpu"],
  860. "deleting a capability drops its task requirements")
  861. check(all(c["name"] != "bigmem"
  862. for c in saddle_card("fake-saddle")["capabilities"]),
  863. "deleting a capability drops it from saddles")
  864. print("== B4: required capabilities on the create form ==")
  865. tasks_state = slot_public(auth, "/tasks", "tasks")
  866. gpu_option = [o for o in tasks_state.get("capability_options", [])
  867. if o["name"] == "gpu"][0]
  868. check(len(tasks_state.get("capability_options", [])) >= 1,
  869. "queue page offers capability options in the create form")
  870. conn = db()
  871. before = conn.execute("select count(*) from tasks").fetchone()[0]
  872. conn.close()
  873. act(auth, tasks_state["create"], {
  874. "project_id": "1",
  875. "title": "Bad cap task",
  876. "capability_id": [gpu_option["id"], "999"],
  877. })
  878. conn = db()
  879. after = conn.execute("select count(*) from tasks").fetchone()[0]
  880. conn.close()
  881. check(after == before, "unknown capability id rejects the create")
  882. act(auth, tasks_state["create"], {
  883. "project_id": "1",
  884. "title": "Cap-on-create task",
  885. "capability_id": [gpu_option["id"]],
  886. })
  887. cap_create = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  888. if t["title"] == "Cap-on-create task"][0])
  889. detail = task_state(auth, cap_create)
  890. check([c["name"] for c in detail["capabilities"]] == ["gpu"],
  891. "capabilities from the create form land on the task")
  892. queue = [t for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  893. if int(t["id"]) == cap_create][0]
  894. check(queue["capabilities"] == ["gpu"],
  895. "queue row shows the created task's requirements")
  896. # The waiting flag must hold it back until the capable saddle frees
  897. # up (the fake saddle still carries gpu from the routing checks).
  898. check(queue.get("waiting_for_saddle") is not True,
  899. "capable saddle available: no wait flag on the new task")
  900. print("== B6: computed display state ==")
  901. conn = db()
  902. conn.execute("insert into tasks (project_id, title, status, priority, created_at)"
  903. " values (1, 'Parent', 'done', 5, strftime('%s','now'))")
  904. parent6 = conn.execute("select max(id) from tasks").fetchone()[0]
  905. # The post-mortem case: stored done WITH an unanswered review block.
  906. conn.execute("insert into sessions (task_id, saddle_id, started_at, ended_at, outcome)"
  907. " values (?, ?, strftime('%s','now'), strftime('%s','now'), 'done')",
  908. (parent6, fake_id))
  909. session6 = conn.execute("select max(id) from sessions").fetchone()[0]
  910. conn.commit()
  911. conn.close()
  912. saddle_api("POST", "/api/saddle/%d/update" % fake_id, fake_token,
  913. {"type": "block", "session_id": session6, "data": {
  914. "question": "Review the final diff", "type": "review"}})
  915. detail = task_state(auth, parent6)
  916. check(detail.get("display") == "review requested" and detail.get("terminal") is True,
  917. "unanswered review overrides the stored done status (terminal flag keeps stored truth)")
  918. queue = [t for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  919. if int(t["id"]) == parent6][0]
  920. check(queue["display"] == "review requested",
  921. "queue row shows the computed review-requested display")
  922. # Delegation: with the review answered, open subtasks delegate.
  923. conn = db()
  924. conn.execute("update task_blocks set answered_at = strftime('%s','now'),"
  925. " answer = 'Human approved the review' where task_id = ?", (parent6,))
  926. conn.execute("insert into tasks (project_id, title, status, priority, parent_task_id,"
  927. " created_at) values (1, 'Sub A', 'working', 5, ?, strftime('%s','now'))",
  928. (parent6,))
  929. conn.execute("insert into tasks (project_id, title, status, priority, parent_task_id,"
  930. " created_at) values (1, 'Sub B', 'done', 5, ?, strftime('%s','now'))",
  931. (parent6,))
  932. conn.commit()
  933. conn.close()
  934. detail = task_state(auth, parent6)
  935. check(detail.get("display") == "delegating 1/2" and detail.get("subtask_open") == 1,
  936. "open subtasks show the delegating breakdown (%s)" % detail.get("display"))
  937. # Flags track the stored status once the display falls through
  938. # (subtasks terminal first — delegating leads while any are open).
  939. conn = db()
  940. conn.execute("update tasks set status = 'failed', completed_at = strftime('%s','now')"
  941. " where id = ?", (parent6,))
  942. conn.execute("update tasks set status = 'failed', completed_at = strftime('%s','now')"
  943. " where title = 'Sub A'")
  944. conn.commit()
  945. conn.close()
  946. detail = task_state(auth, parent6)
  947. check(detail.get("display") == "failed" and detail.get("failed") is True
  948. and detail.get("terminal") is True,
  949. "failed task with answered review and terminal subtasks shows failed + terminal flags")
  950. print("== retry + resume ==")
  951. # A daemon-side failure fails the task with its explanation.
  952. fake.fail_next = True
  953. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  954. "project_id": "1",
  955. "title": "Flaky task",
  956. "priority": "2",
  957. })
  958. flaky = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  959. if t["title"] == "Flaky task"][0])
  960. check(wait_for(lambda: task_state(auth, flaky).get("status") == "failed" or False,
  961. timeout=60, what="flaky task fails") is not None,
  962. "daemon failure fails the task")
  963. detail = task_state(auth, flaky)
  964. check("bash -n rejected" in (detail.get("summary") or ""),
  965. "failure summary shows the daemon's explanation")
  966. # Retry: summary moves into a comment and clears, task requeues,
  967. # history stays, and the re-dispatch restores the workspace.
  968. comments_before = len(detail.get("comments", []))
  969. progress_before = len(detail.get("progress", []))
  970. act(auth, detail["retry"], {})
  971. detail = task_state(auth, flaky)
  972. check(detail["status"] == "queued" and (detail.get("summary") or "") == "",
  973. "retry requeues the task and clears the stale summary")
  974. check(any("bash -n rejected" in c["body"] for c in detail["comments"]),
  975. "failure explanation kept as a bot comment")
  976. check(any(c["body"] == "Task retried by user" for c in detail["comments"]),
  977. "retry lands its audit comment")
  978. check(len(detail["comments"]) >= comments_before + 1,
  979. "retry keeps the comment history")
  980. check(wait_task_done(auth, flaky, timeout=60) is not None,
  981. "retried task dispatches again and completes")
  982. detail = task_state(auth, flaky)
  983. check(any("restored the session context" in p["summary"]
  984. for p in detail.get("progress", [])),
  985. "daemon resume reported on the first status after the restore")
  986. check(len(detail["progress"]) > progress_before,
  987. "resume note appended to the progress timeline")
  988. # Manual resume: a task stuck working on an offline saddle.
  989. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  990. "project_id": "1",
  991. "title": "Stuck task",
  992. "priority": "2",
  993. })
  994. stuck = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  995. if t["title"] == "Stuck task"][0])
  996. check(wait_for(lambda: task_state(auth, stuck).get("status") == "working" or False,
  997. timeout=60, what="stuck task starts working") is not None,
  998. "stuck task reaches working")
  999. conn = db()
  1000. conn.execute("update saddles set status = 'offline' where name = 'fake-saddle'")
  1001. conn.commit()
  1002. conn.close()
  1003. detail = task_state(auth, stuck)
  1004. check(detail.get("resume_available") is True,
  1005. "task page offers resume while its saddle is offline")
  1006. act(auth, detail["resume"], {})
  1007. detail = task_state(auth, stuck)
  1008. check(detail["status"] == "queued",
  1009. "manual resume requeues the stuck task")
  1010. check(any(c["body"] == "Saddle became unreachable; task requeued"
  1011. for c in detail["comments"]),
  1012. "manual resume lands the interruption comment")
  1013. check(all(s["status"] == "offline"
  1014. for s in slot_public(auth, "/saddles", "saddles")["saddles"]),
  1015. "saddle stays offline after the manual requeue")
  1016. # Bringing the saddle back lets the requeued task resume and finish.
  1017. fake.state = "idle"
  1018. fake_card = saddle_card("fake-saddle")
  1019. act(auth, fake_card["refresh"], {})
  1020. check(any(s["status"] == "idle"
  1021. for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  1022. if s["name"] == "fake-saddle"),
  1023. "refreshed saddle returns idle")
  1024. check(wait_task_done(auth, stuck, timeout=60) is not None,
  1025. "requeued task resumes on the refreshed saddle and completes")
  1026. print("== rejected entries (non-fatal) ==")
  1027. # Ingest API: a rejected entry stores on the session and streams
  1028. # through SSE; the task is untouched.
  1029. status, _ = saddle_api("POST", "/api/saddle/%d/update" % fake_id, fake_token,
  1030. {"type": "output", "session_id": session_id, "data": {
  1031. "command": "bash -n script.sh",
  1032. "output": "syntax error near line 3 (recoverable)",
  1033. "exit_code": 1,
  1034. "type": "rejected"}})
  1035. check(status == 200, "rejected entry accepted by the ingest API")
  1036. request = urllib.request.Request(WEB + "/terminal-stream/%d" % session_id)
  1037. with urllib.request.urlopen(request, timeout=15) as stream:
  1038. rejected_event = None
  1039. deadline = time.monotonic() + 8
  1040. while time.monotonic() < deadline and rejected_event is None:
  1041. line = stream.readline().decode().strip()
  1042. if not line.startswith("data: ") or line == "data: ping":
  1043. continue
  1044. event = json.loads(line[6:])
  1045. if event.get("type") == "rejected":
  1046. rejected_event = event
  1047. check(rejected_event is not None
  1048. and "syntax error near line 3" in (rejected_event.get("output") or ""),
  1049. "terminal stream carries rejected entries with their reason")
  1050. # The page template renders them with the distinct non-fatal style.
  1051. status, page = http("GET", "/tasks/%d/terminal" % stuck, timeout=15)
  1052. check(status == 200 and "term-rejected" in page,
  1053. "terminal page renders rejected entries in their own style")
  1054. print("== B8: ANSI SGR in the terminal view ==")
  1055. # Byte-exact SGR delivery: the daemon's coloured chunks flow through
  1056. # the store + SSE untouched (the client renderer styles them).
  1057. coloured = "\x1b[1;32mbold green\x1b[0m then \x1b[38;5;196m256-red\x1b[0m\n"
  1058. conn = db()
  1059. conn.execute("insert into session_raw_log (session_id, seq, chunk)"
  1060. " values (?, 900, ?)", (session_id, coloured))
  1061. conn.commit()
  1062. conn.close()
  1063. request = urllib.request.Request(WEB + "/terminal-stream/%d" % session_id)
  1064. with urllib.request.urlopen(request, timeout=15) as stream:
  1065. coloured_event = None
  1066. deadline = time.monotonic() + 8
  1067. while time.monotonic() < deadline and coloured_event is None:
  1068. line = stream.readline().decode().strip()
  1069. if not line.startswith("data: ") or line == "data: ping":
  1070. continue
  1071. event = json.loads(line[6:])
  1072. if event.get("type") == "raw" and "\x1b[1;32m" in (event.get("text") or ""):
  1073. coloured_event = event
  1074. check(coloured_event is not None
  1075. and "\x1b[38;5;196m" in coloured_event["text"],
  1076. "SGR sequences stream byte-exact to the client renderer")
  1077. status, page = http("GET", "/tasks/%d/terminal" % stuck, timeout=15)
  1078. check(status == 200 and "makeAnsiRenderer" in page and "colour256" in page,
  1079. "terminal page embeds the ANSI renderer (SGR + 256-colour palette)")
  1080. # Dispatch path: a rejected entry from /output flows into the audit
  1081. # trail without failing (or finishing) the task.
  1082. fake.queue_output("rejected", "echo 'too long' | saddle",
  1083. "max_tokens exceeded (recoverable)")
  1084. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  1085. "project_id": "1",
  1086. "title": "Rejected ride",
  1087. "priority": "2",
  1088. })
  1089. rejected_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  1090. if t["title"] == "Rejected ride"][0])
  1091. check(wait_task_done(auth, rejected_task, timeout=60) is not None,
  1092. "task completes normally past rejected entries")
  1093. conn = db()
  1094. stored = conn.execute(
  1095. "select count(*) from session_commands sc join sessions s on s.id = sc.session_id"
  1096. " where s.task_id = ? and sc.type = 'rejected'", (rejected_task,)).fetchone()[0]
  1097. conn.close()
  1098. check(stored >= 1,
  1099. "rejected entries stored in the audit trail (%d rows)" % stored)
  1100. check(task_state(auth, rejected_task)["status"] == "done"
  1101. and "fake daemon finished" in (task_state(auth, rejected_task).get("summary") or ""),
  1102. "rejected entries never fail the task")
  1103. print("== B7: suspended state + per-subtask resume ==")
  1104. fake.suspend_after_start = True
  1105. pushes_before = len(fake.start_payloads)
  1106. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  1107. "project_id": "1",
  1108. "title": "Suspend parent",
  1109. "priority": "2",
  1110. })
  1111. parent7 = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  1112. if t["title"] == "Suspend parent"][0])
  1113. check(wait_for(lambda: task_state(auth, parent7).get("status") == "suspended"
  1114. or False, timeout=60, what="parent parks as suspended") is not None,
  1115. "daemon 'suspended' state parks the task")
  1116. detail = task_state(auth, parent7)
  1117. check(detail.get("display") == "waiting on subtasks",
  1118. "suspended task displays as waiting on subtasks")
  1119. check(any(s["status"] == "idle"
  1120. for s in slot_public(auth, "/saddles", "saddles")["saddles"]),
  1121. "suspension frees the saddle")
  1122. first_push = fake.start_payloads[pushes_before]
  1123. check(first_push.get("subtasks") == [],
  1124. "the /start payload carries the subtasks array (empty here)")
  1125. # Two subtask events land while parked (staged, not dispatched):
  1126. # both must coalesce into one resume with a full report.
  1127. conn = db()
  1128. conn.execute("insert into tasks (project_id, title, status, priority, parent_task_id,"
  1129. " summary, completed_at, created_at) values (1, 'Child one', 'done', 5, ?,"
  1130. " 'built and verified the flange', strftime('%s','now','+1 second'),"
  1131. " strftime('%s','now'))", (parent7,))
  1132. conn.execute("insert into tasks (project_id, title, status, priority, parent_task_id,"
  1133. " summary, completed_at, created_at) values (1, 'Child two', 'failed', 5, ?,"
  1134. " 'the harness refused to start', strftime('%s','now','+1 second'),"
  1135. " strftime('%s','now'))", (parent7,))
  1136. conn.commit()
  1137. conn.close()
  1138. def parent_push_count():
  1139. return len([p for p in fake.start_payloads
  1140. if int(p.get("task", {}).get("id") or 0) == parent7])
  1141. check(wait_for(lambda: parent_push_count() >= 2 or False,
  1142. timeout=45, what="suspended parent resumes") is not None,
  1143. "subtask events resume the suspended parent")
  1144. parent_pushes = [p for p in fake.start_payloads
  1145. if int(p.get("task", {}).get("id") or 0) == parent7]
  1146. check(len(parent_pushes) >= 2,
  1147. "the parent's resume is a fresh /start for the same task")
  1148. resume_push = parent_pushes[-1]
  1149. notice = resume_push.get("resume_notice") or ""
  1150. check("Child one" in notice and "completed" in notice
  1151. and "built and verified the flange" in notice,
  1152. "resume notice reports the finished subtask with its summary")
  1153. check("Child two" in notice and "failed" in notice
  1154. and "the harness refused to start" in notice,
  1155. "coalesced: the second event rides the same resume notice")
  1156. subtask_entries = {(s["title"], s["status"]) for s in resume_push.get("subtasks", [])}
  1157. check(("Child one", "done") in subtask_entries
  1158. and ("Child two", "failed") in subtask_entries,
  1159. "subtasks array carries the children's states at dispatch time")
  1160. check(wait_task_done(auth, parent7, timeout=60) is not None,
  1161. "resumed parent completes")
  1162. detail = task_state(auth, parent7)
  1163. check(any("restored the session context" in p["summary"]
  1164. for p in detail.get("progress", [])),
  1165. "resumed flag reported on the first status after the restore")
  1166. print("== R3 B1: subtask status push ==")
  1167. # A parked (suspended) parent gives delivery a stable open session.
  1168. fake.suspend_after_start = True
  1169. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  1170. "project_id": "1",
  1171. "title": "Push parent",
  1172. "priority": "2",
  1173. })
  1174. push_parent = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  1175. if t["title"] == "Push parent"][0])
  1176. check(wait_for(lambda: task_state(auth, push_parent).get("status") == "suspended"
  1177. or False, timeout=60, what="push parent parks") is not None,
  1178. "push parent parks with an open session")
  1179. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  1180. "project_id": "1",
  1181. "title": "Push child",
  1182. "parent_task_id": str(push_parent),
  1183. })
  1184. push_child = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  1185. if t["title"] == "Push child"][0])
  1186. def events_for(subtask_id):
  1187. return [e for e in fake.subtask_status_events
  1188. if int(e.get("subtask_id") or 0) == subtask_id]
  1189. check(wait_for(lambda: len(events_for(push_child)) > 0 or False,
  1190. timeout=30, what="queued subtask event") is not None,
  1191. "subtask creation pushes a queued status event")
  1192. first_event = events_for(push_child)[0]
  1193. check(int(first_event.get("parent_task_id") or 0) == push_parent
  1194. and first_event.get("title") == "Push child"
  1195. and first_event.get("status") in ("queued", "working"),
  1196. "status event carries parent id, title and a wire status (%s)"
  1197. % first_event.get("status"))
  1198. # Real lifecycle: the child dispatches (working) and completes (done
  1199. # with summary) — the parent's daemon hears every transition.
  1200. check(wait_for(lambda: any(e.get("status") == "done" and "finished" in (e.get("summary") or "")
  1201. for e in events_for(push_child)) or False,
  1202. timeout=90, what="done subtask event") is not None,
  1203. "subtask completion pushes done with the summary")
  1204. check(any(e.get("status") == "working" for e in events_for(push_child)),
  1205. "dispatch pushes a working status event")
  1206. # Best-effort retry: while the daemon 500s, events stay queued and
  1207. # land once it recovers.
  1208. fake.fail_subtask_status = True
  1209. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  1210. "project_id": "1",
  1211. "title": "Push child two",
  1212. "parent_task_id": str(push_parent),
  1213. })
  1214. push_child_two = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  1215. if t["title"] == "Push child two"][0])
  1216. time.sleep(7)
  1217. check(len(events_for(push_child_two)) == 0,
  1218. "failed pushes stay queued (no event delivered)")
  1219. fake.fail_subtask_status = False
  1220. check(wait_for(lambda: len(events_for(push_child_two)) > 0 or False,
  1221. timeout=30, what="retried subtask event") is not None,
  1222. "recovered pushes deliver on a later tick")
  1223. print("== R3 B2: stale-busy saddle healing ==")
  1224. # The observed incident: a saddle left busy with current_task_id 0.
  1225. conn = db()
  1226. conn.execute("update saddles set status = 'busy', current_task_id = 0,"
  1227. " allocated_at = strftime('%s','now') where name = 'fake-saddle'")
  1228. conn.commit()
  1229. conn.close()
  1230. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  1231. "project_id": "1",
  1232. "title": "Heal dispatch",
  1233. "priority": "2",
  1234. })
  1235. heal_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  1236. if t["title"] == "Heal dispatch"][0])
  1237. check(wait_task_done(auth, heal_task, timeout=60) is not None,
  1238. "busy-without-task heals and the queued task dispatches")
  1239. with open(os.path.join(WORK, "app.log")) as handle:
  1240. app_log = handle.read()
  1241. check("Healing stale-busy saddle" in app_log
  1242. and "busy with no current task" in app_log,
  1243. "the busy@0 heal is logged")
  1244. # Contradiction: the daemon reports idle while the web believes the
  1245. # saddle is running the orphaned task.
  1246. conn = db()
  1247. conn.execute("insert into tasks (project_id, title, status, priority, created_at)"
  1248. " values (1, 'Orphan task', 'working', 2, strftime('%s','now'))")
  1249. orphan = conn.execute("select max(id) from tasks").fetchone()[0]
  1250. conn.execute("update saddles set status = 'busy', current_task_id = ?,"
  1251. " allocated_at = strftime('%s','now') where name = 'fake-saddle'",
  1252. (orphan,))
  1253. conn.commit()
  1254. conn.close()
  1255. fake.state = "idle"
  1256. check(wait_task_done(auth, orphan, timeout=60) is not None,
  1257. "contradicted saddle heals, orphan requeues and completes")
  1258. with open(os.path.join(WORK, "app.log")) as handle:
  1259. app_log = handle.read()
  1260. check("daemon idle while the web believes" in app_log,
  1261. "the contradiction heal is logged")
  1262. print("== R4 B1: synchronous subtask callback ==")
  1263. conn = db()
  1264. callback_rows = conn.execute(
  1265. "select token from api_tokens where saddle_id = 0"
  1266. " and token like 'saddle-callback-%'").fetchall()
  1267. conn.close()
  1268. r4_token = callback_rows[0][0] if callback_rows else callback_token
  1269. # Parent with a live parked session (suspended) hosts the callback.
  1270. fake.suspend_after_start = True
  1271. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  1272. "project_id": "1",
  1273. "title": "Callback parent",
  1274. "priority": "2",
  1275. })
  1276. cb_parent = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  1277. if t["title"] == "Callback parent"][0])
  1278. check(wait_for(lambda: task_state(auth, cb_parent).get("status") == "suspended"
  1279. or False, timeout=60, what="callback parent parks") is not None,
  1280. "callback parent parks")
  1281. status, _ = saddle_api("POST", "/api/saddle/subtask", None,
  1282. {"parent_task_id": cb_parent, "title": "no token"})
  1283. check(status == 401, "subtask callback rejects a missing token")
  1284. anchor = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  1285. if t["title"] == "Heal dispatch"][0])
  1286. status, body = saddle_api("POST", "/api/saddle/subtask", r4_token, {
  1287. "parent_task_id": cb_parent,
  1288. "title": "Wire the flange",
  1289. "description": "torque to spec",
  1290. "depends_on": [anchor],
  1291. "session_id": 1,
  1292. })
  1293. check(status == 200 and body.get("ok") is True and int(body.get("task_id") or 0) > 0,
  1294. "callback creates the subtask synchronously")
  1295. subtask_id = int(body.get("task_id"))
  1296. detail = task_state(auth, subtask_id)
  1297. check(detail["title"] == "Wire the flange" and int(detail["project_id"]) == 1
  1298. and int(detail["parent_task_id"]) == cb_parent,
  1299. "subtask row inherits the parent's project and linkage")
  1300. check(detail.get("task_branch") == task_state(auth, cb_parent).get("task_branch"),
  1301. "subtask shares the parent's task branch")
  1302. conn = db()
  1303. dep_rows = conn.execute(
  1304. "select depends_on_task_id from task_dependencies where task_id = ?",
  1305. (subtask_id,)).fetchall()
  1306. conn.close()
  1307. check([r[0] for r in dep_rows] == [anchor],
  1308. "depends_on wired as task_dependencies")
  1309. # Idempotency + entry-poll fallback dedup.
  1310. status, body2 = saddle_api("POST", "/api/saddle/subtask", r4_token, {
  1311. "parent_task_id": cb_parent, "title": "Wire the flange"})
  1312. check(status == 200 and int(body2.get("task_id") or 0) == subtask_id,
  1313. "retried callback returns the same task id")
  1314. conn = db()
  1315. before = conn.execute("select count(*) from tasks where parent_task_id = ?",
  1316. (cb_parent,)).fetchone()[0]
  1317. conn.close()
  1318. conn = db()
  1319. cb_session = conn.execute(
  1320. "select id from sessions where task_id = ? and ended_at is null",
  1321. (cb_parent,)).fetchone()[0]
  1322. conn.close()
  1323. saddle_api("POST", "/api/saddle/%d/update" % fake_id, fake_token,
  1324. {"type": "subtask", "session_id": cb_session, "data": {
  1325. "title": "Wire the flange", "description": "again"}})
  1326. conn = db()
  1327. after = conn.execute("select count(*) from tasks where parent_task_id = ?",
  1328. (cb_parent,)).fetchone()[0]
  1329. timeline = conn.execute(
  1330. "select count(*) from session_commands where session_id = ?"
  1331. " and type = 'subtask'", (cb_session,)).fetchone()[0]
  1332. conn.close()
  1333. check(after == before,
  1334. "entry-poll fallback dedupes against the callback's row")
  1335. check(timeline >= 1, "timeline entry recorded on the parent's session")
  1336. # Validation: unknown parent, unknown depends_on, ancestor cycle.
  1337. status, body = saddle_api("POST", "/api/saddle/subtask", r4_token,
  1338. {"parent_task_id": 99999, "title": "ghost"})
  1339. check(status == 400, "unknown parent rejected")
  1340. status, body = saddle_api("POST", "/api/saddle/subtask", r4_token,
  1341. {"parent_task_id": cb_parent, "title": "bad dep",
  1342. "depends_on": [99999]})
  1343. check(status == 400 and "unknown task" in body.get("error", ""),
  1344. "unknown depends_on id rejected with a clear error")
  1345. status, body = saddle_api("POST", "/api/saddle/subtask", r4_token,
  1346. {"parent_task_id": cb_parent, "title": "cycle dep",
  1347. "depends_on": [cb_parent]})
  1348. check(status == 400 and "cycle" in body.get("error", ""),
  1349. "depending on an ancestor rejected as a cycle")
  1350. finally:
  1351. # Exit-code diagnostic: distinguishes a crashed app (negative
  1352. # returncode, e.g. -11 SIGSEGV) from a clean shutdown. When
  1353. # tearing down mid-exception, also capture the still-alive
  1354. # app's stacks before killing it (a wedged-but-alive server
  1355. # otherwise leaves no evidence).
  1356. print("(app returncode at teardown: %s)" % app.poll())
  1357. if app.poll() is None and sys.exc_info()[0] is not None:
  1358. dump_app_stacks(app.pid)
  1359. app.terminate()
  1360. try:
  1361. app.wait(timeout=5)
  1362. except subprocess.TimeoutExpired:
  1363. app.kill()
  1364. print("%d passed, %d failed" % (passes, failures))
  1365. return 1 if failures else 0
  1366. if __name__ == "__main__":
  1367. sys.exit(main())