|
@@ -490,6 +490,10 @@ class FakeSaddle:
|
|
|
self.pending_block = None # the block payload this daemon waits on (state blocked)
|
|
self.pending_block = None # the block payload this daemon waits on (state blocked)
|
|
|
self.answer_log = [] # recorded POST /answer bodies
|
|
self.answer_log = [] # recorded POST /answer bodies
|
|
|
self.answer_push_delay = 0.2 # the resumed round's first push, after the response
|
|
self.answer_push_delay = 0.2 # the resumed round's first push, after the response
|
|
|
|
|
+ # --- plan 12: goal-agent scripting ---
|
|
|
|
|
+ self.goal_script = [] # behaviours consumed per goal-run assignment
|
|
|
|
|
+ self.goal_behaviour = None # the current round's popped behaviour
|
|
|
|
|
+ self.goal_assignments = [] # captured goal-run assignment payloads
|
|
|
|
|
|
|
|
def set_state(self, state, summary=None):
|
|
def set_state(self, state, summary=None):
|
|
|
"""Advances the scripted lifecycle."""
|
|
"""Advances the scripted lifecycle."""
|
|
@@ -665,6 +669,13 @@ class FakeSaddle:
|
|
|
# workspace still exists restores it; the first
|
|
# workspace still exists restores it; the first
|
|
|
# /status after the restore reports "resumed": true.
|
|
# /status after the restore reports "resumed": true.
|
|
|
self.resumed_pending = True
|
|
self.resumed_pending = True
|
|
|
|
|
+ # Plan 12: a goal-run assignment plays its scripted behaviour
|
|
|
|
|
+ # instead of the plain done push (plan submission, parking,
|
|
|
|
|
+ # question, completion, failure — consumed one per round).
|
|
|
|
|
+ self.goal_behaviour = None
|
|
|
|
|
+ if payload.get("kind") == "goal_run":
|
|
|
|
|
+ self.goal_assignments.append(payload)
|
|
|
|
|
+ self.goal_behaviour = self.goal_script.pop(0) if self.goal_script else ("suspend",)
|
|
|
self.set_state("failing" if self.fail_next else "working")
|
|
self.set_state("failing" if self.fail_next else "working")
|
|
|
self.fail_next = False
|
|
self.fail_next = False
|
|
|
# The round's opening telemetry: the flushed-so-far raw PTY
|
|
# The round's opening telemetry: the flushed-so-far raw PTY
|
|
@@ -806,6 +817,8 @@ class FakeSaddle:
|
|
|
"uptime_seconds": int(time.monotonic() - self.daemon.started_mono),
|
|
"uptime_seconds": int(time.monotonic() - self.daemon.started_mono),
|
|
|
"last_activity_epoch": self.daemon.last_activity_epoch,
|
|
"last_activity_epoch": self.daemon.last_activity_epoch,
|
|
|
"ssh_public_key": self.daemon.ssh_public_key}
|
|
"ssh_public_key": self.daemon.ssh_public_key}
|
|
|
|
|
+ if getattr(self.daemon, "status_extra", None):
|
|
|
|
|
+ response.update(self.daemon.status_extra)
|
|
|
if self.daemon.resumed_pending:
|
|
if self.daemon.resumed_pending:
|
|
|
response["resumed"] = True
|
|
response["resumed"] = True
|
|
|
self.daemon.resumed_pending = False
|
|
self.daemon.resumed_pending = False
|
|
@@ -843,7 +856,45 @@ class FakeSaddle:
|
|
|
self.maybe_trigger_ask("terminal")
|
|
self.maybe_trigger_ask("terminal")
|
|
|
elif self.state == "working":
|
|
elif self.state == "working":
|
|
|
if time.monotonic() - self.state_entered >= FAKE_WORK_S:
|
|
if time.monotonic() - self.state_entered >= FAKE_WORK_S:
|
|
|
- if self.suspend_on_title and self.suspend_on_title == self.running_title:
|
|
|
|
|
|
|
+ if self.goal_behaviour is not None:
|
|
|
|
|
+ # A goal-agent round: play the scripted
|
|
|
|
|
+ # behaviour (the results channel carries the
|
|
|
|
|
+ # goal-context verbs).
|
|
|
|
|
+ behaviour, self.goal_behaviour = self.goal_behaviour, None
|
|
|
|
|
+ kind = behaviour[0]
|
|
|
|
|
+ if kind == "plan":
|
|
|
|
|
+ self.set_state("blocked")
|
|
|
|
|
+ self.push_results("blocked", block={
|
|
|
|
|
+ "type": "plan",
|
|
|
|
|
+ "question": "Plan submitted for review",
|
|
|
|
|
+ "body": behaviour[1]})
|
|
|
|
|
+ if self.ask_on_idle_status:
|
|
|
|
|
+ self.maybe_trigger_ask("parked")
|
|
|
|
|
+ elif kind == "question":
|
|
|
|
|
+ self.set_state("blocked")
|
|
|
|
|
+ self.push_results("blocked", block={
|
|
|
|
|
+ "type": "question",
|
|
|
|
|
+ "question": behaviour[1],
|
|
|
|
|
+ "options": list(behaviour[2])
|
|
|
|
|
+ if len(behaviour) > 2 else None})
|
|
|
|
|
+ if self.ask_on_idle_status:
|
|
|
|
|
+ self.maybe_trigger_ask("parked")
|
|
|
|
|
+ elif kind == "suspend":
|
|
|
|
|
+ self.set_state("suspended")
|
|
|
|
|
+ self.push_results("suspended")
|
|
|
|
|
+ if self.ask_on_idle_status:
|
|
|
|
|
+ self.maybe_trigger_ask("parked")
|
|
|
|
|
+ elif kind == "fail":
|
|
|
|
|
+ self.set_state("failed", behaviour[1])
|
|
|
|
|
+ self.push_results("failed", summary=behaviour[1])
|
|
|
|
|
+ if self.ask_on_terminal:
|
|
|
|
|
+ self.maybe_trigger_ask("terminal")
|
|
|
|
|
+ else: # "done": the completion report
|
|
|
|
|
+ self.set_state("done", behaviour[1])
|
|
|
|
|
+ self.push_results("done", summary=behaviour[1])
|
|
|
|
|
+ if self.ask_on_terminal:
|
|
|
|
|
+ self.maybe_trigger_ask("terminal")
|
|
|
|
|
+ elif self.suspend_on_title and self.suspend_on_title == self.running_title:
|
|
|
# `saddle suspend`: park until a fresh assignment
|
|
# `saddle suspend`: park until a fresh assignment
|
|
|
# resumes the workspace (title-targeted one-shot,
|
|
# resumes the workspace (title-targeted one-shot,
|
|
|
# so an interleaved dispatch cannot eat the flag).
|
|
# so an interleaved dispatch cannot eat the flag).
|
|
@@ -914,6 +965,9 @@ def start_app():
|
|
|
"SADDLE_LOST_WAKE_S": "2",
|
|
"SADDLE_LOST_WAKE_S": "2",
|
|
|
"SADDLE_STALE_S": "3",
|
|
"SADDLE_STALE_S": "3",
|
|
|
"SADDLE_DISPATCH_TICK_S": "1",
|
|
"SADDLE_DISPATCH_TICK_S": "1",
|
|
|
|
|
+ # Plan 12: the goal-agent resume coalescing window shrinks to
|
|
|
|
|
+ # one second so the event-driven resume tests settle quickly.
|
|
|
|
|
+ "SADDLE_GOAL_RESUME_S": os.environ.get("SADDLE_GOAL_RESUME_S", "1"),
|
|
|
# Never inherit a caller-poisoned LD_LIBRARY_PATH (e.g. one that
|
|
# Never inherit a caller-poisoned LD_LIBRARY_PATH (e.g. one that
|
|
|
# resolves the ancient /lib64 copies): empty makes the loader use
|
|
# resolves the ancient /lib64 copies): empty makes the loader use
|
|
|
# the binary's RUNPATH, which is the dev stack it was built
|
|
# the binary's RUNPATH, which is the dev stack it was built
|
|
@@ -2571,8 +2625,11 @@ def bundle_subtasks(ctx):
|
|
|
for s in slot_public(auth, "/saddles", "saddles")["saddles"]),
|
|
for s in slot_public(auth, "/saddles", "saddles")["saddles"]),
|
|
|
"suspension frees the saddle")
|
|
"suspension frees the saddle")
|
|
|
first_push = fake.start_payloads[pushes_before]
|
|
first_push = fake.start_payloads[pushes_before]
|
|
|
- check(first_push.get("subtasks") == [],
|
|
|
|
|
- "the /start payload carries the subtasks array (empty here)")
|
|
|
|
|
|
|
+ check(first_push.get("kind") == "task" and first_push.get("creator") == "human"
|
|
|
|
|
+ and first_push.get("mode") == "implement",
|
|
|
|
|
+ "the /start payload carries the plan-12 identity triplet (kind/creator/mode)")
|
|
|
|
|
+ check("subtasks" not in first_push and "parent" not in first_push,
|
|
|
|
|
+ "the plan-12 payload drops the subtasks/parent members")
|
|
|
|
|
|
|
|
# Two subtask events land while parked (staged, not dispatched):
|
|
# Two subtask events land while parked (staged, not dispatched):
|
|
|
# both must coalesce into one resume with a full report.
|
|
# both must coalesce into one resume with a full report.
|
|
@@ -2607,10 +2664,8 @@ def bundle_subtasks(ctx):
|
|
|
check("Child two" in notice and "failed" in notice
|
|
check("Child two" in notice and "failed" in notice
|
|
|
and "the harness refused to start" in notice,
|
|
and "the harness refused to start" in notice,
|
|
|
"coalesced: the second event rides the same resume notice")
|
|
"coalesced: the second event rides the same resume notice")
|
|
|
- subtask_entries = {(s["title"], s["status"]) for s in resume_push.get("subtasks", [])}
|
|
|
|
|
- check(("Child one", "done") in subtask_entries
|
|
|
|
|
- and ("Child two", "failed") in subtask_entries,
|
|
|
|
|
- "subtasks array carries the children's states at dispatch time")
|
|
|
|
|
|
|
+ check("subtasks" not in resume_push,
|
|
|
|
|
+ "the plan-12 resume payload drops the subtasks member")
|
|
|
# The resumed arc surfaces on a /status read while the resumed
|
|
# The resumed arc surfaces on a /status read while the resumed
|
|
|
# round still runs — the operator's manual refresh in the push
|
|
# round still runs — the operator's manual refresh in the push
|
|
|
# world.
|
|
# world.
|
|
@@ -3051,18 +3106,8 @@ def bundle_lineage(ctx):
|
|
|
"lineage subtask dispatches")
|
|
"lineage subtask dispatches")
|
|
|
lineage_push = [p for p in fake.start_payloads
|
|
lineage_push = [p for p in fake.start_payloads
|
|
|
if int(p.get("task", {}).get("id") or 0) == lineage_task][-1]
|
|
if int(p.get("task", {}).get("id") or 0) == lineage_task][-1]
|
|
|
- parent_obj = lineage_push.get("parent")
|
|
|
|
|
- check(parent_obj is not None
|
|
|
|
|
- and int(parent_obj.get("id") or 0) == auto_parent
|
|
|
|
|
- and parent_obj.get("title") == "Auto parent"
|
|
|
|
|
- and parent_obj.get("status") in ("suspended", "working", "done"),
|
|
|
|
|
- "subtask /start carries the parent object")
|
|
|
|
|
- check(parent_obj.get("description") == "torque to spec"
|
|
|
|
|
- or "Parent plan" in (parent_obj.get("plan") or ""),
|
|
|
|
|
- "parent description/plan content flows through")
|
|
|
|
|
- check(len(parent_obj.get("plan") or "") <= 4000,
|
|
|
|
|
- "parent plan clamped to ~4000 chars (%d)"
|
|
|
|
|
- % len(parent_obj.get("plan") or ""))
|
|
|
|
|
|
|
+ check("parent" not in lineage_push,
|
|
|
|
|
+ "the plan-12 payload drops the parent member (subtask lineage is goal machinery now)")
|
|
|
root_pushes = [p for p in fake.start_payloads
|
|
root_pushes = [p for p in fake.start_payloads
|
|
|
if int(p.get("task", {}).get("id") or 0) == auto_parent]
|
|
if int(p.get("task", {}).get("id") or 0) == auto_parent]
|
|
|
check(all("parent" not in p for p in root_pushes),
|
|
check(all("parent" not in p for p in root_pushes),
|
|
@@ -5055,8 +5100,456 @@ def bundle_push(ctx):
|
|
|
"unsubscribing removes the endpoint's row")
|
|
"unsubscribing removes the endpoint's row")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+# =============================================================================
|
|
|
|
|
+# Bundle: goals — the plan-12 goal lifecycle: creation with branch-name
|
|
|
|
|
+# uniqueness, the start → planning → plan review → active flow with
|
|
|
|
|
+# revisions, the goal agent's task fan-out through the task CLI
|
|
|
|
|
+# callbacks (ownership + creator-only enqueue), the event-driven
|
|
|
|
|
+# coalesced resume, pause/resume queueing, the completion review with
|
|
|
|
|
+# branch push state, the cancel cascade, and the human tasks'
|
|
|
|
|
+# auto-enqueue-after predecessor.
|
|
|
|
|
+# =============================================================================
|
|
|
|
|
+
|
|
|
|
|
+def bundle_goals(ctx):
|
|
|
|
|
+ auth, fake, fake_id, fake_token = ctx.auth, ctx.fake, ctx.fake_id, ctx.fake_token
|
|
|
|
|
+ app = ctx.app
|
|
|
|
|
+
|
|
|
|
|
+ def goal_state(goal_id):
|
|
|
|
|
+ return slot_public(auth, "/goals/%d" % goal_id, "goal")
|
|
|
|
|
+
|
|
|
|
|
+ def create_goal(fields):
|
|
|
|
|
+ act(auth, slot_public(auth, "/goals", "goals")["create"], fields)
|
|
|
|
|
+ rows = [g for g in slot_public(auth, "/goals", "goals")["goals"]
|
|
|
|
|
+ if g["title"] == fields["title"]]
|
|
|
|
|
+ return int(rows[0]["id"])
|
|
|
|
|
+
|
|
|
|
|
+ print("== plan 12: goal creation, validation, lifecycle actions ==")
|
|
|
|
|
+ goal1 = create_goal({"project_id": "1", "title": "Ship the flange",
|
|
|
|
|
+ "brief": "Make the flange module production-grade.",
|
|
|
|
|
+ "branch_name": "goal/flange", "priority": "3"})
|
|
|
|
|
+ state = goal_state(goal1)
|
|
|
|
|
+ check(state["status"] == "draft" and state["display"] == "draft"
|
|
|
|
|
+ and state["branch_name"] == "goal/flange"
|
|
|
|
|
+ and state["base_branch"] == "main",
|
|
|
|
|
+ "a goal lands in draft with its branch pair")
|
|
|
|
|
+ check(state.get("start") is not None and state.get("cancel") is not None
|
|
|
|
|
+ and state.get("pause") is None,
|
|
|
|
|
+ "draft offers start + cancel only")
|
|
|
|
|
+ # Branch-name uniqueness: same name, same involved project → refuse.
|
|
|
|
|
+ duplicate_directives = act(auth, slot_public(auth, "/goals", "goals")["create"], {
|
|
|
|
|
+ "project_id": "1", "title": "Clash", "brief": "b", "branch_name": "goal/flange"})
|
|
|
|
|
+ check(any(d.get("type") == "notify" and d.get("kind") == "error"
|
|
|
|
|
+ and "goal/flange" in (d.get("message") or "")
|
|
|
|
|
+ for d in duplicate_directives),
|
|
|
|
|
+ "a live goal's branch name cannot be reused in an involved project (%s)"
|
|
|
|
|
+ % [d.get("message") for d in duplicate_directives if d.get("type") == "notify"])
|
|
|
|
|
+ conn = db()
|
|
|
|
|
+ conn.execute("insert into projects (name, git_url, default_branch, created_at)"
|
|
|
|
|
+ " values ('other-repo', 'https://example.com/other.git', 'main',"
|
|
|
|
|
+ " strftime('%s','now'))")
|
|
|
|
|
+ conn.commit()
|
|
|
|
|
+ conn.close()
|
|
|
|
|
+ goal_elsewhere = create_goal({"project_id": "2", "title": "Elsewhere",
|
|
|
|
|
+ "brief": "b", "branch_name": "goal/flange"})
|
|
|
|
|
+ check(goal_elsewhere > 0,
|
|
|
|
|
+ "the same branch name in a disjoint project is fine (one per repo)")
|
|
|
|
|
+ # Brief validation.
|
|
|
|
|
+ brief_directives = act(auth, slot_public(auth, "/goals", "goals")["create"], {
|
|
|
|
|
+ "project_id": "1", "title": "No brief", "brief": " ", "branch_name": "goal/x1"})
|
|
|
|
|
+ check(any(d.get("type") == "notify" and d.get("kind") == "error"
|
|
|
|
|
+ and "brief" in (d.get("message") or "").lower()
|
|
|
|
|
+ for d in brief_directives),
|
|
|
|
|
+ "an empty brief rejects")
|
|
|
|
|
+
|
|
|
|
|
+ # Start: planning + the goal-run internal task.
|
|
|
|
|
+ act(auth, state["start"], {})
|
|
|
|
|
+ state = goal_state(goal1)
|
|
|
|
|
+ check(state["status"] == "planning" and state.get("start") is None,
|
|
|
|
|
+ "start moves the goal to planning")
|
|
|
|
|
+ conn = db()
|
|
|
|
|
+ run_row = conn.execute(
|
|
|
|
|
+ "select id, kind, mode, creator, goal_id, priority, status from tasks"
|
|
|
|
|
+ " where goal_id = ? and kind = 'goal_run'", (goal1,)).fetchone()
|
|
|
|
|
+ conn.close()
|
|
|
|
|
+ check(run_row is not None and run_row[1] == "goal_run"
|
|
|
|
|
+ and run_row[2] == "orchestrate" and run_row[3] == "goal"
|
|
|
|
|
+ and run_row[4] == goal1 and run_row[5] == 3
|
|
|
|
|
+ and run_row[6] in ("queued", "assigned", "working"),
|
|
|
|
|
+ "start creates the goal-run task (orchestrate, priority 3, creator goal,"
|
|
|
|
|
+ " queued)")
|
|
|
|
|
+ run_task = int(run_row[0])
|
|
|
|
|
+ check(goal_state(goal1)["run_task_id"] == run_task,
|
|
|
|
|
+ "the goal state carries the run task id")
|
|
|
|
|
+ check(any(e["kind"] == "started" for e in goal_state(goal1)["events"]),
|
|
|
|
|
+ "the narrative records the start")
|
|
|
|
|
+
|
|
|
|
|
+ print("== plan 12: the goal agent plans, plan review, revisions ==")
|
|
|
|
|
+ fake.goal_script = [("plan", "# Plan v1\n\n1. Add tests\n2. Ship")]
|
|
|
|
|
+ check(wait_for(lambda: goal_state(goal1)["status"] == "plan_review" or False,
|
|
|
|
|
+ timeout=60, what="goal agent submits its plan") is not None,
|
|
|
|
|
+ "the goal agent's plan push enters plan review")
|
|
|
|
|
+ state = goal_state(goal1)
|
|
|
|
|
+ check(task_state(auth, run_task)["status"] == "blocked",
|
|
|
|
|
+ "the goal-run task parks on the plan review")
|
|
|
|
|
+ pending = state.get("plan_pending_block")
|
|
|
|
|
+ check(pending and "# Plan v1" in (pending.get("body") or ""),
|
|
|
|
|
+ "the pending plan review carries the submitted markdown")
|
|
|
|
|
+ check(any(b["block_type"] == "plan" and b["is_plan_review"]
|
|
|
|
|
+ for b in state["blocks"]),
|
|
|
|
|
+ "the goal blocks list carries the pending plan block")
|
|
|
|
|
+ # Reject with feedback: the agent revises.
|
|
|
|
|
+ fake.goal_script = [("plan", "# Plan v2\n\n1. Add tests\n2. Add docs\n3. Ship")]
|
|
|
|
|
+ act(auth, pending["answer_action"], {"answer": "Add documentation please"})
|
|
|
|
|
+ check(wait_for(lambda: goal_state(goal1)["status"] == "plan_review" or False,
|
|
|
|
|
+ timeout=60, what="revised plan enters review") is not None,
|
|
|
|
|
+ "feedback sends the agent back to planning; the revision re-enters review")
|
|
|
|
|
+ state = goal_state(goal1)
|
|
|
|
|
+ revisions = state["plan_revisions"]
|
|
|
|
|
+ check(len(revisions) == 2
|
|
|
|
|
+ and revisions[0]["answered"] and "Add documentation please" in revisions[0]["answer"]
|
|
|
|
|
+ and not revisions[1]["answered"],
|
|
|
|
|
+ "rejected revisions stay as answered history (historical-blocks data)")
|
|
|
|
|
+ # Approve: goal.plan = v2, status active, agent resumes and parks.
|
|
|
|
|
+ fake.goal_script = [("suspend",)]
|
|
|
|
|
+ pending = state["plan_pending_block"]
|
|
|
|
|
+ act(auth, pending["answer_action"],
|
|
|
|
|
+ {"answer": "Plan approved. Proceed with implementation."})
|
|
|
|
|
+ check(wait_for(lambda: goal_state(goal1)["status"] == "active" or False,
|
|
|
|
|
+ timeout=60, what="approved plan activates the goal") is not None,
|
|
|
|
|
+ "approving the plan activates the goal")
|
|
|
|
|
+ state = goal_state(goal1)
|
|
|
|
|
+ check("# Plan v2" in state["plan"], "the approved plan markdown is stored on the goal")
|
|
|
|
|
+ check(wait_for(lambda: task_state(auth, run_task)["status"] == "suspended"
|
|
|
|
|
+ or False, timeout=60, what="goal agent parks after approval") is not None,
|
|
|
|
|
+ "the resumed goal agent parks (saddle suspend)")
|
|
|
|
|
+
|
|
|
|
|
+ print("== plan 12: task fan-out via the task CLI callbacks ==")
|
|
|
|
|
+ # The goal agent (parked, open session on its saddle) creates,
|
|
|
|
|
+ # enqueues and deletes its tasks through /api/saddle/task/*.
|
|
|
|
|
+ conn = db()
|
|
|
|
|
+ agent_session = conn.execute(
|
|
|
|
|
+ "select id from sessions where task_id = ? and ended_at is null",
|
|
|
|
|
+ (run_task,)).fetchone()[0]
|
|
|
|
|
+ conn.close()
|
|
|
|
|
+ status, body = saddle_api("POST", "/api/saddle/task/create", fake_token, {
|
|
|
|
|
+ "session_id": agent_session, "title": "Flange tests",
|
|
|
|
|
+ "mandate": "Write the test suite for the flange module.",
|
|
|
|
|
+ "mode": "test"})
|
|
|
|
|
+ check(status == 200 and body.get("ok") is True,
|
|
|
|
|
+ "task create under the goal succeeds (%s)" % body.get("error"))
|
|
|
|
|
+ fan_task = int(body["task_id"])
|
|
|
|
|
+ detail = task_state(auth, fan_task)
|
|
|
|
|
+ check(detail["status"] == "draft" and detail["mode"] == "test"
|
|
|
|
|
+ and detail["creator"] == "goal" and detail.get("goal_id") == goal1,
|
|
|
|
|
+ "the created task is a draft goal task with its mode and creator")
|
|
|
|
|
+ check(detail["task_branch"] == "task/%d-%d" % (goal1, fan_task)
|
|
|
|
|
+ and detail["branch_from"] == "goal/flange",
|
|
|
|
|
+ "goal worker tasks get ephemeral branches cut from the goal branch")
|
|
|
|
|
+ check(any(e["kind"] == "task-created" and int(e["task_id"]) == fan_task
|
|
|
|
|
+ for e in goal_state(goal1)["events"]),
|
|
|
|
|
+ "the narrative records the agent's task creation")
|
|
|
|
|
+
|
|
|
|
|
+ # Draft until the agent enqueues; humans may not (creator-only).
|
|
|
|
|
+ check(detail.get("enqueue") is None,
|
|
|
|
|
+ "the human Enqueue action is not offered for goal tasks (creator-only)")
|
|
|
|
|
+ status, body = saddle_api("POST", "/api/saddle/task/enqueue", fake_token, {
|
|
|
|
|
+ "session_id": agent_session, "task_id": fan_task})
|
|
|
|
|
+ check(status == 200 and task_state(auth, fan_task)["status"] == "queued",
|
|
|
|
|
+ "task enqueue by the goal agent queues the draft")
|
|
|
|
|
+ # Ownership + shape errors, verbatim for the CLI.
|
|
|
|
|
+ status, body = saddle_api("POST", "/api/saddle/task/enqueue", fake_token, {
|
|
|
|
|
+ "session_id": agent_session, "task_id": 999999})
|
|
|
|
|
+ check(status == 400 and "No task exists" in (body.get("error") or ""),
|
|
|
|
|
+ "enqueue of a missing task errors verbatim")
|
|
|
|
|
+ create_queued(auth, {"project_id": "1", "title": "Innocent bystander", "priority": "4"})
|
|
|
|
|
+ bystander = task_id_of("Innocent bystander")
|
|
|
|
|
+ status, body = saddle_api("POST", "/api/saddle/task/delete", fake_token, {
|
|
|
|
|
+ "session_id": agent_session, "task_id": bystander})
|
|
|
|
|
+ check(status == 400 and "does not belong to your goal" in (body.get("error") or ""),
|
|
|
|
|
+ "the goal agent cannot touch tasks outside its goal")
|
|
|
|
|
+ status, body = saddle_api("POST", "/api/saddle/task/create", fake_token, {
|
|
|
|
|
+ "session_id": agent_session, "title": "Bad mode", "mandate": "m", "mode": "party"})
|
|
|
|
|
+ check(status == 400 and "valid modes" in (body.get("error") or ""),
|
|
|
|
|
+ "an unknown mode rejects with the valid list")
|
|
|
|
|
+ status, body = saddle_api("POST", "/api/saddle/task/create", fake_token, {
|
|
|
|
|
+ "session_id": agent_session, "title": "Bad tag", "mandate": "m",
|
|
|
|
|
+ "capabilities": ["warp-drive"]})
|
|
|
|
|
+ check(status == 400 and "warp-drive" in (body.get("error") or "")
|
|
|
|
|
+ and "valid tags" in (body.get("error") or ""),
|
|
|
|
|
+ "an unknown capability tag rejects naming the valid set")
|
|
|
|
|
+ # A plain task's session is not a goal-agent identity.
|
|
|
|
|
+ status, body = saddle_api("POST", "/api/saddle/task/create", fake_token,
|
|
|
|
|
+ {"session_id": 999999, "title": "x", "mandate": "m"})
|
|
|
|
|
+ check(status == 400 and "session" in (body.get("error") or ""),
|
|
|
|
|
+ "an unknown session_id errors verbatim")
|
|
|
|
|
+ # Delete: draft + owned.
|
|
|
|
|
+ status, body = saddle_api("POST", "/api/saddle/task/create", fake_token, {
|
|
|
|
|
+ "session_id": agent_session, "title": "Scratch task", "mandate": "m"})
|
|
|
|
|
+ scratch = int(body["task_id"])
|
|
|
|
|
+ status, body = saddle_api("POST", "/api/saddle/task/delete", fake_token, {
|
|
|
|
|
+ "session_id": agent_session, "task_id": scratch})
|
|
|
|
|
+ check(status == 200 and not any(
|
|
|
|
|
+ t["id"] == scratch for t in slot_public(auth, "/tasks", "tasks")["tasks"]),
|
|
|
|
|
+ "task delete removes the owned draft")
|
|
|
|
|
+ status, body = saddle_api("POST", "/api/saddle/task/delete", fake_token, {
|
|
|
|
|
+ "session_id": agent_session, "task_id": fan_task})
|
|
|
|
|
+ check(status == 400 and "draft" in (body.get("error") or ""),
|
|
|
|
|
+ "deleting a queued task refuses (draft only)")
|
|
|
|
|
+
|
|
|
|
|
+ print("== plan 12: goal context in the worker assignment + coalesced resume ==")
|
|
|
|
|
+ # The queued fan-out task dispatches to the daemon with the goal
|
|
|
|
|
+ # reference + ephemeral branch; its completion schedules the parked
|
|
|
|
|
+ # goal agent's resume (>= SADDLE_GOAL_RESUME_S after the event).
|
|
|
|
|
+ check(wait_for(lambda: any(int(p.get("task", {}).get("id") or 0) == fan_task
|
|
|
|
|
+ for p in fake.start_payloads) or False,
|
|
|
|
|
+ timeout=60, what="fan-out task dispatches") is not None,
|
|
|
|
|
+ "the goal worker task dispatches")
|
|
|
|
|
+ fan_payload = [p for p in fake.start_payloads
|
|
|
|
|
+ if int(p.get("task", {}).get("id") or 0) == fan_task][-1]
|
|
|
|
|
+ goal_ctx = fan_payload.get("goal") or {}
|
|
|
|
|
+ check(goal_ctx.get("id") == goal1 and goal_ctx.get("branch") == "goal/flange"
|
|
|
|
|
+ and goal_ctx.get("base_branch") == "main"
|
|
|
|
|
+ and "flange module" in goal_ctx.get("brief", "")
|
|
|
|
|
+ and "# Plan v2" in goal_ctx.get("plan", ""),
|
|
|
|
|
+ "the worker assignment carries the goal context (read-only brief + plan)")
|
|
|
|
|
+ check(fan_payload.get("ephemeral_branch") == "task/%d-%d" % (goal1, fan_task)
|
|
|
|
|
+ and fan_payload.get("kind") == "task" and fan_payload.get("mode") == "test"
|
|
|
|
|
+ and fan_payload.get("creator") == "goal",
|
|
|
|
|
+ "the worker assignment carries the ephemeral branch + identity triplet")
|
|
|
|
|
+ check(wait_task_done(auth, fan_task, timeout=60) is not None,
|
|
|
|
|
+ "the goal worker task completes")
|
|
|
|
|
+ check(any(e["kind"] == "task-done" and int(e["task_id"]) == fan_task
|
|
|
|
|
+ for e in goal_state(goal1)["events"]),
|
|
|
|
|
+ "the narrative records the task's completion")
|
|
|
|
|
+ goal_runs_before = len(fake.goal_assignments)
|
|
|
|
|
+ check(wait_for(lambda: len(fake.goal_assignments) > goal_runs_before or False,
|
|
|
|
|
+ timeout=30, what="parked goal agent resumes") is not None,
|
|
|
|
|
+ "the task-terminal event resumes the parked goal agent")
|
|
|
|
|
+ resume_payload = fake.goal_assignments[-1]
|
|
|
|
|
+ notice = resume_payload.get("resume_notice") or ""
|
|
|
|
|
+ check("Task statuses" in notice and "Flange tests" in notice
|
|
|
|
|
+ and "done" in notice,
|
|
|
|
|
+ "the resume notice batches ALL plan task statuses")
|
|
|
|
|
+ check("New completion reports" in notice and "fake daemon finished" in notice,
|
|
|
|
|
+ "the resume notice carries the new completion report")
|
|
|
|
|
+ check(resume_payload.get("kind") == "goal_run"
|
|
|
|
|
+ and resume_payload.get("mode") == "orchestrate",
|
|
|
|
|
+ "the resumed goal agent carries its identity triplet")
|
|
|
|
|
+ # The resumed agent parked again (script): two events at once must
|
|
|
|
|
+ # coalesce into ONE resume within the window.
|
|
|
|
|
+ check(wait_for(lambda: task_state(auth, run_task)["status"] == "suspended"
|
|
|
|
|
+ or False, timeout=60, what="goal agent re-parks") is not None,
|
|
|
|
|
+ "the resumed goal agent parks again")
|
|
|
|
|
+
|
|
|
|
|
+ print("== plan 12: pause freezes supervision; events coalesce on resume ==")
|
|
|
|
|
+ state = goal_state(goal1)
|
|
|
|
|
+ act(auth, state["pause"], {})
|
|
|
|
|
+ check(goal_state(goal1)["status"] == "paused"
|
|
|
|
|
+ and goal_state(goal1).get("resume") is not None,
|
|
|
|
|
+ "an active goal pauses and offers resume")
|
|
|
|
|
+ # Terminal events under a paused goal queue without waking anybody.
|
|
|
|
|
+ conn = db()
|
|
|
|
|
+ conn.execute("insert into tasks (project_id, goal_id, kind, title, status, priority,"
|
|
|
|
|
+ " summary, completed_at, created_at) values (1, ?, 'task',"
|
|
|
|
|
+ " 'Quiet task', 'done', 5, 'finished while paused',"
|
|
|
|
|
+ " strftime('%s','now','+1 second'), strftime('%s','now'))", (goal1,))
|
|
|
|
|
+ conn.commit()
|
|
|
|
|
+ conn.close()
|
|
|
|
|
+ paused_runs = len(fake.goal_assignments)
|
|
|
|
|
+ check(quiet_window(quiet_seconds(),
|
|
|
|
|
+ lambda: len(fake.goal_assignments) == paused_runs,
|
|
|
|
|
+ "paused goal does not resume"),
|
|
|
|
|
+ "a paused goal's events queue without scheduling a resume")
|
|
|
|
|
+ state = goal_state(goal1)
|
|
|
|
|
+ act(auth, state["resume"], {})
|
|
|
|
|
+ check(goal_state(goal1)["status"] == "active",
|
|
|
|
|
+ "resume returns the goal to active")
|
|
|
|
|
+ check(wait_for(lambda: len(fake.goal_assignments) > paused_runs or False,
|
|
|
|
|
+ timeout=30, what="queued events flush on resume") is not None,
|
|
|
|
|
+ "the queued events coalesce into the post-resume wake")
|
|
|
|
|
+ check("finished while paused" in (fake.goal_assignments[-1].get("resume_notice") or ""),
|
|
|
|
|
+ "the flushed notice reports the task that finished while paused")
|
|
|
|
|
+ check(wait_for(lambda: task_state(auth, run_task)["status"] == "suspended"
|
|
|
|
|
+ or False, timeout=60, what="goal agent parks again") is not None,
|
|
|
|
|
+ "the goal agent parks again after the flush")
|
|
|
|
|
+
|
|
|
|
|
+ print("== plan 12: completion review, push state, accept ==")
|
|
|
|
|
+ # The daemon exposes goal-branch push state on /status; the web
|
|
|
|
|
+ # caches it on the goal when it probes the goal agent's saddle.
|
|
|
|
|
+ def status_extra():
|
|
|
|
|
+ return None
|
|
|
|
|
+ fake.status_extra = {"goal_branches": [
|
|
|
|
|
+ {"branch": "goal/flange", "pushed": True},
|
|
|
|
|
+ {"branch": "goal/other-repo", "pushed": False}]}
|
|
|
|
|
+ fake_card = saddle_card(auth, "fake-saddle")
|
|
|
|
|
+ act(auth, fake_card["refresh"], {})
|
|
|
|
|
+ check(wait_for(lambda: goal_state(goal1).get("push_branches") or False,
|
|
|
|
|
+ timeout=30, what="push state lands on the goal") is not None,
|
|
|
|
|
+ "the daemon's /status goal_branches exposure caches on the goal")
|
|
|
|
|
+ branches = goal_state(goal1)["push_branches"]
|
|
|
|
|
+ check({(b["branch"], b["pushed"]) for b in branches}
|
|
|
|
|
+ == {("goal/flange", True), ("goal/other-repo", False)},
|
|
|
|
|
+ "the push state renders per branch")
|
|
|
|
|
+ fake.status_extra = None
|
|
|
|
|
+
|
|
|
|
|
+ # `saddle done` in goal context: the completion review.
|
|
|
|
|
+ fake.goal_script = [("done", "# Completion\n\nThe flange ships.")]
|
|
|
|
|
+ # Wake the parked agent: a pending event has waited out the window
|
|
|
|
|
+ # (the pause-flush above consumed the queue, so arm one directly —
|
|
|
|
|
+ # the queued 'Quiet task' row was consumed; use a fresh event).
|
|
|
|
|
+ conn = db()
|
|
|
|
|
+ conn.execute("insert into goal_events (goal_id, kind, text, created_at)"
|
|
|
|
|
+ " values (?, 'task-done', 'Task #999 Warmup — done',"
|
|
|
|
|
+ " strftime('%s','now','-5 seconds'))", (goal1,))
|
|
|
|
|
+ conn.commit()
|
|
|
|
|
+ conn.close()
|
|
|
|
|
+ check(wait_for(lambda: goal_state(goal1)["status"] == "completion_review" or False,
|
|
|
|
|
+ timeout=60, what="goal agent submits its completion") is not None,
|
|
|
|
|
+ "`saddle done` in goal context enters completion review")
|
|
|
|
|
+ state = goal_state(goal1)
|
|
|
|
|
+ check("# Completion" in state["completion_report"]
|
|
|
|
|
+ and state["completion_pending"] is True
|
|
|
|
|
+ and state["completion_pending_block"] is not None
|
|
|
|
|
+ and state["completion_pending_block"] is not False,
|
|
|
|
|
+ "the completion review state carries the report + pending block")
|
|
|
|
|
+ check(task_state(auth, run_task)["status"] == "blocked",
|
|
|
|
|
+ "the goal-run task parks on the completion review")
|
|
|
|
|
+ check(state["compare_url"].endswith("/compare/main...goal/flange"),
|
|
|
|
|
+ "the completion review links the compare view")
|
|
|
|
|
+ # Reject first: feedback returns the goal to active.
|
|
|
|
|
+ fake.goal_script = [("suspend",)]
|
|
|
|
|
+ act(auth, state["completion_pending_block"]["answer_action"],
|
|
|
|
|
+ {"answer": "Not done — the docs are missing"})
|
|
|
|
|
+ check(wait_for(lambda: goal_state(goal1)["status"] == "active" or False,
|
|
|
|
|
+ timeout=30, what="rejected completion reactivates") is not None,
|
|
|
|
|
+ "rejecting the completion returns the goal to active")
|
|
|
|
|
+ # Round 2: submit again and accept.
|
|
|
|
|
+ fake.goal_script = [("done", "# Completion v2\n\nThe flange ships, with docs.")]
|
|
|
|
|
+ conn = db()
|
|
|
|
|
+ conn.execute("insert into goal_events (goal_id, kind, text, created_at)"
|
|
|
|
|
+ " values (?, 'task-done', 'Task #998 Warmup2 — done',"
|
|
|
|
|
+ " strftime('%s','now','-5 seconds'))", (goal1,))
|
|
|
|
|
+ conn.commit()
|
|
|
|
|
+ conn.close()
|
|
|
|
|
+ check(wait_for(lambda: goal_state(goal1)["status"] == "completion_review" or False,
|
|
|
|
|
+ timeout=60, what="resubmitted completion") is not None,
|
|
|
|
|
+ "the agent resubmits its completion after feedback")
|
|
|
|
|
+ state = goal_state(goal1)
|
|
|
|
|
+ act(auth, state["completion_pending_block"]["answer_action"],
|
|
|
|
|
+ {"answer": "Review approved. Continue with the task."})
|
|
|
|
|
+ check(wait_for(lambda: goal_state(goal1)["status"] == "complete" or False,
|
|
|
|
|
+ timeout=30, what="accepted completion completes") is not None,
|
|
|
|
|
+ "accepting the completion completes the goal")
|
|
|
|
|
+ state = goal_state(goal1)
|
|
|
|
|
+ check(state["terminal"] is True and state.get("cancel") is None
|
|
|
|
|
+ and "# Completion v2" in state["completion_report"],
|
|
|
|
|
+ "a complete goal is terminal with its report stored")
|
|
|
|
|
+ check(wait_for(lambda: task_state(auth, run_task)["status"] == "done" or False,
|
|
|
|
|
+ timeout=30, what="goal run task closes") is not None,
|
|
|
|
|
+ "approving the completion closes the goal-run task done")
|
|
|
|
|
+
|
|
|
|
|
+ print("== plan 12: cancel cascades to the goal's tasks ==")
|
|
|
|
|
+ goal2 = create_goal({"project_id": "1", "title": "Doomed goal",
|
|
|
|
|
+ "brief": "This goal will be cancelled.",
|
|
|
|
|
+ "branch_name": "goal/doomed"})
|
|
|
|
|
+ fake.goal_script = [("suspend",)]
|
|
|
|
|
+ act(auth, goal_state(goal2)["start"], {})
|
|
|
|
|
+ conn = db()
|
|
|
|
|
+ run2 = conn.execute("select id from tasks where goal_id = ? and kind = 'goal_run'",
|
|
|
|
|
+ (goal2,)).fetchone()[0]
|
|
|
|
|
+ conn.close()
|
|
|
|
|
+ check(wait_for(lambda: task_state(auth, run2)["status"] == "suspended" or False,
|
|
|
|
|
+ timeout=60, what="doomed goal agent parks") is not None,
|
|
|
|
|
+ "the doomed goal's agent parks")
|
|
|
|
|
+ conn = db()
|
|
|
|
|
+ agent_session2 = conn.execute(
|
|
|
|
|
+ "select id from sessions where task_id = ? and ended_at is null",
|
|
|
|
|
+ (run2,)).fetchone()[0]
|
|
|
|
|
+ conn.close()
|
|
|
|
|
+ status, body = saddle_api("POST", "/api/saddle/task/create", fake_token, {
|
|
|
|
|
+ "session_id": agent_session2, "title": "Doomed draft", "mandate": "m"})
|
|
|
|
|
+ doomed_draft = int(body["task_id"])
|
|
|
|
|
+ doomed_queued_task = None
|
|
|
|
|
+ status, body = saddle_api("POST", "/api/saddle/task/create", fake_token, {
|
|
|
|
|
+ "session_id": agent_session2, "title": "Doomed runner", "mandate": "m"})
|
|
|
|
|
+ doomed_runner = int(body["task_id"])
|
|
|
|
|
+ saddle_api("POST", "/api/saddle/task/enqueue", fake_token,
|
|
|
|
|
+ {"session_id": agent_session2, "task_id": doomed_runner})
|
|
|
|
|
+ act(auth, goal_state(goal2)["cancel"], {})
|
|
|
|
|
+ check(goal_state(goal2)["status"] == "cancelled",
|
|
|
|
|
+ "the goal cancels from active (parked-agent) state")
|
|
|
|
|
+ check(task_state(auth, run2)["status"] == "failed",
|
|
|
|
|
+ "the cascade cancels the goal agent's run task")
|
|
|
|
|
+ check(wait_for(lambda: task_state(auth, doomed_runner)["status"] == "failed"
|
|
|
|
|
+ or False, timeout=30, what="cascade cancels the queued task") is not None,
|
|
|
|
|
+ "the cascade cancels the goal's queued task")
|
|
|
|
|
+ check(not any(t["id"] == doomed_draft
|
|
|
|
|
+ for t in slot_public(auth, "/tasks", "tasks")["tasks"]),
|
|
|
|
|
+ "the cascade deletes the goal's draft tasks")
|
|
|
|
|
+ # A cancelled goal frees its branch name.
|
|
|
|
|
+ reuse = create_goal({"project_id": "1", "title": "Reused name",
|
|
|
|
|
+ "brief": "b", "branch_name": "goal/doomed"})
|
|
|
|
|
+ check(reuse > goal2, "a cancelled goal's branch name is reusable")
|
|
|
|
|
+
|
|
|
|
|
+ print("== plan 12: auto-enqueue-after for human tasks ==")
|
|
|
|
|
+ act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
|
|
|
|
|
+ "project_id": "1", "title": "Predecessor task", "priority": "4"})
|
|
|
|
|
+ predecessor = task_id_of("Predecessor task")
|
|
|
|
|
+ enqueue_task(auth, predecessor)
|
|
|
|
|
+ act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
|
|
|
|
|
+ "project_id": "1", "title": "Successor task", "priority": "4",
|
|
|
|
|
+ "enqueue_after_task_id": str(predecessor)})
|
|
|
|
|
+ successor = task_id_of("Successor task")
|
|
|
|
|
+ check(task_state(auth, successor)["status"] == "draft"
|
|
|
|
|
+ and task_state(auth, successor).get("enqueue_after_task_id") == predecessor,
|
|
|
|
|
+ "a human task can carry an auto-enqueue predecessor")
|
|
|
|
|
+ check(wait_task_done(auth, predecessor, timeout=60) is not None,
|
|
|
|
|
+ "the predecessor completes")
|
|
|
|
|
+ check(wait_for(lambda: task_state(auth, successor)["status"] in
|
|
|
|
|
+ ("queued", "assigned", "working") or False,
|
|
|
|
|
+ timeout=30, what="successor auto-enqueues") is not None,
|
|
|
|
|
+ "the done predecessor auto-enqueues its dependent")
|
|
|
|
|
+ # Cycle validation refuses a predecessor chain that loops.
|
|
|
|
|
+ act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
|
|
|
|
|
+ "project_id": "1", "title": "Cycle A", "priority": "4"})
|
|
|
|
|
+ cycle_a = task_id_of("Cycle A")
|
|
|
|
|
+ act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
|
|
|
|
|
+ "project_id": "1", "title": "Cycle B", "priority": "4",
|
|
|
|
|
+ "enqueue_after_task_id": str(cycle_a)})
|
|
|
|
|
+ cycle_b = task_id_of("Cycle B")
|
|
|
|
|
+ cycle_directives = act(auth, task_state(auth, cycle_a)["update"], {
|
|
|
|
|
+ "title": "Cycle A", "description": "x", "priority": "4",
|
|
|
|
|
+ "enqueue_after_task_id": str(cycle_b)})
|
|
|
|
|
+ check(any(d.get("type") == "notify" and d.get("kind") == "error"
|
|
|
|
|
+ and "cycle" in (d.get("message") or "").lower()
|
|
|
|
|
+ for d in cycle_directives),
|
|
|
|
|
+ "a predecessor cycle rejects with a naming error")
|
|
|
|
|
+ # A failed predecessor leaves the dependent in draft.
|
|
|
|
|
+ fake.fail_next = True
|
|
|
|
|
+ act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
|
|
|
|
|
+ "project_id": "1", "title": "Failing predecessor", "priority": "1"})
|
|
|
|
|
+ failing_pred = task_id_of("Failing predecessor")
|
|
|
|
|
+ enqueue_task(auth, failing_pred)
|
|
|
|
|
+ act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
|
|
|
|
|
+ "project_id": "1", "title": "Stranded successor", "priority": "4",
|
|
|
|
|
+ "enqueue_after_task_id": str(failing_pred)})
|
|
|
|
|
+ stranded = task_id_of("Stranded successor")
|
|
|
|
|
+ check(wait_for(lambda: task_state(auth, failing_pred)["status"] == "failed"
|
|
|
|
|
+ or False, timeout=60, what="failing predecessor fails") is not None,
|
|
|
|
|
+ "the scripted-failure predecessor fails")
|
|
|
|
|
+ check(quiet_window(quiet_seconds(),
|
|
|
|
|
+ lambda: task_state(auth, stranded)["status"] == "draft",
|
|
|
|
|
+ "failed predecessor leaves the draft"),
|
|
|
|
|
+ "a failed predecessor leaves its dependent in draft")
|
|
|
|
|
+
|
|
|
|
|
+ # Successor cleanup: let the queued successor finish so the
|
|
|
|
|
+ # shard's app shuts down with an empty queue.
|
|
|
|
|
+ check(wait_task_done(auth, successor, timeout=90) is not None,
|
|
|
|
|
+ "the auto-enqueued successor completes")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",
|
|
BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",
|
|
|
- "allocation", "lifecycle", "answers", "push")
|
|
|
|
|
|
|
+ "allocation", "lifecycle", "answers", "push", "goals")
|
|
|
|
|
|
|
|
|
|
|
|
|
def run_bundle(name):
|
|
def run_bundle(name):
|