web_stack_test.py 155 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015
  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. - in-process fake saddle daemons speaking the wire contract: POST
  9. /register (plan 10: receives {saddle_token, web_base_url}, answers
  10. {ok, ssh_public_key, version}), POST /wake (records the wake; the
  11. daemon then asks POST /api/saddle/next-task like a phase-1 daemon),
  12. POST /start (captures the payload — the rollback path), GET /status
  13. (scripted state machine), GET /output (empty), GET /log?since= (raw
  14. PTY chunks, one new chunk per request so since-paging is exercised)
  15. It then drives the app the way statum.js does (entrypoint loads + action
  16. POSTs) and asserts the Workstream B features against the wire contract:
  17. 3. plan-review blocks hide their duplicate body preview
  18. 5. rules CRUD/attach + the effective `rules` array in assignments
  19. 6. project notes, POST /api/saddle/note, `project_notes` + `callback`
  20. in assignments
  21. plus the raw-log side: session_raw_log polling + terminal raw view
  22. Plan 10 phase 1 (work acquisition flip):
  23. - per-saddle tokens: registration mints + /register-delivers them,
  24. legacy tokens migrate onto the row at startup, removal revokes
  25. - token auth on every /api/saddle/* endpoint
  26. - next-task allocation: concurrent asks race exactly one winner,
  27. affinity pinning for parked tasks, resume_notice on suspended resumes
  28. - dispatcher: wake -> ask -> allocate roundtrip, the lost-wake guard
  29. (no ask after a wake -> offline, re-wake the next capable), and the
  30. pre-flip /answer + /start resume paths kept as the rollback route
  31. Prints PASS/FAIL lines Statum-style; exits non-zero on any failure.
  32. """
  33. import json
  34. import os
  35. import shutil
  36. import socket
  37. import sqlite3
  38. import subprocess
  39. import sys
  40. import threading
  41. import time
  42. import urllib.error
  43. import urllib.parse
  44. import urllib.request
  45. from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
  46. REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  47. APP = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else os.path.join(REPO, "builddir", "clanker-saddle")
  48. WORK = os.environ.get("WEBSTACK_WORK", "/tmp/kilo/webstack-e2e")
  49. DB_PATH = os.path.join(WORK, "web.sqlite")
  50. passes = 0
  51. failures = 0
  52. def check(condition, label):
  53. global passes, failures
  54. if condition:
  55. passes += 1
  56. print("PASS %s" % label)
  57. else:
  58. failures += 1
  59. print("FAIL %s" % label)
  60. def free_port():
  61. with socket.socket() as sock:
  62. sock.bind(("127.0.0.1", 0))
  63. return sock.getsockname()[1]
  64. APP_PORT = free_port()
  65. DAEMON_PORT = free_port()
  66. WEB = "http://127.0.0.1:%d" % APP_PORT
  67. def http(method, url, data=None, headers=None, timeout=30):
  68. body = None
  69. if data is not None:
  70. pairs = []
  71. for k, v in data.items():
  72. for item in (v if isinstance(v, (list, tuple)) else [v]):
  73. pairs.append((k, item))
  74. body = "&".join("%s=%s" % (k, urllib.parse.quote(str(item))) for k, item in pairs).encode()
  75. request = urllib.request.Request(WEB + url if url.startswith("/") else url,
  76. data=body, method=method)
  77. if data is not None:
  78. request.add_header("Content-Type", "application/x-www-form-urlencoded")
  79. for key, value in (headers or {}).items():
  80. request.add_header(key, value)
  81. # Retry dropped/refused connections: the freshly started app can
  82. # close a very early keep-alive connection once while warming up,
  83. # and its first seconds can wobble under load.
  84. for attempt in range(3):
  85. try:
  86. with urllib.request.urlopen(request, timeout=timeout) as response:
  87. return response.status, response.read().decode()
  88. except urllib.error.HTTPError as error:
  89. return error.code, error.read().decode()
  90. except (urllib.error.URLError, OSError):
  91. if attempt >= 2:
  92. raise
  93. time.sleep(0.75)
  94. def directives(uri, auth=None):
  95. headers = {}
  96. if auth:
  97. headers["X-Statum-Slot"] = "key=%s; as_at=%s" % (auth["key"], auth["as_at"])
  98. status, body = http("GET", "/_statum/entrypoint?uri=" + urllib.parse.quote(uri.split("?")[0]),
  99. headers=headers)
  100. if status != 200:
  101. raise SystemExit("entrypoint %s -> HTTP %d: %s" % (uri, status, body[:300]))
  102. return json.loads(body)
  103. def slot_public(auth, uri, type_name):
  104. for directive in directives(uri, auth):
  105. if directive["type"] == "set":
  106. content = json.loads(directive["snapshot_frame"]["content"])
  107. if content["slot"]["type"] == type_name:
  108. return content["public"]
  109. raise SystemExit("no %s slot in entrypoint state for %s" % (type_name, uri))
  110. def act(auth, action, fields):
  111. headers = {"X-Statum-Slot": "key=%s; as_at=%s" % (auth["key"], auth["as_at"])}
  112. if action.get("private"):
  113. headers["X-Statum-Private"] = action["private"]
  114. status, body = http(action.get("method", "POST"), action["uri"], fields, headers)
  115. if status != 200:
  116. raise SystemExit("action %s -> HTTP %d: %s" % (action["uri"], status, body[:300]))
  117. return json.loads(body)
  118. def login(username, password):
  119. frames = directives("/login")
  120. content = json.loads(frames[0]["snapshot_frame"]["content"])
  121. action = content["public"]["login"]
  122. status, body = http(action.get("method", "POST"), action["uri"],
  123. {"username": username, "password": password})
  124. if status != 200:
  125. raise SystemExit("login -> HTTP %d: %s" % (status, body[:300]))
  126. for directive in json.loads(body):
  127. if directive["type"] == "set":
  128. frame = json.loads(directive["snapshot_frame"]["content"])
  129. if frame["slot"]["type"] == "auth":
  130. return {"key": frame["slot"]["key"], "as_at": frame["as_at"]}
  131. raise SystemExit("login response carried no auth slot: " + body[:300])
  132. def wait_for(predicate, timeout=60, what="condition", interval=0.5):
  133. deadline = time.monotonic() + timeout
  134. last = None
  135. while time.monotonic() < deadline:
  136. last = predicate()
  137. if last is not False and last is not None:
  138. return last
  139. time.sleep(interval)
  140. print(" (timeout waiting for %s; last: %s)" % (what, last))
  141. return None
  142. def task_state(auth, task_id):
  143. return slot_public(auth, "/tasks/%d" % task_id, "task")
  144. def wait_task_done(auth, task_id, timeout=90):
  145. return wait_for(lambda: task_state(auth, task_id).get("status") == "done" or False,
  146. timeout=timeout, what="task %d -> done" % task_id)
  147. def task_id_of(title):
  148. conn = db()
  149. try:
  150. return conn.execute("select id from tasks where title = ?", (title,)).fetchone()[0]
  151. finally:
  152. conn.close()
  153. def saddle_api(method, path, token, payload=None):
  154. """Raw call against the saddle ingest API; returns (status, json)."""
  155. request = urllib.request.Request(WEB + path,
  156. data=json.dumps(payload or {}).encode(), method=method)
  157. request.add_header("Content-Type", "application/json")
  158. if token is not None:
  159. request.add_header("Authorization", "Bearer " + token)
  160. try:
  161. with urllib.request.urlopen(request, timeout=15) as response:
  162. return response.status, json.loads(response.read().decode() or "{}")
  163. except urllib.error.HTTPError as error:
  164. body = error.read().decode()
  165. try:
  166. return error.code, json.loads(body)
  167. except ValueError:
  168. return error.code, {"raw": body}
  169. def db():
  170. return sqlite3.connect(DB_PATH)
  171. class FakeSaddle:
  172. """In-process saddle daemon implementing the wire contract.
  173. Phase-1 ask behaviour: the daemon asks POST /api/saddle/next-task
  174. whenever idle-awake — at registration (its token arrives via
  175. /register), on POST /wake, after its task reaches a terminal state,
  176. and whenever a /status poll observes it parked idle (throttled).
  177. Every trigger can be disabled per test for determinism; ask_now()
  178. performs a synchronous manual ask.
  179. """
  180. PROMPT = "bbarrow@fedora:/workspace/task-1$ "
  181. def __init__(self):
  182. self.lock = threading.Lock()
  183. self.start_payloads = [] # captured assignment payloads (/start + next-task 200s)
  184. self.chunks = [] # [{seq, text}] — the raw log ring
  185. self.next_seq = 1
  186. self.state = "idle"
  187. self.summary = ""
  188. self.state_entered = time.monotonic()
  189. self.fail_next = False # next task drives the task to failed
  190. self.start_counts = {} # task id -> assignment count
  191. self.resumed_pending = False # crash-resume flag on the next /status
  192. self.suspend_on_title = None # this task (by title) parks as suspended
  193. self.pending_output = [] # entries drained by the next /output poll
  194. self.output_seq = 100
  195. self.last_activity_epoch = int(time.time())
  196. self.subtask_status_events = [] # recorded POST /subtask-status payloads
  197. self.fail_subtask_status = False # exercise the web's retry path
  198. self.hang_next = {} # path suffix -> hang count (never responds)
  199. self.fail_log_count = 0 # /log requests failing (500) while > 0
  200. self.scripted_log = None # fixed chunk set served by /log
  201. self.scripted_next = 0 # the daemon-style next for it
  202. self.scripted_corpus = None # chunks "produced" when a task starts
  203. self.race_texts = None # chunks appended DURING /log response
  204. # construction (the daemon-side bug)
  205. self.answer_bodies = [] # recorded POST /answer payloads
  206. self.abort_failures = 0 # /abort requests failing (500) while > 0
  207. self.abort_log = [] # task ids aborted via POST /abort
  208. self.running_task = 0 # task id the daemon believes it runs
  209. self.running_title = "" # its title (suspend targeting)
  210. self.zombie_409 = 0 # /start 409s once per unit (then decrements)
  211. self.conflict_running = 0 # while > 0, /start persistently 409s with this id
  212. self.hold_state = False # freeze /status lifecycle transitions
  213. self.ssh_public_key = "" # reported on /status + /register
  214. self.started_mono = time.monotonic()
  215. self.freeze_activity = False # hold last_activity_epoch still
  216. # --- plan 10 phase 1: ask/wake/register ---
  217. self.token = None # per-saddle token from POST /register
  218. self.register_log = [] # recorded /register bodies
  219. self.wake_log = [] # recorded POST /wake arrivals
  220. self.asks = [] # [{"status": int, "payload": dict|None}]
  221. self.ask_on_register = True
  222. self.ask_on_wake = True
  223. self.ask_on_terminal = True
  224. self.ask_on_idle_status = True
  225. self.serve_register = True # False: 404 /register (pre-flip daemon)
  226. self.last_ask_trigger = 0.0
  227. self.port = None
  228. def set_state(self, state, summary=None):
  229. """Advances the scripted lifecycle.
  230. Time-based on purpose: the web app may probe /status more than
  231. once per tick (status polls, stale-busy healing, manual
  232. refreshes), and a poll-counted machine would conflate them and
  233. race dependent sections.
  234. """
  235. self.state = state
  236. if summary is not None:
  237. self.summary = summary
  238. self.state_entered = time.monotonic()
  239. def queue_output(self, entry_type, command, output):
  240. """Queues a typed entry for the dispatcher's next /output poll."""
  241. with self.lock:
  242. self.pending_output.append({
  243. "id": self.output_seq,
  244. "command": command,
  245. "output": output,
  246. "exit_code": 1,
  247. "type": entry_type,
  248. "timestamp": "2026-09-06T00:00:00Z",
  249. })
  250. self.output_seq += 1
  251. def touch_activity(self):
  252. if not self.freeze_activity:
  253. self.last_activity_epoch = int(time.time())
  254. def append_chunk(self, text):
  255. with self.lock:
  256. self.chunks.append({"seq": self.next_seq, "text": text})
  257. self.next_seq += 1
  258. def log_since(self, since):
  259. with self.lock:
  260. if self.scripted_log is not None:
  261. chunks = [c for c in self.scripted_log if c["seq"] >= since]
  262. return chunks, self.scripted_next
  263. # `since` is the inclusive resume point (wire contract: a
  264. # response ending at seq 12 carries next=13, and polling
  265. # with since=13 must still deliver the chunk numbered 13).
  266. chunks = [c for c in self.chunks if c["seq"] >= since]
  267. return chunks, self.next_seq
  268. # --- plan 10 phase 1: the ask path ---
  269. def ask_now(self):
  270. """Synchronously asks the web for work (a phase-1 daemon's idle
  271. ask). A 200 assignment is accepted exactly like POST /start;
  272. returns (status, payload|None)."""
  273. token = self.token
  274. if token is None:
  275. raise SystemExit("fake daemon asked before receiving its token")
  276. status, payload = saddle_api("POST", "/api/saddle/next-task", token)
  277. with self.lock:
  278. self.asks.append({"status": status, "payload": payload})
  279. if status == 200:
  280. self.accept_assignment(payload)
  281. return status, payload
  282. def _trigger_ask(self, reason):
  283. """Schedules a delayed ask (the daemon woke / went idle-awake)."""
  284. def run():
  285. try:
  286. self.ask_now()
  287. except OSError:
  288. pass # app restarting / shutting down mid-test
  289. threading.Thread(target=lambda: (time.sleep(0.2), run()), daemon=True).start()
  290. def maybe_trigger_ask(self, reason):
  291. now = time.monotonic()
  292. if now - self.last_ask_trigger < 1.0:
  293. return
  294. self.last_ask_trigger = now
  295. self._trigger_ask(reason)
  296. def accept_assignment(self, payload):
  297. """A 200 next-task assignment: adopted exactly like POST /start."""
  298. self.start_payloads.append(payload)
  299. task = payload.get("task") or {}
  300. task_id = task.get("id")
  301. self.running_task = int(task_id or 0)
  302. self.running_title = task.get("title") or ""
  303. count = self.start_counts.get(task_id, 0) + 1
  304. self.start_counts[task_id] = count
  305. if count > 1:
  306. # Crash-resume contract: re-pushing a task whose
  307. # workspace still exists restores it; the first
  308. # /status after the restore reports "resumed": true.
  309. self.resumed_pending = True
  310. self.append_chunk(self.PROMPT)
  311. self.append_chunk("echo hello\r\n")
  312. self.append_chunk("hello\r\n")
  313. self.set_state("failing" if self.fail_next else "working")
  314. self.fail_next = False
  315. if self.scripted_corpus is not None:
  316. with self.lock:
  317. for chunk in self.scripted_corpus:
  318. self.scripted_log.append(dict(chunk))
  319. self.scripted_next = self.scripted_corpus[-1]["seq"] + 1
  320. class Handler(BaseHTTPRequestHandler):
  321. daemon = None # set on the class before serving
  322. protocol_version = "HTTP/1.1"
  323. def log_message(self, fmt, *args):
  324. pass
  325. def maybe_hang(self, path):
  326. """True when this request should hang forever (never respond),
  327. consuming one unit of the path's hang budget."""
  328. for suffix, count in list(self.daemon.hang_next.items()):
  329. if count > 0 and path.startswith(suffix):
  330. self.daemon.hang_next[suffix] = count - 1
  331. return True
  332. return False
  333. def _send(self, status, obj):
  334. body = json.dumps(obj).encode()
  335. self.send_response(status)
  336. self.send_header("Content-Type", "application/json")
  337. self.send_header("Content-Length", str(len(body)))
  338. self.end_headers()
  339. self.wfile.write(body)
  340. def do_POST(self):
  341. self.daemon.touch_activity()
  342. length = int(self.headers.get("Content-Length") or 0)
  343. raw = self.rfile.read(length).decode() if length else "{}"
  344. if self.path == "/register":
  345. # Plan 10: the web delivers the per-saddle token + its
  346. # own base URL; the daemon persists the token for its
  347. # calls home and reports its ssh key + version.
  348. if not self.daemon.serve_register:
  349. self._send(404, {"error": "no such route"})
  350. return
  351. try:
  352. payload = json.loads(raw)
  353. except ValueError:
  354. payload = {"raw": raw}
  355. self.daemon.register_log.append(payload)
  356. if payload.get("saddle_token"):
  357. self.daemon.token = payload["saddle_token"]
  358. self._send(200, {"ok": True,
  359. "ssh_public_key": self.daemon.ssh_public_key,
  360. "version": "0.1-fake"})
  361. if self.daemon.ask_on_register:
  362. self.daemon.maybe_trigger_ask("register")
  363. return
  364. if self.path == "/wake":
  365. # Plan 10: a capable task is ready; a sleeping daemon
  366. # asks for work.
  367. self.daemon.wake_log.append(time.monotonic())
  368. self._send(200, {"ok": True})
  369. if self.daemon.ask_on_wake:
  370. self.daemon.maybe_trigger_ask("wake")
  371. return
  372. if self.path == "/start":
  373. if self.daemon.conflict_running > 0 or self.daemon.zombie_409 > 0:
  374. # Wire contract: a machine-readable conflict.
  375. running = self.daemon.conflict_running
  376. if running == 0:
  377. running = self.daemon.running_task
  378. self.daemon.zombie_409 -= 1
  379. body = json.dumps({"ok": False, "error": "busy",
  380. "state": "working",
  381. "running_task_id": running}).encode()
  382. self.send_response(409)
  383. self.send_header("Content-Type", "application/json")
  384. self.send_header("Content-Length", str(len(body)))
  385. self.end_headers()
  386. self.wfile.write(body)
  387. return
  388. try:
  389. payload = json.loads(raw)
  390. except ValueError:
  391. payload = {"raw": raw}
  392. self.daemon.accept_assignment(payload)
  393. self._send(200, {})
  394. elif self.path == "/answer":
  395. try:
  396. self.daemon.answer_bodies.append(json.loads(raw))
  397. except ValueError:
  398. pass
  399. self._send(200, {})
  400. elif self.path == "/abort":
  401. if self.daemon.abort_failures > 0:
  402. self.daemon.abort_failures -= 1
  403. self._send(500, {"error": "abort failed (simulated)"})
  404. return
  405. aborted = self.daemon.running_task
  406. self.daemon.set_state("idle")
  407. self.daemon.summary = ""
  408. self.daemon.running_task = 0
  409. self.daemon.abort_log.append(aborted)
  410. self._send(200, {"ok": True, "aborted_task_id": aborted})
  411. # An aborted daemon returns to its idle loop: it asks.
  412. self.daemon.maybe_trigger_ask("aborted")
  413. elif self.path == "/subtask-status":
  414. try:
  415. payload = json.loads(raw)
  416. except ValueError:
  417. self._send(400, {"error": "bad json"})
  418. return
  419. if self.daemon.fail_subtask_status:
  420. self._send(500, {"error": "simulated failure"})
  421. return
  422. self.daemon.subtask_status_events.append(payload)
  423. self._send(200, {"ok": True})
  424. else:
  425. self._send(404, {"error": "no such route"})
  426. def do_GET(self):
  427. if self.maybe_hang(self.path):
  428. time.sleep(600)
  429. return
  430. if self.path != "/status":
  431. # /status itself is not daemon activity: the wedge signal
  432. # must reflect loop rounds / command completions.
  433. self.daemon.touch_activity()
  434. if self.path == "/status":
  435. response = {"state": self.daemon.state, "summary": self.daemon.summary,
  436. "uptime_seconds": int(time.monotonic() - self.daemon.started_mono),
  437. "last_activity_epoch": self.daemon.last_activity_epoch,
  438. "ssh_public_key": self.daemon.ssh_public_key}
  439. if self.daemon.resumed_pending:
  440. response["resumed"] = True
  441. self.daemon.resumed_pending = False
  442. if self.daemon.hold_state:
  443. self._send(200, response)
  444. return
  445. if self.daemon.state == "failing":
  446. # Fails fast (a rejected assignment script).
  447. if time.monotonic() - self.daemon.state_entered >= 1:
  448. self.daemon.set_state("failed",
  449. "bash -n rejected the script (simulated)")
  450. elif self.daemon.state == "working":
  451. # Time-based, not poll-based: whoever probes /status
  452. # (dispatcher polls, stale-busy healing, refreshes)
  453. # must not advance the lifecycle.
  454. if time.monotonic() - self.daemon.state_entered >= 8:
  455. if self.daemon.suspend_on_title \
  456. and self.daemon.suspend_on_title == self.daemon.running_title:
  457. # `saddle suspend`: park until a fresh assignment
  458. # resumes the workspace (title-targeted one-shot,
  459. # so an interleaved dispatch cannot eat the flag).
  460. self.daemon.set_state("suspended")
  461. self.daemon.suspend_on_title = None
  462. else:
  463. self.daemon.set_state("done", "fake daemon finished")
  464. # A parked/terminal daemon is idle-awake: a phase-1
  465. # daemon asks (and sleeps on 204). Throttled so status
  466. # poll bursts do not hammer the ask endpoint.
  467. if self.daemon.ask_on_idle_status and self.daemon.state in (
  468. "idle", "done", "failed", "suspended"):
  469. self.daemon.maybe_trigger_ask("idle-status")
  470. self._send(200, response)
  471. elif self.path.startswith("/output"):
  472. with self.daemon.lock:
  473. entries = self.daemon.pending_output
  474. self.daemon.pending_output = []
  475. self._send(200, entries)
  476. elif self.path.startswith("/log"):
  477. if self.daemon.fail_log_count > 0:
  478. self.daemon.fail_log_count -= 1
  479. self._send(500, {"error": "log unavailable (simulated)"})
  480. return
  481. query = urllib.parse.urlparse(self.path).query
  482. since = int(urllib.parse.parse_qs(query).get("since", ["0"])[0])
  483. # The daemon-side snapshot race: the served array is
  484. # snapshotted, chunks are appended, and `next` is computed
  485. # AFTER the appends — the response's next skips chunks the
  486. # array never carried (the live run's 33 lost chunks).
  487. raced = False
  488. if since > 0 and self.daemon.race_texts:
  489. raced = True
  490. with self.daemon.lock:
  491. next_seq = self.daemon.scripted_next
  492. for text in self.daemon.race_texts:
  493. self.daemon.scripted_log.append(
  494. {"seq": next_seq, "text": text})
  495. next_seq += 1
  496. self.daemon.scripted_next = next_seq
  497. self.daemon.race_texts = None
  498. # Simulate the live PTY: each poll produces one more chunk
  499. # (before slicing, so the poller actually sees it).
  500. self.daemon.append_chunk("stream-line-%d\r\n" % self.daemon.next_seq)
  501. chunks, nxt = self.daemon.log_since(since)
  502. if raced:
  503. nxt = self.daemon.scripted_next
  504. self._send(200, {"chunks": chunks, "next": nxt})
  505. else:
  506. self._send(404, {"error": "no such route"})
  507. self.close_connection = True
  508. def run_lifecycle(self):
  509. """Applies the scripted lifecycle on a timer (like a real daemon
  510. whose task ends on its own, not when the web happens to poll):
  511. working ages into done/suspended, failing into failed — and a
  512. task END makes the idle-awake daemon ask for work again."""
  513. while True:
  514. time.sleep(0.5)
  515. if self.hold_state:
  516. continue
  517. if self.state == "failing":
  518. if time.monotonic() - self.state_entered >= 1:
  519. self.set_state("failed", "bash -n rejected the script (simulated)")
  520. if self.ask_on_terminal:
  521. self.maybe_trigger_ask("terminal")
  522. elif self.state == "working":
  523. if time.monotonic() - self.state_entered >= 8:
  524. if self.suspend_on_title and self.suspend_on_title == self.running_title:
  525. self.set_state("suspended")
  526. self.suspend_on_title = None
  527. # A parked daemon is idle-awake: it asks (and
  528. # sleeps on 204) so a later wake can reach it.
  529. if self.ask_on_idle_status:
  530. self.maybe_trigger_ask("parked")
  531. else:
  532. self.set_state("done", "fake daemon finished")
  533. if self.ask_on_terminal:
  534. self.maybe_trigger_ask("terminal")
  535. def serve(self, port=None):
  536. """Binds the daemon's HTTP server. A requested port that is
  537. already taken (free_port() hands out recyclable ports — two
  538. calls can collide) falls back to an OS-assigned one instead of
  539. silently dying and impersonating whatever owns that port."""
  540. if port is None:
  541. port = DAEMON_PORT
  542. handler = type("BoundHandler", (FakeSaddle.Handler,), {"daemon": self})
  543. self.server = None
  544. try:
  545. self.server = ThreadingHTTPServer(("127.0.0.1", port), handler)
  546. except OSError:
  547. print("(fake daemon: port %d taken, falling back)" % port)
  548. self.server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
  549. self.port = self.server.server_address[1]
  550. threading.Thread(target=self.server.serve_forever, daemon=True).start()
  551. threading.Thread(target=self.run_lifecycle, daemon=True).start()
  552. return self.server
  553. def kill(self):
  554. """Stops listening: subsequent connections are refused — a daemon
  555. that died mid-task."""
  556. if getattr(self, "server", None) is not None:
  557. self.server.shutdown()
  558. self.server.server_close()
  559. self.server = None
  560. def start_app():
  561. config = json.load(open(os.path.join(REPO, "web-config.json")))
  562. config["saddle_callback_base_url"] = WEB
  563. with open(os.path.join(WORK, "web-config.json"), "w") as handle:
  564. json.dump(config, handle)
  565. env = dict(os.environ)
  566. env.update({
  567. "SPRY_DB_PATH": DB_PATH,
  568. "SPRY_SEED_ADMIN_USERNAME": "admin",
  569. "SPRY_SEED_ADMIN_PASSWORD": "adminpass123",
  570. "SADDLE_LLM_BASE_URL": "http://127.0.0.1:%d" % DAEMON_PORT,
  571. "SADDLE_PROJECTS_ROOT": os.path.join(WORK, "workspace"),
  572. "SADDLE_HTTP_TIMEOUT_S": "5",
  573. "SADDLE_RAW_PROBE_INTERVAL_S": "5",
  574. "SADDLE_STRAND_GRACE_S": "10",
  575. "SADDLE_WEDGE_THRESHOLD_S": "8",
  576. "SADDLE_LOST_WAKE_S": "2",
  577. # Never inherit a caller-poisoned LD_LIBRARY_PATH (e.g. one that
  578. # resolves the ancient /lib64 copies): empty makes the loader use
  579. # the binary's RUNPATH, which is the dev stack it was built
  580. # against.
  581. "LD_LIBRARY_PATH": "",
  582. })
  583. log_previous = os.path.join(WORK, "app.log.1")
  584. if os.path.exists(os.path.join(WORK, "app.log")):
  585. shutil.move(os.path.join(WORK, "app.log"), log_previous)
  586. log = open(os.path.join(WORK, "app.log"), "w")
  587. command = [APP, str(APP_PORT)]
  588. if os.environ.get("WEBSTACK_GDB"):
  589. # Debugging aid: run the app under gdb; a crash backtrace lands in
  590. # app.log. WEBSTACK_GDB=1 meson test ... to enable.
  591. command = ["gdb", "-batch", "-quiet",
  592. "-ex", "handle SIGPIPE nostop noprint pass",
  593. "-ex", "run", "-ex", "bt", "--args", APP, str(APP_PORT)]
  594. process = subprocess.Popen(command, cwd=WORK, env=env,
  595. stdout=log, stderr=subprocess.STDOUT)
  596. outcome = wait_for(lambda: (app_ready() and "ready")
  597. or (process.poll() is not None and "app-exited-%d" % process.poll())
  598. or False, timeout=30, what="app ready")
  599. if outcome is None:
  600. raise SystemExit("app did not come up; see %s/app.log" % WORK)
  601. if str(outcome).startswith("app-exited-"):
  602. raise SystemExit("app exited during startup (%s); see %s/app.log"
  603. % outcome, WORK)
  604. return process
  605. def app_ready():
  606. try:
  607. status, _ = http("GET", "/login", timeout=3)
  608. return status == 200
  609. except (urllib.error.URLError, OSError):
  610. return False
  611. def dump_app_stacks(pid):
  612. """Best-effort thread backtrace of the (possibly wedged) app."""
  613. try:
  614. fds = os.listdir("/proc/%d/fd" % pid)
  615. print("(app open fds at dump: %d)" % len(fds))
  616. kinds = {}
  617. for fd in fds[:1200]:
  618. try:
  619. target = os.readlink("/proc/%d/fd/%s" % (pid, fd))
  620. except OSError:
  621. continue
  622. key = target.split(":", 1)[0] if ":" in target else target
  623. kinds[key] = kinds.get(key, 0) + 1
  624. print("(fd kinds: %s)" % sorted(kinds.items(), key=lambda kv: -kv[1])[:6])
  625. except OSError:
  626. pass
  627. for command in (["eu-stack", "-p", str(pid)],
  628. ["gdb", "-batch", "-quiet", "-p", str(pid),
  629. "-ex", "thread apply all bt"]):
  630. try:
  631. out = subprocess.run(command, capture_output=True, timeout=60)
  632. text = out.stdout.decode(errors="replace") + out.stderr.decode(errors="replace")
  633. if text.strip():
  634. print("(app stacks via %s)\n%s" % (command[0], text[:12000]))
  635. return
  636. except (OSError, subprocess.SubprocessError):
  637. continue
  638. def main():
  639. if not os.path.exists(APP):
  640. raise SystemExit("app binary not found at %s (build first)" % APP)
  641. fake = FakeSaddle()
  642. fake.serve()
  643. # A rare framework-level crash (stack corruption in Statum slot
  644. # signing during the FIRST login — shared-library codegen bug, moved
  645. # twice already) kills the app before any test runs; a clean restart
  646. # sidesteps it. Only tolerated while nothing has been checked yet.
  647. auth = None
  648. app = None
  649. for attempt in range(3):
  650. if os.path.exists(WORK):
  651. shutil.rmtree(WORK)
  652. os.makedirs(os.path.join(WORK, "workspace"))
  653. try:
  654. app = start_app()
  655. auth = login("admin", "adminpass123")
  656. break
  657. except (SystemExit, OSError, urllib.error.URLError):
  658. if passes > 0:
  659. raise
  660. if app is not None and app.poll() is None:
  661. app.terminate()
  662. try:
  663. app.wait(timeout=5)
  664. except subprocess.TimeoutExpired:
  665. app.kill()
  666. app = None
  667. print("(app died before login; restarting cleanly, attempt %d)" % (attempt + 1))
  668. if app is None or auth is None:
  669. raise SystemExit("app never came up; see %s/app.log" % WORK)
  670. try:
  671. check(True, "login yields an auth slot")
  672. print("== feature 3: plan review hides duplicate body ==")
  673. state = slot_public(auth, "/projects", "projects")
  674. act(auth, state["create"], {
  675. "name": "demo",
  676. "git_url": "https://example.com/demo.git",
  677. "default_branch": "main",
  678. })
  679. conn = db()
  680. conn.execute("insert into tasks (project_id, title, status, priority,"
  681. " is_design_task, plan, created_at)"
  682. " values (1, 'Plan review task', 'working', 5, 1, '# The plan\n\n1. Do it',"
  683. " strftime('%s','now'))")
  684. conn.commit()
  685. plan_task = conn.execute("select max(id) from tasks").fetchone()[0]
  686. saddles_state = slot_public(auth, "/saddles", "saddles")
  687. act(auth, saddles_state["register"], {"name": "fake-saddle",
  688. "host": "127.0.0.1:%d" % DAEMON_PORT})
  689. registered = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  690. if s["name"] == "fake-saddle"]
  691. check(len(registered) == 1 and registered[0]["token"].startswith("saddle-"),
  692. "fake saddle registered with an API token")
  693. fake_id = int(registered[0]["id"])
  694. fake_token = registered[0]["token"]
  695. # Plan 10 phase 1: registration delivers the per-saddle token to
  696. # the daemon via POST /register {saddle_token, web_base_url}.
  697. check(wait_for(lambda: len(fake.register_log) > 0 or False,
  698. timeout=15, what="/register delivery") is not None,
  699. "registration POSTs /register to the daemon")
  700. register_body = fake.register_log[0]
  701. check(register_body.get("saddle_token") == fake_token,
  702. "/register carries the minted per-saddle token")
  703. check(register_body.get("web_base_url") == WEB,
  704. "/register carries the web base URL")
  705. check(fake.token == fake_token,
  706. "daemon persisted the token (its asks use it)")
  707. token_conn = db()
  708. row_token = token_conn.execute("select token from saddles where id = ?",
  709. (fake_id,)).fetchone()[0]
  710. token_conn.close()
  711. check(row_token == fake_token,
  712. "per-saddle token lives on the saddle row")
  713. # The register-time ask (idle-awake daemon): 204, nothing
  714. # offerable yet — the saddle is asleep and wakeable.
  715. check(wait_for(lambda: any(a["status"] == 204 for a in fake.asks) or False,
  716. timeout=15, what="register-time ask") is not None,
  717. "the daemon's idle ask returns 204 (nothing offerable)")
  718. conn.execute("insert into sessions (task_id, saddle_id, started_at, ended_at, outcome)"
  719. " values (?, ?, strftime('%s','now'), strftime('%s','now'), 'done')",
  720. (plan_task, fake_id))
  721. conn.commit()
  722. session_id = conn.execute("select max(id) from sessions").fetchone()[0]
  723. conn.close()
  724. status, body = saddle_api("POST", "/api/saddle/%d/update" % fake_id, fake_token,
  725. {"type": "block", "session_id": session_id, "data": {
  726. "question": "Plan submitted for review",
  727. "type": "rfc",
  728. "body": "# The plan\n\n1. Do it"}})
  729. check(status == 200 and body.get("block_id"), "plan-review block raised")
  730. status, _ = saddle_api("POST", "/api/saddle/%d/update" % fake_id, fake_token,
  731. {"type": "block", "session_id": session_id, "data": {
  732. "question": "Which database?",
  733. "type": "question",
  734. "options": ["SQLite", "Postgres"],
  735. "body": "# Storage choice"}})
  736. check(status == 200, "question block raised")
  737. detail = task_state(auth, plan_task)
  738. blocks = {b["block_type"]: b for b in detail["blocks"]}
  739. check(blocks["rfc"].get("is_plan_review") is True,
  740. "rfc block flagged as the plan review")
  741. check(blocks["rfc"].get("show_body") is False,
  742. "pending plan review hides the duplicate body preview")
  743. check(blocks["question"].get("show_body") is True,
  744. "question block keeps its body preview")
  745. act(auth, blocks["rfc"]["answer_action"], {"answer": "Plan approved. Proceed."})
  746. detail = task_state(auth, plan_task)
  747. rfc = [b for b in detail["blocks"] if b["block_type"] == "rfc"][0]
  748. check(rfc.get("show_body") is True,
  749. "answered plan review shows the body again (review section gone)")
  750. print("== B1: highlight-to-comment quote dialog ==")
  751. status, page = http("GET", "/tasks/%d" % plan_task, timeout=15)
  752. check(status == 200 and "quote-comment-dialog" in page and "quote-text" in page,
  753. "task page carries the quote-comment dialog the popover opens")
  754. check('name="quote"' in page and 'name="return_to"' in page,
  755. "quote dialog posts the quote and return-to fields")
  756. status, page = http("GET", "/tasks/%d/terminal" % plan_task, timeout=15)
  757. check(status == 200 and "quote-comment-dialog" in page,
  758. "terminal page keeps its quote dialog")
  759. print("== B3: review section hides the duplicate body ==")
  760. status, body = saddle_api("POST", "/api/saddle/%d/update" % fake_id, fake_token,
  761. {"type": "block", "session_id": session_id, "data": {
  762. "question": "Review the diff",
  763. "type": "review",
  764. "body": "# The change\n\nReworks the flange module."}})
  765. check(status == 200 and body.get("block_id"), "review block raised")
  766. detail = task_state(auth, plan_task)
  767. review_block = [b for b in detail["blocks"] if b["block_type"] == "review"][0]
  768. check(detail.get("review_pending") is True,
  769. "review section shows while the review is unanswered")
  770. check(review_block.get("show_body") is False,
  771. "pending review block hides its duplicate body preview")
  772. check(review_block.get("body", "").startswith("# The change"),
  773. "review body itself still arrives in state (B2 counterpart)")
  774. act(auth, review_block["answer_action"], {"answer": "Human approved the review"})
  775. detail = task_state(auth, plan_task)
  776. review_block = [b for b in detail["blocks"] if b["block_type"] == "review"][0]
  777. check(review_block.get("show_body") is True,
  778. "answered review shows the body again (banner gone)")
  779. print("== B2: question block body above options ==")
  780. # Contract side (A2 will restore bodies end-to-end): a question
  781. # block raised with a heredoc body carries it; the body preview
  782. # rides above the options (template order) and survives answering
  783. # alongside the selected answer.
  784. detail = task_state(auth, plan_task)
  785. question = [b for b in detail["blocks"] if b["block_type"] == "question"][0]
  786. check(question.get("body") == "# Storage choice"
  787. and question.get("show_body") is True,
  788. "question block keeps its body markdown with the preview on")
  789. act(auth, question["answer_action"], {"option": "SQLite"})
  790. detail = task_state(auth, plan_task)
  791. question = [b for b in detail["blocks"] if b["block_type"] == "question"][0]
  792. check(question.get("answered") is True and question.get("answer") == "SQLite"
  793. and question.get("body") == "# Storage choice"
  794. and question.get("show_body") is True,
  795. "answered question shows body + selected answer")
  796. print("== B5: compare-changes SSH -> HTTPS ==")
  797. def diff_url_for_project(git_url, suffix):
  798. state = slot_public(auth, "/projects", "projects")
  799. act(auth, state["create"], {"name": "diff-%s" % suffix, "git_url": git_url})
  800. project_id = int([p["id"] for p in slot_public(auth, "/projects", "projects")["projects"]
  801. if p["name"] == "diff-%s" % suffix][0])
  802. conn = db()
  803. conn.execute("insert into tasks (project_id, title, status, task_branch,"
  804. " branch_from, created_at) values (?, ?, 'working', 'feature/x',"
  805. " 'main', strftime('%s','now'))", (project_id, "Diff %s" % suffix))
  806. conn.commit()
  807. task_id = conn.execute("select max(id) from tasks").fetchone()[0]
  808. conn.execute("insert into sessions (task_id, saddle_id, started_at, ended_at,"
  809. " outcome) values (?, ?, strftime('%s','now'), strftime('%s','now'),"
  810. " 'done')", (task_id, fake_id))
  811. conn.commit()
  812. session = conn.execute("select max(id) from sessions").fetchone()[0]
  813. conn.close()
  814. saddle_api("POST", "/api/saddle/%d/update" % fake_id, fake_token,
  815. {"type": "block", "session_id": session, "data": {
  816. "question": "Review the diff", "type": "review"}})
  817. return task_state(auth, task_id).get("review_diff_url") or ""
  818. check(diff_url_for_project("ssh://fabrica.unitatem.net:222/clanker/clanker-saddle.git",
  819. "fabrica")
  820. == "https://fabrica.unitatem.net/clanker/clanker-saddle/compare/main...feature/x",
  821. "ssh://git@host:port/owner/repo.git translates (port dropped)")
  822. check(diff_url_for_project("git@github.com:acme/widgets.git", "scp")
  823. == "https://github.com/acme/widgets/compare/main...feature/x",
  824. "git@host:owner/repo.git translates to the browse host")
  825. check(diff_url_for_project("https://example.com/plain/repo", "plain")
  826. == "https://example.com/plain/repo/compare/main...feature/x",
  827. "HTTPS URLs pass through unchanged")
  828. print("== feature 5: rules ==")
  829. rules_state = slot_public(auth, "/rules", "rules")
  830. for name, body in [
  831. ("Use Invercargill", "Never use libgee; use Invercargill enumerables."),
  832. ("Write Vala docs", "Every public class carries a doc comment."),
  833. ("Prefer meson", "Builds go through meson; no makefiles."),
  834. ("Co-dev rule", "Keep the wire contract exactly."),
  835. ("Reference rule", "Must never ride along."),
  836. ]:
  837. act(auth, rules_state["create"], {"name": name, "body": body})
  838. listed = {r["name"]: r for r in slot_public(auth, "/rules", "rules")["rules"]}
  839. check(len(listed) == 5, "five rules created and listed on /rules")
  840. def rule_id(name):
  841. rows = [r for r in slot_public(auth, "/rules", "rules")["rules"] if r["name"] == name]
  842. return int(rows[0]["id"])
  843. state = slot_public(auth, "/projects", "projects")
  844. act(auth, state["create"], {"name": "co", "git_url": "https://example.com/co.git"})
  845. act(auth, state["create"], {"name": "ref", "git_url": "https://example.com/ref.git"})
  846. co_id = int([p["id"] for p in slot_public(auth, "/projects", "projects")["projects"]
  847. if p["name"] == "co"][0])
  848. ref_id = int([p["id"] for p in slot_public(auth, "/projects", "projects")["projects"]
  849. if p["name"] == "ref"][0])
  850. project = slot_public(auth, "/projects/1", "project")
  851. act(auth, project["add_link"], {"linked_project_id": str(co_id),
  852. "relationship": "co-developed"})
  853. act(auth, project["add_link"], {"linked_project_id": str(ref_id),
  854. "relationship": "reference"})
  855. act(auth, project["attach_rule"], {"rule_id": str(rule_id("Use Invercargill"))})
  856. act(auth, project["attach_rule"], {"rule_id": str(rule_id("Prefer meson"))})
  857. project = slot_public(auth, "/projects/1", "project")
  858. check(sorted(r["name"] for r in project["rules"]) == ["Prefer meson", "Use Invercargill"],
  859. "project page lists its attached rules")
  860. co_project = slot_public(auth, "/projects/%d" % co_id, "project")
  861. act(auth, co_project["attach_rule"], {"rule_id": str(rule_id("Co-dev rule"))})
  862. ref_project = slot_public(auth, "/projects/%d" % ref_id, "project")
  863. act(auth, ref_project["attach_rule"], {"rule_id": str(rule_id("Reference rule"))})
  864. tasks_state = slot_public(auth, "/tasks", "tasks")
  865. act(auth, tasks_state["create"], {
  866. "project_id": "1",
  867. "title": "Rules dispatch",
  868. "description": "Effective rule set rides the /start payload.",
  869. "priority": "2",
  870. })
  871. rule_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  872. if t["title"] == "Rules dispatch"][0])
  873. detail = task_state(auth, rule_task)
  874. act(auth, detail["attach_rule"], {"rule_id": str(rule_id("Write Vala docs"))})
  875. act(auth, detail["attach_rule"], {"rule_id": str(rule_id("Use Invercargill"))})
  876. detail = task_state(auth, rule_task)
  877. check(sorted(r["name"] for r in detail["rules"]) == ["Use Invercargill", "Write Vala docs"],
  878. "task page lists its attached rules")
  879. check(wait_for(lambda: len(fake.start_payloads) > 0 or False,
  880. timeout=30, what="/start push") is not None,
  881. "dispatcher pushes the task to the fake saddle")
  882. payload = fake.start_payloads[-1]
  883. rule_names = [r["name"] for r in payload.get("rules", [])]
  884. check(rule_names == ["Write Vala docs", "Use Invercargill", "Prefer meson", "Co-dev rule"],
  885. "effective rules in /start: task, primary, co-developed order (%s)" % rule_names)
  886. check(all(r.get("body") for r in payload.get("rules", [])),
  887. "rules carry their bodies")
  888. check("Reference rule" not in rule_names,
  889. "reference project rules excluded")
  890. detail = task_state(auth, rule_task)
  891. act(auth, detail["rules"][0]["detach"], {})
  892. check([r["name"] for r in task_state(auth, rule_task)["rules"]] == ["Use Invercargill"],
  893. "detached task rule disappears")
  894. rules_rows = slot_public(auth, "/rules", "rules")["rules"]
  895. docs_row = [r for r in rules_rows if r["name"] == "Write Vala docs"][0]
  896. act(auth, docs_row["update"], {"name": "Write Vala docs", "body": "Doc comments or bust."})
  897. check(any(r["body"] == "Doc comments or bust."
  898. for r in slot_public(auth, "/rules", "rules")["rules"]),
  899. "rule edit saves the body")
  900. check(wait_task_done(auth, rule_task, timeout=60) is not None,
  901. "dispatched task completes against the fake daemon")
  902. print("== feature 6: project notes + callback ==")
  903. # Plan 10 phase 1: the shared saddle-callback token is gone —
  904. # the per-saddle token authorises the callback endpoints.
  905. callback_token = fake_token
  906. check(callback_token.startswith("saddle-") and "saddle-callback-" not in callback_token,
  907. "note callback authorised by the per-saddle token")
  908. status, _ = saddle_api("POST", "/api/saddle/note", None,
  909. {"project": "demo", "body": "x"})
  910. check(status == 401, "note endpoint rejects a missing token")
  911. status, _ = saddle_api("POST", "/api/saddle/note", "saddle-callback-not-real",
  912. {"project": "demo", "body": "x"})
  913. check(status == 401, "note endpoint rejects a wrong (legacy-style) token")
  914. status, body = saddle_api("POST", "/api/saddle/note", callback_token,
  915. {"task_id": rule_task, "project": "demo",
  916. "body": "style.css tokens live in :root"})
  917. check(status == 200 and body.get("ok") is True, "note accepted with the callback token")
  918. status, _ = saddle_api("POST", "/api/saddle/note", callback_token,
  919. {"project": "no-such-project", "body": "x"})
  920. check(status == 400, "unknown project rejected")
  921. status, _ = saddle_api("POST", "/api/saddle/note", callback_token,
  922. {"project": "demo", "body": " "})
  923. check(status == 400, "empty note body rejected")
  924. project = slot_public(auth, "/projects/1", "project")
  925. check(any("style.css tokens" in n["body"] and int(n["source_task_id"]) == rule_task
  926. for n in project["notes"]),
  927. "project page lists the note with its source task")
  928. act(auth, [n for n in project["notes"] if "style.css tokens" in n["body"]][0]["delete"], {})
  929. check(all("style.css tokens" not in n["body"]
  930. for n in slot_public(auth, "/projects/1", "project")["notes"]),
  931. "note deleted from the project page")
  932. # /start payload: notes of primary + co-developed (not reference),
  933. # oldest first; callback carries the configured base URL + token.
  934. saddle_api("POST", "/api/saddle/note", callback_token,
  935. {"project": "demo", "body": "note-one"})
  936. saddle_api("POST", "/api/saddle/note", callback_token,
  937. {"project": "demo", "body": "note-two"})
  938. saddle_api("POST", "/api/saddle/note", callback_token,
  939. {"project": "co", "body": "co-note"})
  940. conn = db()
  941. conn.execute("insert into project_notes (project_id, body, created_at)"
  942. " values (?, 'ref-note-must-not-ship', strftime('%s','now'))", (ref_id,))
  943. conn.commit()
  944. conn.close()
  945. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  946. "project_id": "1",
  947. "title": "Notes dispatch",
  948. "description": "Project notes ride the /start payload.",
  949. "priority": "2",
  950. })
  951. check(wait_for(lambda: len(fake.start_payloads) >= 2 or False,
  952. timeout=30, what="second /start push") is not None,
  953. "dispatcher pushes the notes task")
  954. payload = fake.start_payloads[-1]
  955. notes = payload.get("project_notes", [])
  956. check([n["body"] for n in notes] == ["note-one", "note-two", "co-note"],
  957. "project_notes: primary + co-developed, oldest first (%s)" % [n["body"] for n in notes])
  958. check(all(n.get("project") for n in notes)
  959. and notes[2]["project"] == "co",
  960. "project_notes carry the project name")
  961. check("ref-note-must-not-ship" not in [n["body"] for n in notes],
  962. "reference project notes excluded")
  963. callback = payload.get("callback", {})
  964. check(callback.get("base_url") == WEB and callback.get("token") == callback_token,
  965. "callback carries the configured base URL and the minted token")
  966. # entry cap: 30 of 33 candidates
  967. conn = db()
  968. for i in range(33):
  969. conn.execute("insert into project_notes (project_id, body, created_at)"
  970. " values (?, ?, strftime('%s','now'))", (1, "bulk-%02d" % i))
  971. conn.commit()
  972. conn.close()
  973. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  974. "project_id": "1",
  975. "title": "Entry cap dispatch",
  976. "priority": "2",
  977. })
  978. check(wait_for(lambda: len(fake.start_payloads) >= 3 or False,
  979. timeout=30, what="third /start push") is not None,
  980. "dispatcher pushes the entry-cap task")
  981. notes = fake.start_payloads[-1].get("project_notes", [])
  982. check(len(notes) == 30 and notes[0]["body"] == "note-one" and notes[1]["body"] == "note-two",
  983. "project_notes capped at 30 entries, oldest kept (%d)" % len(notes))
  984. # char cap: the giant note is truncated so the total stays <= 20000
  985. conn = db()
  986. conn.execute("delete from project_notes where body like 'bulk-%' or body = 'co-note'")
  987. conn.execute("insert into project_notes (project_id, body, created_at)"
  988. " values (?, ?, strftime('%s','now'))", (1, "giant!" + "x" * 25000,))
  989. conn.commit()
  990. conn.close()
  991. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  992. "project_id": "1",
  993. "title": "Char cap dispatch",
  994. "priority": "2",
  995. })
  996. check(wait_for(lambda: len(fake.start_payloads) >= 4 or False,
  997. timeout=30, what="fourth /start push") is not None,
  998. "dispatcher pushes the char-cap task")
  999. notes = fake.start_payloads[-1].get("project_notes", [])
  1000. total = sum(len(n["body"]) for n in notes)
  1001. check(len(notes) == 3 and total == 20000 and notes[2]["body"].startswith("giant!"),
  1002. "project_notes char cap: head kept, giant truncated (entries=%d, chars=%d)"
  1003. % (len(notes), total))
  1004. print("== raw log polling + terminal raw view ==")
  1005. # Wait for the last dispatch to finish so its final polls ran.
  1006. check(wait_task_done(auth, task_id_of("Char cap dispatch"), timeout=60) is not None,
  1007. "char-cap task completes")
  1008. conn = db()
  1009. raw_session = conn.execute(
  1010. "select s.id from sessions s join tasks t on t.id = s.task_id"
  1011. " where t.title = 'Char cap dispatch' order by s.id desc limit 1").fetchone()[0]
  1012. rows = conn.execute("select seq, chunk from session_raw_log where session_id = ?"
  1013. " order by seq", (raw_session,)).fetchall()
  1014. conn.close()
  1015. check(len(rows) >= 3, "session_raw_log captured the daemon's chunks (%d)" % len(rows))
  1016. seqs = [r[0] for r in rows]
  1017. check(seqs == sorted(seqs) and len(set(seqs)) == len(seqs),
  1018. "raw chunks stored once, seq strictly increasing")
  1019. text = "".join(r[1] for r in rows)
  1020. check(fake.PROMPT in text and "echo hello" in text and "hello" in text,
  1021. "raw log keeps the prompt and echoed output byte-exact")
  1022. check("stream-line-" in text, "incremental /log polls append later chunks")
  1023. terminal = slot_public(auth, "/tasks/%d/terminal" % task_id_of("Char cap dispatch"),
  1024. "terminal")
  1025. check(terminal.get("has_raw_log") is True and int(terminal["session_id"]) == raw_session,
  1026. "terminal view selects raw rendering for the raw session")
  1027. # Old sessions (typed entries, no raw rows) keep the fallback view.
  1028. conn = db()
  1029. conn.execute("insert into session_commands (session_id, command_text, output_text,"
  1030. " type, started_at) values (?, 'ls', 'old-output', 'command',"
  1031. " strftime('%s','now'))", (session_id,))
  1032. conn.commit()
  1033. conn.close()
  1034. legacy = slot_public(auth, "/tasks/%d/terminal" % plan_task, "terminal")
  1035. check(legacy.get("has_raw_log") is False,
  1036. "legacy session falls back to the entry-type terminal view")
  1037. request = urllib.request.Request(WEB + "/terminal-stream/%d" % raw_session)
  1038. with urllib.request.urlopen(request, timeout=15) as stream:
  1039. raw_events = []
  1040. deadline = time.monotonic() + 8
  1041. while time.monotonic() < deadline and len(raw_events) < 3:
  1042. line = stream.readline().decode().strip()
  1043. if not line.startswith("data: ") or line == "data: ping":
  1044. continue
  1045. event = json.loads(line[6:])
  1046. if event.get("type") == "raw":
  1047. raw_events.append(event)
  1048. check(len(raw_events) >= 3 and raw_events[0]["text"] == fake.PROMPT
  1049. and raw_events[0]["seq"] < raw_events[1]["seq"],
  1050. "terminal stream serves raw chunks in seq order (prompt first)")
  1051. print("== capabilities: tag-based routing ==")
  1052. saddles_state = slot_public(auth, "/saddles", "saddles")
  1053. act(auth, saddles_state["create_capability"], {"name": "gpu"})
  1054. act(auth, saddles_state["create_capability"], {"name": "bigmem"})
  1055. saddles_state = slot_public(auth, "/saddles", "saddles")
  1056. caps = {c["name"]: c for c in saddles_state["capabilities"]}
  1057. check(len(caps) == 2 and all(int(c["saddle_count"]) == 0 and int(c["task_count"]) == 0
  1058. for c in caps.values()),
  1059. "capabilities created and listed with zero usage")
  1060. def saddle_card(name):
  1061. rows = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  1062. if s["name"] == name]
  1063. return rows[0]
  1064. fake_card = saddle_card("fake-saddle")
  1065. check(fake_card["capabilities"] == [],
  1066. "saddle card starts with no capability chips")
  1067. # A task requiring 'gpu' waits while the only idle saddle lacks it;
  1068. # a later task without requirements still dispatches (skip-and-continue).
  1069. tasks_state = slot_public(auth, "/tasks", "tasks")
  1070. act(auth, tasks_state["create"], {
  1071. "project_id": "1",
  1072. "title": "GPU task",
  1073. "priority": "2",
  1074. })
  1075. gpu_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  1076. if t["title"] == "GPU task"][0])
  1077. detail = task_state(auth, gpu_task)
  1078. act(auth, detail["attach_capability"], {"capability_id": str(int(caps["gpu"]["id"]))})
  1079. detail = task_state(auth, gpu_task)
  1080. check([c["name"] for c in detail["capabilities"]] == ["gpu"],
  1081. "task page lists the required capability")
  1082. # The only idle saddle lacks the tag, so the queue flags the wait
  1083. # (read before any other task claims the idle saddle).
  1084. queue = [t for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  1085. if int(t["id"]) == gpu_task][0]
  1086. check(queue["status"] == "queued" and queue["capabilities"] == ["gpu"]
  1087. and queue.get("waiting_for_saddle") is True,
  1088. "queue row shows the waiting task with its tag and wait flag")
  1089. act(auth, tasks_state["create"], {
  1090. "project_id": "1",
  1091. "title": "Any-saddle task",
  1092. "priority": "3",
  1093. })
  1094. any_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  1095. if t["title"] == "Any-saddle task"][0])
  1096. check(wait_for(lambda: task_state(auth, any_task).get("status") != "queued" or False,
  1097. timeout=30, what="uncapable task dispatches") is not None,
  1098. "task without requirements dispatches past the waiting task")
  1099. with open(os.path.join(WORK, "app.log")) as handle:
  1100. app_log = handle.read()
  1101. check("waits for a capable saddle" in app_log and "requires: gpu" in app_log,
  1102. "dispatcher logs the capability wait")
  1103. # Attaching the tag to the saddle releases the task.
  1104. fake_card = saddle_card("fake-saddle")
  1105. act(auth, fake_card["attach_capability"], {"capability_id": str(int(caps["gpu"]["id"]))})
  1106. fake_card = saddle_card("fake-saddle")
  1107. check([c["name"] for c in fake_card["capabilities"]] == ["gpu"],
  1108. "saddle card shows the attached chip")
  1109. check(wait_task_done(auth, gpu_task, timeout=60) is not None,
  1110. "task dispatches once the saddle carries the required tag")
  1111. queue = [t for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  1112. if int(t["id"]) == gpu_task][0]
  1113. check(queue.get("waiting_for_saddle") is not True,
  1114. "wait flag clears once dispatched")
  1115. # Multi-tag: gpu + bigmem required, only gpu carried → waits; adding
  1116. # bigmem completes the superset.
  1117. act(auth, tasks_state["create"], {
  1118. "project_id": "1",
  1119. "title": "Big task",
  1120. "priority": "2",
  1121. })
  1122. big_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  1123. if t["title"] == "Big task"][0])
  1124. detail = task_state(auth, big_task)
  1125. act(auth, detail["attach_capability"], {"capability_id": str(int(caps["gpu"]["id"]))})
  1126. act(auth, detail["attach_capability"], {"capability_id": str(int(caps["bigmem"]["id"]))})
  1127. check(wait_for(lambda: ([t for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  1128. if int(t["id"]) == big_task][0].get("waiting_for_saddle")
  1129. is True) or False, timeout=30, what="big task waits") is not None,
  1130. "task waits when the saddle's tags are not a superset")
  1131. fake_card = saddle_card("fake-saddle")
  1132. act(auth, fake_card["attach_capability"], {"capability_id": str(int(caps["bigmem"]["id"]))})
  1133. check(wait_task_done(auth, big_task, timeout=60) is not None,
  1134. "task dispatches once the superset is complete")
  1135. # Detach a requirement; delete a capability everywhere.
  1136. detail = task_state(auth, big_task)
  1137. act(auth, [c for c in detail["capabilities"] if c["name"] == "bigmem"][0]["detach"], {})
  1138. check([c["name"] for c in task_state(auth, big_task)["capabilities"]] == ["gpu"],
  1139. "detached requirement disappears from the task")
  1140. bigmem_row = [c for c in slot_public(auth, "/saddles", "saddles")["capabilities"]
  1141. if c["name"] == "bigmem"][0]
  1142. act(auth, bigmem_row["delete"], {})
  1143. saddles_state = slot_public(auth, "/saddles", "saddles")
  1144. check(all(c["name"] != "bigmem" for c in saddles_state["capabilities"]),
  1145. "deleted capability gone from the catalogue")
  1146. check([c["name"] for c in task_state(auth, big_task)["capabilities"]] == ["gpu"],
  1147. "deleting a capability drops its task requirements")
  1148. check(all(c["name"] != "bigmem"
  1149. for c in saddle_card("fake-saddle")["capabilities"]),
  1150. "deleting a capability drops it from saddles")
  1151. print("== B4: required capabilities on the create form ==")
  1152. tasks_state = slot_public(auth, "/tasks", "tasks")
  1153. gpu_option = [o for o in tasks_state.get("capability_options", [])
  1154. if o["name"] == "gpu"][0]
  1155. check(len(tasks_state.get("capability_options", [])) >= 1,
  1156. "queue page offers capability options in the create form")
  1157. conn = db()
  1158. before = conn.execute("select count(*) from tasks").fetchone()[0]
  1159. conn.close()
  1160. act(auth, tasks_state["create"], {
  1161. "project_id": "1",
  1162. "title": "Bad cap task",
  1163. "capability_id": [gpu_option["id"], "999"],
  1164. })
  1165. conn = db()
  1166. after = conn.execute("select count(*) from tasks").fetchone()[0]
  1167. conn.close()
  1168. check(after == before, "unknown capability id rejects the create")
  1169. act(auth, tasks_state["create"], {
  1170. "project_id": "1",
  1171. "title": "Cap-on-create task",
  1172. "capability_id": [gpu_option["id"]],
  1173. })
  1174. cap_create = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  1175. if t["title"] == "Cap-on-create task"][0])
  1176. detail = task_state(auth, cap_create)
  1177. check([c["name"] for c in detail["capabilities"]] == ["gpu"],
  1178. "capabilities from the create form land on the task")
  1179. queue = [t for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  1180. if int(t["id"]) == cap_create][0]
  1181. check(queue["capabilities"] == ["gpu"],
  1182. "queue row shows the created task's requirements")
  1183. # The waiting flag must hold it back until the capable saddle frees
  1184. # up (the fake saddle still carries gpu from the routing checks).
  1185. check(queue.get("waiting_for_saddle") is not True,
  1186. "capable saddle available: no wait flag on the new task")
  1187. print("== B6: computed display state ==")
  1188. conn = db()
  1189. conn.execute("insert into tasks (project_id, title, status, priority, created_at)"
  1190. " values (1, 'Parent', 'done', 5, strftime('%s','now'))")
  1191. parent6 = conn.execute("select max(id) from tasks").fetchone()[0]
  1192. # The post-mortem case: stored done WITH an unanswered review block.
  1193. conn.execute("insert into sessions (task_id, saddle_id, started_at, ended_at, outcome)"
  1194. " values (?, ?, strftime('%s','now'), strftime('%s','now'), 'done')",
  1195. (parent6, fake_id))
  1196. session6 = conn.execute("select max(id) from sessions").fetchone()[0]
  1197. conn.commit()
  1198. conn.close()
  1199. saddle_api("POST", "/api/saddle/%d/update" % fake_id, fake_token,
  1200. {"type": "block", "session_id": session6, "data": {
  1201. "question": "Review the final diff", "type": "review"}})
  1202. detail = task_state(auth, parent6)
  1203. check(detail.get("display") == "review requested" and detail.get("terminal") is True,
  1204. "unanswered review overrides the stored done status (terminal flag keeps stored truth)")
  1205. queue = [t for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  1206. if int(t["id"]) == parent6][0]
  1207. check(queue["display"] == "review requested",
  1208. "queue row shows the computed review-requested display")
  1209. # Delegation: with the review answered, open subtasks delegate.
  1210. conn = db()
  1211. conn.execute("update task_blocks set answered_at = strftime('%s','now'),"
  1212. " answer = 'Human approved the review' where task_id = ?", (parent6,))
  1213. conn.execute("insert into tasks (project_id, title, status, priority, parent_task_id,"
  1214. " created_at) values (1, 'Sub A', 'working', 5, ?, strftime('%s','now'))",
  1215. (parent6,))
  1216. conn.execute("insert into tasks (project_id, title, status, priority, parent_task_id,"
  1217. " created_at) values (1, 'Sub B', 'done', 5, ?, strftime('%s','now'))",
  1218. (parent6,))
  1219. conn.commit()
  1220. conn.close()
  1221. detail = task_state(auth, parent6)
  1222. check(detail.get("display") == "delegating 1/2" and detail.get("subtask_open") == 1,
  1223. "open subtasks show the delegating breakdown (%s)" % detail.get("display"))
  1224. # Flags track the stored status once the display falls through
  1225. # (subtasks terminal first — delegating leads while any are open).
  1226. conn = db()
  1227. conn.execute("update tasks set status = 'failed', completed_at = strftime('%s','now')"
  1228. " where id = ?", (parent6,))
  1229. conn.execute("update tasks set status = 'failed', completed_at = strftime('%s','now')"
  1230. " where title = 'Sub A'")
  1231. conn.commit()
  1232. conn.close()
  1233. detail = task_state(auth, parent6)
  1234. check(detail.get("display") == "failed" and detail.get("failed") is True
  1235. and detail.get("terminal") is True,
  1236. "failed task with answered review and terminal subtasks shows failed + terminal flags")
  1237. print("== retry + resume ==")
  1238. # A daemon-side failure fails the task with its explanation.
  1239. fake.fail_next = True
  1240. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  1241. "project_id": "1",
  1242. "title": "Flaky task",
  1243. "priority": "2",
  1244. })
  1245. flaky = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  1246. if t["title"] == "Flaky task"][0])
  1247. check(wait_for(lambda: task_state(auth, flaky).get("status") == "failed" or False,
  1248. timeout=60, what="flaky task fails") is not None,
  1249. "daemon failure fails the task")
  1250. detail = task_state(auth, flaky)
  1251. check("bash -n rejected" in (detail.get("summary") or ""),
  1252. "failure summary shows the daemon's explanation")
  1253. # Retry: summary moves into a comment and clears, task requeues,
  1254. # history stays, and the re-dispatch restores the workspace.
  1255. comments_before = len(detail.get("comments", []))
  1256. progress_before = len(detail.get("progress", []))
  1257. act(auth, detail["retry"], {})
  1258. detail = task_state(auth, flaky)
  1259. check(detail["status"] == "queued" and (detail.get("summary") or "") == "",
  1260. "retry requeues the task and clears the stale summary")
  1261. check(any("bash -n rejected" in c["body"] for c in detail["comments"]),
  1262. "failure explanation kept as a bot comment")
  1263. check(any(c["body"] == "Task retried by user" for c in detail["comments"]),
  1264. "retry lands its audit comment")
  1265. check(len(detail["comments"]) >= comments_before + 1,
  1266. "retry keeps the comment history")
  1267. check(wait_task_done(auth, flaky, timeout=60) is not None,
  1268. "retried task dispatches again and completes")
  1269. detail = task_state(auth, flaky)
  1270. check(any("restored the session context" in p["summary"]
  1271. for p in detail.get("progress", [])),
  1272. "daemon resume reported on the first status after the restore")
  1273. check(len(detail["progress"]) > progress_before,
  1274. "resume note appended to the progress timeline")
  1275. # Manual resume: a task stuck working on an offline saddle.
  1276. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  1277. "project_id": "1",
  1278. "title": "Stuck task",
  1279. "priority": "2",
  1280. })
  1281. stuck = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  1282. if t["title"] == "Stuck task"][0])
  1283. check(wait_for(lambda: task_state(auth, stuck).get("status") == "working" or False,
  1284. timeout=60, what="stuck task starts working") is not None,
  1285. "stuck task reaches working")
  1286. conn = db()
  1287. conn.execute("update saddles set status = 'offline' where name = 'fake-saddle'")
  1288. conn.commit()
  1289. conn.close()
  1290. detail = task_state(auth, stuck)
  1291. check(detail.get("resume_available") is True,
  1292. "task page offers resume while its saddle is offline")
  1293. act(auth, detail["resume"], {})
  1294. detail = task_state(auth, stuck)
  1295. check(detail["status"] == "queued",
  1296. "manual resume requeues the stuck task")
  1297. check(any(c["body"] == "Saddle became unreachable; task requeued"
  1298. for c in detail["comments"]),
  1299. "manual resume lands the interruption comment")
  1300. check(all(s["status"] == "offline"
  1301. for s in slot_public(auth, "/saddles", "saddles")["saddles"]),
  1302. "saddle stays offline after the manual requeue")
  1303. # Bringing the saddle back lets the requeued task resume and finish.
  1304. fake.state = "idle"
  1305. fake_card = saddle_card("fake-saddle")
  1306. act(auth, fake_card["refresh"], {})
  1307. check(any(s["status"] == "idle"
  1308. for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  1309. if s["name"] == "fake-saddle"),
  1310. "refreshed saddle returns idle")
  1311. check(wait_task_done(auth, stuck, timeout=60) is not None,
  1312. "requeued task resumes on the refreshed saddle and completes")
  1313. print("== rejected entries (non-fatal) ==")
  1314. # Ingest API: a rejected entry stores on the session and streams
  1315. # through SSE; the task is untouched.
  1316. status, _ = saddle_api("POST", "/api/saddle/%d/update" % fake_id, fake_token,
  1317. {"type": "output", "session_id": session_id, "data": {
  1318. "command": "bash -n script.sh",
  1319. "output": "syntax error near line 3 (recoverable)",
  1320. "exit_code": 1,
  1321. "type": "rejected"}})
  1322. check(status == 200, "rejected entry accepted by the ingest API")
  1323. request = urllib.request.Request(WEB + "/terminal-stream/%d" % session_id)
  1324. with urllib.request.urlopen(request, timeout=15) as stream:
  1325. rejected_event = None
  1326. deadline = time.monotonic() + 8
  1327. while time.monotonic() < deadline and rejected_event is None:
  1328. line = stream.readline().decode().strip()
  1329. if not line.startswith("data: ") or line == "data: ping":
  1330. continue
  1331. event = json.loads(line[6:])
  1332. if event.get("type") == "rejected":
  1333. rejected_event = event
  1334. check(rejected_event is not None
  1335. and "syntax error near line 3" in (rejected_event.get("output") or ""),
  1336. "terminal stream carries rejected entries with their reason")
  1337. # The page template renders them with the distinct non-fatal style.
  1338. status, page = http("GET", "/tasks/%d/terminal" % stuck, timeout=15)
  1339. check(status == 200 and "term-rejected" in page,
  1340. "terminal page renders rejected entries in their own style")
  1341. print("== B8: ANSI SGR in the terminal view ==")
  1342. # Byte-exact SGR delivery: the daemon's coloured chunks flow through
  1343. # the store + SSE untouched (the client renderer styles them).
  1344. coloured = "\x1b[1;32mbold green\x1b[0m then \x1b[38;5;196m256-red\x1b[0m\n"
  1345. conn = db()
  1346. conn.execute("insert into session_raw_log (session_id, seq, chunk)"
  1347. " values (?, 900, ?)", (session_id, coloured))
  1348. conn.commit()
  1349. conn.close()
  1350. request = urllib.request.Request(WEB + "/terminal-stream/%d" % session_id)
  1351. with urllib.request.urlopen(request, timeout=15) as stream:
  1352. coloured_event = None
  1353. deadline = time.monotonic() + 8
  1354. while time.monotonic() < deadline and coloured_event is None:
  1355. line = stream.readline().decode().strip()
  1356. if not line.startswith("data: ") or line == "data: ping":
  1357. continue
  1358. event = json.loads(line[6:])
  1359. if event.get("type") == "raw" and "\x1b[1;32m" in (event.get("text") or ""):
  1360. coloured_event = event
  1361. check(coloured_event is not None
  1362. and "\x1b[38;5;196m" in coloured_event["text"],
  1363. "SGR sequences stream byte-exact to the client renderer")
  1364. status, page = http("GET", "/tasks/%d/terminal" % stuck, timeout=15)
  1365. check(status == 200 and "makeAnsiRenderer" in page and "colour256" in page,
  1366. "terminal page embeds the ANSI renderer (SGR + 256-colour palette)")
  1367. # Dispatch path: a rejected entry from /output flows into the audit
  1368. # trail without failing (or finishing) the task.
  1369. fake.queue_output("rejected", "echo 'too long' | saddle",
  1370. "max_tokens exceeded (recoverable)")
  1371. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  1372. "project_id": "1",
  1373. "title": "Rejected ride",
  1374. "priority": "2",
  1375. })
  1376. rejected_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  1377. if t["title"] == "Rejected ride"][0])
  1378. check(wait_task_done(auth, rejected_task, timeout=60) is not None,
  1379. "task completes normally past rejected entries")
  1380. conn = db()
  1381. stored = conn.execute(
  1382. "select count(*) from session_commands sc join sessions s on s.id = sc.session_id"
  1383. " where s.task_id = ? and sc.type = 'rejected'", (rejected_task,)).fetchone()[0]
  1384. conn.close()
  1385. check(stored >= 1,
  1386. "rejected entries stored in the audit trail (%d rows)" % stored)
  1387. check(task_state(auth, rejected_task)["status"] == "done"
  1388. and "fake daemon finished" in (task_state(auth, rejected_task).get("summary") or ""),
  1389. "rejected entries never fail the task")
  1390. print("== B7: suspended state + per-subtask resume ==")
  1391. fake.suspend_on_title = "Suspend parent"
  1392. pushes_before = len(fake.start_payloads)
  1393. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  1394. "project_id": "1",
  1395. "title": "Suspend parent",
  1396. "priority": "2",
  1397. })
  1398. parent7 = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  1399. if t["title"] == "Suspend parent"][0])
  1400. check(wait_for(lambda: task_state(auth, parent7).get("status") == "suspended"
  1401. or False, timeout=60, what="parent parks as suspended") is not None,
  1402. "daemon 'suspended' state parks the task")
  1403. detail = task_state(auth, parent7)
  1404. check(detail.get("display") == "waiting on subtasks",
  1405. "suspended task displays as waiting on subtasks")
  1406. check(any(s["status"] == "idle"
  1407. for s in slot_public(auth, "/saddles", "saddles")["saddles"]),
  1408. "suspension frees the saddle")
  1409. first_push = fake.start_payloads[pushes_before]
  1410. check(first_push.get("subtasks") == [],
  1411. "the /start payload carries the subtasks array (empty here)")
  1412. # Two subtask events land while parked (staged, not dispatched):
  1413. # both must coalesce into one resume with a full report.
  1414. conn = db()
  1415. conn.execute("insert into tasks (project_id, title, status, priority, parent_task_id,"
  1416. " summary, completed_at, created_at) values (1, 'Child one', 'done', 5, ?,"
  1417. " 'built and verified the flange', strftime('%s','now','+1 second'),"
  1418. " strftime('%s','now'))", (parent7,))
  1419. conn.execute("insert into tasks (project_id, title, status, priority, parent_task_id,"
  1420. " summary, completed_at, created_at) values (1, 'Child two', 'failed', 5, ?,"
  1421. " 'the harness refused to start', strftime('%s','now','+1 second'),"
  1422. " strftime('%s','now'))", (parent7,))
  1423. conn.commit()
  1424. conn.close()
  1425. def parent_push_count():
  1426. return len([p for p in fake.start_payloads
  1427. if int(p.get("task", {}).get("id") or 0) == parent7])
  1428. check(wait_for(lambda: parent_push_count() >= 2 or False,
  1429. timeout=45, what="suspended parent resumes") is not None,
  1430. "subtask events resume the suspended parent")
  1431. parent_pushes = [p for p in fake.start_payloads
  1432. if int(p.get("task", {}).get("id") or 0) == parent7]
  1433. check(len(parent_pushes) >= 2,
  1434. "the parent's resume is a fresh /start for the same task")
  1435. resume_push = parent_pushes[-1]
  1436. notice = resume_push.get("resume_notice") or ""
  1437. check("Child one" in notice and "completed" in notice
  1438. and "built and verified the flange" in notice,
  1439. "resume notice reports the finished subtask with its summary")
  1440. check("Child two" in notice and "failed" in notice
  1441. and "the harness refused to start" in notice,
  1442. "coalesced: the second event rides the same resume notice")
  1443. subtask_entries = {(s["title"], s["status"]) for s in resume_push.get("subtasks", [])}
  1444. check(("Child one", "done") in subtask_entries
  1445. and ("Child two", "failed") in subtask_entries,
  1446. "subtasks array carries the children's states at dispatch time")
  1447. check(wait_task_done(auth, parent7, timeout=60) is not None,
  1448. "resumed parent completes")
  1449. detail = task_state(auth, parent7)
  1450. check(any("restored the session context" in p["summary"]
  1451. for p in detail.get("progress", [])),
  1452. "resumed flag reported on the first status after the restore")
  1453. print("== R3 B1: subtask status push ==")
  1454. # A parked (suspended) parent gives delivery a stable open session.
  1455. fake.suspend_on_title = "Push parent"
  1456. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  1457. "project_id": "1",
  1458. "title": "Push parent",
  1459. "priority": "2",
  1460. })
  1461. push_parent = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  1462. if t["title"] == "Push parent"][0])
  1463. check(wait_for(lambda: task_state(auth, push_parent).get("status") == "suspended"
  1464. or False, timeout=60, what="push parent parks") is not None,
  1465. "push parent parks with an open session")
  1466. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  1467. "project_id": "1",
  1468. "title": "Push child",
  1469. "parent_task_id": str(push_parent),
  1470. })
  1471. push_child = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  1472. if t["title"] == "Push child"][0])
  1473. def events_for(subtask_id):
  1474. return [e for e in fake.subtask_status_events
  1475. if int(e.get("subtask_id") or 0) == subtask_id]
  1476. check(wait_for(lambda: len(events_for(push_child)) > 0 or False,
  1477. timeout=30, what="queued subtask event") is not None,
  1478. "subtask creation pushes a queued status event")
  1479. first_event = events_for(push_child)[0]
  1480. check(int(first_event.get("parent_task_id") or 0) == push_parent
  1481. and first_event.get("title") == "Push child"
  1482. and first_event.get("status") in ("queued", "working"),
  1483. "status event carries parent id, title and a wire status (%s)"
  1484. % first_event.get("status"))
  1485. # Real lifecycle: the child dispatches (working) and completes (done
  1486. # with summary) — the parent's daemon hears every transition.
  1487. check(wait_for(lambda: any(e.get("status") == "done" and "finished" in (e.get("summary") or "")
  1488. for e in events_for(push_child)) or False,
  1489. timeout=90, what="done subtask event") is not None,
  1490. "subtask completion pushes done with the summary")
  1491. check(any(e.get("status") == "working" for e in events_for(push_child)),
  1492. "dispatch pushes a working status event")
  1493. # Best-effort retry: while the daemon 500s, events stay queued and
  1494. # land once it recovers. The parent's resumed run re-parks (the
  1495. # ask-flow resume races this section otherwise: its completion
  1496. # would close the parked session the events are delivered to).
  1497. fake.fail_subtask_status = True
  1498. fake.suspend_on_title = "Push parent"
  1499. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  1500. "project_id": "1",
  1501. "title": "Push child two",
  1502. "parent_task_id": str(push_parent),
  1503. })
  1504. push_child_two = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  1505. if t["title"] == "Push child two"][0])
  1506. time.sleep(7)
  1507. check(len(events_for(push_child_two)) == 0,
  1508. "failed pushes stay queued (no event delivered)")
  1509. fake.fail_subtask_status = False
  1510. check(wait_for(lambda: len(events_for(push_child_two)) > 0 or False,
  1511. timeout=30, what="retried subtask event") is not None,
  1512. "recovered pushes deliver on a later tick")
  1513. print("== R3 B2: stale-busy saddle healing ==")
  1514. # The observed incident: a saddle left busy with current_task_id 0.
  1515. conn = db()
  1516. conn.execute("update saddles set status = 'busy', current_task_id = 0,"
  1517. " allocated_at = strftime('%s','now') where name = 'fake-saddle'")
  1518. conn.commit()
  1519. conn.close()
  1520. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  1521. "project_id": "1",
  1522. "title": "Heal dispatch",
  1523. "priority": "2",
  1524. })
  1525. heal_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  1526. if t["title"] == "Heal dispatch"][0])
  1527. check(wait_task_done(auth, heal_task, timeout=60) is not None,
  1528. "busy-without-task heals and the queued task dispatches")
  1529. with open(os.path.join(WORK, "app.log")) as handle:
  1530. app_log = handle.read()
  1531. check("Healing stale-busy saddle" in app_log
  1532. and "busy with no current task" in app_log,
  1533. "the busy@0 heal is logged")
  1534. # Contradiction: the daemon reports idle while the web believes the
  1535. # saddle is running the orphaned task. Drain everything in flight
  1536. # first — forcing the daemon's state mid-run requeues whatever it
  1537. # is really executing.
  1538. def in_flight_count():
  1539. conn = db()
  1540. try:
  1541. # The plan-review fixture (task 1) is inserted 'working'
  1542. # on purpose and never dispatches; it is not in flight.
  1543. return conn.execute(
  1544. "select count(*) from tasks where status in"
  1545. " ('queued', 'assigned', 'working')"
  1546. " and title != 'Plan review task'").fetchone()[0]
  1547. finally:
  1548. conn.close()
  1549. def in_flight_titles():
  1550. conn = db()
  1551. try:
  1552. return [r[0] for r in conn.execute(
  1553. "select title from tasks where status in"
  1554. " ('queued', 'assigned', 'working')"
  1555. " and title != 'Plan review task'")]
  1556. finally:
  1557. conn.close()
  1558. check(wait_for(lambda: in_flight_count() == 0 or False,
  1559. timeout=90, what="in-flight work settles (%s)" % in_flight_titles()) is not None,
  1560. "in-flight work settles before the contradiction run")
  1561. conn = db()
  1562. conn.execute("insert into tasks (project_id, title, status, priority, created_at)"
  1563. " values (1, 'Orphan task', 'working', 2, strftime('%s','now'))")
  1564. orphan = conn.execute("select max(id) from tasks").fetchone()[0]
  1565. conn.execute("update saddles set status = 'busy', current_task_id = ?,"
  1566. " allocated_at = strftime('%s','now') where name = 'fake-saddle'",
  1567. (orphan,))
  1568. conn.commit()
  1569. conn.close()
  1570. fake.state = "idle"
  1571. check(wait_task_done(auth, orphan, timeout=60) is not None,
  1572. "contradicted saddle heals, orphan requeues and completes")
  1573. with open(os.path.join(WORK, "app.log")) as handle:
  1574. app_log = handle.read()
  1575. check("daemon idle while the web believes" in app_log,
  1576. "the contradiction heal is logged")
  1577. print("== R4 B1: synchronous subtask callback ==")
  1578. # Plan 10: the subtask callback is per-saddle-token authenticated.
  1579. r4_token = fake_token
  1580. # Parent with a live parked session (suspended) hosts the callback.
  1581. fake.suspend_on_title = "Callback parent"
  1582. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  1583. "project_id": "1",
  1584. "title": "Callback parent",
  1585. "priority": "2",
  1586. })
  1587. cb_parent = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  1588. if t["title"] == "Callback parent"][0])
  1589. check(wait_for(lambda: task_state(auth, cb_parent).get("status") == "suspended"
  1590. or False, timeout=60, what="callback parent parks") is not None,
  1591. "callback parent parks")
  1592. status, _ = saddle_api("POST", "/api/saddle/subtask", None,
  1593. {"parent_task_id": cb_parent, "title": "no token"})
  1594. check(status == 401, "subtask callback rejects a missing token")
  1595. anchor = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  1596. if t["title"] == "Heal dispatch"][0])
  1597. status, body = saddle_api("POST", "/api/saddle/subtask", r4_token, {
  1598. "parent_task_id": cb_parent,
  1599. "title": "Wire the flange",
  1600. "description": "torque to spec",
  1601. "depends_on": [anchor],
  1602. "session_id": 1,
  1603. })
  1604. check(status == 200 and body.get("ok") is True and int(body.get("task_id") or 0) > 0,
  1605. "callback creates the subtask synchronously")
  1606. subtask_id = int(body.get("task_id"))
  1607. detail = task_state(auth, subtask_id)
  1608. check(detail["title"] == "Wire the flange" and int(detail["project_id"]) == 1
  1609. and int(detail["parent_task_id"]) == cb_parent,
  1610. "subtask row inherits the parent's project and linkage")
  1611. check(detail.get("task_branch") == task_state(auth, cb_parent).get("task_branch"),
  1612. "subtask shares the parent's task branch")
  1613. conn = db()
  1614. dep_rows = conn.execute(
  1615. "select depends_on_task_id from task_dependencies where task_id = ?",
  1616. (subtask_id,)).fetchall()
  1617. conn.close()
  1618. check([r[0] for r in dep_rows] == [anchor],
  1619. "depends_on wired as task_dependencies")
  1620. # Idempotency + entry-poll fallback dedup.
  1621. status, body2 = saddle_api("POST", "/api/saddle/subtask", r4_token, {
  1622. "parent_task_id": cb_parent, "title": "Wire the flange"})
  1623. check(status == 200 and int(body2.get("task_id") or 0) == subtask_id,
  1624. "retried callback returns the same task id")
  1625. conn = db()
  1626. before = conn.execute("select count(*) from tasks where parent_task_id = ?",
  1627. (cb_parent,)).fetchone()[0]
  1628. conn.close()
  1629. conn = db()
  1630. cb_session = conn.execute(
  1631. "select id from sessions where task_id = ? and ended_at is null",
  1632. (cb_parent,)).fetchone()[0]
  1633. conn.close()
  1634. saddle_api("POST", "/api/saddle/%d/update" % fake_id, fake_token,
  1635. {"type": "subtask", "session_id": cb_session, "data": {
  1636. "title": "Wire the flange", "description": "again"}})
  1637. conn = db()
  1638. after = conn.execute("select count(*) from tasks where parent_task_id = ?",
  1639. (cb_parent,)).fetchone()[0]
  1640. timeline = conn.execute(
  1641. "select count(*) from session_commands where session_id = ?"
  1642. " and type = 'subtask'", (cb_session,)).fetchone()[0]
  1643. conn.close()
  1644. check(after == before,
  1645. "entry-poll fallback dedupes against the callback's row")
  1646. check(timeline >= 1, "timeline entry recorded on the parent's session")
  1647. # Validation: unknown parent, unknown depends_on, ancestor cycle.
  1648. status, body = saddle_api("POST", "/api/saddle/subtask", r4_token,
  1649. {"parent_task_id": 99999, "title": "ghost"})
  1650. check(status == 400, "unknown parent rejected")
  1651. status, body = saddle_api("POST", "/api/saddle/subtask", r4_token,
  1652. {"parent_task_id": cb_parent, "title": "bad dep",
  1653. "depends_on": [99999]})
  1654. check(status == 400 and "unknown task" in body.get("error", ""),
  1655. "unknown depends_on id rejected with a clear error")
  1656. status, body = saddle_api("POST", "/api/saddle/subtask", r4_token,
  1657. {"parent_task_id": cb_parent, "title": "cycle dep",
  1658. "depends_on": [cb_parent]})
  1659. check(status == 400 and "cycle" in body.get("error", ""),
  1660. "depending on an ancestor rejected as a cycle")
  1661. print("== R4 B2: subtask auto-approve ==")
  1662. # Settle earlier stragglers first: the round-3/B1 suspended parents
  1663. # resume (a fresh /start) once their last subtask completes — those
  1664. # pushes would consume the one-shot suspend flag below.
  1665. check(wait_task_done(auth, push_parent, timeout=90) is not None
  1666. and wait_task_done(auth, push_child_two, timeout=90) is not None
  1667. and wait_task_done(auth, cb_parent, timeout=90) is not None,
  1668. "earlier suspended parents settle before the auto-approve run")
  1669. # Parent parks; its toggle then covers all descendants. Children are
  1670. # created with an unmet 'gpu' requirement so they stay queued (and
  1671. # untouched) until the requirement is lifted for the final-resume leg.
  1672. fake.suspend_on_title = "Auto parent"
  1673. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  1674. "project_id": "1",
  1675. "title": "Auto parent",
  1676. "priority": "2",
  1677. })
  1678. auto_parent = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  1679. if t["title"] == "Auto parent"][0])
  1680. check(wait_for(lambda: task_state(auth, auto_parent).get("status") == "suspended"
  1681. or False, timeout=60, what="auto parent parks") is not None,
  1682. "auto parent parks")
  1683. detail = task_state(auth, auto_parent)
  1684. check(detail.get("auto_approve") is False,
  1685. "auto-approve defaults to off")
  1686. act(auth, detail["toggle_auto_approve"], {})
  1687. check(task_state(auth, auto_parent).get("auto_approve") is True,
  1688. "toggle turns auto-approve on")
  1689. gpu_option = int([o["id"] for o in slot_public(auth, "/tasks", "tasks")["capability_options"]
  1690. if o["name"] == "gpu"][0])
  1691. status, body = saddle_api("POST", "/api/saddle/subtask", r4_token,
  1692. {"parent_task_id": auto_parent,
  1693. "title": "Auto child", "depends_on": []})
  1694. auto_child = int(body["task_id"])
  1695. status, body = saddle_api("POST", "/api/saddle/subtask", r4_token,
  1696. {"parent_task_id": auto_child, "title": "Auto grandchild"})
  1697. auto_grand = int(body["task_id"])
  1698. act(auth, task_state(auth, auto_child)["attach_capability"],
  1699. {"capability_id": str(gpu_option)})
  1700. act(auth, task_state(auth, auto_grand)["attach_capability"],
  1701. {"capability_id": str(gpu_option)})
  1702. # Transitive coverage: the GRANDCHILD's review is auto-answered
  1703. # (any ancestor set), answered-by 'auto', timeline entry recorded.
  1704. conn = db()
  1705. conn.execute("insert into sessions (task_id, saddle_id, started_at)"
  1706. " values (?, ?, strftime('%s','now'))", (auto_grand, fake_id))
  1707. conn.commit()
  1708. grand_session = conn.execute("select max(id) from sessions").fetchone()[0]
  1709. conn.close()
  1710. saddle_api("POST", "/api/saddle/%d/update" % fake_id, fake_token,
  1711. {"type": "review", "session_id": grand_session, "data": {
  1712. "summary": "the flange is wired"}})
  1713. grand_detail = task_state(auth, auto_grand)
  1714. grand_review = [b for b in grand_detail["blocks"] if b["block_type"] == "review"][0]
  1715. check(grand_review.get("answered") is True
  1716. and grand_review.get("answer") == "Auto-approved (subtask auto-approve is on)"
  1717. and grand_review.get("answered_by") == "auto",
  1718. "covered grandchild review auto-answered as 'auto'")
  1719. conn = db()
  1720. timeline_auto = conn.execute(
  1721. "select count(*) from session_commands where session_id = ?"
  1722. " and type = 'auto-approve'", (grand_session,)).fetchone()[0]
  1723. conn.close()
  1724. check(timeline_auto >= 1, "auto action visible in the timeline")
  1725. # Questions and RFCs still block for a human.
  1726. saddle_api("POST", "/api/saddle/%d/update" % fake_id, fake_token,
  1727. {"type": "block", "session_id": grand_session, "data": {
  1728. "question": "Which torque spec?", "type": "question"}})
  1729. saddle_api("POST", "/api/saddle/%d/update" % fake_id, fake_token,
  1730. {"type": "block", "session_id": grand_session, "data": {
  1731. "question": "RFC: approve the design", "type": "rfc",
  1732. "body": "# Design"}})
  1733. grand_detail = task_state(auth, auto_grand)
  1734. check(all(b.get("answered") is not True for b in grand_detail["blocks"]
  1735. if b["block_type"] in ("question", "rfc")),
  1736. "questions and RFCs are never auto-answered")
  1737. # The toggle covers DESCENDANTS only: the parent's own review waits.
  1738. conn = db()
  1739. conn.execute("insert into sessions (task_id, saddle_id, started_at, ended_at,"
  1740. " outcome) values (?, ?, strftime('%s','now'), strftime('%s','now'),"
  1741. " 'done')", (auto_parent, fake_id))
  1742. conn.commit()
  1743. parent_session = conn.execute("select max(id) from sessions").fetchone()[0]
  1744. conn.close()
  1745. saddle_api("POST", "/api/saddle/%d/update" % fake_id, fake_token,
  1746. {"type": "review", "session_id": parent_session, "data": {
  1747. "summary": "the whole change"}})
  1748. parent_detail = task_state(auth, auto_parent)
  1749. parent_review = [b for b in parent_detail["blocks"] if b["block_type"] == "review"][0]
  1750. check(parent_review.get("answered") is not True,
  1751. "the top-level parent's own review is never auto-approved")
  1752. # All descendants terminal → the suspended parent resumes with the
  1753. # final-review instruction (lift the capability requirement first).
  1754. act(auth, task_state(auth, auto_child)["capabilities"][0]["detach"], {})
  1755. act(auth, task_state(auth, auto_grand)["capabilities"][0]["detach"], {})
  1756. def pushes_for(task_id):
  1757. return len([p for p in fake.start_payloads
  1758. if int(p.get("task", {}).get("id") or 0) == task_id])
  1759. before_pushes = pushes_for(auto_parent)
  1760. check(wait_for(lambda: pushes_for(auto_parent) > before_pushes or False,
  1761. timeout=90, what="auto parent resumes") is not None,
  1762. "terminal subtasks resume the suspended parent")
  1763. notice = ""
  1764. for p in fake.start_payloads:
  1765. if int(p.get("task", {}).get("id") or 0) == auto_parent:
  1766. notice = p.get("resume_notice") or ""
  1767. check("All subtasks are complete" in notice
  1768. and "ONE final review" in notice and "saddle review" in notice,
  1769. "resume notice ends with the final-review instruction")
  1770. print("== phase 1: ask/allocation — token auth + concurrent race ==")
  1771. # Plan 10 phase 1. Registration mints per-saddle tokens; the
  1772. # ask endpoint authenticates by them; concurrent asks race to
  1773. # exactly one winner through the allocation transaction.
  1774. status, _ = saddle_api("POST", "/api/saddle/next-task", None)
  1775. check(status == 401, "next-task rejects a missing token")
  1776. status, _ = saddle_api("POST", "/api/saddle/next-task", "saddle-bogus-token")
  1777. check(status == 401, "next-task rejects a wrong token")
  1778. status, body = saddle_api("POST", "/api/saddle/next-task", fake_token)
  1779. check(status == 204, "next-task answers 204 (nothing offerable — daemon sleeps)")
  1780. # Two more racers: real daemons on their own ports; fake2 reports
  1781. # its ssh key at /register so the key lands on the row directly.
  1782. fake2 = FakeSaddle()
  1783. fake2.ssh_public_key = "ssh-ed25519 AAAAFake2RacerKey saddle-2@fedora"
  1784. fake2.serve(port=free_port())
  1785. fake3 = FakeSaddle()
  1786. fake3.serve(port=free_port())
  1787. saddles_state = slot_public(auth, "/saddles", "saddles")
  1788. act(auth, saddles_state["register"], {"name": "racer-two",
  1789. "host": "127.0.0.1:%d" % fake2.port})
  1790. act(auth, saddles_state["register"], {"name": "racer-three",
  1791. "host": "127.0.0.1:%d" % fake3.port})
  1792. check(wait_for(lambda: len(fake2.register_log) > 0 and len(fake3.register_log) > 0
  1793. or False, timeout=15, what="racers registered") is not None,
  1794. "both racers received /register")
  1795. check(wait_for(lambda: "AAAAFake2RacerKey" in
  1796. [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  1797. if s["name"] == "racer-two"][0].get("ssh_public_key", "") or False,
  1798. timeout=15, what="register-reported key") is not None,
  1799. "the ssh key reported at /register lands on the saddle row")
  1800. racer_cards = {}
  1801. for name, daemon in (("fake-saddle", fake), ("racer-two", fake2), ("racer-three", fake3)):
  1802. card = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  1803. if s["name"] == name][0]
  1804. racer_cards[daemon] = {"id": int(card["id"]), "token": card["token"]}
  1805. # Revocation: a registered saddle's token works until the saddle
  1806. # is removed, then nothing authorises its calls.
  1807. fake4 = FakeSaddle()
  1808. fake4.ask_on_register = False # a pre-flip daemon: never asks
  1809. fake4.serve(port=free_port())
  1810. saddles_state = slot_public(auth, "/saddles", "saddles")
  1811. act(auth, saddles_state["register"], {"name": "old-daemon",
  1812. "host": "127.0.0.1:%d" % fake4.port})
  1813. old_card = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  1814. if s["name"] == "old-daemon"][0]
  1815. status, _ = saddle_api("POST", "/api/saddle/next-task", old_card["token"])
  1816. check(status in (200, 204),
  1817. "the old daemon's fresh token authenticates next-task")
  1818. status, _ = saddle_api("POST", "/api/saddle/note", old_card["token"],
  1819. {"project": "demo", "body": "from the old daemon"})
  1820. check(status == 200, "note accepts any registered saddle's token")
  1821. act(auth, old_card["unregister"], {})
  1822. status, _ = saddle_api("POST", "/api/saddle/next-task", old_card["token"])
  1823. check(status == 401, "removing the saddle revokes its token")
  1824. status, _ = saddle_api("POST", "/api/saddle/note", old_card["token"],
  1825. {"project": "demo", "body": "should fail"})
  1826. check(status == 401, "revocation covers the note endpoint too")
  1827. # Deterministic race: settle the queue first (the auto-approve
  1828. # stragglers must finish — their parked resumes stay offerable
  1829. # otherwise and would steal the race's asks), freeze every
  1830. # daemon's self-ask triggers, put all three to sleep (204),
  1831. # then fire one task worth of concurrent asks.
  1832. check(wait_task_done(auth, auto_child, timeout=90) is not None
  1833. and wait_task_done(auth, auto_grand, timeout=90) is not None
  1834. and wait_task_done(auth, auto_parent, timeout=90) is not None,
  1835. "the auto-approve stragglers settle before the race")
  1836. for daemon in (fake, fake2, fake3):
  1837. daemon.ask_on_register = False
  1838. daemon.ask_on_wake = False
  1839. daemon.ask_on_terminal = False
  1840. daemon.ask_on_idle_status = False
  1841. time.sleep(1.5) # let any in-flight triggered asks land
  1842. def queued_count():
  1843. conn = db()
  1844. try:
  1845. return conn.execute(
  1846. "select count(*) from tasks where status = 'queued'").fetchone()[0]
  1847. finally:
  1848. conn.close()
  1849. check(wait_for(lambda: queued_count() == 0 or False,
  1850. timeout=60, what="queue settles before the race") is not None,
  1851. "the queue settles before the race")
  1852. sleep_statuses = [daemon.ask_now()[0] for daemon in (fake, fake2, fake3)]
  1853. check(sleep_statuses == [204, 204, 204],
  1854. "all three racers sleep (204) before the task exists")
  1855. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  1856. "project_id": "1",
  1857. "title": "Race task",
  1858. "priority": "2",
  1859. })
  1860. race_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  1861. if t["title"] == "Race task"][0])
  1862. race_results = []
  1863. def race_ask(daemon):
  1864. # A real racing daemon accepts the assignment it wins — the
  1865. # manual ask path does exactly that on a 200.
  1866. race_results.append(daemon.ask_now())
  1867. threads = [threading.Thread(target=race_ask, args=(daemon,))
  1868. for daemon in (fake, fake2, fake3)]
  1869. for thread in threads:
  1870. thread.start()
  1871. for thread in threads:
  1872. thread.join()
  1873. race_statuses = sorted(r[0] for r in race_results)
  1874. check(race_statuses == [200, 204, 204],
  1875. "concurrent asks race to exactly one winner (%s)" % race_statuses)
  1876. winner = [r for r in race_results if r[0] == 200][0][1]
  1877. check(int(winner["task"]["id"]) == race_task, "the winner's payload carries the task")
  1878. check(int(winner["session_id"]) > 0, "the assignment carries the session id")
  1879. conn = db()
  1880. race_sessions = conn.execute(
  1881. "select count(*) from sessions where task_id = ?", (race_task,)).fetchone()[0]
  1882. conn.close()
  1883. check(race_sessions == 1,
  1884. "exactly one session row — the transaction serialised the claims (%d)"
  1885. % race_sessions)
  1886. check(wait_task_done(auth, race_task, timeout=60) is not None,
  1887. "the raced task completes on the winning daemon")
  1888. for daemon in (fake, fake2, fake3):
  1889. daemon.ask_on_register = True
  1890. daemon.ask_on_wake = True
  1891. daemon.ask_on_terminal = True
  1892. daemon.ask_on_idle_status = True
  1893. # Re-establish sleeping explicitly: the winner's post-race
  1894. # re-ask timing is not deterministic, and the affinity section
  1895. # below needs every racer provably asleep. Any stray offerable
  1896. # drains into these asks first.
  1897. def all_asleep():
  1898. return all(daemon.ask_now()[0] == 204 for daemon in (fake, fake2, fake3))
  1899. check(wait_for(lambda: all_asleep() or False, timeout=30,
  1900. what="racers re-sleep after the race") is not None,
  1901. "all racers re-sleep after the race")
  1902. print("== phase 1: workspace affinity pins parked tasks ==")
  1903. # A suspended parent parked on racer-two may be resumed ONLY by
  1904. # racer-two: a tag-capable non-affine saddle asking gets 204, and
  1905. # the wake targets the affine saddle. Only racer-two carries the
  1906. # tag at dispatch time (the race's winner is nondeterministic, so
  1907. # the park must not depend on which racers are free); racer-three
  1908. # gains it afterwards to prove the pinning is by workspace, not
  1909. # tags.
  1910. saddles_state = slot_public(auth, "/saddles", "saddles")
  1911. act(auth, saddles_state["create_capability"], {"name": "affinity-tag"})
  1912. affinity_cap = int([c["id"] for c in slot_public(auth, "/saddles", "saddles")["capabilities"]
  1913. if c["name"] == "affinity-tag"][0])
  1914. racer2_card = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  1915. if s["name"] == "racer-two"][0]
  1916. racer3_card = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  1917. if s["name"] == "racer-three"][0]
  1918. act(auth, racer2_card["attach_capability"], {"capability_id": str(affinity_cap)})
  1919. fake2.suspend_on_title = "Affinity parent"
  1920. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  1921. "project_id": "1",
  1922. "title": "Affinity parent",
  1923. "priority": "2",
  1924. "capability_id": [str(affinity_cap)],
  1925. })
  1926. affinity_parent = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  1927. if t["title"] == "Affinity parent"][0])
  1928. check(wait_for(lambda: task_state(auth, affinity_parent).get("status") == "suspended"
  1929. or False, timeout=60, what="affinity parent parks") is not None,
  1930. "affinity parent parks (suspended) on the tag-carrying racer")
  1931. conn = db()
  1932. affinity_session = conn.execute(
  1933. "select saddle_id from sessions where task_id = ? and ended_at is null",
  1934. (affinity_parent,)).fetchone()[0]
  1935. conn.close()
  1936. check(affinity_session == int(racer2_card["id"]),
  1937. "the parked workspace sits on racer-two (the only tag-carrier)")
  1938. # Now racer-three carries the tag too — the pinning is by
  1939. # workspace, not tags.
  1940. # A terminal subtask makes the parked task resume-offerable —
  1941. # affine to racer-two only. The child's completion is stamped
  1942. # slightly in the future: the poller may not have observed (and
  1943. # stamped) the parent's suspension yet, and the resume trigger
  1944. # needs completed_at > suspended_at. Freeze racer-two's ask
  1945. # triggers so the manual ask below is the only contender (its
  1946. # park-time idle-status ask would otherwise race this claim).
  1947. conn = db()
  1948. conn.execute("insert into tasks (project_id, title, status, priority, parent_task_id,"
  1949. " summary, completed_at, created_at) values (1, 'Affinity child', 'done',"
  1950. " 5, ?, 'affinity child finished', strftime('%s','now','+15 seconds'),"
  1951. " strftime('%s','now'))", (affinity_parent,))
  1952. conn.commit()
  1953. conn.close()
  1954. fake2.ask_on_wake = False
  1955. fake2.ask_on_idle_status = False
  1956. time.sleep(1.2) # any in-flight park-time trigger ask lands (204)
  1957. act(auth, racer3_card["attach_capability"], {"capability_id": str(affinity_cap)})
  1958. status, _ = fake3.ask_now()
  1959. check(status == 204,
  1960. "a tag-capable non-affine saddle is refused the parked task (204)")
  1961. wakes_before_2 = len(fake2.wake_log)
  1962. check(wait_for(lambda: len(fake2.wake_log) > wakes_before_2 or False,
  1963. timeout=30, what="affine wake") is not None,
  1964. "the dispatcher's wake targets the affine saddle")
  1965. status, resume_payload = fake2.ask_now()
  1966. fake2.ask_on_wake = True # restored whatever the claim returned
  1967. fake2.ask_on_idle_status = True
  1968. check(status == 200 and int(resume_payload["task"]["id"]) == affinity_parent,
  1969. "the affine saddle's ask claims the parked task")
  1970. check("Affinity child" in (resume_payload.get("resume_notice") or "")
  1971. and "affinity child finished" in (resume_payload.get("resume_notice") or ""),
  1972. "the resume assignment carries the subtask report notice")
  1973. check(wait_task_done(auth, affinity_parent, timeout=60) is not None,
  1974. "the resumed parent completes")
  1975. print("== phase 1: lost wake -> offline + re-wake of the next capable ==")
  1976. # racer-three sleeps through wakes (a daemon that died after its
  1977. # last 204): the guard window (SADDLE_LOST_WAKE_S=2) passes with
  1978. # no ask, the saddle goes offline, and the work moves to the
  1979. # next capable sleeping saddle.
  1980. fake3.ask_on_wake = False
  1981. saddles_state = slot_public(auth, "/saddles", "saddles")
  1982. act(auth, saddles_state["create_capability"], {"name": "lostwake"})
  1983. lostwake_cap = int([c["id"] for c in slot_public(auth, "/saddles", "saddles")["capabilities"]
  1984. if c["name"] == "lostwake"][0])
  1985. racer3_card = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  1986. if s["name"] == "racer-three"][0]
  1987. act(auth, racer3_card["attach_capability"], {"capability_id": str(lostwake_cap)})
  1988. # Racer-three must be asleep to be woken: park it with a manual
  1989. # 204 ask (nothing offerable for it — the task below is not
  1990. # created yet).
  1991. status, _ = fake3.ask_now()
  1992. check(status == 204, "racer-three sleeps before the lost-wake run")
  1993. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  1994. "project_id": "1",
  1995. "title": "Lost wake task",
  1996. "priority": "2",
  1997. "capability_id": [str(lostwake_cap)],
  1998. })
  1999. lost_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  2000. if t["title"] == "Lost wake task"][0])
  2001. check(wait_for(lambda: len(fake3.wake_log) > 0 or False,
  2002. timeout=30, what="wake sent to racer-three") is not None,
  2003. "the only capable sleeping saddle is woken")
  2004. check(wait_for(lambda: [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  2005. if s["name"] == "racer-three"][0]["status"] == "offline"
  2006. or False, timeout=30, what="lost-wake -> offline") is not None,
  2007. "no ask after the wake: the saddle goes offline (lost-wake guard)")
  2008. check(task_state(auth, lost_task)["status"] == "queued",
  2009. "the task is untouched while its wake was pending")
  2010. # Re-wake the next capable: the healthy saddle gains the tag and
  2011. # picks the work up via its own wake -> ask -> allocate roundtrip.
  2012. fake_card = saddle_card("fake-saddle")
  2013. act(auth, fake_card["attach_capability"], {"capability_id": str(lostwake_cap)})
  2014. wakes_before = len(fake.wake_log)
  2015. check(wait_for(lambda: len(fake.wake_log) > wakes_before or False,
  2016. timeout=30, what="re-wake of the next capable") is not None,
  2017. "the next capable sleeping saddle is woken for the task")
  2018. check(wait_task_done(auth, lost_task, timeout=60) is not None,
  2019. "wake -> ask -> allocate: the task completes on the healthy saddle")
  2020. check(wait_for(lambda: any(int(p.get("task", {}).get("id") or 0) == lost_task
  2021. for p in fake.start_payloads) or False,
  2022. timeout=10, what="lost task assignment captured") is not None,
  2023. "the re-woken saddle's ask produced the assignment")
  2024. fake3.ask_on_wake = True
  2025. # The racers leave the dispatch pool: the remaining sections
  2026. # (rollback resume, R5-R11) predate multi-daemon routing and
  2027. # assert against the primary fake's payloads. racer-three is
  2028. # already offline (the lost-wake guard); racer-two joins it.
  2029. conn = db()
  2030. conn.execute("update saddles set status = 'offline' where name = 'racer-two'")
  2031. conn.commit()
  2032. conn.close()
  2033. # Tasks caught working on a saddle that went offline requeue with a
  2034. # bot comment each tick.
  2035. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  2036. "project_id": "1",
  2037. "title": "Offline saddle task",
  2038. "priority": "2",
  2039. })
  2040. offline_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  2041. if t["title"] == "Offline saddle task"][0])
  2042. check(wait_for(lambda: task_state(auth, offline_task).get("status") == "working"
  2043. or False, timeout=60, what="offline task starts working") is not None,
  2044. "task reaches working before its saddle disappears")
  2045. conn = db()
  2046. conn.execute("update saddles set status = 'offline' where name = 'fake-saddle'")
  2047. conn.commit()
  2048. conn.close()
  2049. # The requeue frees the saddle in the same tick, so 'queued' is
  2050. # transient; the durable evidence is the bot comment (and the task
  2051. # completing again on the healthy daemon).
  2052. check(wait_for(lambda: any(c["body"] == "saddle went offline; task requeued"
  2053. for c in task_state(auth, offline_task).get("comments", []))
  2054. or False, timeout=30, what="offline requeue comment") is not None,
  2055. "offline saddle requeues the task")
  2056. check(wait_task_done(auth, offline_task, timeout=60) is not None,
  2057. "requeued task completes again on the healthy daemon")
  2058. print("== R5 B1: /start parent context ==")
  2059. # A fresh subtask dispatch carries the parent object (plan clamped).
  2060. conn = db()
  2061. conn.execute("update tasks set plan = ? where id = ?",
  2062. ("# Parent plan\n\n" + "step. " * 1200, auto_parent))
  2063. conn.commit()
  2064. conn.close()
  2065. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  2066. "project_id": "1",
  2067. "title": "Lineage subtask",
  2068. "priority": "3",
  2069. })
  2070. lineage_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  2071. if t["title"] == "Lineage subtask"][0])
  2072. conn = db()
  2073. conn.execute("update tasks set parent_task_id = ?, description = 'torque to spec'"
  2074. " where id = ?", (auto_parent, lineage_task))
  2075. conn.commit()
  2076. conn.close()
  2077. check(wait_for(lambda: any(int(p.get("task", {}).get("id") or 0) == lineage_task
  2078. for p in fake.start_payloads) or False,
  2079. timeout=60, what="lineage subtask dispatches") is not None,
  2080. "lineage subtask dispatches")
  2081. lineage_push = [p for p in fake.start_payloads
  2082. if int(p.get("task", {}).get("id") or 0) == lineage_task][-1]
  2083. parent_obj = lineage_push.get("parent")
  2084. check(parent_obj is not None
  2085. and int(parent_obj.get("id") or 0) == auto_parent
  2086. and parent_obj.get("title") == "Auto parent"
  2087. and parent_obj.get("status") in ("suspended", "working", "done"),
  2088. "subtask /start carries the parent object")
  2089. check(parent_obj.get("description") == "torque to spec"
  2090. or "Parent plan" in (parent_obj.get("plan") or ""),
  2091. "parent description/plan content flows through")
  2092. check(len(parent_obj.get("plan") or "") <= 4000,
  2093. "parent plan clamped to ~4000 chars (%d)"
  2094. % len(parent_obj.get("plan") or ""))
  2095. root_pushes = [p for p in fake.start_payloads
  2096. if int(p.get("task", {}).get("id") or 0) == offline_task]
  2097. check(all("parent" not in p for p in root_pushes),
  2098. "root task /start carries no parent object")
  2099. print("== R5 B2: task-reading API ==")
  2100. status, _ = saddle_api("GET", "/api/saddle/tasks", None)
  2101. check(status == 401, "tasks endpoint rejects a missing token")
  2102. status, _ = saddle_api("GET", "/api/saddle/task/%d" % auto_child, None)
  2103. check(status == 401, "task detail endpoint rejects a missing token")
  2104. status, body = saddle_api("GET", "/api/saddle/tasks", r4_token)
  2105. check(status == 200 and len(body.get("tasks", [])) > 5,
  2106. "tasks endpoint lists tasks without filters")
  2107. row = [t for t in body["tasks"] if int(t["id"]) == auto_child][0]
  2108. check(row["title"] == "Auto child" and int(row["parent_task_id"]) == auto_parent
  2109. and row["status"] in ("done", "queued", "working"),
  2110. "compact rows carry id/title/status/parent_task_id")
  2111. status, body = saddle_api("GET", "/api/saddle/tasks?project_id=1", r4_token)
  2112. other_project = int([p["id"] for p in slot_public(auth, "/projects", "projects")["projects"]
  2113. if p["name"] == "diff-fabrica"][0])
  2114. check(all(True for _ in [1]) # project 1 dominates the data set
  2115. and not any(int(t["id"]) == 0 for t in body["tasks"]),
  2116. "project_id filter accepted")
  2117. status, body = saddle_api("GET",
  2118. "/api/saddle/tasks?project_id=%d" % other_project, r4_token)
  2119. check(all(t["title"].startswith("Diff") for t in body["tasks"]),
  2120. "other project lists only its own tasks")
  2121. status, body = saddle_api("GET", "/api/saddle/tasks?q=FLANGE", r4_token)
  2122. titles = [t["title"] for t in body["tasks"]]
  2123. check("Wire the flange" in titles,
  2124. "q matches titles case-insensitively")
  2125. status, body = saddle_api("GET", "/api/saddle/tasks?q=torque%20to%20spec", r4_token)
  2126. check(any(t["title"] == "Lineage subtask" for t in body["tasks"]),
  2127. "q matches description text")
  2128. status, body = saddle_api("GET", "/api/saddle/tasks?q=parent%20plan", r4_token)
  2129. check(any(t["title"] == "Auto parent" for t in body["tasks"]),
  2130. "q matches plan text")
  2131. status, body = saddle_api("GET", "/api/saddle/task/%d" % auto_child, r4_token)
  2132. task_view = body.get("task", {})
  2133. check(status == 200 and int(task_view.get("id") or 0) == auto_child
  2134. and task_view.get("parent_title") == "Auto parent"
  2135. and int(task_view.get("parent_task_id") or 0) == auto_parent,
  2136. "single-task view joins the parent title")
  2137. check("status" in task_view and "description" in task_view
  2138. and "plan" in task_view and "summary" in task_view,
  2139. "single-task view carries the full detail fields")
  2140. status, body = saddle_api("GET", "/api/saddle/task/99999", r4_token)
  2141. check(status == 404, "unknown task id returns 404")
  2142. print("== R6 F1: a hanging request cannot freeze the tick ==")
  2143. # One wedged /output (never responds) blocks a poll; the Soup
  2144. # timeout must fail it and later ticks keep dispatching.
  2145. task_state(auth, lineage_task)
  2146. conn = db()
  2147. conn.execute("insert into tasks (project_id, title, status, priority, created_at)"
  2148. " values (1, 'Hang survivor', 'queued', 2, strftime('%s','now'))")
  2149. hang_task = conn.execute("select max(id) from tasks").fetchone()[0]
  2150. conn.close()
  2151. fake.hang_next["/output"] = 1
  2152. # Dispatch a task first so a poll is in flight against the hang,
  2153. # then verify a later task still dispatches (tick not frozen).
  2154. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  2155. "project_id": "1",
  2156. "title": "Hang runner",
  2157. "priority": "3",
  2158. })
  2159. hang_runner = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  2160. if t["title"] == "Hang runner"][0])
  2161. check(wait_task_done(auth, hang_runner, timeout=90) is not None,
  2162. "dispatching task survives a hung daemon request")
  2163. # The hung poll itself fails after the timeout; its task requeues
  2164. # or completes on later ticks — the system kept moving either way.
  2165. with open(os.path.join(WORK, "app.log")) as handle:
  2166. app_log = handle.read()
  2167. check("tick exceeded" not in app_log or "Dispatch tick failed" in app_log,
  2168. "a hung request never freezes the tick latch")
  2169. print("== R6 F2: refresh must not corrupt parked bookkeeping ==")
  2170. fake.suspend_on_title = "Parked refresh parent"
  2171. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  2172. "project_id": "1",
  2173. "title": "Parked refresh parent",
  2174. "priority": "2",
  2175. })
  2176. parked = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  2177. if t["title"] == "Parked refresh parent"][0])
  2178. check(wait_for(lambda: task_state(auth, parked).get("status") == "suspended"
  2179. or False, timeout=60, what="parked parent suspends") is not None,
  2180. "parent parks before the refresh")
  2181. fake_card = saddle_card("fake-saddle")
  2182. act(auth, fake_card["refresh"], {})
  2183. fake_card = saddle_card("fake-saddle")
  2184. check(fake_card["status"] == "idle" and int(fake_card["current_task_id"] or 0) == 0,
  2185. "refresh on a parked (suspended) daemon leaves the saddle idle")
  2186. # The parked task keeps its open session and resumes normally.
  2187. conn = db()
  2188. conn.execute("insert into tasks (project_id, title, status, priority, parent_task_id,"
  2189. " summary, completed_at, created_at) values (1, 'Parked child', 'done', 5,"
  2190. " ?, 'child finished', strftime('%s','now','+1 second'),"
  2191. " strftime('%s','now'))", (parked,))
  2192. conn.commit()
  2193. conn.close()
  2194. check(wait_task_done(auth, parked, timeout=90) is not None,
  2195. "parked parent still resumes after the refresh")
  2196. print("== R6 F3: raw-log probe retries instead of deciding once ==")
  2197. fake.fail_log_count = 1 # the seed-time probe fails exactly once
  2198. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  2199. "project_id": "1",
  2200. "title": "Raw retry task",
  2201. "priority": "2",
  2202. })
  2203. raw_retry = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  2204. if t["title"] == "Raw retry task"][0])
  2205. def raw_rows_for(task_id):
  2206. conn = db()
  2207. try:
  2208. return conn.execute(
  2209. "select count(*) from session_raw_log where session_id in"
  2210. " (select id from sessions where task_id = ?)", (task_id,)).fetchone()[0]
  2211. finally:
  2212. conn.close()
  2213. check(wait_for(lambda: raw_rows_for(raw_retry) > 0 or False,
  2214. timeout=60, what="raw log rows appear after retry") is not None,
  2215. "a failed seed probe retries and the raw log fills")
  2216. check(wait_task_done(auth, raw_retry, timeout=60) is not None,
  2217. "raw retry task completes")
  2218. print("== R6 F4: stranded assigned recovery ==")
  2219. # The live incident's shape: task assigned, session open, zero
  2220. # output ever, daemon idle. Grace period 10s in the harness env.
  2221. fake.state = "idle"
  2222. conn = db()
  2223. conn.execute("insert into tasks (project_id, title, status, priority, created_at)"
  2224. " values (1, 'Stranded assigned', 'assigned', 2, strftime('%s','now'))")
  2225. stranded = conn.execute("select max(id) from tasks").fetchone()[0]
  2226. conn.execute("insert into sessions (task_id, saddle_id, started_at)"
  2227. " values (?, ?, strftime('%s','now','-180 seconds'))", (stranded, fake_id))
  2228. # Saddle row idle (the busy@task shape is the stale-busy heal's
  2229. # case); this exercises the strand pass specifically.
  2230. conn.execute("update saddles set status = 'idle', current_task_id = 0,"
  2231. " allocated_at = null where name = 'fake-saddle'")
  2232. conn.commit()
  2233. conn.close()
  2234. check(wait_for(lambda: any(c["body"] == "task stranded with no daemon output; task requeued"
  2235. for c in task_state(auth, stranded).get("comments", []))
  2236. or False, timeout=40, what="stranded requeue comment") is not None,
  2237. "stranded assignment requeues with a bot comment")
  2238. check(wait_task_done(auth, stranded, timeout=90) is not None,
  2239. "recovered task dispatches and completes")
  2240. print("== R7 B1: cancel reaches the daemon ==")
  2241. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  2242. "project_id": "1",
  2243. "title": "Cancel live",
  2244. "priority": "2",
  2245. })
  2246. cancel_live = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  2247. if t["title"] == "Cancel live"][0])
  2248. check(wait_for(lambda: task_state(auth, cancel_live).get("status") == "working"
  2249. or False, timeout=60, what="cancel-live working") is not None,
  2250. "task reaches working before the cancel")
  2251. act(auth, task_state(auth, cancel_live)["cancel"], {})
  2252. check(wait_for(lambda: cancel_live in fake.abort_log or False,
  2253. timeout=30, what="daemon abort recorded") is not None,
  2254. "cancelling POSTs /abort to the daemon")
  2255. check(any(c["body"] == "cancelled; daemon aborted"
  2256. for c in task_state(auth, cancel_live).get("comments", [])),
  2257. "the abort outcome is narrated")
  2258. check(task_state(auth, cancel_live)["status"] == "failed",
  2259. "cancelled task lands failed as before")
  2260. # Transport/500 failure on abort fences the saddle; recovery probe
  2261. # clears the fence; nothing dispatches while fenced.
  2262. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  2263. "project_id": "1",
  2264. "title": "Fence runner",
  2265. "priority": "2",
  2266. })
  2267. fence_runner = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  2268. if t["title"] == "Fence runner"][0])
  2269. check(wait_for(lambda: task_state(auth, fence_runner).get("status") == "working"
  2270. or False, timeout=60, what="fence runner working") is not None,
  2271. "second task reaches working")
  2272. fake.abort_failures = 1
  2273. act(auth, task_state(auth, fence_runner)["cancel"], {})
  2274. check(any(c["body"] == "cancelled; daemon unreachable — saddle fenced"
  2275. for c in task_state(auth, fence_runner).get("comments", [])),
  2276. "failed abort narrates the fence")
  2277. check(saddle_card("fake-saddle")["status"] == "recovering",
  2278. "saddle fenced as recovering")
  2279. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  2280. "project_id": "1",
  2281. "title": "During fence",
  2282. "priority": "3",
  2283. })
  2284. during_fence = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  2285. if t["title"] == "During fence"][0])
  2286. time.sleep(7)
  2287. check(task_state(auth, during_fence)["status"] == "queued",
  2288. "fenced saddle is not dispatchable")
  2289. # A real daemon lands idle once its (unaborted) round ends; the
  2290. # fake's scripted lifecycle parks on 'done', so model that.
  2291. fake.state = "idle"
  2292. check(wait_for(lambda: saddle_card("fake-saddle")["status"] == "idle" or False,
  2293. timeout=30, what="fence clears") is not None,
  2294. "an idle probe clears the fence")
  2295. check(wait_task_done(auth, during_fence, timeout=60) is not None,
  2296. "queued work dispatches once the fence clears")
  2297. print("== phase 1: rollback resume path (pre-flip daemon) ==")
  2298. # Phase 1 keeps the /answer + /start resume machinery for daemons
  2299. # that never ask (the rollback route; the 409-aware /start push
  2300. # machinery itself is rollback-only now that dispatch flows
  2301. # through asks). A pre-flip daemon's parked suspended parent is
  2302. # resumed by a /start push carrying the subtask report.
  2303. fake4.ask_on_register = False
  2304. fake4.ask_on_wake = False
  2305. fake4.ask_on_terminal = False
  2306. fake4.ask_on_idle_status = False
  2307. fake4.serve_register = False # a pre-flip daemon has no /register
  2308. registers_before = len(fake4.register_log)
  2309. saddles_state = slot_public(auth, "/saddles", "saddles")
  2310. act(auth, saddles_state["register"], {"name": "preflip-daemon",
  2311. "host": "127.0.0.1:%d" % fake4.port})
  2312. preflip_card = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  2313. if s["name"] == "preflip-daemon"][0]
  2314. preflip_id = int(preflip_card["id"])
  2315. check(len(fake4.register_log) == registers_before,
  2316. "the pre-flip daemon 404s /register (registration tolerated)")
  2317. conn = db()
  2318. conn.execute("insert into tasks (project_id, title, status, priority, suspended_at,"
  2319. " created_at) values (1, 'Preflip parked parent', 'suspended', 2,"
  2320. " strftime('%s','now','-60 seconds'), strftime('%s','now','-120 seconds'))")
  2321. preflip_parent = conn.execute("select max(id) from tasks").fetchone()[0]
  2322. conn.execute("insert into sessions (task_id, saddle_id, started_at)"
  2323. " values (?, ?, strftime('%s','now','-90 seconds'))",
  2324. (preflip_parent, preflip_id))
  2325. conn.execute("insert into tasks (project_id, title, status, priority, parent_task_id,"
  2326. " summary, completed_at, created_at) values (1, 'Preflip child', 'done',"
  2327. " 5, ?, 'child of the pre-flip era',"
  2328. " strftime('%s','now','-30 seconds'),"
  2329. " strftime('%s','now','-100 seconds'))", (preflip_parent,))
  2330. conn.commit()
  2331. conn.close()
  2332. pushes_before = len(fake4.start_payloads)
  2333. check(wait_for(lambda: len(fake4.start_payloads) > pushes_before or False,
  2334. timeout=45, what="pre-flip /start resume push") is not None,
  2335. "the pre-flip daemon's parked parent resumes via POST /start")
  2336. preflip_push = [p for p in fake4.start_payloads
  2337. if int(p.get("task", {}).get("id") or 0) == preflip_parent][-1]
  2338. check("Preflip child" in (preflip_push.get("resume_notice") or "")
  2339. and "child of the pre-flip era" in (preflip_push.get("resume_notice") or ""),
  2340. "the rollback /start resume carries the subtask report notice")
  2341. check(wait_task_done(auth, preflip_parent, timeout=60) is not None,
  2342. "the pre-flip parent completes through the rollback path")
  2343. print("== R7 B3: wedged daemon, health panel, raw log, vanishing daemon ==")
  2344. # Raw-log polling starts from the FIRST push (no probe failure):
  2345. # a normally dispatched task accumulates raw rows immediately.
  2346. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  2347. "project_id": "1",
  2348. "title": "Raw from first push",
  2349. "priority": "2",
  2350. })
  2351. raw_first = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  2352. if t["title"] == "Raw from first push"][0])
  2353. def raw_count(task_id):
  2354. conn = db()
  2355. try:
  2356. return conn.execute(
  2357. "select count(*) from session_raw_log where session_id in"
  2358. " (select id from sessions where task_id = ?)", (task_id,)).fetchone()[0]
  2359. finally:
  2360. conn.close()
  2361. check(wait_for(lambda: raw_count(raw_first) > 0 or False,
  2362. timeout=60, what="raw rows from first push") is not None,
  2363. "raw-log polling runs from the first push (session-4 gap)")
  2364. check(wait_task_done(auth, raw_first, timeout=60) is not None,
  2365. "raw-first task completes")
  2366. # Health panel: after the polls above the snapshot knows the fake.
  2367. card = saddle_card("fake-saddle")
  2368. check(card.get("health") in ("healthy", "stale")
  2369. and card.get("daemon_state") in ("working", "idle", "done")
  2370. and card.get("uptime_display", "") != ""
  2371. and card.get("last_contact_display", "") not in ("", "never"),
  2372. "saddles page shows daemon state, uptime and last contact")
  2373. # Alive-but-wedged: a working daemon whose last activity is stale
  2374. # (threshold 3s in the harness) fences the saddle and requeues the
  2375. # task with a bot comment.
  2376. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  2377. "project_id": "1",
  2378. "title": "Wedge victim",
  2379. "priority": "2",
  2380. })
  2381. wedge = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  2382. if t["title"] == "Wedge victim"][0])
  2383. check(wait_for(lambda: task_state(auth, wedge).get("status") == "working"
  2384. or False, timeout=60, what="wedge victim working") is not None,
  2385. "wedge victim reaches working")
  2386. fake.hold_state = True
  2387. fake.freeze_activity = True
  2388. check(wait_for(lambda: any("saddle wedged" in c["body"]
  2389. for c in task_state(auth, wedge).get("comments", []))
  2390. or False, timeout=40, what="wedge comment") is not None,
  2391. "stale activity wedges the saddle with a bot comment")
  2392. check(saddle_card("fake-saddle")["status"] == "recovering",
  2393. "wedged saddle is fenced")
  2394. card = saddle_card("fake-saddle")
  2395. check(card.get("health") == "fenced",
  2396. "health badge reports fenced")
  2397. fake.hold_state = False
  2398. fake.freeze_activity = False
  2399. # The fake's scripted lifecycle parks on 'done' when released
  2400. # mid-working; a real daemon lands idle after its round.
  2401. fake.state = "idle"
  2402. check(wait_for(lambda: saddle_card("fake-saddle")["health"] in ("healthy", "stale")
  2403. or False, timeout=40, what="health recovers") is not None,
  2404. "health badge recovers with the fence")
  2405. check(wait_task_done(auth, wedge, timeout=90) is not None,
  2406. "wedged task requeues and completes after recovery")
  2407. print("== R8: raw-log ingestion is lossless ==")
  2408. # Dynamic scripted ring: empty at seed (the pre-push probe
  2409. # baselines at next=1), then the daemon "produces" the corpus on
  2410. # /start — seq 1 coloured prompt run, 2 plain, 3 a bare CRLF
  2411. # between prompts, 4..6 following. Every non-empty served chunk
  2412. # must land, exactly once.
  2413. corpus = [
  2414. {"seq": 1, "text": "\x1b[01;32muser@host\x1b[00m:\x1b[01;34m~/task-1\x1b[00m$ "},
  2415. {"seq": 2, "text": "echo hello\r\n"},
  2416. {"seq": 3, "text": "\r\n"},
  2417. {"seq": 4, "text": "hello\r\n"},
  2418. {"seq": 5, "text": "\x1b[01;32muser@host\x1b[00m:\x1b[01;34m~/task-1\x1b[00m$ "},
  2419. {"seq": 6, "text": "done\r\n"},
  2420. ]
  2421. fake.scripted_corpus = corpus
  2422. fake.scripted_log = []
  2423. fake.scripted_next = 1
  2424. fake.state = "working"
  2425. fake.hold_state = True
  2426. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  2427. "project_id": "1",
  2428. "title": "Lossless log task",
  2429. "priority": "2",
  2430. })
  2431. lossless = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  2432. if t["title"] == "Lossless log task"][0])
  2433. def stored_seqs(task_id):
  2434. conn = db()
  2435. try:
  2436. return [r[0] for r in conn.execute(
  2437. "select seq from session_raw_log where session_id in"
  2438. " (select id from sessions where task_id = ?)"
  2439. " order by seq", (task_id,))]
  2440. finally:
  2441. conn.close()
  2442. check(wait_for(lambda: task_state(auth, lossless).get("status") == "working"
  2443. or False, timeout=60, what="lossless task working") is not None,
  2444. "lossless task dispatches with a pre-push probe on an empty ring")
  2445. check(wait_for(lambda: stored_seqs(lossless) == [1, 2, 3, 4, 5, 6] or False,
  2446. timeout=30, what="all six chunks stored") is not None,
  2447. "every served chunk lands in session_raw_log, CRLF-only included (%s)"
  2448. % stored_seqs(lossless))
  2449. time.sleep(7)
  2450. check(stored_seqs(lossless) == [1, 2, 3, 4, 5, 6],
  2451. "no duplicates or losses on later polls")
  2452. request = urllib.request.Request(
  2453. WEB + "/terminal-stream/%d" % [r[0] for r in db().execute(
  2454. "select id from sessions where task_id = ?", (lossless,))][0])
  2455. with urllib.request.urlopen(request, timeout=15) as stream:
  2456. texts = []
  2457. deadline = time.monotonic() + 8
  2458. while time.monotonic() < deadline and len(texts) < 6:
  2459. line = stream.readline().decode().strip()
  2460. if not line.startswith("data: ") or line == "data: ping":
  2461. continue
  2462. event = json.loads(line[6:])
  2463. if event.get("type") == "raw":
  2464. texts.append(event.get("text") or "")
  2465. check("\r\n" in texts and len(texts) == 6,
  2466. "SSE serves the whitespace chunk unfiltered")
  2467. fake.hold_state = False
  2468. fake.state = "idle"
  2469. fake.scripted_log = None
  2470. fake.scripted_corpus = None
  2471. check(wait_task_done(auth, lossless, timeout=60) is not None,
  2472. "lossless task completes")
  2473. print("== R9 B1/B2: git identity + ssh key ==")
  2474. # The key travels on /status into the saddle row and the page view.
  2475. fake.ssh_public_key = "ssh-ed25519 AAAAC3NzaC1lZDI1teStKey saddle-1@fedora"
  2476. fake.state = "idle"
  2477. fake_card = saddle_card("fake-saddle")
  2478. check(fake_card.get("ssh_public_key", "") == "",
  2479. "no key shown before the daemon reports one")
  2480. act(auth, fake_card["refresh"], {})
  2481. check(wait_for(lambda: "AAAAC3NzaC1lZDI1teStKey" in
  2482. saddle_card("fake-saddle").get("ssh_public_key", "") or False,
  2483. timeout=30, what="key lands on the row") is not None,
  2484. "reported ssh_public_key persists onto the saddle row")
  2485. conn = db()
  2486. row_key = conn.execute("select ssh_public_key from saddles where name = 'fake-saddle'"
  2487. ).fetchone()[0]
  2488. conn.close()
  2489. check(row_key == fake.ssh_public_key,
  2490. "the key is stored verbatim in the database")
  2491. # Identity edit flows through the UI into the /start payload.
  2492. fake_card = saddle_card("fake-saddle")
  2493. act(auth, fake_card["update_identity"],
  2494. {"git_name": "clanker-bot", "git_email": "bot@unitatem.net"})
  2495. fake_card = saddle_card("fake-saddle")
  2496. check(fake_card.get("git_name") == "clanker-bot"
  2497. and fake_card.get("git_email") == "bot@unitatem.net",
  2498. "inline edit saves the git identity")
  2499. pushes_before = len(fake.start_payloads)
  2500. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  2501. "project_id": "1",
  2502. "title": "Identity task",
  2503. "priority": "2",
  2504. })
  2505. identity_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  2506. if t["title"] == "Identity task"][0])
  2507. check(wait_for(lambda: any(int(p.get("task", {}).get("id") or 0) == identity_task
  2508. for p in fake.start_payloads) or False,
  2509. timeout=60, what="identity task pushed") is not None,
  2510. "identity task dispatches")
  2511. payload = [p for p in fake.start_payloads
  2512. if int(p.get("task", {}).get("id") or 0) == identity_task][-1]
  2513. identity = payload.get("git_identity") or {}
  2514. check(identity.get("name") == "clanker-bot"
  2515. and identity.get("email") == "bot@unitatem.net",
  2516. "/start carries the saddle's git_identity")
  2517. # Earlier pushes (identity unset) carry no git_identity member.
  2518. check(all("git_identity" not in p for p in fake.start_payloads[:-1]),
  2519. "unset identity omits the member (daemon fallback applies)")
  2520. # Clearing the identity omits it from later pushes.
  2521. fake_card = saddle_card("fake-saddle")
  2522. act(auth, fake_card["update_identity"], {"git_name": "", "git_email": ""})
  2523. check(saddle_card("fake-saddle").get("git_name", "") == "",
  2524. "clearing the identity empties the row")
  2525. check(wait_task_done(auth, identity_task, timeout=60) is not None,
  2526. "identity task completes")
  2527. print("== R10: branch_advanced + gap forensics ==")
  2528. # --- branch_advanced on the /poll verdict channel ---
  2529. fake.state = "working"
  2530. fake.hold_state = True
  2531. conn = db()
  2532. conn.execute("insert into tasks (project_id, title, status, priority,"
  2533. " created_at) values (1, 'Poll grandparent', 'working', 5,"
  2534. " strftime('%s','now','-300 seconds'))")
  2535. poll_grand = conn.execute("select max(id) from tasks").fetchone()[0]
  2536. conn.execute("insert into tasks (project_id, title, status, priority,"
  2537. " parent_task_id, created_at) values (1, 'Poll answer parent',"
  2538. " 'blocked', 5, ?, strftime('%s','now','-120 seconds'))",
  2539. (poll_grand,))
  2540. poll_parent = conn.execute("select max(id) from tasks").fetchone()[0]
  2541. conn.execute("insert into sessions (task_id, saddle_id, started_at)"
  2542. " values (?, ?, strftime('%s','now','-120 seconds'))",
  2543. (poll_parent, fake_id))
  2544. poll_session = conn.execute("select max(id) from sessions").fetchone()[0]
  2545. conn.execute("insert into task_blocks (task_id, question, type, answer,"
  2546. " answered_at, created_at) values (?, 'Which DB?', 'question',"
  2547. " 'SQLite', strftime('%s','now','-60 seconds'),"
  2548. " strftime('%s','now','-100 seconds'))", (poll_parent,))
  2549. conn.execute("update saddles set status = 'busy', current_task_id = ?,"
  2550. " allocated_at = strftime('%s','now') where name = 'fake-saddle'",
  2551. (poll_parent,))
  2552. conn.commit()
  2553. conn.close()
  2554. status, body = saddle_api("GET", "/api/saddle/%d/poll" % fake_id, fake_token)
  2555. check(status == 200 and len(body.get("answers", [])) == 1
  2556. and body["answers"][0].get("branch_advanced") is False,
  2557. "/poll verdicts carry branch_advanced=false with no siblings")
  2558. # A sibling under the same parent completed after the park marker.
  2559. conn = db()
  2560. conn.execute("insert into tasks (project_id, title, status, priority,"
  2561. " parent_task_id, summary, completed_at, created_at)"
  2562. " values (1, 'Poll sibling', 'done', 5, ?, 'sibling finished',"
  2563. " strftime('%s','now'), strftime('%s','now','-200 seconds'))",
  2564. (poll_grand,))
  2565. conn.execute("insert into task_blocks (task_id, question, type, answer,"
  2566. " answered_at, created_at) values (?, 'Second question?',"
  2567. " 'question', 'Yes', strftime('%s','now'),"
  2568. " strftime('%s','now','-90 seconds'))", (poll_parent,))
  2569. conn.commit()
  2570. conn.close()
  2571. status, body = saddle_api("GET", "/api/saddle/%d/poll" % fake_id, fake_token)
  2572. check(status == 200 and len(body.get("answers", [])) == 1
  2573. and body["answers"][0].get("branch_advanced") is True,
  2574. "sibling completion after park flips branch_advanced to true")
  2575. # Release the staged task: cancel it so the held-working poll loop
  2576. # does not carry it into the race subsection.
  2577. act(auth, task_state(auth, poll_parent)["cancel"], {})
  2578. fake.hold_state = False
  2579. fake.state = "idle"
  2580. # --- the /log snapshot race loses nothing ---
  2581. fake.state = "working"
  2582. fake.hold_state = True
  2583. fake.scripted_corpus = [
  2584. {"seq": 1, "text": "prompt-one\r\n"},
  2585. {"seq": 2, "text": "echo a\r\n"},
  2586. {"seq": 3, "text": "a\r\n"},
  2587. {"seq": 4, "text": "prompt-two\r\n"},
  2588. ]
  2589. fake.scripted_log = []
  2590. fake.scripted_next = 1
  2591. fake.race_texts = ["raced-five\r\n", "raced-six\r\n"]
  2592. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  2593. "project_id": "1",
  2594. "title": "Race forensics task",
  2595. "priority": "2",
  2596. })
  2597. race_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  2598. if t["title"] == "Race forensics task"][0])
  2599. def race_seqs():
  2600. conn = db()
  2601. try:
  2602. return [r[0] for r in conn.execute(
  2603. "select seq from session_raw_log where session_id in"
  2604. " (select id from sessions where task_id = ?)"
  2605. " order by seq", (race_task,))]
  2606. finally:
  2607. conn.close()
  2608. check(wait_for(lambda: race_seqs() == [1, 2, 3, 4, 5, 6] or False,
  2609. timeout=40, what="race chunks all stored") is not None,
  2610. "the snapshot race loses nothing: complete 1..6, no gaps (%s)"
  2611. % race_seqs())
  2612. time.sleep(7)
  2613. check(race_seqs() == [1, 2, 3, 4, 5, 6],
  2614. "race chunks stored exactly once")
  2615. fake.hold_state = False
  2616. fake.state = "idle"
  2617. fake.scripted_log = None
  2618. fake.scripted_corpus = None
  2619. check(wait_task_done(auth, race_task, timeout=60) is not None,
  2620. "race forensics task completes")
  2621. print("== R11: live blocks arrive without a reload ==")
  2622. # The browser flow on each pulse: statum.act(task.refresh) — the
  2623. # sealed GET action must rehydrate the task slot with the new
  2624. # block. (The channel chain itself — entrypoint subscribe on
  2625. # load, SSE set events on bump — was verified end-to-end against
  2626. # a real channel client during the investigation.)
  2627. fake.state = "working"
  2628. fake.hold_state = True
  2629. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  2630. "project_id": "1",
  2631. "title": "Live block task",
  2632. "priority": "2",
  2633. })
  2634. live_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  2635. if t["title"] == "Live block task"][0])
  2636. check(wait_for(lambda: task_state(auth, live_task).get("status") == "working"
  2637. or False, timeout=60, what="live task working") is not None,
  2638. "live-block task reaches working")
  2639. conn = db()
  2640. live_session = conn.execute(
  2641. "select id from sessions where task_id = ?", (live_task,)).fetchone()[0]
  2642. conn.close()
  2643. status, _ = saddle_api("POST", "/api/saddle/%d/update" % fake_id, fake_token,
  2644. {"type": "block", "session_id": live_session, "data": {
  2645. "question": "Pick one", "type": "question",
  2646. "options": ["A", "B"]}})
  2647. check(status == 200, "block raised via the daemon")
  2648. check(wait_for(lambda: any(b.get("question") == "Pick one"
  2649. for b in task_state(auth, live_task).get("blocks", []))
  2650. or False, timeout=30, what="block lands in state") is not None,
  2651. "the raised block reaches the task state")
  2652. detail = task_state(auth, live_task)
  2653. check(detail.get("refresh") is not None,
  2654. "the sealed GET refresh action is still authored")
  2655. status, _ = http("GET", detail["refresh"]["uri"], timeout=15)
  2656. check(status in (200, 401),
  2657. "the refresh URI resolves as a GET (client roundtrip shape)")
  2658. act(auth, detail["answer_action"] if "answer_action" in detail
  2659. else detail["blocks"][0]["answer_action"], {"answer": "A"})
  2660. fake.hold_state = False
  2661. fake.state = "idle"
  2662. check(wait_task_done(auth, live_task, timeout=60) is not None,
  2663. "live-block task completes after answering")
  2664. # The daemon vanishing mid-task: three refused polls → offline,
  2665. # task requeued with a bot comment, UI reflects.
  2666. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  2667. "project_id": "1",
  2668. "title": "Vanish victim",
  2669. "priority": "2",
  2670. })
  2671. vanish = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  2672. if t["title"] == "Vanish victim"][0])
  2673. check(wait_for(lambda: task_state(auth, vanish).get("status") == "working"
  2674. or False, timeout=60, what="vanish victim working") is not None,
  2675. "vanish victim reaches working")
  2676. fake.kill()
  2677. check(wait_for(lambda: saddle_card("fake-saddle")["status"] == "offline" or False,
  2678. timeout=60, what="daemon death → offline") is not None,
  2679. "vanishing daemon drives the saddle offline")
  2680. check(wait_for(lambda: any("unreachable" in c["body"]
  2681. for c in task_state(auth, vanish).get("comments", []))
  2682. or False, timeout=30, what="unreachable comment") is not None,
  2683. "the affected task gets an unreachable bot comment")
  2684. check(task_state(auth, vanish)["status"] == "queued",
  2685. "the task requeues after the daemon death")
  2686. card = saddle_card("fake-saddle")
  2687. check(card.get("health") == "offline",
  2688. "health badge reports offline")
  2689. # The unreachable row flag marks the detection window: a task the
  2690. # web still sees working while its saddle is offline/fenced, before
  2691. # the requeue pass moves it. Controlled window (the real
  2692. # transitions above are atomic with their requeues).
  2693. conn = db()
  2694. conn.execute("insert into tasks (project_id, title, status, priority,"
  2695. " created_at) values (1, 'Unreachable row', 'working', 5,"
  2696. " strftime('%s','now'))")
  2697. unreachable_row = conn.execute("select max(id) from tasks").fetchone()[0]
  2698. conn.execute("insert into sessions (task_id, saddle_id, started_at)"
  2699. " values (?, ?, strftime('%s','now'))", (unreachable_row, fake_id))
  2700. conn.commit()
  2701. conn.close()
  2702. row = [t for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  2703. if int(t["id"]) == unreachable_row][0]
  2704. check(row.get("unreachable") is True,
  2705. "queue row shows the unreachable state in the window")
  2706. print("== phase 1: startup migrates the per-saddle token ==")
  2707. # Saddles registered before the flip hold legacy api_tokens rows.
  2708. # On startup every saddle row gets its canonical token: the
  2709. # legacy value it already holds (daemons keep their credential)
  2710. # or a freshly minted one.
  2711. conn = db()
  2712. conn.execute("insert into api_tokens (saddle_id, token, created_at)"
  2713. " values (?, 'saddle-legacy-copied-token', strftime('%s','now'))",
  2714. (int(racer_cards[fake2]["id"]),))
  2715. conn.execute("update saddles set token = null where name = 'racer-two'")
  2716. conn.execute("insert into saddles (name, host, status, current_task_id)"
  2717. " values ('tokenless-orphan', '127.0.0.1:1', 'offline', 0)")
  2718. conn.commit()
  2719. conn.close()
  2720. app.terminate()
  2721. app.wait(timeout=10)
  2722. app = start_app()
  2723. auth = login("admin", "adminpass123")
  2724. def saddle_row(name):
  2725. conn = db()
  2726. try:
  2727. return conn.execute("select id, token from saddles where name = ?",
  2728. (name,)).fetchone()
  2729. finally:
  2730. conn.close()
  2731. check(wait_for(lambda: saddle_row("tokenless-orphan") is not None
  2732. and (saddle_row("tokenless-orphan")[1] or "").startswith("saddle-")
  2733. or False, timeout=20, what="tokenless row minted") is not None,
  2734. "startup mints a token for a saddle row without one")
  2735. check(wait_for(lambda: saddle_row("racer-two") is not None
  2736. and saddle_row("racer-two")[1] == "saddle-legacy-copied-token"
  2737. or False, timeout=20, what="legacy token copied") is not None,
  2738. "startup copies the legacy api_tokens value onto the row")
  2739. fake2.token = "saddle-legacy-copied-token" # the daemon asks with it
  2740. status, _ = saddle_api("POST", "/api/saddle/next-task", "saddle-legacy-copied-token")
  2741. check(status in (200, 204),
  2742. "the legacy token still authenticates (copied, not replaced)")
  2743. conn = db()
  2744. conn.execute("delete from saddles where name = 'tokenless-orphan'")
  2745. conn.commit()
  2746. conn.close()
  2747. # The restart wiped the in-memory ask bookkeeping; the fleet's
  2748. # daemons re-establish sleeping with a fresh ask each (the real
  2749. # daemons' idle-awake ask / 15-minute safety self-ask).
  2750. for daemon in (fake2, fake3):
  2751. daemon.ask_now()
  2752. check(True, "fleet re-asks after the restart (sleeping re-established)")
  2753. finally:
  2754. # Exit-code diagnostic: distinguishes a crashed app (negative
  2755. # returncode, e.g. -11 SIGSEGV) from a clean shutdown. When
  2756. # tearing down mid-exception, also capture the still-alive
  2757. # app's stacks before killing it (a wedged-but-alive server
  2758. # otherwise leaves no evidence).
  2759. print("(app returncode at teardown: %s)" % app.poll())
  2760. if app.poll() is None and sys.exc_info()[0] is not None:
  2761. dump_app_stacks(app.pid)
  2762. app.terminate()
  2763. try:
  2764. app.wait(timeout=5)
  2765. except subprocess.TimeoutExpired:
  2766. app.kill()
  2767. print("%d passed, %d failed" % (passes, failures))
  2768. return 1 if failures else 0
  2769. if __name__ == "__main__":
  2770. sys.exit(main())