harness.py 65 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474
  1. #!/usr/bin/env python3
  2. """The web-stack test harness (plan 16 R5, arch 18): everything the
  3. scenario bundles share, plus the runner (sequential by default,
  4. `--parallel [N]` for capped parallel shards).
  5. Layout: test/harness.py (this file — the FakeSaddle daemon, the
  6. http/act/login/slot_public wire helpers, wait_for/quiet_window/db/check,
  7. the per-shard fixtures via start_bundle/Ctx, and run_bundle +
  8. run_all_parallel); test/bundles/<name>.py (one module per bundle, its
  9. run(ctx) driving that shard's scenarios); test/web_stack_test.py (the
  10. thin entry shim meson invokes; it also stays directly runnable for the
  11. per-bundle shard processes the parallel runner spawns).
  12. Unlike integration_test.py (which needs a real saddle daemon and a mock
  13. LLM), this suite spawns everything it needs:
  14. - the clanker-saddle app binary (path from argv[1], or builddir default)
  15. in a scratch directory with its own web-config.json (statum keys copied
  16. from the repo, plus `saddle_callback_base_url`) and SQLite database
  17. - in-process fake saddle daemons speaking the wire contract: POST
  18. /register (plan 10: receives {saddle_token, web_base_url}, answers
  19. {ok, ssh_public_key, version}), POST /wake (records the wake; the
  20. daemon then asks POST /api/saddle/next-task), POST /task-update
  21. (cancel/comment/subtask-status), GET /status (scripted reflection —
  22. exceptional use only), and the phase-2 push channels home:
  23. POST /api/saddle/chunks (raw PTY batches) and POST /api/saddle/results
  24. (round entries + state transitions). The retired /start, /output and
  25. /log routes 404 like a post-phase-2 daemon.
  26. It then drives the app the way statum.js does (entrypoint loads + action
  27. POSTs) and asserts the Workstream B features against the wire contract:
  28. 3. plan-review blocks hide their duplicate body preview
  29. 5. rules CRUD/attach + the effective `rules` array in assignments
  30. 6. project notes, POST /api/saddle/note, `project_notes` + `callback`
  31. in assignments
  32. plus the raw-log side: pushed chunks -> session_raw_log + terminal
  33. raw view
  34. Plan 10 phase 1 (work acquisition flip):
  35. - per-saddle tokens: registration mints + /register-delivers them,
  36. the row is the single credential store (the api_tokens fallback
  37. died with arch 24), removal revokes
  38. - token auth on every /api/saddle/* endpoint
  39. - next-task allocation: concurrent asks race exactly one winner,
  40. affinity pinning for parked tasks, resume_notice on suspended resumes
  41. - dispatcher: wake -> ask -> allocate roundtrip, the lost-wake guard
  42. (no ask after a wake -> offline, re-wake the next capable), and the
  43. pre-flip /answer + /start resume paths kept as the rollback route
  44. Plan 10 phase 2 (telemetry flip):
  45. - pushed chunks: idempotent on (session_id, seq), ordered, landing in
  46. session_raw_log so the SSE terminal keeps working
  47. - pushed results: entries store like the /output path did (redelivery
  48. idempotent on the daemon entry ids), blocked raises the block row +
  49. parks + bumps the pulse (sealed refresh shows it), done/failed
  50. complete/fail the task, suspended parks for the affinity resume
  51. - answer resumes, both daemon shapes: a SLEEPING affine saddle (its
  52. post-park ask returned 204) is woken, its ask allocating the resume
  53. with the verdict riding the assignment; a daemon parked MID-ROUND
  54. (its round holds the block's answer wait open, so it can never ask)
  55. gets the verdict delivered into that wait via POST /answer — same
  56. session, no wake. A daemon dying with a parked-with-answered task
  57. never requeues it: the verdict and session survive for the resume
  58. - staleness: a silent runner gets ONE exceptional GET /status probe —
  59. alive keeps it running, dead requeues the task with an agent comment and
  60. marks the saddle offline; a recovered daemon's ask flips offline rows
  61. back to idle
  62. - the deleted layers are gone: /output, /log and /start 404 on the
  63. daemon side, and nothing on the web side calls them
  64. Plan 10 phase 3 (capability awareness + UI):
  65. - assignment capability context: {saddle_tags, fleet_tags} on every
  66. payload, GET /api/saddle/capabilities (per-saddle token) feeding
  67. the daemon's `saddle capabilities` CLI
  68. - subtask callback capabilities: explicit-only attachment validated
  69. against the operator tag set, unknown tags rejected with the valid
  70. list in the error
  71. - task page: status block pinned to the top, blocking sections
  72. newest-first (sealed GET state)
  73. - home dashboard: pinned OPEN blocks as newest-first link-through
  74. cards (no inline answer forms) above the cross-task activity
  75. timeline (last ~50 events newest-first)
  76. - lost-wake-guard regression: a saddle that asks SLOWLY after its
  77. wake (busy winding down) still claims the task — the wake is never
  78. silently dropped, no re-wake storm fires, and the never-asks guard
  79. path (offline + re-wake of the next capable) stays green
  80. Prints PASS/FAIL lines Statum-style; exits non-zero on any failure.
  81. Plan 11 task D+E (UI + notifications), the `push` shard:
  82. - task page: progress feed above the status card, status + description
  83. in a 50/50 grid, edit expander under the history expander, plan
  84. behind an expander, answered blocks behind an expander; queue screen:
  85. New Task expander above the filters; dashboard waiting-on-drafts
  86. block with links to where Enqueue lives (pages shard)
  87. - real Web Push over VAPID: keypair generated at startup into
  88. web-config.json, GET /sw.js service worker with click-through,
  89. subscribe/unsubscribe + per-task Notify me actions, block events and
  90. waiting-on-drafts pushed globally, first start/failure/completion to
  91. opted-in users, best-effort queue with dispatcher-tick retry, 410/404
  92. endpoint pruning — payloads verified protocol-level (RFC 8291
  93. decryption + ES256 JWT signature) because the harness owns the
  94. browser-side keys
  95. Heredoc completion reports (pages shard):
  96. - terminal tasks render tasks.summary (set by done/fail) as a
  97. markdown completion-report card below the status/description grid —
  98. "Completion report" when done, "Failure summary" when failed, no
  99. card when empty; the summary renders through the shipped
  100. stm-markdown renderer (headings/code, no syntax leaks, HTML
  101. escaped) and the dashboard timeline snippets its first line
  102. Speed pass (plan 11, task 0):
  103. - SHARDING: the scenario groups are partitioned into seven independent
  104. bundles (pages, dispatch, subtasks, lineage, allocation, lifecycle,
  105. answers), each running in its own process against its own app
  106. instance, fake daemons and SQLite database. The default invocation
  107. runs the bundles SEQUENTIALLY, in-process, one after another — the
  108. unbounded parallel fan-out (one app + fake daemons per shard, all at
  109. once) OOM'd the machine. `--bundle NAME` runs one shard for
  110. debugging; `--parallel [N]` opts back into parallel shards, at most N
  111. at a time (default 4).
  112. - Within a bundle, scenario groups SHARE the app+daemon instance and
  113. carry per-group state as database rows (task/session/block
  114. fixtures), never process restarts; the one deliberate restart (the
  115. startup token migration) lives in the answers bundle.
  116. - EVENT-DRIVEN WAITS: wait_for polls at 50ms; the fixed sleeps were
  117. converted either to observable-condition waits (daemon-side
  118. counters, e.g. failed-delivery hits) or to quiet_window() — a
  119. bounded negative-assertion poll that FAILS LOUDLY the moment the
  120. event it guards against happens (a sleep could silently pass).
  121. - ENV KNOBS: SADDLE_DISPATCH_TICK_S=1 (dispatcher cadence; default 5
  122. in production), SADDLE_HTTP_TIMEOUT_S=2, SADDLE_LOST_WAKE_S=2,
  123. SADDLE_STALE_S=3, plus the fake daemon's scripted timings — the
  124. wire-traffic shapes stay realistic, just faster.
  125. """
  126. import hashlib
  127. import json
  128. import os
  129. import re
  130. import shutil
  131. import socket
  132. import sqlite3
  133. import subprocess
  134. import sys
  135. import threading
  136. import time
  137. import urllib.error
  138. import urllib.parse
  139. import urllib.request
  140. from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
  141. # Web Push protocol verification (plan 11 task E): the test harness
  142. # owns the browser-side keys, so it can do everything the push service
  143. # does — decrypt the RFC 8291 body and verify the ES256 VAPID JWT.
  144. import base64
  145. from cryptography.hazmat.primitives import hashes
  146. from cryptography.hazmat.primitives.asymmetric import ec
  147. from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
  148. from cryptography.hazmat.primitives.ciphers.aead import AESGCM
  149. from cryptography.hazmat.primitives.kdf.hkdf import HKDF
  150. from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
  151. REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  152. APP = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else os.path.join(REPO, "builddir", "clanker-saddle")
  153. # --- speed-pass tuning -----------------------------------------------------
  154. # The fake daemon's scripted timings and the dispatch-tick cadence the
  155. # quiet windows are sized against. All overridable for debugging.
  156. TICK_S = float(os.environ.get("SADDLE_DISPATCH_TICK_S", "1") or 1)
  157. FAKE_WORK_S = float(os.environ.get("WEBSTACK_FAKE_WORK_S", "1.2") or 1.2)
  158. FAKE_FAIL_S = float(os.environ.get("WEBSTACK_FAKE_FAIL_S", "0.5") or 0.5)
  159. FAKE_HEARTBEAT_S = float(os.environ.get("WEBSTACK_FAKE_HEARTBEAT_S", "0.4") or 0.4)
  160. FAKE_TRIGGER_S = float(os.environ.get("WEBSTACK_FAKE_TRIGGER_S", "0.05") or 0.05)
  161. SHARD_TIMEOUT_S = float(os.environ.get("WEBSTACK_SHARD_TIMEOUT_S", "600") or 600)
  162. def quiet_seconds():
  163. """A negative-assertion window spanning several dispatch ticks (the
  164. converted fixed sleeps were 3-7s at the old 5s tick; the bound never
  165. exceeds the old timeout)."""
  166. return min(2 * TICK_S + 0.8, 7.0)
  167. WORK = os.environ.get("WEBSTACK_WORK", "/tmp/kilo/webstack-e2e")
  168. DB_PATH = os.path.join(WORK, "web.sqlite")
  169. passes = 0
  170. failures = 0
  171. # The current shard's fixture context (goal_state reads its auth — the
  172. # restart scenarios re-login and re-point ctx.auth mid-run)
  173. CTX = None
  174. def check(condition, label):
  175. global passes, failures
  176. if condition:
  177. passes += 1
  178. print("PASS %s" % label)
  179. else:
  180. failures += 1
  181. print("FAIL %s" % label)
  182. def free_port():
  183. with socket.socket() as sock:
  184. sock.bind(("127.0.0.1", 0))
  185. return sock.getsockname()[1]
  186. APP_PORT = free_port()
  187. DAEMON_PORT = free_port()
  188. WEB = "http://127.0.0.1:%d" % APP_PORT
  189. def bundle_env(name):
  190. """Point the module's runtime globals at one shard's scratch dir,
  191. database and ports (each bundle is a process with its own app,
  192. daemons and SQLite file)."""
  193. global WORK, DB_PATH, APP_PORT, DAEMON_PORT, WEB
  194. base = os.environ.get("WEBSTACK_WORK", "/tmp/kilo/webstack-e2e")
  195. WORK = "%s-%s" % (base, name)
  196. DB_PATH = os.path.join(WORK, "web.sqlite")
  197. APP_PORT = free_port()
  198. DAEMON_PORT = free_port()
  199. WEB = "http://127.0.0.1:%d" % APP_PORT
  200. def http(method, url, data=None, headers=None, timeout=30):
  201. body = None
  202. if data is not None:
  203. pairs = []
  204. for k, v in data.items():
  205. for item in (v if isinstance(v, (list, tuple)) else [v]):
  206. pairs.append((k, item))
  207. body = "&".join("%s=%s" % (k, urllib.parse.quote(str(item))) for k, item in pairs).encode()
  208. request = urllib.request.Request(WEB + url if url.startswith("/") else url,
  209. data=body, method=method)
  210. if data is not None:
  211. request.add_header("Content-Type", "application/x-www-form-urlencoded")
  212. for key, value in (headers or {}).items():
  213. request.add_header(key, value)
  214. # Retry dropped/refused connections: the freshly started app can
  215. # close a very early keep-alive connection once while warming up,
  216. # and its first seconds can wobble under load.
  217. for attempt in range(6):
  218. try:
  219. with urllib.request.urlopen(request, timeout=timeout) as response:
  220. return response.status, response.read().decode()
  221. except urllib.error.HTTPError as error:
  222. return error.code, error.read().decode()
  223. except (urllib.error.URLError, OSError):
  224. if attempt >= 5:
  225. raise
  226. time.sleep(0.75)
  227. def directives(uri, auth=None):
  228. headers = {}
  229. if auth:
  230. headers["X-Statum-Slot"] = "key=%s; as_at=%s" % (auth["key"], auth["as_at"])
  231. status, body = http("GET", "/_statum/entrypoint?uri=" + urllib.parse.quote(uri.split("?")[0]),
  232. headers=headers)
  233. if status != 200:
  234. raise SystemExit("entrypoint %s -> HTTP %d: %s" % (uri, status, body[:300]))
  235. return json.loads(body)
  236. def slot_public(auth, uri, type_name):
  237. for directive in directives(uri, auth):
  238. if directive["type"] == "set":
  239. content = directive["snapshot_frame"].get("content")
  240. if not content:
  241. continue
  242. content = json.loads(content)
  243. if content["slot"]["type"] == type_name:
  244. return content["public"]
  245. raise SystemExit("no %s slot in entrypoint state for %s" % (type_name, uri))
  246. def act(auth, action, fields):
  247. headers = {"X-Statum-Slot": "key=%s; as_at=%s" % (auth["key"], auth["as_at"])}
  248. if action.get("private"):
  249. headers["X-Statum-Private"] = action["private"]
  250. status, body = http(action.get("method", "POST"), action["uri"], fields, headers)
  251. if status != 200:
  252. raise SystemExit("action %s -> HTTP %d: %s" % (action["uri"], status, body[:300]))
  253. return json.loads(body)
  254. def login(username, password):
  255. frames = directives("/login")
  256. content = json.loads(frames[0]["snapshot_frame"]["content"])
  257. action = content["public"]["login"]
  258. status, body = http(action.get("method", "POST"), action["uri"],
  259. {"username": username, "password": password})
  260. if status != 200:
  261. raise SystemExit("login -> HTTP %d: %s" % (status, body[:300]))
  262. for directive in json.loads(body):
  263. if directive["type"] == "set":
  264. frame = json.loads(directive["snapshot_frame"]["content"])
  265. if frame["slot"]["type"] == "auth":
  266. return {"key": frame["slot"]["key"], "as_at": frame["as_at"]}
  267. raise SystemExit("login response carried no auth slot: " + body[:300])
  268. def wait_for(predicate, timeout=60, what="condition", interval=0.05):
  269. deadline = time.monotonic() + timeout
  270. last = None
  271. while time.monotonic() < deadline:
  272. last = predicate()
  273. if last is not False and last is not None:
  274. return last
  275. time.sleep(interval)
  276. print(" (timeout waiting for %s; last: %s)" % (what, last))
  277. dump_threads()
  278. return None
  279. def quiet_window(seconds, predicate, what):
  280. """A converted fixed sleep: asserts a NEGATIVE condition (nothing may
  281. happen) across a window spanning several dispatch ticks, polling at
  282. 50ms. The moment the condition breaks the window fails loudly
  283. (diagnostics + thread dump) instead of silently passing a sleep."""
  284. deadline = time.monotonic() + seconds
  285. while time.monotonic() < deadline:
  286. value = predicate()
  287. if value is False or value is None:
  288. remaining = deadline - time.monotonic()
  289. print(" (quiet window '%s' broken after %.2fs of %.2fs — the"
  290. " guarded event DID happen)" % (what, seconds - remaining, seconds))
  291. dump_threads()
  292. return False
  293. time.sleep(0.05)
  294. return True
  295. def dump_threads():
  296. """Stacks of suite threads not parked in sleep — pinpoints a wedged
  297. fake lifecycle when a wait times out."""
  298. import traceback
  299. shown = 0
  300. for thread_id, frame in sys._current_frames().items():
  301. stack = "".join(traceback.format_stack(frame)[-4:])
  302. if (("harness.py" in stack or "bundles" in stack
  303. or "web_stack_test.py" in stack) and ("sleep" not in stack)
  304. and shown < 6):
  305. shown += 1
  306. print(" --- thread %s ---\n%s" % (thread_id, stack))
  307. def task_state(auth, task_id):
  308. return slot_public(auth, "/tasks/%d" % task_id, "task")
  309. def wait_task_done(auth, task_id, timeout=90):
  310. return wait_for(lambda: task_state(auth, task_id).get("status") == "done" or False,
  311. timeout=timeout, what="task %d -> done" % task_id)
  312. def task_id_of(title):
  313. conn = db()
  314. try:
  315. return conn.execute("select id from tasks where title = ?", (title,)).fetchone()[0]
  316. finally:
  317. conn.close()
  318. def enqueue_task(auth, task_id):
  319. """The human-only Enqueue action (plan 11): draft -> queued."""
  320. detail = task_state(auth, task_id)
  321. assert detail["status"] == "draft", "task %d not in draft (%s)" % (task_id, detail["status"])
  322. act(auth, detail["enqueue"], {})
  323. return task_state(auth, task_id)["status"]
  324. def create_queued(auth, fields):
  325. """Create via the queue form (lands in draft) and enqueue it — the
  326. standard 'this task should dispatch' fixture of the plan-11 world."""
  327. act(auth, slot_public(auth, "/tasks", "tasks")["create"], fields)
  328. task_id = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  329. if t["title"] == fields["title"]][0])
  330. enqueue_task(auth, task_id)
  331. return task_id
  332. def saddle_api(method, path, token, payload=None):
  333. """Raw call against the saddle ingest API; returns (status, json)."""
  334. request = urllib.request.Request(WEB + path,
  335. data=json.dumps(payload or {}).encode(), method=method)
  336. request.add_header("Content-Type", "application/json")
  337. if token is not None:
  338. request.add_header("Authorization", "Bearer " + token)
  339. try:
  340. with urllib.request.urlopen(request, timeout=15) as response:
  341. return response.status, json.loads(response.read().decode() or "{}")
  342. except urllib.error.HTTPError as error:
  343. body = error.read().decode()
  344. try:
  345. return error.code, json.loads(body)
  346. except ValueError:
  347. return error.code, {"raw": body}
  348. def db():
  349. return sqlite3.connect(DB_PATH)
  350. def http_get_page(auth, uri):
  351. """The compiled page HTML for a statum route (auth via the slot
  352. header), for asserting page-level embeds the slot state cannot
  353. see."""
  354. status, body = http("GET", uri, headers={
  355. "X-Statum-Slot": "key=%s; as_at=%s" % (auth["key"], auth["as_at"])})
  356. if status != 200:
  357. raise SystemExit("GET %s -> HTTP %d" % (uri, status))
  358. return body
  359. def goal_state(goal_id):
  360. """The goal slot for the CURRENT shard's admin login (the x7
  361. per-bundle duplicates collapsed here, plan 16 R5). Reads the
  362. runner's fixture context — the restart scenarios re-login and
  363. re-point ctx.auth mid-run, and late binding follows."""
  364. return slot_public(CTX.auth, "/goals/%d" % goal_id, "goal")
  365. def saddle_card(auth, name):
  366. rows = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  367. if s["name"] == name]
  368. return rows[0]
  369. def queued_count():
  370. conn = db()
  371. try:
  372. return conn.execute(
  373. "select count(*) from tasks where status = 'queued'").fetchone()[0]
  374. finally:
  375. conn.close()
  376. def command_rows(session):
  377. conn = db()
  378. try:
  379. return conn.execute(
  380. "select kind, payload from events"
  381. " where session_id = ? order by id", (session,)).fetchall()
  382. finally:
  383. conn.close()
  384. def event_rows(task_id, kinds=None):
  385. """The task's ledger rows (kind, payload JSON), oldest first."""
  386. conn = db()
  387. try:
  388. if kinds:
  389. marks = ",".join("?" for _ in kinds)
  390. return conn.execute(
  391. "select kind, payload from events"
  392. " where scope_type = 'task' and scope_id = ? and kind in (%s)"
  393. " order by id" % marks, (task_id,) + tuple(kinds)).fetchall()
  394. return conn.execute(
  395. "select kind, payload from events"
  396. " where scope_type = 'task' and scope_id = ? order by id",
  397. (task_id,)).fetchall()
  398. finally:
  399. conn.close()
  400. class FakeSaddle:
  401. """In-process saddle daemon implementing the wire contract.
  402. Phase-2 push behaviour: the daemon asks POST /api/saddle/next-task
  403. whenever idle-awake (at registration, on POST /wake, after its task
  404. reaches a terminal state, and when a /status probe observes it
  405. parked idle — throttled), accepts assignments like POST /start used
  406. to, then PUSHES its telemetry home: raw PTY chunks via POST
  407. /api/saddle/chunks and round results with state transitions via
  408. POST /api/saddle/results (working at round start, terminal states
  409. when the scripted lifecycle ends them). /output, /log and /start
  410. are gone (404) like a post-phase-2 daemon; /task-update serves the
  411. cancel/comment/subtask-status kinds; POST /answer receives a
  412. delivered verdict when the round is parked in a block's answer
  413. wait (409 when nothing waits — the real daemon's contract; the
  414. resumed round continues after the response). Every trigger can be
  415. disabled per test for determinism; ask_now() performs a
  416. synchronous manual ask.
  417. """
  418. PROMPT = "bbarrow@fedora:/workspace/task-1$ "
  419. def __init__(self):
  420. self.lock = threading.Lock()
  421. self.start_payloads = [] # captured assignment payloads (/start + next-task 200s)
  422. self.state = "idle"
  423. self.summary = ""
  424. self.state_entered = time.monotonic()
  425. self.fail_next = False # next task drives the task to failed
  426. self.start_counts = {} # task id -> assignment count
  427. self.resumed_pending = False # crash-resume flag on the next /status
  428. self.suspend_on_title = None # this task (by title) parks as suspended
  429. self.output_seq = 100 # daemon-global entry ids for pushed results
  430. self.last_activity_epoch = int(time.time())
  431. self.hang_next = {} # path suffix -> hang count (never responds)
  432. self.abort_failures = 0 # cancel task-updates failing (500) while > 0
  433. self.abort_log = [] # task ids aborted via task-update cancel
  434. self.running_task = 0 # task id the daemon believes it runs
  435. self.running_title = "" # its title (suspend targeting)
  436. self.session_id = None # the web-side run row of the running task
  437. self.hold_state = False # freeze lifecycle + heartbeat pushes
  438. self.ssh_public_key = "" # reported on /status + /register
  439. self.workspace_root = "/workspace" # goal 8 task 6: reported on /register
  440. self.started_mono = time.monotonic()
  441. self.freeze_activity = False # hold last_activity_epoch still
  442. self.silent = False # suppress pushes (staleness tests)
  443. self.dead = False # kill(): no serving AND no calls home
  444. self.status_hits = 0 # GET /status arrivals (probe accounting)
  445. self.comment_log = [] # recorded /task-update comment payloads
  446. self.scripted_corpus = None # chunk batch pushed when a task starts
  447. # --- plan 10 phase 1: ask/wake/register ---
  448. self.token = None # per-saddle token from POST /register
  449. self.register_log = [] # recorded /register bodies
  450. self.wake_log = [] # recorded POST /wake arrivals
  451. self.asks = [] # [{"status": int, "payload": dict|None}]
  452. self.ask_on_register = True
  453. self.ask_on_wake = True
  454. self.ask_on_terminal = True
  455. self.ask_on_idle_status = True
  456. self.ask_delay = 0.0 # seconds; a slow daemon delays its wake-triggered ask
  457. self.serve_register = True # False: 404 /register (pre-flip daemon)
  458. self.last_ask_trigger = 0.0
  459. self.port = None
  460. self.ask_threads = [] # scheduled ask-trigger threads (settle_asks joins them)
  461. # --- answer delivery (the parked-session resume) ---
  462. self.pending_block = None # the block payload this daemon waits on (state blocked)
  463. self.answer_log = [] # recorded POST /answer bodies
  464. self.answer_push_delay = 0.2 # the resumed round's first push, after the response
  465. # --- plan 12: goal-agent scripting ---
  466. self.goal_script = [] # behaviours consumed per goal-run assignment
  467. self.goal_behaviour = None # the current round's popped behaviour
  468. self.goal_assignments = [] # captured goal-run assignment payloads
  469. def set_state(self, state, summary=None):
  470. """Advances the scripted lifecycle."""
  471. self.state = state
  472. if summary is not None:
  473. self.summary = summary
  474. self.state_entered = time.monotonic()
  475. def touch_activity(self):
  476. if not self.freeze_activity:
  477. self.last_activity_epoch = int(time.time())
  478. def queue_output(self, entry_type, command, output):
  479. """Pushes one command_output event home (the daemon's entry log
  480. now rides the events channel; the old results-entries path is
  481. gone)."""
  482. self.push_events([{"kind": "command_output",
  483. "payload": {"command": command, "text": output}}])
  484. # --- plan 10 phase 2: the push channels ---
  485. def push_events(self, events):
  486. """Pushes a batch of ledger events home (plan 17 §7.2): each
  487. element a {seq?, kind, actor?, ts?, payload?} dict; a missing
  488. seq is assigned from the per-session monotonic counter.
  489. Pushes are suppressed while silent/hold_state (the staleness
  490. tests' silent runner)."""
  491. if self.dead or self.silent or self.token is None or self.session_id is None:
  492. return
  493. batch = []
  494. with self.lock:
  495. for event in events:
  496. item = dict(event)
  497. item["seq"] = int(item.pop("seq", 0) or self.next_push_seq())
  498. item.setdefault("kind", "progress")
  499. item.setdefault("actor", "model")
  500. item.setdefault("payload", {})
  501. batch.append(item)
  502. try:
  503. saddle_api("POST", "/api/saddle/chunks", self.token,
  504. {"task_id": self.running_task,
  505. "session_id": self.session_id, "events": batch})
  506. except (OSError, ValueError):
  507. pass # app restarting / shutting down mid-test
  508. def push_chunks(self, chunks):
  509. """Legacy-shaped helper: raw texts (or {seq, text} dicts, a
  510. redelivery) push as progress events carrying the text."""
  511. batch = []
  512. for chunk in chunks:
  513. if isinstance(chunk, dict):
  514. batch.append({"seq": chunk.get("seq"),
  515. "kind": chunk.get("kind", "progress"),
  516. "payload": chunk.get("payload",
  517. {"text": chunk.get("text", "")})})
  518. else:
  519. batch.append({"kind": "progress", "payload": {"text": chunk}})
  520. self.push_events(batch)
  521. def next_push_seq(self):
  522. # The daemon-side per-session event counter for ad-hoc pushes.
  523. self._push_seq = getattr(self, "_push_seq", 0) + 1
  524. return self._push_seq
  525. def push_results(self, state, entries=None, block=None, summary=None, session_id=None):
  526. """Pushes one round-results payload home (state transition plus
  527. its entries). Returns the HTTP status when it went out, None
  528. when suppressed."""
  529. if self.dead or self.silent:
  530. return None
  531. if self.token is None:
  532. return None
  533. target = session_id if session_id is not None else self.session_id
  534. if target is None:
  535. return None
  536. payload = {"session_id": target, "state": state}
  537. if block is not None:
  538. payload["block"] = block
  539. if state == "blocked":
  540. # The daemon parks its round on this block's answer wait
  541. # (the real daemon's wait_for_answer).
  542. self.pending_block = dict(block)
  543. if summary is not None:
  544. payload["summary"] = summary
  545. try:
  546. status, _ = saddle_api("POST", "/api/saddle/results", self.token, payload)
  547. if status != 200:
  548. print(" [fake %s] push %s -> HTTP %s (task=%s)"
  549. % (self.port, state, status, self.running_task))
  550. return status
  551. except (OSError, ValueError) as error:
  552. print(" [fake %s] push %s failed: %r (task=%s)"
  553. % (self.port, state, error, self.running_task))
  554. return None
  555. # --- plan 10 phase 1: the ask path ---
  556. def ask_now(self):
  557. """Synchronously asks the web for work (a phase-1 daemon's idle
  558. ask). A 200 assignment is accepted exactly like POST /start;
  559. returns (status, payload|None)."""
  560. token = self.token
  561. if token is None:
  562. raise SystemExit("fake daemon asked before receiving its token")
  563. status, payload = saddle_api("POST", "/api/saddle/next-task", token)
  564. with self.lock:
  565. self.asks.append({"status": status, "payload": payload})
  566. if status == 200:
  567. self.accept_assignment(payload)
  568. return status, payload
  569. def _trigger_ask(self, reason):
  570. """Schedules a delayed ask (the daemon woke / went idle-awake).
  571. ask_delay models a SLOW asker — a daemon still winding down its
  572. round when the wake lands: it honours the wake, but its ask
  573. arrives late (still inside the web's lost-wake guard window)."""
  574. def run():
  575. if self.state in ("working", "failing"):
  576. return # a running daemon does not ask
  577. if self.ask_delay > 0:
  578. time.sleep(self.ask_delay)
  579. for attempt in range(3):
  580. try:
  581. status, _ = self.ask_now()
  582. if status in (200, 204):
  583. return
  584. print(" (ask %s -> HTTP %s, attempt %d)" % (reason, status, attempt + 1))
  585. except Exception as error: # app restarting / blips: retry
  586. print(" (ask %s failed: %r, attempt %d)" % (reason, error, attempt + 1))
  587. time.sleep(1.0)
  588. def starter():
  589. time.sleep(FAKE_TRIGGER_S)
  590. run()
  591. thread = threading.Thread(target=starter, daemon=True)
  592. with self.lock:
  593. self.ask_threads.append(thread)
  594. thread.start()
  595. def settle_asks(self, timeout=15):
  596. """Joins every ask-trigger thread scheduled so far — the
  597. event-driven replacement for the fixed sleeps that waited for
  598. in-flight asks to land after triggers were frozen."""
  599. with self.lock:
  600. threads = [t for t in self.ask_threads if t.is_alive()]
  601. deadline = time.monotonic() + timeout
  602. for thread in threads:
  603. thread.join(timeout=max(0.1, deadline - time.monotonic()))
  604. with self.lock:
  605. self.ask_threads = [t for t in self.ask_threads if t.is_alive()]
  606. return all(not thread.is_alive() for thread in threads)
  607. def maybe_trigger_ask(self, reason, throttle=False):
  608. """Schedules an idle-awake ask. Protocol events (wake, abort,
  609. round end, park) ask singularly and immediately; only the
  610. /status-observation path throttles (a burst of probes must not
  611. hammer the ask endpoint)."""
  612. now = time.monotonic()
  613. if throttle and now - self.last_ask_trigger < 1.0:
  614. return
  615. self.last_ask_trigger = now
  616. self._trigger_ask(reason)
  617. def accept_assignment(self, payload):
  618. """A 200 next-task assignment: adopted exactly like POST /start."""
  619. self.start_payloads.append(payload)
  620. task = payload.get("task") or {}
  621. task_id = task.get("id")
  622. self.running_task = int(task_id or 0)
  623. self.running_title = task.get("title") or ""
  624. self.session_id = payload.get("session_id")
  625. # The assignment's answers resolve any block this daemon was
  626. # parked on (resume-carries-answer).
  627. self.pending_block = None
  628. count = self.start_counts.get(task_id, 0) + 1
  629. self.start_counts[task_id] = count
  630. if count > 1:
  631. # Crash-resume contract: re-pushing a task whose
  632. # workspace still exists restores it; the first
  633. # /status after the restore reports "resumed": true.
  634. self.resumed_pending = True
  635. # Plan 12: a goal-run assignment plays its scripted behaviour
  636. # instead of the plain done push (plan submission, parking,
  637. # question, completion, failure — consumed one per round).
  638. self.goal_behaviour = None
  639. if payload.get("kind") == "goal_run":
  640. self.goal_assignments.append(payload)
  641. self.goal_behaviour = self.goal_script.pop(0) if self.goal_script else ("suspend",)
  642. self.set_state("failing" if self.fail_next else "working")
  643. self.fail_next = False
  644. # The round's opening telemetry: the §7.2 event batch (turn 1:
  645. # lifecycle, turn text, tool call + result, usage) plus the
  646. # working state push. A new session restarts the per-session
  647. # seq counter; a scripted corpus supplements the batch.
  648. self._push_seq = 0
  649. opening = [
  650. {"kind": "lifecycle",
  651. "actor": "harness",
  652. "payload": {"action": "start", "text": "round started"}},
  653. {"kind": "turn_text",
  654. "payload": {"turn": 1, "text": "Starting the assigned task."}},
  655. {"kind": "tool_call",
  656. "payload": {"turn": 1, "tool": "run_command",
  657. "args": {"command": "echo hello"}}},
  658. {"kind": "tool_result",
  659. "actor": "harness",
  660. "payload": {"turn": 1, "stdout": "hello\n", "exit_code": 0}},
  661. {"kind": "usage",
  662. "actor": "harness",
  663. "payload": {"turn": 1, "input_tokens": 120, "output_tokens": 30}},
  664. ]
  665. if self.scripted_corpus is not None:
  666. opening = [dict(chunk) for chunk in self.scripted_corpus]
  667. self.push_events(opening)
  668. self.push_results("working")
  669. class Handler(BaseHTTPRequestHandler):
  670. daemon = None # set on the class before serving
  671. protocol_version = "HTTP/1.1"
  672. def log_message(self, fmt, *args):
  673. pass
  674. def maybe_hang(self, path):
  675. """True when this request should hang forever (never respond),
  676. consuming one unit of the path's hang budget."""
  677. for suffix, count in list(self.daemon.hang_next.items()):
  678. if count > 0 and path.startswith(suffix):
  679. self.daemon.hang_next[suffix] = count - 1
  680. return True
  681. return False
  682. def _send(self, status, obj):
  683. body = json.dumps(obj).encode()
  684. self.send_response(status)
  685. self.send_header("Content-Type", "application/json")
  686. self.send_header("Content-Length", str(len(body)))
  687. self.end_headers()
  688. self.wfile.write(body)
  689. def do_POST(self):
  690. if self.maybe_hang(self.path):
  691. time.sleep(600)
  692. return
  693. self.daemon.touch_activity()
  694. length = int(self.headers.get("Content-Length") or 0)
  695. raw = self.rfile.read(length).decode() if length else "{}"
  696. if self.path == "/register":
  697. # Plan 10: the web delivers the per-saddle token + its
  698. # own base URL; the daemon persists the token for its
  699. # calls home and reports its ssh key + version.
  700. if not self.daemon.serve_register:
  701. self._send(404, {"error": "no such route"})
  702. return
  703. try:
  704. payload = json.loads(raw)
  705. except ValueError:
  706. payload = {"raw": raw}
  707. self.daemon.register_log.append(payload)
  708. if payload.get("saddle_token"):
  709. self.daemon.token = payload["saddle_token"]
  710. self._send(200, {"ok": True,
  711. "ssh_public_key": self.daemon.ssh_public_key,
  712. "version": "0.1-fake",
  713. "workspace_root": self.daemon.workspace_root})
  714. if self.daemon.ask_on_register:
  715. self.daemon.maybe_trigger_ask("register")
  716. return
  717. if self.path == "/wake":
  718. # Plan 10: a capable task is ready; a sleeping daemon
  719. # asks for work (immediately — a wake is a direct
  720. # command, never throttled).
  721. self.daemon.wake_log.append(time.monotonic())
  722. self._send(200, {"ok": True})
  723. if self.daemon.ask_on_wake:
  724. self.daemon.maybe_trigger_ask("wake")
  725. return
  726. if self.path == "/answer":
  727. # Plan 11 (daemon Task A): parks FREE the daemon, so no
  728. # block ever waits in-process — the verdict travels in
  729. # the resuming allocation's resume_notice. Always 409,
  730. # exactly like the real daemon's contract.
  731. try:
  732. payload = json.loads(raw)
  733. except ValueError:
  734. self._send(400, {"error": "bad json"})
  735. return
  736. self.daemon.answer_log.append({
  737. "refused": True,
  738. "answer": payload.get("answer", "")})
  739. self._send(409, {"error": "no block is waiting for an answer — parks resume via allocation"})
  740. return
  741. if self.path == "/task-update":
  742. # Plan 10: cancel (abort machinery) and comment
  743. # (injected turn) — delivered directly to a RUNNING
  744. # daemon, no auth.
  745. try:
  746. payload = json.loads(raw)
  747. except ValueError:
  748. self._send(400, {"error": "bad json"})
  749. return
  750. kind = payload.get("kind")
  751. if kind == "cancel":
  752. if self.daemon.abort_failures > 0:
  753. self.daemon.abort_failures -= 1
  754. self._send(500, {"error": "cancel failed (simulated)"})
  755. return
  756. aborted = self.daemon.running_task
  757. self.daemon.set_state("idle")
  758. self.daemon.summary = ""
  759. self.daemon.running_task = 0
  760. self.daemon.session_id = None
  761. self.daemon.abort_log.append(aborted)
  762. self._send(200, {"ok": True, "aborted_task_id": aborted})
  763. # An aborted daemon returns to its idle loop: it asks.
  764. self.daemon.maybe_trigger_ask("aborted")
  765. elif kind == "comment":
  766. self.daemon.comment_log.append(payload)
  767. self._send(200, {"ok": True})
  768. elif kind == "answer":
  769. # Goal 28 task F (busy-answer delivery): the web
  770. # injects a block verdict into the RUNNING round —
  771. # a human answered while this session was already
  772. # running, so no allocation resume can deliver it.
  773. self.daemon.answer_log.append({
  774. "injected": True,
  775. "text": payload.get("text", ""),
  776. "task_id": payload.get("task_id")})
  777. self._send(200, {"ok": True})
  778. else:
  779. self._send(400, {"error": "unknown task-update kind"})
  780. return
  781. self._send(404, {"error": "no such route"})
  782. self.close_connection = True
  783. def do_GET(self):
  784. if self.maybe_hang(self.path):
  785. time.sleep(600)
  786. return
  787. if self.path != "/status":
  788. # /status itself is not daemon activity: the liveness
  789. # signal must reflect loop rounds / command completions.
  790. self.daemon.touch_activity()
  791. if self.path == "/status":
  792. self.daemon.status_hits += 1
  793. response = {"state": self.daemon.state, "summary": self.daemon.summary,
  794. "uptime_seconds": int(time.monotonic() - self.daemon.started_mono),
  795. "last_activity_epoch": self.daemon.last_activity_epoch,
  796. "ssh_public_key": self.daemon.ssh_public_key,
  797. # The wire contract's task confirmation (the
  798. # probe-recovery flip): a running daemon names
  799. # the task it runs; an idle one reports null.
  800. # Without it every answered probe looks like a
  801. # crash orphan and requeues the task.
  802. "task_id": self.daemon.running_task or None}
  803. if getattr(self.daemon, "status_extra", None):
  804. response.update(self.daemon.status_extra)
  805. if self.daemon.resumed_pending:
  806. response["resumed"] = True
  807. self.daemon.resumed_pending = False
  808. # A parked/terminal daemon is idle-awake: a phase-1
  809. # daemon asks (and sleeps on 204). Throttled so status
  810. # poll bursts do not hammer the ask endpoint.
  811. if self.daemon.ask_on_idle_status and self.daemon.state in (
  812. "idle", "done", "failed", "suspended"):
  813. self.daemon.maybe_trigger_ask("idle-status", throttle=True)
  814. self._send(200, response)
  815. else:
  816. self._send(404, {"error": "no such route"})
  817. self.close_connection = True
  818. def run_lifecycle(self):
  819. """Applies the scripted lifecycle on a timer (like a real daemon
  820. whose task ends on its own): working heartbeats chunks (the
  821. flush timer's stand-in, keeping the staleness clock fed), works
  822. for FAKE_WORK_S, then pushes done/suspended; failing pushes
  823. failed after FAKE_FAIL_S — and a task END makes the idle-awake
  824. daemon ask again."""
  825. while True:
  826. time.sleep(0.1)
  827. if self.hold_state or self.silent:
  828. # hold_state freezes the script; silent models a daemon
  829. # mid-round with nothing to flush — its round has not
  830. # ended, so no terminal push fires while quiet.
  831. continue
  832. if self.state == "failing":
  833. if time.monotonic() - self.state_entered >= FAKE_FAIL_S:
  834. self.set_state("failed", "bash -n rejected the script (simulated)")
  835. self.push_results("failed",
  836. summary="bash -n rejected the script (simulated)")
  837. if self.ask_on_terminal:
  838. self.maybe_trigger_ask("terminal")
  839. elif self.state == "working":
  840. if time.monotonic() - self.state_entered >= FAKE_WORK_S:
  841. if self.goal_behaviour is not None:
  842. # A goal-agent round: play the scripted
  843. # behaviour (the results channel carries the
  844. # goal-context verbs).
  845. behaviour, self.goal_behaviour = self.goal_behaviour, None
  846. kind = behaviour[0]
  847. if kind == "plan":
  848. self.set_state("blocked")
  849. self.push_results("blocked", block={
  850. "type": "plan",
  851. "question": "Plan submitted for review",
  852. "body": behaviour[1]})
  853. if self.ask_on_idle_status:
  854. self.maybe_trigger_ask("parked")
  855. elif kind == "question":
  856. self.set_state("blocked")
  857. self.push_results("blocked", block={
  858. "type": "question",
  859. "question": behaviour[1],
  860. "options": list(behaviour[2])
  861. if len(behaviour) > 2 else None})
  862. if self.ask_on_idle_status:
  863. self.maybe_trigger_ask("parked")
  864. elif kind == "suspend":
  865. self.set_state("suspended")
  866. self.push_results("suspended")
  867. if self.ask_on_idle_status:
  868. self.maybe_trigger_ask("parked")
  869. elif kind == "fail":
  870. self.set_state("failed", behaviour[1])
  871. self.push_results("failed", summary=behaviour[1])
  872. if self.ask_on_terminal:
  873. self.maybe_trigger_ask("terminal")
  874. else: # "done": the completion report
  875. self.set_state("done", behaviour[1])
  876. self.push_results("done", summary=behaviour[1])
  877. if self.ask_on_terminal:
  878. self.maybe_trigger_ask("terminal")
  879. elif self.suspend_on_title and self.suspend_on_title == self.running_title:
  880. # `saddle suspend`: park until a fresh assignment
  881. # resumes the workspace (title-targeted one-shot,
  882. # so an interleaved dispatch cannot eat the flag).
  883. self.set_state("suspended")
  884. self.suspend_on_title = None
  885. self.push_results("suspended")
  886. # A parked daemon is idle-awake: it asks (and
  887. # sleeps on 204) so a later wake can reach it.
  888. if self.ask_on_idle_status:
  889. self.maybe_trigger_ask("parked")
  890. else:
  891. self.set_state("done", "fake daemon finished")
  892. self.push_results("done", summary="fake daemon finished")
  893. if self.ask_on_terminal:
  894. self.maybe_trigger_ask("terminal")
  895. elif time.monotonic() - getattr(self, "last_heartbeat", 0) >= FAKE_HEARTBEAT_S:
  896. # The flush timer: output keeps flowing, so chunks
  897. # keep arriving (the staleness window never opens on
  898. # a healthy runner).
  899. self.last_heartbeat = time.monotonic()
  900. self.push_chunks(["working-heartbeat\r\n"])
  901. def serve(self, port=None):
  902. """Binds the daemon's HTTP server. A requested port that is
  903. already taken (free_port() hands out recyclable ports — two
  904. calls can collide) falls back to an OS-assigned one instead of
  905. silently dying and impersonating whatever owns that port."""
  906. if port is None:
  907. port = DAEMON_PORT
  908. handler = type("BoundHandler", (FakeSaddle.Handler,), {"daemon": self})
  909. self.server = None
  910. try:
  911. self.server = ThreadingHTTPServer(("127.0.0.1", port), handler)
  912. except OSError:
  913. print("(fake daemon: port %d taken, falling back)" % port)
  914. self.server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
  915. self.port = self.server.server_address[1]
  916. threading.Thread(target=self.server.serve_forever, daemon=True).start()
  917. threading.Thread(target=self.run_lifecycle, daemon=True).start()
  918. return self.server
  919. def kill(self):
  920. """The daemon dies: it stops listening (connections refused) AND
  921. stops calling home — no pushes, no asks (a dead process has no
  922. outbound anything; in-flight handler threads may linger but the
  923. wire contract's traffic is one-directional now)."""
  924. self.dead = True
  925. if getattr(self, "server", None) is not None:
  926. self.server.shutdown()
  927. self.server.server_close()
  928. self.server = None
  929. def start_app():
  930. config = json.load(open(os.path.join(REPO, "web-config.json")))
  931. config["saddle_callback_base_url"] = WEB
  932. with open(os.path.join(WORK, "web-config.json"), "w") as handle:
  933. json.dump(config, handle)
  934. env = dict(os.environ)
  935. env.update({
  936. "SPRY_DB_PATH": DB_PATH,
  937. "SPRY_SEED_ADMIN_USERNAME": "admin",
  938. "SPRY_SEED_ADMIN_PASSWORD": "adminpass123",
  939. "SADDLE_LLM_BASE_URL": "http://127.0.0.1:%d" % DAEMON_PORT,
  940. "SADDLE_PROJECTS_ROOT": os.path.join(WORK, "workspace"),
  941. "SADDLE_HTTP_TIMEOUT_S": "2",
  942. "SADDLE_LOST_WAKE_S": "2",
  943. "SADDLE_STALE_S": "3",
  944. "SADDLE_DISPATCH_TICK_S": "1",
  945. # Plan 12: the goal-agent resume coalescing window shrinks to
  946. # one second so the event-driven resume tests settle quickly.
  947. "SADDLE_GOAL_RESUME_S": os.environ.get("SADDLE_GOAL_RESUME_S", "1"),
  948. # Never inherit a caller-poisoned LD_LIBRARY_PATH (e.g. one that
  949. # resolves the ancient /lib64 copies): empty makes the loader use
  950. # the binary's RUNPATH, which is the dev stack it was built
  951. # against.
  952. "LD_LIBRARY_PATH": "",
  953. })
  954. log_previous = os.path.join(WORK, "app.log.1")
  955. if os.path.exists(os.path.join(WORK, "app.log")):
  956. shutil.move(os.path.join(WORK, "app.log"), log_previous)
  957. log = open(os.path.join(WORK, "app.log"), "w")
  958. command = [APP, str(APP_PORT)]
  959. if os.environ.get("WEBSTACK_GDB"):
  960. # Debugging aid: run the app under gdb; a crash backtrace lands in
  961. # app.log. WEBSTACK_GDB=1 meson test ... to enable.
  962. command = ["gdb", "-batch", "-quiet",
  963. "-ex", "handle SIGPIPE nostop noprint pass",
  964. "-ex", "run", "-ex", "bt", "--args", APP, str(APP_PORT)]
  965. process = subprocess.Popen(command, cwd=WORK, env=env,
  966. stdout=log, stderr=subprocess.STDOUT)
  967. # The ready probe must require OUR process alive: a free_port()
  968. # TOCTOU can hand this port to another shard's app — ours then dies
  969. # at bind (MHD error()) while the probe happily answers from the
  970. # intruder (same admin seed), and every check would silently drive
  971. # the WRONG app. poll() != None with a serving port is exactly that
  972. # shape: take the exit branch, the start_bundle retry re-ports.
  973. outcome = wait_for(lambda: (app_ready() and process.poll() is None and "ready")
  974. or (process.poll() is not None and "app-exited-%d" % process.poll())
  975. or False, timeout=30, what="app ready")
  976. if outcome is None:
  977. raise SystemExit("app did not come up; see %s/app.log" % WORK)
  978. if str(outcome).startswith("app-exited-"):
  979. raise SystemExit("app exited during startup (%s); see %s/app.log"
  980. % outcome, WORK)
  981. return process
  982. def app_ready():
  983. try:
  984. status, _ = http("GET", "/login", timeout=3)
  985. return status == 200
  986. except (urllib.error.URLError, OSError):
  987. return False
  988. def dump_app_stacks(pid):
  989. """Best-effort thread backtrace of the (possibly wedged) app."""
  990. try:
  991. fds = os.listdir("/proc/%d/fd" % pid)
  992. print("(app open fds at dump: %d)" % len(fds))
  993. kinds = {}
  994. for fd in fds[:1200]:
  995. try:
  996. target = os.readlink("/proc/%d/fd/%s" % (pid, fd))
  997. except OSError:
  998. continue
  999. key = target.split(":", 1)[0] if ":" in target else target
  1000. kinds[key] = kinds.get(key, 0) + 1
  1001. print("(fd kinds: %s)" % sorted(kinds.items(), key=lambda kv: -kv[1])[:6])
  1002. except OSError:
  1003. pass
  1004. for command in (["eu-stack", "-p", str(pid)],
  1005. ["gdb", "-batch", "-quiet", "-p", str(pid),
  1006. "-ex", "thread apply all bt"]):
  1007. try:
  1008. out = subprocess.run(command, capture_output=True, timeout=60)
  1009. text = out.stdout.decode(errors="replace") + out.stderr.decode(errors="replace")
  1010. if text.strip():
  1011. print("(app stacks via %s)\n%s" % (command[0], text[:12000]))
  1012. return
  1013. except (OSError, subprocess.SubprocessError):
  1014. continue
  1015. # =============================================================================
  1016. # Fake push service + Web Push protocol helpers (plan 11 task E)
  1017. # =============================================================================
  1018. def b64url(raw):
  1019. return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
  1020. def b64url_dec(text):
  1021. return base64.urlsafe_b64decode(text + "=" * ((4 - len(text) % 4) % 4))
  1022. class FakePushService:
  1023. """In-process Web Push endpoint server: every subscription the tests
  1024. register points at a path here (a distinct "browser endpoint").
  1025. Responses are scriptable per path — a fixed status (the 410-prune
  1026. fixture) or N transient 500s (the retry-next-tick fixture)."""
  1027. def __init__(self):
  1028. self.requests = [] # [{"path", "headers", "body", "at"}]
  1029. self.fail_next = {} # path -> remaining 500s
  1030. self.status_for = {} # path -> fixed status
  1031. self.lock = threading.Lock()
  1032. self.port = None
  1033. self.server = None
  1034. def serve(self):
  1035. service = self
  1036. class Handler(BaseHTTPRequestHandler):
  1037. protocol_version = "HTTP/1.1"
  1038. def log_message(self, fmt, *args):
  1039. pass
  1040. def do_POST(self):
  1041. length = int(self.headers.get("Content-Length") or 0)
  1042. body = self.rfile.read(length)
  1043. status = 201
  1044. if self.path in service.status_for:
  1045. status = service.status_for[self.path]
  1046. elif service.fail_next.get(self.path, 0) > 0:
  1047. service.fail_next[self.path] -= 1
  1048. status = 500
  1049. with service.lock:
  1050. service.requests.append({
  1051. "path": self.path,
  1052. "headers": {key.lower(): value
  1053. for key, value in self.headers.items()},
  1054. "body": body,
  1055. "at": time.monotonic(),
  1056. })
  1057. payload = b"{}"
  1058. self.send_response(status)
  1059. self.send_header("Content-Type", "application/json")
  1060. self.send_header("Content-Length", str(len(payload)))
  1061. self.end_headers()
  1062. self.wfile.write(payload)
  1063. self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
  1064. self.port = self.server.server_address[1]
  1065. threading.Thread(target=self.server.serve_forever, daemon=True).start()
  1066. return self.server
  1067. def url(self, path):
  1068. return "http://127.0.0.1:%d%s" % (self.port, path)
  1069. def hits(self, prefix):
  1070. with self.lock:
  1071. return [request for request in self.requests
  1072. if request["path"].startswith(prefix)]
  1073. def decrypt_webpush(request, client_private_key, client_public, auth_secret):
  1074. """RFC 8291 decryption of one captured push: returns
  1075. (record_size, payload dict) — the payload builder's exact output."""
  1076. body = request["body"]
  1077. salt = body[:16]
  1078. record_size = int.from_bytes(body[16:20], "big")
  1079. key_id_len = body[20]
  1080. eph_public = body[21:21 + key_id_len]
  1081. ciphertext = body[21 + key_id_len:]
  1082. eph_point = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(),
  1083. eph_public)
  1084. shared = client_private_key.exchange(ec.ECDH(), eph_point)
  1085. info = b"WebPush: info\x00" + eph_public + client_public
  1086. cek = HKDF(algorithm=hashes.SHA256(), length=16, salt=salt,
  1087. info=info).derive(shared + auth_secret)
  1088. nonce = HKDF(algorithm=hashes.SHA256(), length=12, salt=salt,
  1089. info=info).derive(shared + auth_secret)
  1090. plaintext = AESGCM(cek).decrypt(nonce, ciphertext, None)
  1091. return record_size, json.loads(plaintext[:-1]) # strip the 0x02 delimiter
  1092. def verify_vapid(request, vapid_public, vapid_private):
  1093. """Verifies the ES256 VAPID JWT on one captured push; returns its
  1094. claims dict (aud/exp/sub) — raises on a bad signature."""
  1095. header = request["headers"].get("authorization", "")
  1096. assert header.startswith("vapid t="), "no vapid authorization header"
  1097. token_part = header[len("vapid t="):].split(",")[0]
  1098. sent_key = header.split("k=")[1]
  1099. head_b64, claims_b64, sig_b64 = token_part.split(".")
  1100. signature = b64url_dec(sig_b64)
  1101. key = ec.derive_private_key(
  1102. int.from_bytes(b64url_dec(vapid_private), "big"), ec.SECP256R1())
  1103. key.public_key().verify(
  1104. encode_dss_signature(int.from_bytes(signature[:32], "big"),
  1105. int.from_bytes(signature[32:], "big")),
  1106. (head_b64 + "." + claims_b64).encode(),
  1107. ec.ECDSA(hashes.SHA256()))
  1108. claims = json.loads(b64url_dec(claims_b64))
  1109. assert sent_key == vapid_public, "the k= key is not the app's VAPID key"
  1110. return claims
  1111. def action_notify(result):
  1112. """The message of the first notify directive an action returned."""
  1113. for directive in result:
  1114. if directive.get("type") == "notify":
  1115. return directive.get("message", "")
  1116. return None
  1117. # =============================================================================
  1118. # Shard orchestration
  1119. # =============================================================================
  1120. class Ctx:
  1121. """Per-bundle shared fixtures: the app process, the admin auth slot
  1122. and the primary fake saddle (registered + token delivered)."""
  1123. def start_bundle(name):
  1124. """Boots one shard's environment: scratch dir, database, ports, the
  1125. primary fake daemon and the app, then the shared fixtures every
  1126. bundle builds on (the demo project + the registered fake saddle).
  1127. Scenario groups inside the bundle share this app+daemon instance
  1128. and sequence their state as DB rows, never process restarts."""
  1129. global APP_PORT, WEB, DAEMON_PORT
  1130. bundle_env(name)
  1131. fake = FakeSaddle()
  1132. fake.serve()
  1133. DAEMON_PORT = fake.port # the actually-bound port (fallback-safe)
  1134. # A rare framework-level crash (stack corruption in Statum slot
  1135. # signing during the FIRST login — shared-library codegen bug, moved
  1136. # twice already) kills the app before any test runs; a clean restart
  1137. # (with fresh ports) sidesteps it. Only tolerated while nothing has
  1138. # been checked yet.
  1139. auth = None
  1140. app = None
  1141. for attempt in range(3):
  1142. APP_PORT = free_port()
  1143. WEB = "http://127.0.0.1:%d" % APP_PORT
  1144. if os.path.exists(WORK):
  1145. shutil.rmtree(WORK)
  1146. os.makedirs(os.path.join(WORK, "workspace"))
  1147. try:
  1148. app = start_app()
  1149. auth = login("admin", "adminpass123")
  1150. break
  1151. except (SystemExit, OSError, urllib.error.URLError):
  1152. if passes > 0:
  1153. raise
  1154. if app is not None and app.poll() is None:
  1155. app.terminate()
  1156. try:
  1157. app.wait(timeout=5)
  1158. except subprocess.TimeoutExpired:
  1159. app.kill()
  1160. app = None
  1161. print("(app died before login; restarting cleanly, attempt %d)" % (attempt + 1))
  1162. if app is None or auth is None:
  1163. raise SystemExit("app never came up; see %s/app.log" % WORK)
  1164. state = slot_public(auth, "/projects", "projects")
  1165. act(auth, state["create"], {
  1166. "name": "demo",
  1167. "git_url": "https://example.com/demo.git",
  1168. "default_branch": "main",
  1169. })
  1170. saddles_state = slot_public(auth, "/saddles", "saddles")
  1171. act(auth, saddles_state["register"], {"name": "fake-saddle",
  1172. "host": "127.0.0.1:%d" % fake.port})
  1173. registered = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  1174. if s["name"] == "fake-saddle"]
  1175. if wait_for(lambda: len(fake.register_log) > 0 or False,
  1176. timeout=15, what="/register delivery") is None:
  1177. raise SystemExit("fake saddle never received /register")
  1178. ctx = Ctx()
  1179. ctx.name = name
  1180. ctx.app = app
  1181. ctx.auth = auth
  1182. ctx.fake = fake
  1183. ctx.fake_id = int(registered[0]["id"])
  1184. ctx.fake_token = registered[0]["token"]
  1185. return ctx
  1186. BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",
  1187. "allocation", "lifecycle", "answers", "answerwake", "push",
  1188. "goals", "replay", "goalui", "forcewake", "users", "stacks",
  1189. "notes", "resurrect", "origins", "parity", "understanding",
  1190. "discoveries", "knowledge_cycle", "advisor", "chatui",
  1191. "advisor_arc", "advisor_fix", "advisor_refine", "canon",
  1192. "canon_deploy")
  1193. def run_bundle(name):
  1194. """Runs one shard to completion; returns a failure count."""
  1195. global CTX
  1196. started = time.monotonic()
  1197. ctx = start_bundle(name)
  1198. CTX = ctx
  1199. import importlib
  1200. bundle = importlib.import_module("bundles." + name)
  1201. # Runtime globals the star-import captured at module load can be
  1202. # stale (the sequential debug path reuses modules across shards) —
  1203. # re-point them at this shard's environment. The shared helpers
  1204. # read them from harness globals already.
  1205. for runtime_name in ("WORK", "DB_PATH", "APP_PORT", "DAEMON_PORT", "WEB"):
  1206. setattr(bundle, runtime_name, globals()[runtime_name])
  1207. runner = bundle.run
  1208. try:
  1209. runner(ctx)
  1210. finally:
  1211. # Exit-code diagnostic: distinguishes a crashed app (negative
  1212. # returncode, e.g. -11 SIGSEGV) from a clean shutdown. When
  1213. # tearing down mid-exception, also capture the still-alive
  1214. # app's stacks before killing it (a wedged-but-alive server
  1215. # otherwise leaves no evidence).
  1216. print("(app returncode at teardown: %s)" % ctx.app.poll())
  1217. if ctx.app.poll() is None and sys.exc_info()[0] is not None:
  1218. dump_app_stacks(ctx.app.pid)
  1219. ctx.fake.kill()
  1220. try:
  1221. ctx.app.terminate()
  1222. try:
  1223. ctx.app.wait(timeout=5)
  1224. except subprocess.TimeoutExpired:
  1225. ctx.app.kill()
  1226. check(ctx.app.poll() in (0, -15),
  1227. "bundle %s app shut down cleanly (exit %s)"
  1228. % (name, ctx.app.poll()))
  1229. except OSError:
  1230. pass
  1231. print("%d passed, %d failed" % (passes, failures))
  1232. print("[bundle %s] %.1fs" % (name, time.monotonic() - started))
  1233. return failures
  1234. def run_all_parallel(limit):
  1235. """The explicit opt-in: one subprocess per bundle (each with its own
  1236. app, daemons and database), at most `limit` in flight at a time
  1237. (every shard is an app plus fake daemons — an unbounded fan-out
  1238. OOMs the machine), verdicts aggregated into one exit code and one
  1239. combined check-count summary."""
  1240. base = os.environ.get("WEBSTACK_WORK", "/tmp/kilo/webstack-e2e")
  1241. log_dir = os.path.join(os.path.dirname(base) or "/tmp/kilo",
  1242. "webstack-shards")
  1243. os.makedirs(log_dir, exist_ok=True)
  1244. started = time.monotonic()
  1245. limit = max(1, int(limit))
  1246. pending = list(BUNDLES_ORDER)
  1247. running = []
  1248. finished = []
  1249. while pending or running:
  1250. while pending and len(running) < limit:
  1251. name = pending.pop(0)
  1252. out_path = os.path.join(log_dir, "bundle-%s.log" % name)
  1253. handle = open(out_path, "w")
  1254. proc = subprocess.Popen(
  1255. [sys.executable, "-u",
  1256. os.path.join(os.path.dirname(os.path.abspath(__file__)),
  1257. "web_stack_test.py"), APP, "--bundle", name],
  1258. stdout=handle, stderr=subprocess.STDOUT)
  1259. running.append((name, proc, handle, out_path, time.monotonic()))
  1260. time.sleep(0.2)
  1261. still = []
  1262. for name, proc, handle, out_path, spawned in running:
  1263. if proc.poll() is None:
  1264. if time.monotonic() - spawned > SHARD_TIMEOUT_S:
  1265. proc.kill()
  1266. proc.wait()
  1267. else:
  1268. still.append((name, proc, handle, out_path, spawned))
  1269. continue
  1270. handle.close()
  1271. finished.append((name, proc, out_path))
  1272. running = still
  1273. total_pass = total_fail = 0
  1274. broken = []
  1275. by_name = {name: (proc, out_path) for name, proc, out_path in finished}
  1276. for name in BUNDLES_ORDER:
  1277. proc, out_path = by_name[name]
  1278. text = open(out_path).read()
  1279. match = re.search(r"(\d+) passed, (\d+) failed", text)
  1280. took = re.search(r"\[bundle %s\] ([0-9.]+)s" % name, text)
  1281. bundle_pass = int(match.group(1)) if match else 0
  1282. bundle_fail = int(match.group(2)) if match else -1
  1283. total_pass += max(bundle_pass, 0)
  1284. total_fail += max(bundle_fail, 0)
  1285. status = "ok"
  1286. if proc.returncode != 0 or bundle_fail != 0 or match is None:
  1287. status = "BROKEN (exit %s)" % proc.returncode
  1288. broken.append(name)
  1289. print("[shard %-10s] %6ss %3d passed %3d failed %s"
  1290. % (name, took.group(1) if took else "?",
  1291. bundle_pass, bundle_fail, status))
  1292. if status != "ok":
  1293. tail = "\n".join(text.rstrip().split("\n")[-60:])
  1294. print("--- web-stack/%s tail (full log: %s) ---" % (name, out_path))
  1295. print(tail)
  1296. print("--- end web-stack/%s tail ---" % name)
  1297. print("shards wall: %.1fs (logs under %s)" % (time.monotonic() - started, log_dir))
  1298. print("ALL SHARDS: %d passed, %d failed%s"
  1299. % (total_pass, total_fail,
  1300. "" if not broken else " BROKEN: " + ", ".join(broken)))
  1301. return 1 if broken or total_fail else 0
  1302. def main():
  1303. if not os.path.exists(APP):
  1304. raise SystemExit("app binary not found at %s (build first)" % APP)
  1305. argv = sys.argv[2:]
  1306. if "--bundle" in argv:
  1307. sys.exit(1 if run_bundle(argv[argv.index("--bundle") + 1]) else 0)
  1308. if "--parallel" in argv:
  1309. at = argv.index("--parallel")
  1310. limit = 4
  1311. if len(argv) > at + 1 and argv[at + 1].isdigit():
  1312. limit = int(argv[at + 1])
  1313. return run_all_parallel(limit)
  1314. for name in BUNDLES_ORDER:
  1315. if run_bundle(name):
  1316. return 1
  1317. return 0
  1318. if __name__ == "__main__":
  1319. sys.exit(main())