harness.py 69 KB

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