subtasks.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. """The subtasks bundle (moved verbatim from the pre-split web_stack_test.py;
  2. see test/harness.py for the shared machinery and the runner)."""
  3. from harness import * # noqa: F401,F403 — the shared wire helpers
  4. # =============================================================================
  5. # Bundle: subtasks — the plan-12 task graph: plain-task parking (no
  6. # subtask events exist to resume it), the DELETED subtask machinery's
  7. # verbatim refusals, and the goal agent's task CLI in depth
  8. # (capabilities, ownership, the daemon's task-reading API, the
  9. # goal-run block answered from the task page).
  10. # =============================================================================
  11. def run(ctx):
  12. auth, fake, fake_id, fake_token = ctx.auth, ctx.fake, ctx.fake_id, ctx.fake_token
  13. app = ctx.app
  14. # A hold tag nothing carries: goal tasks created with it stay
  15. # queued (a stable parked agent) until released at the settle.
  16. saddles_state = slot_public(auth, "/saddles", "saddles")
  17. act(auth, saddles_state["create_capability"], {"name": "gpu"})
  18. gpu_cap_id = [c["id"] for c in slot_public(auth, "/saddles", "saddles")["capabilities"]
  19. if c["name"] == "gpu"][0]
  20. def create_goal_row(fields):
  21. act(auth, slot_public(auth, "/goals", "goals")["create"], fields)
  22. rows = [g for g in slot_public(auth, "/goals", "goals")["goals"]
  23. if g["title"] == fields["title"]]
  24. return int(rows[0]["id"])
  25. print("== B7: plain-task parking stays put (no subtask events) ==")
  26. fake.suspend_on_title = "Suspend plain"
  27. pushes_before = len(fake.start_payloads)
  28. plain = create_queued(auth, {
  29. "project_id": "1",
  30. "title": "Suspend plain",
  31. "priority": "2",
  32. })
  33. check(wait_for(lambda: task_state(auth, plain).get("status") == "suspended"
  34. or False, timeout=60, what="plain task parks") is not None,
  35. "daemon 'suspended' state parks the task")
  36. detail = task_state(auth, plain)
  37. check(detail.get("display") == "parked",
  38. "a parked plain task displays as parked (%s)" % detail.get("display"))
  39. check(any(s["status"] == "idle"
  40. for s in slot_public(auth, "/saddles", "saddles")["saddles"]),
  41. "suspension frees the saddle")
  42. first_push = fake.start_payloads[pushes_before]
  43. check(first_push.get("kind") == "task" and first_push.get("creator") == "human"
  44. and first_push.get("mode") == "implement",
  45. "the /start payload carries the plan-12 identity triplet (kind/creator/mode)")
  46. check("subtasks" not in first_push and "parent" not in first_push,
  47. "the plan-12 payload drops the subtasks/parent members")
  48. # Other tasks finishing must NOT resume a parked plain task (the
  49. # subtask-event channel is gone; only goal events resume goal runs).
  50. conn = db()
  51. conn.execute("insert into tasks (project_id, title, status, priority, summary,"
  52. " completed_at, created_at) values (1, 'Unrelated finisher', 'done',"
  53. " 5, 'finished loudly', strftime('%s','now','+1 second'),"
  54. " strftime('%s','now'))")
  55. conn.commit()
  56. conn.close()
  57. check(quiet_window(quiet_seconds(),
  58. lambda: task_state(auth, plain)["status"] == "suspended",
  59. "plain park survives unrelated finishes"),
  60. "a parked plain task never resumes on other tasks' events")
  61. print("== the deleted machinery refuses, verbatim ==")
  62. status, body = saddle_api("POST", "/api/saddle/subtask", fake_token,
  63. {"parent_task_id": plain, "title": "ghost"})
  64. check(status == 404,
  65. "the /api/saddle/subtask callback route is gone (404)")
  66. # The poll-era ingest channel went with it (arch 19): the whole
  67. # /update surface 404s — subtask and plan verb arms included — and
  68. # the verdict channel (/poll) with it.
  69. conn = db()
  70. plain_session = conn.execute(
  71. "select id from sessions where task_id = ? and ended_at is null",
  72. (plain,)).fetchone()[0]
  73. conn.close()
  74. status, body = saddle_api("POST", "/api/saddle/%d/update" % fake_id, fake_token,
  75. {"type": "subtask", "session_id": plain_session,
  76. "data": {"title": "Ghost child"}})
  77. check(status == 404,
  78. "the /update ingest surface is gone (404, the subtask arm with it)")
  79. status, body = saddle_api("POST", "/api/saddle/%d/update" % fake_id, fake_token,
  80. {"type": "plan", "session_id": plain_session,
  81. "data": {"plan": "# nope"}})
  82. check(status == 404,
  83. "the task-level 'plan' update arm is gone with the channel (404)")
  84. # The daemon's task CLI: no parent/plan members; goal context instead.
  85. status, body = saddle_api("GET", "/api/saddle/task/%d" % plain, fake_token)
  86. check(status == 200 and "plan" not in body.get("task", {})
  87. and "parent_task_id" not in body.get("task", {})
  88. and "goal_id" in body.get("task", {}),
  89. "the task CLI detail drops parent/plan and carries goal_id")
  90. status, body = saddle_api("GET", "/api/saddle/tasks?project_id=1", fake_token)
  91. check(status == 200 and all("parent_task_id" not in t and "goal_id" in t
  92. for t in body.get("tasks", [])),
  93. "the task CLI list drops parent_task_id and carries goal_id")
  94. # Settle the parked plain task: nothing will resume it — cancel it.
  95. act(auth, task_state(auth, plain)["cancel"], {})
  96. check(wait_for(lambda: task_state(auth, plain)["status"] == "failed" or False,
  97. timeout=30, what="parked plain task cancelled") is not None,
  98. "the parked plain task cancels (the human cleanup path)")
  99. print("== the goal agent's task CLI in depth ==")
  100. goal = create_goal_row({"project_id": "1", "title": "Sequencing goal",
  101. "brief": "Fan tasks out with the CLI.",
  102. "branch_name": "goal/sequence"})
  103. fake.goal_script = [("plan", "# Plan\n\n1. Fan out")]
  104. act(auth, goal_state(goal)["start"], {})
  105. check(wait_for(lambda: goal_state(goal)["status"] == "plan_review" or False,
  106. timeout=60, what="sequencing goal plans") is not None,
  107. "the sequencing goal reaches plan review")
  108. fake.goal_script = [("suspend",)]
  109. act(auth, goal_state(goal)["plan_pending_block"]["answer_action"],
  110. {"answer": "Plan approved. Proceed with implementation."})
  111. conn = db()
  112. run_task = conn.execute(
  113. "select id from tasks where goal_id = ? and kind = 'goal_run'",
  114. (goal,)).fetchone()[0]
  115. agent_session = conn.execute(
  116. "select id from sessions where task_id = ? and ended_at is null",
  117. (run_task,)).fetchone()[0]
  118. conn.close()
  119. check(wait_for(lambda: task_state(auth, run_task)["status"] == "suspended"
  120. or False, timeout=60, what="sequencing agent parks") is not None,
  121. "the sequencing goal agent parks")
  122. # Create with capabilities: attached until assignment (the hold
  123. # keeps the task queued), mandate + mode stored, ephemeral branch.
  124. status, body = saddle_api("POST", "/api/saddle/task/create", fake_token, {
  125. "session_id": agent_session, "title": "Held worker",
  126. "mandate": "Do the held work.", "mode": "review",
  127. "capabilities": ["gpu"]})
  128. check(status == 200 and body.get("ok") is True,
  129. "task create with a capability tag succeeds")
  130. held = int(body["task_id"])
  131. detail = task_state(auth, held)
  132. check(detail["status"] == "draft" and detail["mode"] == "review",
  133. "the held worker drafts with its mode")
  134. check([c["name"] for c in detail.get("capabilities", [])] == ["gpu"],
  135. "the capability attaches to the created task")
  136. check(detail["task_branch"] == "task/%d-%d" % (goal, held)
  137. and detail["branch_from"] == "goal/sequence",
  138. "the CLI task's ephemeral branch pair is stored")
  139. status, body = saddle_api("POST", "/api/saddle/task/enqueue", fake_token, {
  140. "session_id": agent_session, "task_id": held})
  141. check(status == 200, "the held worker enqueues")
  142. check(quiet_window(3, lambda: task_state(auth, held)["status"] == "queued",
  143. "unsatisfiable capability holds dispatch"),
  144. "the unsatisfiable capability holds the queued worker (never assigned)")
  145. queue_row = [t for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  146. if int(t["id"]) == held][0]
  147. check(queue_row.get("waiting_for_saddle") is True,
  148. "the queue annotates the held worker as waiting for a saddle")
  149. # The human edit path: goal tasks never carry enqueue-after (the
  150. # field is human-tasks-only — the update silently ignores it).
  151. act(auth, task_state(auth, held)["update"], {
  152. "priority": "4", "enqueue_after_task_id": str(held - 1)})
  153. check(task_state(auth, held).get("enqueue_after_task_id") in (None, 0, "0"),
  154. "a goal task ignores the auto-enqueue-after field on edit")
  155. # Ownership: the same saddle's goal agent cannot touch another
  156. # goal's task; a plain session is not a goal-agent identity.
  157. other_goal = create_goal_row({"project_id": "1", "title": "Other goal",
  158. "brief": "b", "branch_name": "goal/other",
  159. "priority": "9"})
  160. status, body = saddle_api("POST", "/api/saddle/task/create", fake_token,
  161. {"session_id": agent_session, "title": "x",
  162. "mandate": "m"})
  163. scope_task = int(body["task_id"])
  164. check(int(task_state(auth, scope_task)["goal_id"] or 0) == goal,
  165. "the callback scopes task creation to its own goal")
  166. status, body = saddle_api("POST", "/api/saddle/task/delete", fake_token,
  167. {"session_id": agent_session, "task_id": scope_task})
  168. check(status == 200, "the owned draft deletes")
  169. check(not any(t["id"] == scope_task
  170. for t in slot_public(auth, "/tasks", "tasks")["tasks"]),
  171. "the deleted task is gone from the queue")
  172. status, body = saddle_api("POST", "/api/saddle/task/enqueue", fake_token,
  173. {"session_id": agent_session, "task_id": plain})
  174. check(status == 400 and "does not belong to your goal" in (body.get("error") or ""),
  175. "the goal agent cannot enqueue a standalone task")
  176. # The daemon's task CLI detail carries the goal context.
  177. status, body = saddle_api("GET", "/api/saddle/task/%d" % held, fake_token)
  178. check(status == 200
  179. and int(body["task"].get("goal_id") or 0) == goal
  180. and body["task"].get("goal_title") == "Sequencing goal",
  181. "the task CLI detail names the owning goal")
  182. print("== the goal-run block answers from the task page (C1) ==")
  183. # A question block raised in goal context lands on the run task's
  184. # own row; the TASK page's answer action funnels into the one
  185. # goal answer path and resumes the agent with the verdict riding
  186. # the allocation (the goal page shares the path).
  187. fake.goal_script = [("question", "Which harness: pytest or vitest?",
  188. ["pytest", "vitest"])]
  189. conn = db()
  190. # Plan 15 C3: a terminal transition (a done task row newer than
  191. # the watermark) is the resume signal — no event rows exist.
  192. conn.execute("insert into tasks (project_id, goal_id, kind, title, status,"
  193. " priority, completed_at, created_at) values (1, ?, 'task',"
  194. " 'Nudge', 'done', 5,"
  195. " strftime('%s','now'),"
  196. " strftime('%s','now','-30 seconds'))", (goal,))
  197. conn.commit()
  198. conn.close()
  199. check(wait_for(lambda: task_state(auth, run_task)["status"] == "blocked"
  200. or False, timeout=60, what="goal block parks the run") is not None,
  201. "a goal question parks the run task on its own block row")
  202. run_detail = task_state(auth, run_task)
  203. question_block = [b for b in run_detail["blocks"]
  204. if b["block_type"] == "question"][0]
  205. check(question_block.get("question") == "Which harness: pytest or vitest?",
  206. "the run task's block carries the goal question verbatim")
  207. goal_block_ids = [int(b["id"]) for b in goal_state(goal)["blocks"]
  208. if b.get("block_type") == "question"]
  209. check(goal_block_ids == [int(question_block["id"])],
  210. "the goal page's card is the SAME row (no dual-row pair)")
  211. act(auth, question_block["answer_action"], {"answer": "pytest"})
  212. fake.goal_script = [("suspend",)]
  213. check(wait_for(lambda: task_state(auth, run_task)["status"] == "suspended"
  214. or False, timeout=60, what="answered block resumes the agent")
  215. is not None,
  216. "the answered block resumes the goal agent")
  217. answered = [b for b in task_state(auth, run_task)["blocks"]
  218. if b["block_type"] == "question"][0]
  219. check(answered.get("answer") == "pytest",
  220. "the verdict is stored on the single block row")
  221. print("== settle: release the hold and let the shard drain ==")
  222. # Drop the hold tag so the held worker can dispatch and complete;
  223. # the parked agent resumes on its completion, re-parks (script),
  224. # and the shard shuts down with an empty queue.
  225. act(auth, task_state(auth, held)["capabilities"][0]["detach"], {})
  226. check(wait_task_done(auth, held, timeout=90) is not None,
  227. "the released held worker completes")
  228. check(wait_for(lambda: any(e["kind"] == "task-done" and int(e["task_id"]) == held
  229. for e in goal_state(goal)["events"]) or False,
  230. timeout=30, what="narrative hears the held worker") is not None,
  231. "the narrative records the held worker's completion")
  232. act(auth, goal_state(goal)["cancel"], {})
  233. check(wait_for(lambda: goal_state(goal)["status"] == "cancelled" or False,
  234. timeout=30, what="sequencing goal cancelled") is not None,
  235. "the sequencing goal cancels (the shard's cleanup path)")