SaddleService.vala 83 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709
  1. using Invercargill;
  2. using Invercargill.DataStructures;
  3. using Invercargill.Expressions;
  4. using InvercargillSql;
  5. using InvercargillSql.Orm;
  6. using Inversion;
  7. using Soup;
  8. using ClankerSaddle.Data;
  9. namespace ClankerSaddle.Services {
  10. /**
  11. * Valid saddle statuses (the `saddles.status` wire values, plan 16
  12. * R2 / arch item 26 — the raw literals died with these). The row
  13. * default and the migration seed keep their literals (the Data
  14. * layer does not reference the services).
  15. */
  16. public const string SADDLE_IDLE = "idle";
  17. public const string SADDLE_BUSY = "busy";
  18. public const string SADDLE_OFFLINE = "offline";
  19. /**
  20. * Session outcomes (the `sessions.outcome` values the park, the
  21. * ONE release path and the terminal pushes write): `parked` (a
  22. * round that parked on a block or a suspend — the row closes at
  23. * park time and the workspace awaits its resuming allocation;
  24. * plan 16 D4, aligned with the daemon's file-log brackets),
  25. * `stopped` (a cancelled/requeued/unreachable release),
  26. * `done`/`failed` (the terminal pushes).
  27. */
  28. public const string OUTCOME_STOPPED = "stopped";
  29. public const string OUTCOME_PARKED = "parked";
  30. public const string OUTCOME_DONE = "done";
  31. public const string OUTCOME_FAILED = "failed";
  32. /** Caps of the `project_notes` array in the /start payload. */
  33. public const int64 PROJECT_NOTES_MAX_ENTRIES = 30;
  34. public const int64 PROJECT_NOTES_MAX_CHARS = 20000;
  35. /** A block answer awaiting collection by one saddle's next poll. */
  36. public class PendingAnswer : Object {
  37. public int64 block_id { get; set; }
  38. public string answer { get; set; default = ""; }
  39. public bool branch_advanced { get; set; }
  40. }
  41. /** A saddle registration plus the API token generated for it. */
  42. public class SaddleRegistration : Object {
  43. public Saddle saddle { get; set; }
  44. public string token { get; set; default = ""; }
  45. }
  46. /**
  47. * A saddle's current work as the cards render it: the running
  48. * task's title, plus the GOAL it stands for when that task is a
  49. * goal agent's internal run.
  50. */
  51. public class CurrentWork : Object {
  52. public int64 task_id { get; set; }
  53. public string task_title { get; set; default = ""; }
  54. /** Set when the current task is a goal agent's run: the card links the goal. */
  55. public int64 goal_id { get; set; }
  56. public string goal_title { get; set; default = ""; }
  57. /** The running session the derivation keyed on (plan 16 item 23). */
  58. public int64 session_id { get; set; }
  59. /** When that session started — the "allocated" display stamp. */
  60. public DateTime? started_at { get; set; default = null; }
  61. }
  62. /**
  63. * The OpenAI-compatible endpoint every saddle assignment is sent to,
  64. * read from the environment: `SADDLE_LLM_BASE_URL` (required — dispatch
  65. * pauses without it), `SADDLE_LLM_API_KEY` and `SADDLE_LLM_MODEL`.
  66. */
  67. public class LlmEndpoint : Object {
  68. public string base_url { get; set; default = ""; }
  69. public string api_key { get; set; default = ""; }
  70. public string model { get; set; default = ""; }
  71. /** The configured endpoint, or null when the base URL is unset. */
  72. public static LlmEndpoint? from_environment() {
  73. var base_url = (Environment.get_variable("SADDLE_LLM_BASE_URL") ?? "").strip();
  74. if (base_url.length == 0) {
  75. return null;
  76. }
  77. return new LlmEndpoint() {
  78. base_url = base_url,
  79. api_key = (Environment.get_variable("SADDLE_LLM_API_KEY") ?? "").strip(),
  80. model = (Environment.get_variable("SADDLE_LLM_MODEL") ?? "").strip()
  81. };
  82. }
  83. }
  84. /**
  85. * Saddle registry and control-plane client (plan 10 phase 2): registers
  86. * containers, derives idle capacity for the dispatcher's wake
  87. * computation (plan 16 item 23: busy iff an open session on an
  88. * assigned/working task exists — {@link current_work_view} is the ONE
  89. * truth accessor; the saddle row keeps only the OFFLINE probe state),
  90. * wakes sleeping daemons, and ingests the telemetry the daemons PUSH —
  91. * raw PTY chunks (`POST /api/saddle/chunks`) and round results with
  92. * state transitions (`POST /api/saddle/results`) — reusing the storage
  93. * logic the deleted `/output` + `/log` pollers ran. GET /status
  94. * against a daemon is exceptional only: the staleness probe for a
  95. * silent runner and the manual refresh button. Block answers are
  96. * handed out exactly once (delivered ids are remembered in memory; a
  97. * restart re-delivers and the daemon deduplicates).
  98. */
  99. public class SaddleService : Object {
  100. private OrmSession db = inject<OrmSession>();
  101. private Connection connection = inject<Connection>();
  102. /**
  103. * Every daemon request shares this session and therefore its
  104. * timeouts: without them a single hung request (a daemon wedged
  105. * mid-response over the keep-alive pool) froze the dispatcher's
  106. * tick latch forever. `timeout` bounds each request
  107. * (SADDLE_HTTP_TIMEOUT_S, default 30s); `idle_timeout` reaps
  108. * pooled connections so a peer that vanished between requests is
  109. * not dialed again.
  110. */
  111. private Soup.Session http = new Soup.Session() {
  112. timeout = http_timeout_seconds(),
  113. idle_timeout = 10
  114. };
  115. private static uint http_timeout_seconds() {
  116. var raw = Environment.get_variable("SADDLE_HTTP_TIMEOUT_S") ?? "";
  117. int64 parsed = int64.parse(raw);
  118. return parsed > 0 ? (uint) parsed : 30;
  119. }
  120. private SaddlePollState poll_state = inject<SaddlePollState>();
  121. private StatePulse pulse = inject<StatePulse>();
  122. private PushService push = inject<PushService>();
  123. private RuleService rules = inject<RuleService>();
  124. private CapabilityService capability_service = inject<CapabilityService>();
  125. private TaskService task_service = inject<TaskService>();
  126. private GoalService goal_service = inject<GoalService>();
  127. private EventService event_service = inject<EventService>();
  128. private Astralis.WebConfig config = inject<Astralis.WebConfig>();
  129. /**
  130. * Registers a saddle container: inserts the row with a freshly
  131. * minted per-saddle token (plan 10) and hands the credential to
  132. * the daemon via `POST /register {saddle_token, web_base_url}`
  133. * (best effort — an older daemon without the route stays
  134. * registered; the operator copies the token from the saddles
  135. * page instead). `host` is host:port of its control server; the
  136. * returned {@link SaddleRegistration} carries the token the
  137. * daemon must present as `Authorization: Bearer …` on
  138. * `/api/saddle/*` calls.
  139. */
  140. public async SaddleRegistration register_saddle(string name, string host) throws Error {
  141. var saddle = new Saddle() {
  142. name = name,
  143. host = host,
  144. status = SADDLE_IDLE,
  145. token = mint_saddle_token()
  146. };
  147. yield db.insert_async<Saddle>(saddle);
  148. yield register_daemon(saddle);
  149. yield pulse.bump(0, "saddle registered");
  150. return new SaddleRegistration() { saddle = saddle, token = saddle.token ?? "" };
  151. }
  152. /** A fresh per-saddle bearer token (`saddle-<random>`). */
  153. private static string mint_saddle_token() {
  154. return @"saddle-$(Uuid.string_random().replace("-", ""))";
  155. }
  156. /**
  157. * Delivers the per-saddle token to the daemon's
  158. * `POST /register` endpoint (wire contract): the daemon
  159. * persists it for its calls home and answers
  160. * `{ok, ssh_public_key, version}` — the key is stored on the
  161. * row the moment the daemon reports one. A daemon without the
  162. * route (HTTP 404/405) or unreachable at registration time is
  163. * tolerated: the row keeps the token, the operator can rety
  164. * via a refresh or copy the token manually.
  165. */
  166. public async void register_daemon(Saddle saddle) throws Error {
  167. var payload = new Json.Builder();
  168. payload.begin_object();
  169. payload.set_member_name("saddle_token");
  170. payload.add_string_value(saddle.token ?? "");
  171. payload.set_member_name("web_base_url");
  172. payload.add_string_value(callback_base_url());
  173. payload.end_object();
  174. try {
  175. var response = yield request_saddle_json(saddle.host, "POST", "/register",
  176. (owned) payload);
  177. string reported_key = EntryJson.string_member(response, "ssh_public_key");
  178. // goal 8 task 6: the daemon reports the workspace root it
  179. // mounts so assignment paths address the ASKING saddle's
  180. // filesystem; absent on older daemons (kept null → the
  181. // SADDLE_PROJECTS_ROOT fallback chain)
  182. string reported_root = EntryJson.string_member(response, "workspace_root");
  183. if ((reported_key.length > 0 && reported_key != (saddle.ssh_public_key ?? ""))
  184. || reported_root != (saddle.workspace_root ?? "")) {
  185. if (reported_key.length > 0) {
  186. saddle.ssh_public_key = reported_key;
  187. }
  188. saddle.workspace_root = reported_root.length > 0 ? reported_root : null;
  189. yield db.update_async<Saddle>(saddle);
  190. }
  191. print(@"[ClankerSaddle] Registered with saddle '$(saddle.name)'s daemon (token delivered)\n");
  192. } catch (Error e) {
  193. print(@"[ClankerSaddle] Saddle '$(saddle.name)'s daemon did not accept /register ($(e.message)); token stays on the row\n");
  194. }
  195. }
  196. /**
  197. * The saddle a bearer token belongs to, or null — the
  198. * credential check for the id-less `/api/saddle/*` endpoints
  199. * (`next-task`, `note`, `task`): the per-saddle row token,
  200. * the single credential store since plan 10 (arch 24 dropped
  201. * the legacy api_tokens fallback — nothing deployed predates
  202. * the row token). Removing the saddle revokes it.
  203. */
  204. public async Saddle? saddle_for_token(string token) throws Error {
  205. if (token.length == 0) {
  206. return null;
  207. }
  208. return yield db.query<Saddle>()
  209. .where(expr("token == $0", elem(token)))
  210. .first_async();
  211. }
  212. /**
  213. * This app's externally reachable base URL — what daemons use to
  214. * POST notes back — read from the `saddle_callback_base_url` key
  215. * of web-config.json, falling back to the
  216. * `SADDLE_CALLBACK_BASE_URL` environment variable and then to
  217. * `http://localhost:8080`. The daemon typically runs in a
  218. * container or on another host, so its view of this app differs
  219. * from the browser's; set the key to whatever reaches this
  220. * process from there.
  221. */
  222. public string callback_base_url() {
  223. string configured = config.get_string("saddle_callback_base_url", "").strip();
  224. if (configured.length > 0) {
  225. return configured;
  226. }
  227. var from_env = (Environment.get_variable("SADDLE_CALLBACK_BASE_URL") ?? "").strip();
  228. if (from_env.length > 0) {
  229. return from_env;
  230. }
  231. return "http://localhost:8080";
  232. }
  233. /**
  234. * Removes a saddle registration; the task it was running (the
  235. * DERIVED running work — plan 16 item 23: sessions are the
  236. * truth) releases through the ONE path — back to the queue,
  237. * its open sessions closed stopped (no orphan open session
  238. * outlives the row). Revocation: the row dies here, and its
  239. * per-saddle token with it — nothing authorises
  240. * `/api/saddle/*` for it afterwards.
  241. */
  242. public async void unregister_saddle(int64 id) throws Error {
  243. var saddle = yield get_saddle(id);
  244. if (saddle == null) {
  245. return;
  246. }
  247. var work = yield current_work_view((!)saddle);
  248. if (work != null) {
  249. yield task_service.release_task(((!)work).task_id, OUTCOME_STOPPED, true);
  250. }
  251. yield db.delete_async<Saddle>((!)saddle);
  252. yield pulse.bump(0, "saddle removed");
  253. }
  254. /** Loads the saddle identified by `id`, or null. */
  255. public async Saddle? get_saddle(int64 id) throws Error {
  256. return yield db.query<Saddle>()
  257. .where(expr("id == $0", elem<int64?>(id)))
  258. .first_async();
  259. }
  260. /** Lists every saddle ordered by name. */
  261. public async ImmutableLot<Saddle> list_saddles() throws Error {
  262. return yield db.query<Saddle>()
  263. .order_by(expr("name"))
  264. .materialise_async();
  265. }
  266. /**
  267. * The saddle's current work resolved from the SESSION records —
  268. * the ONE truth accessor every surface reads (plan 16 item 23:
  269. * sessions own "who runs what"; the saddle row's cache columns
  270. * are gone). The running session is the newest open session on
  271. * the saddle whose task is still assigned/working — busy iff it
  272. * exists, idle otherwise; a parked task's session closed
  273. * 'parked' at its park (D4), so parks read as free exactly
  274. * like the daemon's file-log brackets say. The task join also
  275. * makes the derivation drift-tolerant: a stale open row on a
  276. * task that has since moved on cannot wedge a saddle busy.
  277. * Null when the saddle holds no running work.
  278. */
  279. public async CurrentWork? current_work_view(Saddle saddle) throws Error {
  280. var session = yield running_session_on_saddle(saddle.id);
  281. if (session == null) {
  282. return null;
  283. }
  284. var work = new CurrentWork() {
  285. task_id = ((!)session).task_id,
  286. session_id = ((!)session).id,
  287. started_at = ((!)session).started_at
  288. };
  289. var task = yield db.query<TaskEntity>()
  290. .where(expr("id == $0", elem<int64?>(((!)session).task_id)))
  291. .first_async();
  292. if (task == null) {
  293. return work;
  294. }
  295. work.task_title = ((!)task).title;
  296. if (((!)task).kind == TASK_KIND_GOAL_RUN) {
  297. var goal = yield db.query<Goal>()
  298. .where(expr("id == $0", elem<int64?>(((!)task).goal_id)))
  299. .first_async();
  300. if (goal != null) {
  301. work.goal_id = ((!)goal).id;
  302. work.goal_title = ((!)goal).title;
  303. }
  304. }
  305. return work;
  306. }
  307. /**
  308. * The derived busy/idle/offline status label every saddle view
  309. * shares — 'busy' iff {@link current_work_view} holds running
  310. * work, else the row's stored OFFLINE probe state ('offline' or
  311. * 'idle').
  312. */
  313. public static string derived_status(Saddle saddle, CurrentWork? work) {
  314. if (work != null) {
  315. return SADDLE_BUSY;
  316. }
  317. return saddle.status == SADDLE_OFFLINE ? SADDLE_OFFLINE : SADDLE_IDLE;
  318. }
  319. /**
  320. * The saddle's RUNNING session — the newest open session whose
  321. * task is assigned/working (the null check runs in SQL: the
  322. * expression layer cannot compare columns against null). At
  323. * most one exists per saddle by construction: every park closes
  324. * its row (D4) and the claim's guard refuses a saddle that
  325. * already holds one.
  326. */
  327. private async ClankerSaddle.Data.Session? running_session_on_saddle(int64 saddle_id)
  328. throws Error {
  329. var command = connection.create_command(
  330. "SELECT s.id AS session_id, s.task_id AS task_id, s.started_at AS started"
  331. + " FROM sessions s JOIN tasks t ON t.id = s.task_id"
  332. + " WHERE s.saddle_id = :saddle AND s.ended_at IS NULL"
  333. + " AND t.status IN ('assigned', 'working')"
  334. + " ORDER BY s.id DESC LIMIT 1");
  335. command.with_parameter<Invercargill.Element>("saddle",
  336. elem<int64?>(saddle_id));
  337. ClankerSaddle.Data.Session? found = null;
  338. foreach (var row in yield command.execute_query_async()) {
  339. int64? session_id = 0;
  340. int64? task_id = 0;
  341. ((!)row.get("session_id")).try_get_as<int64?>(out session_id);
  342. ((!)row.get("task_id")).try_get_as<int64?>(out task_id);
  343. found = new ClankerSaddle.Data.Session() {
  344. id = session_id ?? 0,
  345. task_id = task_id ?? 0,
  346. saddle_id = saddle_id,
  347. started_at = raw_date_time_of(row.get("started"))
  348. };
  349. }
  350. return found;
  351. }
  352. /** A raw query column back as a DateTime (null when null/unreadable). */
  353. private static DateTime? raw_date_time_of(Invercargill.Element? element) {
  354. if (element == null) {
  355. return null;
  356. }
  357. DateTime value;
  358. return ((!)element).try_get_as<DateTime>(out value) ? value : null;
  359. }
  360. /**
  361. * The ids (decimal strings — the OR-chain batch convention) of
  362. * every saddle holding a running session — the derived busy
  363. * set the fleet lists and the offline stamp share.
  364. */
  365. private async Invercargill.DataStructures.HashSet<string> busy_saddle_ids()
  366. throws Error {
  367. var busy = new Invercargill.DataStructures.HashSet<string>();
  368. var command = connection.create_command(
  369. "SELECT DISTINCT s.saddle_id AS saddle_id FROM sessions s"
  370. + " JOIN tasks t ON t.id = s.task_id"
  371. + " WHERE s.ended_at IS NULL"
  372. + " AND t.status IN ('assigned', 'working')");
  373. foreach (var row in yield command.execute_query_async()) {
  374. int64? saddle_id = 0;
  375. ((!)row.get("saddle_id")).try_get_as<int64?>(out saddle_id);
  376. if (saddle_id != null) {
  377. busy.add(((!)saddle_id).to_string());
  378. }
  379. }
  380. return busy;
  381. }
  382. /**
  383. * Saddles holding a running session (the dispatcher's poll
  384. * candidates) — derived from the session records, registration
  385. * order.
  386. */
  387. public async ImmutableLot<Saddle> list_busy_saddles() throws Error {
  388. var busy = yield busy_saddle_ids();
  389. if (busy.count() == 0) {
  390. return (new Vector<Saddle>()).to_immutable_buffer();
  391. }
  392. var rows = new Vector<Saddle>();
  393. foreach (var saddle in yield db.query<Saddle>()
  394. .where(TaskService.id_in("id", busy))
  395. .order_by(expr("id"))
  396. .materialise_async()) {
  397. rows.add(saddle);
  398. }
  399. return rows.to_immutable_buffer();
  400. }
  401. /**
  402. * Persists the saddle's git identity — what its daemon commits
  403. * as (`git_identity` in every /start). Empty/null clears the
  404. * fields so the daemon's fallback applies.
  405. */
  406. public async void update_git_identity(int64 id, string? git_name,
  407. string? git_email) throws Error {
  408. var saddle = yield get_saddle(id);
  409. if (saddle == null) {
  410. throw new Error(Quark.from_string("clanker-saddle"), 17,
  411. @"No saddle exists with id $(id)");
  412. }
  413. ((!)saddle).git_name = git_name != null && git_name.length > 0 ? git_name : null;
  414. ((!)saddle).git_email = git_email != null && git_email.length > 0 ? git_email : null;
  415. yield db.update_async<Saddle>((!)saddle);
  416. yield pulse.bump(0, "saddle identity saved");
  417. }
  418. /**
  419. * Persists the daemon's reported `ssh_public_key` onto the saddle
  420. * row (the plan-9 contract: the key travels on /status, empty
  421. * until the daemon generates one; the saddles page shows it for
  422. * adding as a deploy key). A single-column UPDATE — the
  423. * full-row write could stomp a concurrent ask claim. Only writes
  424. * when it changed.
  425. */
  426. private async void store_reported_ssh_key(Saddle saddle, Json.Object status) throws Error {
  427. if (!status.has_member("ssh_public_key")) {
  428. return;
  429. }
  430. string reported = status.get_string_member_with_default("ssh_public_key", "");
  431. string stored = saddle.ssh_public_key ?? "";
  432. if (reported == stored) {
  433. return;
  434. }
  435. var command = connection.create_command(
  436. "UPDATE saddles SET ssh_public_key = :key WHERE id = :id");
  437. command.with_parameter<Invercargill.Element>("key", elem(reported.length > 0 ? reported : null));
  438. command.with_parameter<Invercargill.Element>("id", elem<int64?>(saddle.id));
  439. yield command.execute_non_query_async();
  440. }
  441. /**
  442. * Records a /status observation: the health snapshot, the
  443. * daemon-reported ssh key, and the goal-branch push flags
  444. * (plan 15 C4). The `resumed` flag needs no projection — the
  445. * restore's record is the daemon's own system entry in the
  446. * raw terminal log (plan 16 S5/U2).
  447. */
  448. private async void record_status_probe(Saddle saddle, Json.Object status) throws Error {
  449. string state = status.has_member("state")
  450. ? status.get_string_member_with_default("state", "") : "";
  451. int64 uptime = status.has_member("uptime_seconds")
  452. && status.get_member("uptime_seconds").get_value_type() == typeof(int64)
  453. ? status.get_int_member("uptime_seconds") : 0;
  454. int64 activity = status.has_member("last_activity_epoch")
  455. && status.get_member("last_activity_epoch").get_value_type() == typeof(int64)
  456. ? status.get_int_member("last_activity_epoch") : 0;
  457. poll_state.record_probe(saddle.id, state, uptime, activity);
  458. yield store_reported_ssh_key(saddle, status);
  459. // Plan 15 C4: a daemon running (or parking — the
  460. // workspace stays while the saddle frees) a goal agent
  461. // exposes the per-repo push flags of the goal branches on
  462. // /status; they are recorded as LIVE probe state on the
  463. // saddle (never a stored snapshot on the goal row — a
  464. // pre-push snapshot is exactly the F9 bug), and the goal
  465. // page + the completion-accept gate read them back at
  466. // render/answer time.
  467. if (status.has_member("goal_branches")) {
  468. var goal_branches = status.get_member("goal_branches");
  469. if (goal_branches.get_node_type() == Json.NodeType.ARRAY
  470. || goal_branches.get_node_type() == Json.NodeType.OBJECT) {
  471. var generator = new Json.Generator();
  472. generator.set_root(goal_branches);
  473. poll_state.record_goal_branches(saddle.id, generator.to_data(null));
  474. }
  475. }
  476. // Plan 16 S5 (U2): the daemon's own system entry (typed
  477. // 'system' in the results stream) is the raw-terminal
  478. // record of a workspace restore — the web no longer authors
  479. // a duplicate timeline row for it. The `resumed` flag
  480. // carries no further web-side projection.
  481. }
  482. /**
  483. * Wakes a sleeping daemon (plan 10 wire contract): `POST /wake`
  484. * `{}` — a capable task is ready; sleeping daemons ask for work,
  485. * busy ones ignore it, idle-awake ones re-ask. Idempotent. The
  486. * caller records the pending wake for the lost-wake guard.
  487. */
  488. public async void wake_saddle(Saddle saddle) throws Error {
  489. var payload = new Json.Builder();
  490. payload.begin_object();
  491. payload.end_object();
  492. Json.Node? root = null;
  493. int status_code = yield request_saddle(saddle.host, "POST", "/wake",
  494. (owned) payload, out root);
  495. if (status_code >= 400) {
  496. throw new Error(Quark.from_string("clanker-saddle"), 49,
  497. @"wake returned HTTP $(status_code)");
  498. }
  499. }
  500. /**
  501. * Cancelling / requeueing / stopping a task with a live session
  502. * reaches its daemon (wire contract): `POST /task-update
  503. * {kind: "cancel"}` — the authoritative stop through the daemon's
  504. * abort machinery, the workspace kept on disk per the park
  505. * contract. Success is narrated as "<verb>; daemon aborted"; a
  506. * transport failure marks the saddle offline (the daemon's next
  507. * ask proves it alive again and flips the row back to idle). The
  508. * daemon must never keep running a task the web has moved on
  509. * from as a zombie.
  510. */
  511. public async void abort_live_session(TaskService tasks, int64 task_id,
  512. string verb) throws Error {
  513. var session = yield tasks.open_session_for_task(task_id);
  514. if (session == null) {
  515. return;
  516. }
  517. yield abort_session_on_its_saddle(tasks, (!)session, verb);
  518. }
  519. /**
  520. * SADDLE_STALE_S (default 90s): the silence window after which a
  521. * running daemon owes an exceptional GET /status probe.
  522. */
  523. public static uint stale_after_seconds() {
  524. var raw = Environment.get_variable("SADDLE_STALE_S") ?? "";
  525. int64 parsed = int64.parse(raw);
  526. return parsed > 0 ? (uint) parsed : 90;
  527. }
  528. /**
  529. * {@link abort_live_session} keyed by saddle instead of task —
  530. * the unregister path. The cancel is delivered to the saddle's
  531. * RUNNING session (the derived record; plan 16 item 23) — not
  532. * re-resolved through a task's lowest-id open session, which a
  533. * stale fixture row on another saddle would redirect to the
  534. * wrong daemon.
  535. */
  536. public async void abort_saddle_session(TaskService tasks, int64 saddle_id,
  537. string verb) throws Error {
  538. var session = yield running_session_on_saddle(saddle_id);
  539. if (session == null) {
  540. return;
  541. }
  542. yield abort_session_on_its_saddle(tasks, (!)session, verb);
  543. }
  544. /**
  545. * The cancel delivery core shared by the task-keyed and
  546. * saddle-keyed abort paths: `POST /task-update {kind: cancel}`
  547. * to the daemon holding THIS session.
  548. */
  549. private async void abort_session_on_its_saddle(TaskService tasks,
  550. ClankerSaddle.Data.Session session, string verb) throws Error {
  551. var saddle = yield get_saddle(session.saddle_id);
  552. if (saddle == null) {
  553. return;
  554. }
  555. var payload = new Json.Builder();
  556. payload.begin_object();
  557. payload.set_member_name("kind");
  558. payload.add_string_value("cancel");
  559. payload.end_object();
  560. Json.Node? root = null;
  561. try {
  562. int status_code = yield request_saddle(((!)saddle).host, "POST",
  563. "/task-update", (owned) payload, out root);
  564. if (status_code >= 400) {
  565. throw new Error(Quark.from_string("clanker-saddle"), 45,
  566. @"task-update cancel returned HTTP $(status_code)");
  567. }
  568. yield tasks.add_system_comment(session.task_id, @"$(verb); daemon aborted");
  569. } catch (Error e) {
  570. warning(@"[ClankerSaddle] Abort for task $(session.task_id) failed: $(e.message); marking saddle '$(((!)saddle).name)' offline");
  571. yield mark_saddle_unreachable(((!)saddle).id, tasks,
  572. @"abort delivery failed ($(e.message))");
  573. yield tasks.add_system_comment(session.task_id,
  574. @"$(verb); daemon unreachable — saddle marked offline");
  575. }
  576. }
  577. /**
  578. * Requeues tasks caught running on saddles that have since gone
  579. * offline (a manual status refresh, a restart) — without this
  580. * pass they would wait forever for a daemon that will not answer.
  581. * Only `assigned`/`working` tasks move: parked tasks
  582. * (blocked/review/suspended, their sessions closed 'parked')
  583. * keep their records, which the resume machinery needs. Each
  584. * move is {@link TaskService.requeue_task} — the release owner
  585. * closes the session (the close IS the free, item 23) — plus a
  586. * system comment so the timeline shows why the task restarted.
  587. */
  588. public async void requeue_tasks_on_offline_saddles(TaskService tasks) throws Error {
  589. foreach (var status in new string[] { STATUS_ASSIGNED, STATUS_WORKING }) {
  590. foreach (var task in yield db.query<TaskEntity>()
  591. .where(expr("status == $0", elem(status)))
  592. .materialise_async()) {
  593. var session = yield tasks.open_session_for_task(task.id);
  594. if (session == null) {
  595. continue;
  596. }
  597. if (yield tasks.newest_block_answered(task.id)) {
  598. // Parked-with-answered: a verdict awaits the
  599. // resume machinery — never requeue it.
  600. continue;
  601. }
  602. var saddle = yield get_saddle(((!)session).saddle_id);
  603. if (saddle == null || ((!)saddle).status != SADDLE_OFFLINE) {
  604. continue;
  605. }
  606. print(@"[ClankerSaddle] Task $(task.id) requeued: its saddle went offline\n");
  607. yield tasks.requeue_task(task.id);
  608. yield tasks.add_system_comment(task.id,
  609. "saddle went offline; task requeued");
  610. }
  611. }
  612. }
  613. /**
  614. * Every saddle free for work — derived (plan 16 item 23): not
  615. * offline (the probe state) and holding no running session —
  616. * registration order, the dispatcher's capability-matching
  617. * candidate list. A parked task's closed session reads free
  618. * exactly like the freed daemon it brackets.
  619. */
  620. public async ImmutableLot<Saddle> list_idle_saddles() throws Error {
  621. var busy = yield busy_saddle_ids();
  622. var rows = new Vector<Saddle>();
  623. foreach (var saddle in yield db.query<Saddle>()
  624. .order_by(expr("id"))
  625. .materialise_async()) {
  626. if (saddle.status == SADDLE_OFFLINE
  627. || busy.contains(((int64) saddle.id).to_string())) {
  628. continue;
  629. }
  630. rows.add(saddle);
  631. }
  632. return rows.to_immutable_buffer();
  633. }
  634. /**
  635. * Marks a saddle offline when its control server cannot be
  636. * reached. The task it was RUNNING (the derived running
  637. * session — plan 16 item 23) releases back to 'queued' through
  638. * {@link TaskService.release_task} (the ONE owner; it closes
  639. * the session 'stopped'), with a system comment so the timeline
  640. * shows the interruption; combined with the daemon's
  641. * crash-resume, the next dispatch restores the workspace and
  642. * reports `resumed` on /status. A task that parked mid-flight
  643. * (its session already closed 'parked', the verdict awaiting
  644. * the resume) keeps its park. The offline stamp itself is the
  645. * ONE surviving guarded write — see {@link stamp_offline}.
  646. */
  647. public async void mark_saddle_unreachable(int64 id, TaskService tasks,
  648. string? reason = null) throws Error {
  649. var saddle = yield get_saddle(id);
  650. if (saddle == null) {
  651. return;
  652. }
  653. var session = yield running_session_on_saddle(id);
  654. int64 seen_session = session != null ? ((!)session).id : 0;
  655. int64 held_task = 0;
  656. if (session != null) {
  657. held_task = ((!)session).task_id;
  658. // The requeue flip is guarded: a park (or terminal)
  659. // interleaving this path keeps its state — the parked
  660. // verdict must survive for the resume.
  661. var flip = connection.create_command(
  662. "UPDATE tasks SET status = 'queued' WHERE id = :id"
  663. + " AND status IN ('assigned', 'working')");
  664. flip.with_parameter<Invercargill.Element>("id", elem<int64?>(held_task));
  665. if ((yield flip.execute_non_query_async()) > 0) {
  666. string unreachable_text = reason != null && reason.length > 0
  667. ? @"Saddle became unreachable ($(reason)); task requeued"
  668. : "Saddle became unreachable; task requeued";
  669. yield tasks.add_system_comment(held_task, unreachable_text);
  670. // Closes the held session FIRST — the offline
  671. // stamp's guard refuses to land over a running
  672. // session, and by then there is none.
  673. yield tasks.release_task(held_task, OUTCOME_STOPPED, false);
  674. } else {
  675. // Parked (or finished) mid-flight — the park closed
  676. // its session and the verdict must survive for the
  677. // resume; nothing to release.
  678. held_task = 0;
  679. }
  680. }
  681. yield stamp_offline(id, seen_session);
  682. // An offline saddle is neither sleeping nor wake-awaiting;
  683. // a restarted daemon re-establishes both by asking.
  684. poll_state.clear_ask(id);
  685. yield pulse.bump(0, "saddle unreachable");
  686. }
  687. /**
  688. * The ONE surviving guarded saddle write (plan 16 item 23):
  689. * the OFFLINE probe stamp. The guard is the sessions-truth
  690. * analogue of the old seen-assignment guard — the stamp lands
  691. * unless a running session the probe did NOT see at entry
  692. * appeared meanwhile (an ask claim is proof of life and must
  693. * not be stomped offline). The session observed at entry
  694. * (`seen_session`) never blocks its own stamp.
  695. */
  696. private async void stamp_offline(int64 saddle_id, int64 seen_session) throws Error {
  697. var command = connection.create_command(
  698. "UPDATE saddles SET status = '" + SADDLE_OFFLINE + "' WHERE id = :id"
  699. + " AND NOT EXISTS (SELECT 1 FROM sessions s"
  700. + " JOIN tasks t ON t.id = s.task_id"
  701. + " WHERE s.saddle_id = :id AND s.ended_at IS NULL"
  702. + " AND t.status IN ('assigned', 'working')"
  703. + " AND s.id != :seen)");
  704. command.with_parameter<Invercargill.Element>("id", elem<int64?>(saddle_id));
  705. command.with_parameter<Invercargill.Element>("seen", elem<int64?>(seen_session));
  706. yield command.execute_non_query_async();
  707. }
  708. /**
  709. * Assembles the assignment payload shared by the /start push and
  710. * the `POST /api/saddle/next-task` response — `{task, kind,
  711. * mode, creator, goal?, rules, project_notes, callback,
  712. * llm_config, session_id}` — with the project, links (the
  713. * goal's own links for goal work, else the project's), branch
  714. * fields and recent comments inside the task object, plus any
  715. * block answers not yet handed over (a resumed workspace picks
  716. * its pending answers up from the assignment itself), the
  717. * effective rule set ({@link RuleService.effective_rules}) as
  718. * the top-level `rules` array, the capped `project_notes` of
  719. * the primary and co-developed projects, the note-creation
  720. * `callback` (base URL + the saddle's own bearer token), the
  721. * saddle's `git_identity`, a `capabilities` context (plan 10
  722. * phase 3: `saddle_tags` — the allocated saddle's own tags —
  723. * and `fleet_tags` — every tag name known across the fleet),
  724. * the plan-12 goal context (`goal` for goal work; the
  725. * worker's `ephemeral_branch`) and — for resumed workspaces —
  726. * the `resume_notice`/`branch_advanced` context. `session_id`
  727. * (the web-side run row the daemon reports against) rides
  728. * along so pushed telemetry can key on it. Project checkouts
  729. * are addressed by the `SADDLE_PROJECTS_ROOT` convention
  730. * (`root/task-<task id>/name`) and cloned from each project's
  731. * `git_url`; the LLM endpoint by the {@link LlmEndpoint}
  732. * environment.
  733. */
  734. public async Json.Object build_assignment_payload(Saddle saddle, TaskEntity task,
  735. int64 session_id, ProjectService projects, string? resume_notice = null,
  736. bool? branch_advanced = null,
  737. string? context_update = null) throws Error {
  738. var endpoint = LlmEndpoint.from_environment();
  739. if (endpoint == null) {
  740. throw new Error(Quark.from_string("clanker-saddle"), 24,
  741. "No LLM endpoint configured (set SADDLE_LLM_BASE_URL)");
  742. }
  743. var project = task.project_id > 0
  744. ? yield db.query<Project>()
  745. .where(expr("id == $0", elem<int64?>(task.project_id)))
  746. .first_async()
  747. : null;
  748. // Goal 8 task 4: project_id 0 is a STANDALONE task — no
  749. // project row exists (and none is looked up), the
  750. // co-developed/reference arrays ship EMPTY and the daemon
  751. // renders the standalone prompt section. A task with a
  752. // non-zero project_id whose row is missing is still the
  753. // data-integrity error it always was.
  754. if (project == null && task.project_id > 0) {
  755. throw new Error(Quark.from_string("clanker-saddle"), 12,
  756. @"Task $(task.id) has no project $(task.project_id)");
  757. }
  758. // Plan 12: goal work (the agent's own run and its worker
  759. // tasks) scopes its co-developed/reference set through the
  760. // GOAL's links; standalone tasks through the project's.
  761. // Standalone tasks (project 0) own no links at all — the
  762. // simplest consistent shape: both arrays stay empty.
  763. Goal? goal = null;
  764. if (task.goal_id > 0) {
  765. goal = yield db.query<Goal>()
  766. .where(expr("id == $0", elem<int64?>(task.goal_id)))
  767. .first_async();
  768. }
  769. var co_developed = new Vector<Project>();
  770. var references = new Vector<Project>();
  771. if (goal != null) {
  772. foreach (var link in yield goal_service.list_links(task.goal_id)) {
  773. var linked = yield db.query<Project>()
  774. .where(expr("id == $0", elem<int64?>(link.project_id)))
  775. .first_async();
  776. if (linked == null) {
  777. continue;
  778. }
  779. if (link.relationship == LINK_CO_DEVELOPED) {
  780. co_developed.add((!)linked);
  781. } else if (link.relationship == LINK_REFERENCE) {
  782. references.add((!)linked);
  783. }
  784. }
  785. } else if (project != null) {
  786. foreach (var link in yield projects.list_links(task.project_id)) {
  787. var linked = yield db.query<Project>()
  788. .where(expr("id == $0", elem<int64?>(link.linked_project_id)))
  789. .first_async();
  790. if (linked == null) {
  791. continue;
  792. }
  793. if (link.relationship == LINK_CO_DEVELOPED) {
  794. co_developed.add((!)linked);
  795. } else if (link.relationship == LINK_REFERENCE) {
  796. references.add((!)linked);
  797. }
  798. }
  799. }
  800. var payload = new Json.Builder();
  801. payload.begin_object();
  802. payload.set_member_name("task");
  803. payload.begin_object();
  804. payload.set_member_name("id");
  805. payload.add_int_value(task.id);
  806. payload.set_member_name("title");
  807. payload.add_string_value(task.title);
  808. payload.set_member_name("description");
  809. payload.add_string_value(task.description ?? "");
  810. payload.set_member_name("project_name");
  811. payload.add_string_value(project != null ? ((!)project).name : "standalone");
  812. payload.set_member_name("project_branch");
  813. payload.add_string_value(project != null ? ((!)project).default_branch : "");
  814. payload.set_member_name("task_branch");
  815. payload.add_string_value(task.task_branch ?? "");
  816. payload.set_member_name("branch_from");
  817. payload.add_string_value(task.branch_from
  818. ?? (project != null ? ((!)project).default_branch : ""));
  819. payload.set_member_name("co_developed");
  820. payload.begin_array();
  821. if (project != null) {
  822. payload.add_value(project_node(((!)project), task.id, false, saddle));
  823. }
  824. foreach (var linked in co_developed) {
  825. payload.add_value(project_node(linked, task.id, false, saddle));
  826. }
  827. payload.end_array();
  828. payload.set_member_name("reference");
  829. payload.begin_array();
  830. foreach (var linked in references) {
  831. payload.add_value(project_node(linked, task.id, true, saddle));
  832. }
  833. payload.end_array();
  834. payload.set_member_name("comments");
  835. payload.begin_array();
  836. var comments = yield db.query<TaskComment>()
  837. .where(expr("task_id == $0", elem<int64?>(task.id)))
  838. .order_by(expr("id"))
  839. .materialise_async();
  840. foreach (var comment in comments) {
  841. payload.begin_object();
  842. payload.set_member_name("author");
  843. payload.add_string_value(comment.author);
  844. payload.set_member_name("body");
  845. payload.add_string_value(comment.body);
  846. if (comment.quote != null) {
  847. payload.set_member_name("quote");
  848. payload.add_string_value(((!)comment.quote));
  849. }
  850. payload.end_object();
  851. }
  852. payload.end_array();
  853. payload.set_member_name("answers");
  854. payload.begin_array();
  855. foreach (var pending in yield take_pending_answers_for_task(task.id)) {
  856. payload.begin_object();
  857. payload.set_member_name("block_id");
  858. payload.add_int_value(pending.block_id);
  859. payload.set_member_name("answer");
  860. payload.add_string_value(raw_answer_for_saddle(pending.answer));
  861. if (branch_advanced != null) {
  862. payload.set_member_name("branch_advanced");
  863. payload.add_boolean_value((!)branch_advanced);
  864. }
  865. payload.end_object();
  866. }
  867. payload.end_array();
  868. payload.end_object();
  869. // The plan-12 wire additions: kind ("task"|"goal_run"),
  870. // mode and creator ride the top level beside the task.
  871. payload.set_member_name("kind");
  872. payload.add_string_value(task.kind);
  873. payload.set_member_name("mode");
  874. payload.add_string_value(task.mode);
  875. payload.set_member_name("creator");
  876. payload.add_string_value(task.creator);
  877. // The goal context (goal work only): the goal agent gets
  878. // the full brief + approved plan; a goal worker gets the
  879. // same object as read-only reference (the daemon adds the
  880. // stick-to-mandate shaping) plus its ephemeral branch.
  881. if (goal != null) {
  882. payload.set_member_name("goal");
  883. payload.begin_object();
  884. payload.set_member_name("id");
  885. payload.add_int_value(((!)goal).id);
  886. payload.set_member_name("branch");
  887. payload.add_string_value(((!)goal).branch_name);
  888. payload.set_member_name("base_branch");
  889. payload.add_string_value(((!)goal).base_branch
  890. ?? (project != null ? ((!)project).default_branch : ""));
  891. payload.set_member_name("brief");
  892. payload.add_string_value(((!)goal).brief);
  893. payload.set_member_name("plan");
  894. payload.add_string_value(((!)goal).plan ?? "");
  895. payload.end_object();
  896. if (task.kind == TASK_KIND_TASK) {
  897. payload.set_member_name("ephemeral_branch");
  898. payload.add_string_value(@"task/$(task.goal_id)-$(task.id)");
  899. }
  900. }
  901. // The wire contract's top-level `rules` addition: the
  902. // effective rule set (task ∪ primary ∪ co-developed, deduped,
  903. // stable order — see RuleService.effective_rules). Goal work
  904. // expands co-developed through the goal's links.
  905. payload.set_member_name("rules");
  906. payload.begin_array();
  907. foreach (var rule in yield rules.effective_rules_for(task, projects,
  908. goal != null ? task.goal_id : 0)) {
  909. payload.begin_object();
  910. payload.set_member_name("name");
  911. payload.add_string_value(rule.name);
  912. payload.set_member_name("body");
  913. payload.add_string_value(rule.body);
  914. payload.end_object();
  915. }
  916. payload.end_array();
  917. // The wire contract's `git_identity` addition (plan 9): the
  918. // name/email the daemon commits as, from the saddle row.
  919. // Omitted entirely when unset so the daemon's fallback
  920. // identity applies.
  921. string identity_name = saddle.git_name ?? "";
  922. string identity_email = saddle.git_email ?? "";
  923. if (identity_name.length > 0 || identity_email.length > 0) {
  924. payload.set_member_name("git_identity");
  925. payload.begin_object();
  926. payload.set_member_name("name");
  927. payload.add_string_value(identity_name);
  928. payload.set_member_name("email");
  929. payload.add_string_value(identity_email);
  930. payload.end_object();
  931. }
  932. // The wire contract's `capabilities` context (plan 10
  933. // phase 3): the allocated saddle's own tags plus every tag
  934. // name known across the fleet (the operator-managed
  935. // catalogue) — the daemon renders both into its system
  936. // prompt and validates `task create --capability`.
  937. payload.set_member_name("capabilities");
  938. payload.begin_object();
  939. payload.set_member_name("saddle_tags");
  940. payload.begin_array();
  941. foreach (var carried in yield capability_service.list_for_saddle(saddle.id)) {
  942. payload.add_string_value(carried.name);
  943. }
  944. payload.end_array();
  945. payload.set_member_name("fleet_tags");
  946. payload.begin_array();
  947. foreach (var known in yield capability_service.list_capabilities()) {
  948. payload.add_string_value(known.name);
  949. }
  950. payload.end_array();
  951. payload.end_object();
  952. // The wire contract's `resume_notice` addition: on a
  953. // workspace-adopting start (a suspended parent's resume), the
  954. // daemon injects it as the next user turn instead of the
  955. // opening prompt.
  956. if (resume_notice != null && resume_notice.length > 0) {
  957. payload.set_member_name("resume_notice");
  958. payload.add_string_value(resume_notice);
  959. }
  960. // The wire's `branch_advanced` (resume pushes): whether the
  961. // shared branch moved while this task was parked — false or
  962. // absent means the daemon omits the pull instruction entirely.
  963. if (branch_advanced != null) {
  964. payload.set_member_name("branch_advanced");
  965. payload.add_boolean_value((!)branch_advanced);
  966. }
  967. // The wire's `context_update` (resume pushes): a short
  968. // markdown delta of the instruction context that changed
  969. // while the workspace was parked — the goal's plan
  970. // approved since the run's last round, rules created,
  971. // edited or attached, or new project notes. The daemon
  972. // appends it as a TURN (prompt-cache friendly) instead of
  973. // refreshing the system prompt; absent when nothing
  974. // changed.
  975. if (context_update != null && context_update.length > 0) {
  976. payload.set_member_name("context_update");
  977. payload.add_string_value(context_update);
  978. }
  979. // The wire contract's `project_notes` addition: notes of the
  980. // primary and co-developed projects, oldest first, capped at
  981. // 30 entries / 20 000 chars (head kept).
  982. var note_project_names = new Dictionary<string, string>();
  983. var project_notes_rows = new Vector<ClankerSaddle.Data.ProjectNote>();
  984. if (project != null) {
  985. note_project_names.set(((!)project).id.to_string(), ((!)project).name);
  986. foreach (var note in yield projects.list_notes(((!)project).id)) {
  987. project_notes_rows.add(note);
  988. }
  989. }
  990. foreach (var linked in co_developed) {
  991. note_project_names.set(linked.id.to_string(), linked.name);
  992. foreach (var note in yield projects.list_notes(linked.id)) {
  993. project_notes_rows.add(note);
  994. }
  995. }
  996. var project_notes = project_notes_rows.sort((a, b) => (int)(a.id - b.id));
  997. payload.set_member_name("project_notes");
  998. payload.begin_array();
  999. int64 note_entries = 0;
  1000. int64 note_chars = 0;
  1001. foreach (var note in project_notes) {
  1002. if (note_entries >= PROJECT_NOTES_MAX_ENTRIES
  1003. || note_chars >= PROJECT_NOTES_MAX_CHARS) {
  1004. break;
  1005. }
  1006. string note_body = note.body;
  1007. if (note_chars + note_body.length > PROJECT_NOTES_MAX_CHARS) {
  1008. note_body = note_body.substring(0,
  1009. (int)(PROJECT_NOTES_MAX_CHARS - note_chars));
  1010. }
  1011. payload.begin_object();
  1012. payload.set_member_name("project");
  1013. payload.add_string_value(note_project_names.get_or_default(note.project_id.to_string()) ?? "");
  1014. payload.set_member_name("body");
  1015. payload.add_string_value(note_body);
  1016. payload.end_object();
  1017. note_entries++;
  1018. note_chars += note_body.length;
  1019. }
  1020. payload.end_array();
  1021. // The wire contract's `callback` addition: this app's
  1022. // externally reachable base URL plus the bearer token the
  1023. // daemon presents when posting back — its own per-saddle
  1024. // credential (plan 10), which every `/api/saddle/*` call
  1025. // carries.
  1026. payload.set_member_name("callback");
  1027. payload.begin_object();
  1028. payload.set_member_name("base_url");
  1029. payload.add_string_value(callback_base_url());
  1030. payload.set_member_name("token");
  1031. payload.add_string_value(saddle.token ?? "");
  1032. payload.end_object();
  1033. payload.set_member_name("llm_config");
  1034. payload.begin_object();
  1035. payload.set_member_name("base_url");
  1036. payload.add_string_value(((!)endpoint).base_url);
  1037. payload.set_member_name("api_key");
  1038. payload.add_string_value(((!)endpoint).api_key);
  1039. payload.set_member_name("model");
  1040. payload.add_string_value(((!)endpoint).model);
  1041. payload.end_object();
  1042. // The web-side run row the daemon's pushed telemetry keys on
  1043. // (plan-10 phase-1 wire addition: it rides every assignment
  1044. // so /start consumers can ignore it harmlessly).
  1045. payload.set_member_name("session_id");
  1046. payload.add_int_value(session_id);
  1047. payload.end_object();
  1048. var root = payload.get_root().copy();
  1049. return root.get_object();
  1050. }
  1051. /**
  1052. * Probes a saddle's `GET /status` and records what a probe can
  1053. * tell: the health snapshot, the daemon-reported ssh key and
  1054. * the goal-branch push flags. Busy/idle need NO write anymore —
  1055. * they are derived from the session records (plan 16 item 23).
  1056. * The row's only remaining write is the OFFLINE probe state: a
  1057. * successful probe is proof of life and clears it; a failed
  1058. * probe stamps it through the guarded write (an ask claim that
  1059. * landed while the probe was in flight is a NEWER proof of
  1060. * life and must not be stomped).
  1061. */
  1062. public async void refresh_saddle_status(int64 id) throws Error {
  1063. var saddle = yield get_saddle(id);
  1064. if (saddle == null) {
  1065. throw new Error(Quark.from_string("clanker-saddle"), 17,
  1066. @"No saddle exists with id $(id)");
  1067. }
  1068. var session = yield running_session_on_saddle(id);
  1069. int64 seen_session = session != null ? ((!)session).id : 0;
  1070. try {
  1071. var status = yield request_saddle_json(((!)saddle).host, "GET", "/status", null);
  1072. yield record_status_probe(((!)saddle), status);
  1073. var clear = connection.create_command(
  1074. "UPDATE saddles SET status = '" + SADDLE_IDLE
  1075. + "' WHERE id = :id AND status = '" + SADDLE_OFFLINE + "'");
  1076. clear.with_parameter<Invercargill.Element>("id", elem<int64?>(id));
  1077. yield clear.execute_non_query_async();
  1078. } catch (Error e) {
  1079. yield stamp_offline(id, seen_session);
  1080. }
  1081. yield pulse.bump(0, "saddle status refreshed");
  1082. }
  1083. /**
  1084. * Answered blocks of `task_id` not yet handed over anywhere;
  1085. * marks them delivered — a resumed assignment carries its
  1086. * pending answers in the payload ({@link push_task_to_saddle}).
  1087. */
  1088. public async ImmutableLot<PendingAnswer> take_pending_answers_for_task(int64 task_id) throws Error {
  1089. var blocks = yield db.query<TaskBlock>()
  1090. .where(expr("task_id == $0", elem<int64?>(task_id)))
  1091. .materialise_async();
  1092. var rows = new Vector<PendingAnswer>();
  1093. foreach (var block in blocks) {
  1094. if (block.answer == null) {
  1095. continue;
  1096. }
  1097. if (!poll_state.mark_delivered(block.id)) {
  1098. continue;
  1099. }
  1100. rows.add(new PendingAnswer() {
  1101. block_id = block.id,
  1102. answer = raw_answer_for_saddle(((!)block).answer ?? "")
  1103. });
  1104. }
  1105. return rows.to_immutable_buffer();
  1106. }
  1107. /**
  1108. * Pushes the task's undelivered human comments to the RUNNING
  1109. * daemon's `POST /task-update {kind: "comment"}` endpoint so
  1110. * they land in the live conversation. Best effort: a daemon
  1111. * without the route (HTTP 404/405) keeps the comments pending,
  1112. * and other failures are logged and retried on the next
  1113. * dispatcher tick.
  1114. */
  1115. public async void deliver_pending_comments(int64 saddle_id, int64 task_id) throws Error {
  1116. var saddle = yield get_saddle(saddle_id);
  1117. if (saddle == null) {
  1118. return;
  1119. }
  1120. var comments = yield db.query<TaskComment>()
  1121. .where(expr("task_id == $0", elem<int64?>(task_id)))
  1122. .order_by(expr("id"))
  1123. .materialise_async();
  1124. foreach (var comment in comments) {
  1125. if (!comment.author.has_prefix("user:")
  1126. || poll_state.is_comment_delivered(comment.id)) {
  1127. continue;
  1128. }
  1129. var payload = new Json.Builder();
  1130. payload.begin_object();
  1131. payload.set_member_name("kind");
  1132. payload.add_string_value("comment");
  1133. payload.set_member_name("text");
  1134. payload.add_string_value(comment.body);
  1135. payload.set_member_name("quote");
  1136. payload.add_string_value(comment.quote ?? "");
  1137. payload.end_object();
  1138. Json.Node? root = null;
  1139. int status_code = yield request_saddle(((!)saddle).host, "POST",
  1140. "/task-update", (owned) payload, out root);
  1141. if (status_code == 200 || status_code == 204) {
  1142. poll_state.mark_comment_delivered(comment.id);
  1143. } else if (status_code != 404 && status_code != 405) {
  1144. warning(@"[ClankerSaddle] Comment task-update to saddle $(saddle_id) returned HTTP $(status_code)");
  1145. }
  1146. }
  1147. }
  1148. /**
  1149. * Marks the task's human comments as delivered — they were handed
  1150. * over inside an assignment payload, so neither the poll endpoint
  1151. * nor `/notify` should repeat them.
  1152. */
  1153. public async void mark_task_comments_delivered(int64 task_id) throws Error {
  1154. var comments = yield db.query<TaskComment>()
  1155. .where(expr("task_id == $0", elem<int64?>(task_id)))
  1156. .materialise_async();
  1157. foreach (var comment in comments) {
  1158. if (comment.author.has_prefix("user:")) {
  1159. poll_state.mark_comment_delivered(comment.id);
  1160. }
  1161. }
  1162. }
  1163. /**
  1164. * The goal id when `task_id` is a goal agent's run task (kind
  1165. * 'goal_run'), else 0 — the discriminator for the goal-context
  1166. * verb handling above.
  1167. */
  1168. private async int64 goal_id_of_run(int64 task_id) throws Error {
  1169. var task = yield db.query<TaskEntity>()
  1170. .where(expr("id == $0", elem<int64?>(task_id)))
  1171. .first_async();
  1172. return task != null && ((!)task).kind == TASK_KIND_GOAL_RUN
  1173. ? ((!)task).goal_id : 0;
  1174. }
  1175. /**
  1176. * Raises the daemon's pushed blocking prompt as a `task_blocks`
  1177. * row exactly once: a not-yet-answered row, or an answered row
  1178. * whose answer has not been delivered to the daemon yet, means the
  1179. * prompt is already represented. The results push's `block`
  1180. * object carries the question plus the block type, options and
  1181. * markdown body (the `/status` field names — `block_question`
  1182. * &c — are accepted as a fallback). `saddle block rfc` carries
  1183. * no question text, so rfc prompts surface under a stable
  1184. * fallback title.
  1185. */
  1186. private async void ensure_block(TaskService tasks, int64 task_id, Json.Object block) throws Error {
  1187. string question = block_question_from_payload(block);
  1188. if (question.length == 0) {
  1189. return;
  1190. }
  1191. string incoming_type = block_string_or(block, "type", "block_type");
  1192. foreach (var existing in yield tasks.list_blocks(task_id)) {
  1193. if (existing.answer == null || !poll_state.is_delivered(existing.id)) {
  1194. // A pending review-family block of EITHER kind
  1195. // (review/completion) swallows the other's push: the
  1196. // daemon's done park and the web's completion raise
  1197. // are the SAME review raised from two paths — one
  1198. // row, whichever landed first.
  1199. if ((incoming_type == BLOCK_REVIEW || incoming_type == BLOCK_COMPLETION)
  1200. && (existing.block_type == BLOCK_REVIEW
  1201. || existing.block_type == BLOCK_COMPLETION)) {
  1202. return;
  1203. }
  1204. }
  1205. if (existing.question != question) {
  1206. continue;
  1207. }
  1208. if (existing.answer == null || !poll_state.is_delivered(existing.id)) {
  1209. return;
  1210. }
  1211. }
  1212. yield tasks.add_block(task_id, question,
  1213. block_string_or(block, "type", "block_type"),
  1214. EntryJson.string_array_member(block, block.has_member("options") ? "options" : "block_options"),
  1215. EntryJson.optional_string_member(block, block.has_member("body") ? "body" : "block_body"));
  1216. }
  1217. /** The first non-empty of two string members (empty when both are). */
  1218. private static string block_string_or(Json.Object obj, string primary, string fallback) {
  1219. string value = EntryJson.string_member(obj, primary);
  1220. return value.length > 0 ? value : EntryJson.string_member(obj, fallback);
  1221. }
  1222. /**
  1223. * The question a daemon block is recognised by: `question`
  1224. * (or the `/status`-style `block_question`) verbatim, or the
  1225. * rfc fallback when the daemon raised an RFC (the
  1226. * `saddle block rfc` shape carries its document as the body and
  1227. * no question operand). Both the block-raising and answer-
  1228. * delivery paths key blocks by this string.
  1229. */
  1230. private static string block_question_from_payload(Json.Object block) {
  1231. string question = block_string_or(block, "question", "block_question");
  1232. if (question.length > 0) {
  1233. return question;
  1234. }
  1235. return block_string_or(block, "type", "block_type") == BLOCK_RFC
  1236. ? "Request for comments" : "";
  1237. }
  1238. /**
  1239. * Applies a pushed state transition (`POST /api/saddle/results`,
  1240. * plan 10 phase 2) — the same task/saddle bookkeeping the
  1241. * deleted /status poll switch ran, driven by the daemon's push:
  1242. * `done`/`failed` complete or fail the task with the pushed
  1243. * summary (closing the session through the release owner) —
  1244. * except on a goal-run session, where they submit the
  1245. * completion report or fail the goal (plan 12); `blocked`
  1246. * raises the block payload as an answerable block on the
  1247. * goal run's OWN task row (plan 15 C1: the goal agent's
  1248. * blocks ride the run task — no mirrored second row exists)
  1249. * and parks the round (the raise closes its session 'parked',
  1250. * D4); `suspended`
  1251. * parks the same way — for a goal agent the resume is driven
  1252. * by the goal's task-terminal events; and `working` flips an
  1253. * ASSIGNED task to working (a parked task keeps its park —
  1254. * the verdict arrives as a resuming allocation, never as a
  1255. * state push; plan 16 D4). No saddle-row write remains
  1256. * (plan 16 item 23): busy/idle derive from the sessions.
  1257. *
  1258. * Plan 13 B2: a push against a task that is already terminal —
  1259. * or a goal-context push against a finished goal — is an
  1260. * outbox redelivery and is consumed silently, whatever state
  1261. * it carries; terminal transitions and their narrative fire
  1262. * once, ever.
  1263. */
  1264. public async void apply_result_state(ClankerSaddle.Data.Session session,
  1265. string state, Json.Object? block, string? summary, TaskService tasks) throws Error {
  1266. int64 task_id = session.task_id;
  1267. int64 goal_id = yield goal_id_of_run(task_id);
  1268. bool terminal = yield task_is_terminal(task_id);
  1269. if (!terminal && goal_id > 0) {
  1270. terminal = yield goal_is_terminal(goal_id);
  1271. }
  1272. if (terminal) {
  1273. return;
  1274. }
  1275. switch (state) {
  1276. case "done":
  1277. if (goal_id > 0) {
  1278. // Goal context: `saddle done` submits the
  1279. // completion report and enters completion
  1280. // review — the run parks on the mirrored
  1281. // block (submit_completion's raise already
  1282. // closed the session 'parked'), it does not
  1283. // complete.
  1284. yield goal_service.submit_completion(goal_id,
  1285. summary != null && summary.length > 0 ? summary
  1286. : "The goal agent submitted its completion report");
  1287. break;
  1288. }
  1289. yield tasks.complete_task(task_id, summary, OUTCOME_DONE);
  1290. break;
  1291. case "failed":
  1292. string explanation = summary ?? "";
  1293. if (goal_id > 0) {
  1294. yield goal_service.fail_goal(goal_id,
  1295. explanation.length > 0 ? explanation : null);
  1296. break;
  1297. }
  1298. yield tasks.fail_task(task_id, explanation.length > 0 ? explanation : null);
  1299. break;
  1300. case "blocked":
  1301. if (goal_id > 0) {
  1302. // Goal context: the block lands on the run
  1303. // task itself (plan 15 C1) — a plan payload
  1304. // enters plan review, anything else raises a
  1305. // goal question/RFC; the run parks on that
  1306. // very row for the verdict resume. A BLOCKED
  1307. // push WITHOUT a
  1308. // block payload (the park-unwinding round-end
  1309. // push, its trailing entries) raises nothing —
  1310. // the payload-carrying transition already did,
  1311. // and a synthesised fallback row would dupe it.
  1312. if (block == null) {
  1313. yield tasks.set_status(task_id, STATUS_BLOCKED);
  1314. yield tasks.park_open_sessions(task_id);
  1315. break;
  1316. }
  1317. string question = block_question_from_payload((!)block);
  1318. if (question.length == 0) {
  1319. question = "The goal agent raised a question";
  1320. }
  1321. if (block_string_or((!)block, "type", "block_type") == BLOCK_PLAN) {
  1322. yield goal_service.submit_plan(goal_id,
  1323. EntryJson.optional_string_member((!)block, "body") ?? question);
  1324. } else if (block_string_or((!)block, "type", "block_type") == BLOCK_REVIEW) {
  1325. // The daemon's submit_report done-park: a
  1326. // review-kind block on the goal run IS the
  1327. // completion submission (submit_completion
  1328. // adopts the raised row — one row, whichever
  1329. // path landed first — and drives the
  1330. // completion_review transition).
  1331. yield goal_service.submit_completion(goal_id,
  1332. EntryJson.optional_string_member((!)block, "body") ?? question);
  1333. } else {
  1334. yield goal_service.raise_agent_block(goal_id, question,
  1335. block_string_or((!)block, "type", "block_type"),
  1336. EntryJson.string_array_member((!)block,
  1337. (!)block.has_member("options") ? "options" : "block_options"),
  1338. EntryJson.optional_string_member((!)block, "body"));
  1339. }
  1340. yield tasks.set_status(task_id, STATUS_BLOCKED);
  1341. break;
  1342. }
  1343. yield ensure_block(tasks, task_id, block != null ? (!)block : new Json.Object());
  1344. {
  1345. // A review-kind block parks as review_requested
  1346. // (the banner state); every other kind as
  1347. // blocked.
  1348. bool review = block != null
  1349. && block_string_or((!)block, "type", "block_type") == BLOCK_REVIEW;
  1350. yield tasks.set_status(task_id,
  1351. review ? STATUS_REVIEW_REQUESTED : STATUS_BLOCKED);
  1352. }
  1353. // The daemon parks the task's workspace while
  1354. // blocked, freeing the saddle for other work
  1355. // (plan 16 item 23: the raise already closed the
  1356. // round's session 'parked' and the task's blocked
  1357. // status IS the pause — no saddle-row write
  1358. // remains; busy/idle derive from the sessions).
  1359. break;
  1360. case "suspended":
  1361. // `saddle suspend`: the same parking as a block, but
  1362. // the web drives resumption — a wake of the affine
  1363. // saddle once a goal event lands, the resume
  1364. // assignment carrying the coalesced report.
  1365. // (set_parked closed the round's session 'parked';
  1366. // the freed saddle is the derived view's reading.)
  1367. yield tasks.set_parked(task_id);
  1368. break;
  1369. case "working":
  1370. // A working push only advances a task that is
  1371. // actually running — assigned (the claim's flip)
  1372. // or a legacy never-allocated queued row. A
  1373. // PARKED task (blocked / review / suspended, its
  1374. // session closed at the raise — plan 16 D4) keeps
  1375. // its park: the verdict arrives as a resuming
  1376. // allocation, never as a state push, so a
  1377. // park-unwinding or late round push can never
  1378. // resurrect the workspace as "working" out from
  1379. // under its review banner.
  1380. var advance = connection.create_command(
  1381. "UPDATE tasks SET status = 'working' WHERE id = :id"
  1382. + " AND status IN ('assigned', 'queued')"
  1383. + " AND status != 'working'");
  1384. advance.with_parameter<Invercargill.Element>("id",
  1385. elem<int64?>(task_id));
  1386. bool flipped = (yield advance.execute_non_query_async()) > 0;
  1387. var current = yield db.query<TaskEntity>()
  1388. .where(expr("id == $0", elem<int64?>(task_id)))
  1389. .first_async();
  1390. if (flipped) {
  1391. var moved = new Json.Object();
  1392. moved.set_string_member("from", STATUS_ASSIGNED);
  1393. moved.set_string_member("to", STATUS_WORKING);
  1394. moved.set_string_member("text", "Status → working");
  1395. yield event_service.append(EVENT_STATE_CHANGE,
  1396. EVENT_ACTOR_SYSTEM, EVENT_SCOPE_TASK, task_id, moved);
  1397. }
  1398. // started_at (and the first-start push) only stamp
  1399. // for work that never allocated; the guarded
  1400. // advance above means the re-read task is working
  1401. // exactly when the flip landed or it already ran.
  1402. if (current != null && ((!)current).status == STATUS_WORKING
  1403. && ((!)current).started_at == null) {
  1404. ((!)current).started_at = new DateTime.now_local();
  1405. yield db.update_async<TaskEntity>((!)current);
  1406. // The first-start push lives at the allocation
  1407. // (where started_at is stamped for dispatched
  1408. // tasks); this path only covers legacy status
  1409. // pushes for never-allocated work.
  1410. push.notify_opted_in(task_id, PushService.lifecycle_notification(
  1411. ((!)current).title, task_id, "started", null));
  1412. }
  1413. break;
  1414. default:
  1415. break;
  1416. }
  1417. }
  1418. /** True when the task's status is done or failed. */
  1419. private async bool task_is_terminal(int64 task_id) throws Error {
  1420. var task = yield db.query<TaskEntity>()
  1421. .where(expr("id == $0", elem<int64?>(task_id)))
  1422. .first_async();
  1423. return task != null && (((!)task).status == STATUS_DONE
  1424. || ((!)task).status == STATUS_FAILED);
  1425. }
  1426. /** True when the goal's status is complete, failed or cancelled. */
  1427. private async bool goal_is_terminal(int64 goal_id) throws Error {
  1428. var goal = yield db.query<Goal>()
  1429. .where(expr("id == $0", elem<int64?>(goal_id)))
  1430. .first_async();
  1431. return goal != null && (((!)goal).status == GOAL_COMPLETE
  1432. || ((!)goal).status == GOAL_FAILED || ((!)goal).status == GOAL_CANCELLED);
  1433. }
  1434. /**
  1435. * The running-liveness probe (plan 10 phase 2): a busy saddle's
  1436. * session has seen neither chunks nor results for the staleness
  1437. * window, so ONE exceptional `GET /status` decides its fate —
  1438. * an answer means the daemon is alive (the health snapshot is
  1439. * refreshed and the task keeps running; the silence episode
  1440. * gets no second probe), a failure requeues the task with an agent
  1441. * comment and marks the saddle offline (the normal unreachable
  1442. * bookkeeping). The probe flag was taken by the caller; the
  1443. * next push resets it.
  1444. */
  1445. public async void probe_stale_saddle(Saddle saddle, TaskService tasks,
  1446. int64 running_task_id) throws Error {
  1447. try {
  1448. var status = yield request_saddle_json(saddle.host, "GET", "/status", null);
  1449. yield record_status_probe(saddle, status);
  1450. print(@"[ClankerSaddle] Stale saddle '$(saddle.name)' answered its exceptional status probe; task $(running_task_id) keeps running\n");
  1451. } catch (Error e) {
  1452. warning(@"[ClankerSaddle] Saddle '$(saddle.name)' sent neither chunks nor results for $(stale_after_seconds())s and its status probe failed ($(e.message)); marking offline");
  1453. yield mark_saddle_unreachable(saddle.id, tasks,
  1454. @"no chunks or results for $(stale_after_seconds())s; status probe failed ($(e.message))");
  1455. }
  1456. }
  1457. /**
  1458. * Performs an HTTP request against a saddle daemon and parses the
  1459. * response body as JSON; transport failures propagate to the
  1460. * caller (the dispatcher marks the saddle unreachable). Transport
  1461. * blips — typically a pooled keep-alive connection closed by the
  1462. * daemon at the same instant the request reused it — are retried
  1463. * once before giving up. Unlike {@link request_saddle_json} the
  1464. * HTTP status is returned rather than thrown, and array bodies
  1465. * parse too.
  1466. */
  1467. private async int request_saddle(string host, string method, string path,
  1468. owned Json.Builder? payload, out Json.Node? root) throws Error {
  1469. root = null;
  1470. GLib.Error? failure = null;
  1471. for (var attempt = 0; attempt < 2; attempt++) {
  1472. try {
  1473. root = null;
  1474. int status = yield request_saddle_once(host, method, path, payload, out root);
  1475. return status;
  1476. } catch (Error e) {
  1477. failure = e;
  1478. }
  1479. if (((!)failure).code != 13 && ((!)failure).code != 14) {
  1480. break;
  1481. }
  1482. GLib.Timeout.add(250, request_saddle.callback);
  1483. yield;
  1484. }
  1485. throw (!)failure;
  1486. }
  1487. private async int request_saddle_once(string host, string method, string path,
  1488. Json.Builder? payload, out Json.Node? root) throws Error {
  1489. root = null;
  1490. string base_url = host.has_prefix("http") ? host : @"http://$(host)";
  1491. var message = new Soup.Message(method, @"$(base_url)$(path)");
  1492. if (payload != null) {
  1493. var generator = new Json.Generator();
  1494. generator.set_root(((!)payload).get_root());
  1495. message.set_request_body_from_bytes("application/json",
  1496. new Bytes(generator.to_data(null).data));
  1497. }
  1498. InputStream input;
  1499. try {
  1500. input = yield http.send_async(message, Priority.DEFAULT, null);
  1501. } catch (Error e) {
  1502. throw new Error(Quark.from_string("clanker-saddle"), 13,
  1503. @"Saddle request to $(base_url)$(path) failed: $(e.message)");
  1504. }
  1505. var output = new MemoryOutputStream.resizable();
  1506. try {
  1507. yield output.splice_async(input, OutputStreamSpliceFlags.CLOSE_SOURCE,
  1508. Priority.DEFAULT, null);
  1509. yield output.close_async(Priority.DEFAULT, null);
  1510. } catch (Error e) {
  1511. throw new Error(Quark.from_string("clanker-saddle"), 14,
  1512. @"Failed reading saddle response: $(e.message)");
  1513. }
  1514. var written = output.get_data_size();
  1515. uint8[] data = output.steal_data();
  1516. // steal_data's array length is -1 and the buffer is sized to
  1517. // the data — there is no NUL terminator to rely on — so slice
  1518. // by the written byte count before reading it as a string.
  1519. string response_text = written > 0 && data != null && data[0] != '\0'
  1520. ? ((string) data).substring(0, (long) written) : "{}";
  1521. var parser = new Json.Parser();
  1522. try {
  1523. parser.load_from_data(response_text);
  1524. } catch (Error e) {
  1525. throw new Error(Quark.from_string("clanker-saddle"), 16,
  1526. @"Saddle returned invalid JSON: $(e.message)");
  1527. }
  1528. root = parser.get_root();
  1529. return (int) message.status_code;
  1530. }
  1531. /**
  1532. * Performs an HTTP request against a saddle daemon and returns the
  1533. * parsed JSON object body (an empty object for non-object bodies);
  1534. * HTTP statuses of 400 and above throw.
  1535. */
  1536. private async Json.Object request_saddle_json(string host, string method, string path,
  1537. owned Json.Builder? payload) throws Error {
  1538. Json.Node? root = null;
  1539. int status_code = yield request_saddle(host, method, path, (owned) payload, out root);
  1540. if (status_code == 409) {
  1541. // Machine-readable per the wire contract: the body (state,
  1542. // running_task_id) rides in the message so the dispatch
  1543. // path can distinguish zombie conflicts from transport
  1544. // failures and recover.
  1545. string body_text = "{}";
  1546. if (root != null && root.get_node_type() == Json.NodeType.OBJECT) {
  1547. var generator = new Json.Generator();
  1548. generator.set_root(root);
  1549. body_text = generator.to_data(null);
  1550. }
  1551. throw new Error(Quark.from_string("clanker-saddle"), 409,
  1552. @"busy: $(body_text)");
  1553. }
  1554. if (status_code >= 400) {
  1555. throw new Error(Quark.from_string("clanker-saddle"), 15,
  1556. @"Saddle request returned HTTP $(status_code)");
  1557. }
  1558. if (root == null || root.get_node_type() != Json.NodeType.OBJECT) {
  1559. return new Json.Object();
  1560. }
  1561. return root.get_object();
  1562. }
  1563. /**
  1564. * A `{"name", "git_url", "path", "branch"}` project reference in
  1565. * the saddle daemon's wire shape: `git_url` is the clone source,
  1566. * and the checkout lives at
  1567. * `SADDLE_PROJECTS_ROOT/task-<task_id>/<name>` inside the
  1568. * saddle's container — one workspace per task, so concurrent
  1569. * tasks never share a checkout.
  1570. */
  1571. /**
  1572. * The projects root assignment paths are addressed against: the
  1573. * ASKING saddle's registered `workspace_root` (the daemon's own
  1574. * SADDLE_WORKSPACE_ROOT, reported at /register — goal 8 task 6,
  1575. * so a fleet no longer assumes one shared /workspace mount),
  1576. * else the `SADDLE_PROJECTS_ROOT` environment override, else
  1577. * the historical "/workspace" default. Paths are ADVISORY: the
  1578. * daemon owns its filesystem and rewrites them onto its own
  1579. * workspace root.
  1580. */
  1581. private static string projects_root_for_saddle(Saddle? saddle) {
  1582. var registered = saddle != null ? ((!)saddle).workspace_root : null;
  1583. if (registered != null && ((!)registered).strip().length > 0) {
  1584. return ((!)registered).strip();
  1585. }
  1586. var root_var = (Environment.get_variable("SADDLE_PROJECTS_ROOT") ?? "").strip();
  1587. return root_var.length > 0 ? root_var : "/workspace";
  1588. }
  1589. private static Json.Node project_node(Project project, int64 task_id, bool is_reference = false,
  1590. Saddle? saddle = null) {
  1591. string projects_root = projects_root_for_saddle(saddle);
  1592. var builder = new Json.Builder();
  1593. builder.begin_object();
  1594. builder.set_member_name("name");
  1595. builder.add_string_value(project.name);
  1596. builder.set_member_name("git_url");
  1597. builder.add_string_value(project.git_url);
  1598. builder.set_member_name("path");
  1599. builder.add_string_value(is_reference
  1600. ? @"$(projects_root)/task-$(task_id)/reference/$(project.name)"
  1601. : @"$(projects_root)/task-$(task_id)/$(project.name)");
  1602. builder.set_member_name("branch");
  1603. builder.add_string_value(project.default_branch);
  1604. builder.end_object();
  1605. return builder.get_root();
  1606. }
  1607. /**
  1608. * The answer as the saddle daemon expects it over /answer (or in a
  1609. * resume payload): verbatim. The daemon applies its own
  1610. * BlockRequest formatting, so this only strips the "Human
  1611. * selected: …"/"Human answered (other): …" prefixes still stored
  1612. * by answers recorded before the daemon took over formatting.
  1613. */
  1614. private static string raw_answer_for_saddle(string answer) {
  1615. if (answer.has_prefix("Human selected: ")) {
  1616. return answer.substring("Human selected: ".length);
  1617. }
  1618. if (answer.has_prefix("Human answered (other): ")) {
  1619. return answer.substring("Human answered (other): ".length);
  1620. }
  1621. return answer;
  1622. }
  1623. }
  1624. }