Ver código fonte

feat: branch_advanced on verdict and resume paths — any sibling (same parent, or same project+branch) completing/failing/dispatching/progressing after the task's park marker (suspended_at, else newest block, else last session activity) flips it true; carried on POST /answer, /notify verdicts, /poll answers and resume /start payloads so the daemon can skip the pointless pull --rebase

clanker 1 mês atrás
pai
commit
4d7354bd13

+ 10 - 0
src/Api/SaddleApi.vala

@@ -320,6 +320,7 @@ namespace ClankerSaddle.Api {
     public class SaddlePollEndpoint : Object, Endpoint {
 
         private SaddleService saddles = inject<SaddleService>();
+        private TaskService tasks = inject<TaskService>();
 
         public virtual async HttpResult handle_request(HttpContext http_context,
                 RouteContext route_context) throws Error {
@@ -334,10 +335,19 @@ namespace ClankerSaddle.Api {
             var answers = new Json.Array();
             var comments = new Json.Array();
             try {
+                bool advanced = false;
+                bool advanced_known = false;
                 foreach (var pending in yield saddles.take_pending_answers(saddle_id)) {
+                    if (!advanced_known) {
+                        advanced_known = true;
+                        var saddle_row = yield saddles.get_saddle(saddle_id);
+                        advanced = saddle_row != null
+                            && yield tasks.branch_advanced_since(((!)saddle_row).current_task_id);
+                    }
                     var element = new Json.Object();
                     element.set_int_member("block_id", pending.block_id);
                     element.set_string_member("answer", pending.answer);
+                    element.set_boolean_member("branch_advanced", advanced);
                     answers.add_object_element(element);
                 }
                 foreach (var pending in yield saddles.take_pending_comments(saddle_id)) {

+ 29 - 7
src/Services/SaddleService.vala

@@ -19,6 +19,7 @@ namespace ClankerSaddle.Services {
     public class PendingAnswer : Object {
         public int64 block_id { get; set; }
         public string answer { get; set; default = ""; }
+        public bool branch_advanced { get; set; }
     }
 
     /** A human comment awaiting delivery to one saddle's conversation. */
@@ -840,7 +841,8 @@ namespace ClankerSaddle.Services {
          * response body.
          */
         public async Json.Object push_task_to_saddle(int64 saddle_id, TaskEntity task, int64 session_id,
-                ProjectService projects, string? resume_notice = null) throws Error {
+                ProjectService projects, string? resume_notice = null,
+                bool? branch_advanced = null) throws Error {
             var saddle = yield get_saddle(saddle_id);
             if (saddle == null) {
                 throw new Error(Quark.from_string("clanker-saddle"), 11,
@@ -941,6 +943,10 @@ namespace ClankerSaddle.Services {
                 payload.add_int_value(pending.block_id);
                 payload.set_member_name("answer");
                 payload.add_string_value(raw_answer_for_saddle(pending.answer));
+                if (branch_advanced != null) {
+                    payload.set_member_name("branch_advanced");
+                    payload.add_boolean_value((!)branch_advanced);
+                }
                 payload.end_object();
             }
             payload.end_array();
@@ -1035,6 +1041,14 @@ namespace ClankerSaddle.Services {
                 payload.add_string_value(resume_notice);
             }
 
+            // The wire's `branch_advanced` (resume pushes): whether the
+            // shared branch moved while this task was parked — false or
+            // absent means the daemon omits the pull instruction entirely.
+            if (branch_advanced != null) {
+                payload.set_member_name("branch_advanced");
+                payload.add_boolean_value((!)branch_advanced);
+            }
+
             // The wire contract's `project_notes` addition: notes of the
             // primary and co-developed projects, oldest first, capped at
             // 30 entries / 20 000 chars (head kept).
@@ -1528,8 +1542,10 @@ namespace ClankerSaddle.Services {
             }
 
             string state = status.has_member("state") ? status.get_string_member("state") : "";
+            bool advanced = yield tasks.branch_advanced_since(task.id);
             if (state == "blocked") {
-                yield deliver_answered_blocks((!)saddle, task.id, block_question_from_status(status));
+                yield deliver_answered_blocks((!)saddle, task.id,
+                    block_question_from_status(status), advanced);
                 yield tasks.update_task(task.id, null, null, null, STATUS_WORKING);
                 yield update_saddle_status(((!)saddle).id, "busy", task.id);
             } else if (state == "working") {
@@ -1537,7 +1553,7 @@ namespace ClankerSaddle.Services {
                 // which reports without waiting): re-pushing /start would
                 // collide with the live session, so the verdict joins the
                 // conversation as a human comment via /notify.
-                yield deliver_answered_verdicts((!)saddle, task.id);
+                yield deliver_answered_verdicts((!)saddle, task.id, advanced);
                 yield tasks.update_task(task.id, null, null, null, STATUS_WORKING);
                 yield update_saddle_status(((!)saddle).id, "busy", task.id);
             } else {
@@ -1545,10 +1561,12 @@ namespace ClankerSaddle.Services {
                 // fresh /start adopts the workspace and restores the
                 // conversation — carrying the resume notice, when the
                 // dispatcher resumes a suspended parent, as the injected
-                // next user turn.
+                // next user turn, plus whether the shared branch moved
+                // while parked.
                 yield update_saddle_status(((!)saddle).id, "busy", task.id);
                 yield seed_session_cursor(((!)saddle).id, session.id);
-                yield push_task_to_saddle(((!)saddle).id, task, session.id, projects, resume_notice);
+                yield push_task_to_saddle(((!)saddle).id, task, session.id, projects,
+                    resume_notice, advanced);
                 yield mark_task_comments_delivered(task.id);
                 yield tasks.update_task(task.id, null, null, null, STATUS_ASSIGNED);
             }
@@ -1873,7 +1891,8 @@ namespace ClankerSaddle.Services {
          * nobody waiting (409 — the session moved on), so each answer is
          * pushed exactly once.
          */
-        private async void deliver_answered_blocks(Saddle saddle, int64 task_id, string question) throws Error {
+        private async void deliver_answered_blocks(Saddle saddle, int64 task_id,
+                string question, bool branch_advanced) throws Error {
             foreach (var block in yield db.query<TaskBlock>()
                     .where(expr("task_id == $0", elem<int64?>(task_id)))
                     .order_by(expr("id"))
@@ -1886,6 +1905,8 @@ namespace ClankerSaddle.Services {
                 payload.begin_object();
                 payload.set_member_name("answer");
                 payload.add_string_value(raw_answer_for_saddle(((!)block).answer ?? ""));
+                payload.set_member_name("branch_advanced");
+                payload.add_boolean_value(branch_advanced);
                 payload.end_object();
                 Json.Node? root = null;
                 int status_code = yield request_saddle(saddle.host, "POST", "/answer",
@@ -1906,7 +1927,8 @@ namespace ClankerSaddle.Services {
          * a `/start` re-push would collide with the live session. Delivery
          * is marked once the daemon accepts it (200/204).
          */
-        private async void deliver_answered_verdicts(Saddle saddle, int64 task_id) throws Error {
+        private async void deliver_answered_verdicts(Saddle saddle, int64 task_id,
+                bool branch_advanced) throws Error {
             foreach (var block in yield db.query<TaskBlock>()
                     .where(expr("task_id == $0", elem<int64?>(task_id)))
                     .order_by(expr("id"))

+ 108 - 0
src/Services/TaskService.vala

@@ -974,6 +974,114 @@ namespace ClankerSaddle.Services {
             yield db.update_async<TaskEntity>((!)task);
         }
 
+        /**
+         * Whether any OTHER task sharing this task's working context
+         * moved while it was parked — a sibling under the same parent,
+         * or any task of the same project on the same shared working
+         * branch. "Moved" means any recorded transition after the park
+         * timestamp: a completion/failure (`completed_at`), a dispatch
+         * (session start), or a progress entry. The park marker is the
+         * suspension stamp, else the newest block's creation (the
+         * blocked-at marker), else the session's last recorded
+         * activity. False with no siblings or nothing moved — the wire's
+         * `branch_advanced`, so the daemon can skip the pointless
+         * `git pull --rebase` when the shared branch did not advance.
+         */
+        public async bool branch_advanced_since(int64 task_id) throws Error {
+            var me = yield db.query<TaskEntity>()
+                .where(expr("id == $0", elem<int64?>(task_id)))
+                .first_async();
+            if (me == null) {
+                return false;
+            }
+
+            DateTime? park = ((!)me).suspended_at;
+            if (park == null) {
+                foreach (var block in yield db.query<TaskBlock>()
+                        .where(expr("task_id == $0", elem<int64?>(task_id)))
+                        .order_by(expr("id"))
+                        .materialise_async()) {
+                    if (block.created_at != null) {
+                        park = ((!)block).created_at;
+                    }
+                }
+            }
+            if (park == null) {
+                foreach (var session in yield db.query<Session>()
+                        .where(expr("task_id == $0", elem<int64?>(task_id)))
+                        .materialise_async()) {
+                    foreach (var entry in yield db.query<SessionCommand>()
+                            .where(expr("session_id == $0", elem<int64?>(session.id)))
+                            .materialise_async()) {
+                        if (entry.started_at != null
+                                && (park == null
+                                    || ((!)entry.started_at).compare((!)park) > 0)) {
+                            park = (!)entry.started_at;
+                        }
+                    }
+                }
+            }
+            if (park == null) {
+                // Never parked (or nothing recorded): nothing could have
+                // advanced past a marker that does not exist.
+                return false;
+            }
+
+            foreach (var sibling in yield sibling_tasks((!)me)) {
+                if (sibling.completed_at != null
+                        && ((!)sibling.completed_at).compare((!)park) > 0) {
+                    return true;
+                }
+                foreach (var session in yield db.query<Session>()
+                        .where(expr("task_id == $0", elem<int64?>(sibling.id)))
+                        .materialise_async()) {
+                    if (session.started_at != null
+                            && session.started_at.compare((!)park) > 0) {
+                        return true;
+                    }
+                }
+                foreach (var progress in yield db.query<TaskProgress>()
+                        .where(expr("task_id == $0", elem<int64?>(sibling.id)))
+                        .materialise_async()) {
+                    if (progress.created_at != null
+                            && progress.created_at.compare((!)park) > 0) {
+                        return true;
+                    }
+                }
+            }
+            return false;
+        }
+
+        /**
+         * The tasks sharing `task`'s working context (excluding it):
+         * siblings under the same parent, plus same-project tasks on the
+         * same non-empty working branch.
+         */
+        private async ImmutableLot<TaskEntity> sibling_tasks(TaskEntity task) throws Error {
+            var rows = new Vector<TaskEntity>();
+            var seen = new Invercargill.DataStructures.HashSet<string>();
+            if (task.parent_task_id > 0) {
+                foreach (var sibling in yield db.query<TaskEntity>()
+                        .where(expr("parent_task_id == $0", elem<int64?>(task.parent_task_id)))
+                        .materialise_async()) {
+                    if (sibling.id != task.id && seen.add(sibling.id.to_string())) {
+                        rows.add(sibling);
+                    }
+                }
+            }
+            if (task.task_branch != null && ((!)task.task_branch).length > 0) {
+                foreach (var sibling in yield db.query<TaskEntity>()
+                        .where(expr("project_id == $0 && task_branch == $1",
+                            elem<int64?>(task.project_id), elem(((!)task).task_branch)))
+                        .materialise_async()) {
+                    if (sibling.id != task.id && seen.add(sibling.id.to_string())) {
+                        rows.add(sibling);
+                    }
+                }
+            }
+            return rows.to_immutable_buffer();
+        }
+
         /**
          * True when any child of the (suspended) parent reached a
          * terminal state after the parent parked — the per-subtask

BIN
test/__pycache__/web_stack_test.cpython-314.pyc


+ 125 - 0
test/web_stack_test.py

@@ -220,6 +220,9 @@ class FakeSaddle:
         self.scripted_log = None         # fixed chunk set served by /log
         self.scripted_next = 0           # the daemon-style next for it
         self.scripted_corpus = None      # chunks "produced" when a task starts
+        self.race_texts = None           # chunks appended DURING /log response
+                                       # construction (the daemon-side bug)
+        self.answer_bodies = []          # recorded POST /answer payloads
         self.abort_failures = 0          # /abort requests failing (500) while > 0
         self.abort_log = []              # task ids aborted via POST /abort
         self.running_task = 0            # task id the daemon believes it runs
@@ -346,6 +349,10 @@ class FakeSaddle:
                         self.daemon.scripted_next = self.daemon.scripted_corpus[-1]["seq"] + 1
                 self._send(200, {})
             elif self.path == "/answer":
+                try:
+                    self.daemon.answer_bodies.append(json.loads(raw))
+                except ValueError:
+                    pass
                 self._send(200, {})
             elif self.path == "/abort":
                 if self.daemon.abort_failures > 0:
@@ -421,10 +428,27 @@ class FakeSaddle:
                     return
                 query = urllib.parse.urlparse(self.path).query
                 since = int(urllib.parse.parse_qs(query).get("since", ["0"])[0])
+                # The daemon-side snapshot race: the served array is
+                # snapshotted, chunks are appended, and `next` is computed
+                # AFTER the appends — the response's next skips chunks the
+                # array never carried (the live run's 33 lost chunks).
+                raced = False
+                if since > 0 and self.daemon.race_texts:
+                    raced = True
+                    with self.daemon.lock:
+                        next_seq = self.daemon.scripted_next
+                        for text in self.daemon.race_texts:
+                            self.daemon.scripted_log.append(
+                                {"seq": next_seq, "text": text})
+                            next_seq += 1
+                        self.daemon.scripted_next = next_seq
+                        self.daemon.race_texts = None
                 # Simulate the live PTY: each poll produces one more chunk
                 # (before slicing, so the poller actually sees it).
                 self.daemon.append_chunk("stream-line-%d\r\n" % self.daemon.next_seq)
                 chunks, nxt = self.daemon.log_since(since)
+                if raced:
+                    nxt = self.daemon.scripted_next
                 self._send(200, {"chunks": chunks, "next": nxt})
             else:
                 self._send(404, {"error": "no such route"})
@@ -2320,6 +2344,107 @@ def main():
               "identity task completes")
 
 
+
+        print("== R10: branch_advanced + gap forensics ==")
+        # --- branch_advanced on the /poll verdict channel ---
+        fake.state = "working"
+        fake.hold_state = True
+        conn = db()
+        conn.execute("insert into tasks (project_id, title, status, priority,"
+                     " created_at) values (1, 'Poll grandparent', 'working', 5,"
+                     " strftime('%s','now','-300 seconds'))")
+        poll_grand = conn.execute("select max(id) from tasks").fetchone()[0]
+        conn.execute("insert into tasks (project_id, title, status, priority,"
+                     " parent_task_id, created_at) values (1, 'Poll answer parent',"
+                     " 'blocked', 5, ?, strftime('%s','now','-120 seconds'))",
+                     (poll_grand,))
+        poll_parent = conn.execute("select max(id) from tasks").fetchone()[0]
+        conn.execute("insert into sessions (task_id, saddle_id, started_at)"
+                     " values (?, ?, strftime('%s','now','-120 seconds'))",
+                     (poll_parent, fake_id))
+        poll_session = conn.execute("select max(id) from sessions").fetchone()[0]
+        conn.execute("insert into task_blocks (task_id, question, type, answer,"
+                     " answered_at, created_at) values (?, 'Which DB?', 'question',"
+                     " 'SQLite', strftime('%s','now','-60 seconds'),"
+                     " strftime('%s','now','-100 seconds'))", (poll_parent,))
+        conn.execute("update saddles set status = 'busy', current_task_id = ?,"
+                     " allocated_at = strftime('%s','now') where name = 'fake-saddle'",
+                     (poll_parent,))
+        conn.commit()
+        conn.close()
+        status, body = saddle_api("GET", "/api/saddle/%d/poll" % fake_id, fake_token)
+        check(status == 200 and len(body.get("answers", [])) == 1
+              and body["answers"][0].get("branch_advanced") is False,
+              "/poll verdicts carry branch_advanced=false with no siblings")
+
+        # A sibling under the same parent completed after the park marker.
+        conn = db()
+        conn.execute("insert into tasks (project_id, title, status, priority,"
+                     " parent_task_id, summary, completed_at, created_at)"
+                     " values (1, 'Poll sibling', 'done', 5, ?, 'sibling finished',"
+                     " strftime('%s','now'), strftime('%s','now','-200 seconds'))",
+                     (poll_grand,))
+        conn.execute("insert into task_blocks (task_id, question, type, answer,"
+                     " answered_at, created_at) values (?, 'Second question?',"
+                     " 'question', 'Yes', strftime('%s','now'),"
+                     " strftime('%s','now','-90 seconds'))", (poll_parent,))
+        conn.commit()
+        conn.close()
+        status, body = saddle_api("GET", "/api/saddle/%d/poll" % fake_id, fake_token)
+        check(status == 200 and len(body.get("answers", [])) == 1
+              and body["answers"][0].get("branch_advanced") is True,
+              "sibling completion after park flips branch_advanced to true")
+
+        # Release the staged task: cancel it so the held-working poll loop
+        # does not carry it into the race subsection.
+        act(auth, task_state(auth, poll_parent)["cancel"], {})
+        fake.hold_state = False
+        fake.state = "idle"
+
+        # --- the /log snapshot race loses nothing ---
+        fake.state = "working"
+        fake.hold_state = True
+        fake.scripted_corpus = [
+            {"seq": 1, "text": "prompt-one\r\n"},
+            {"seq": 2, "text": "echo a\r\n"},
+            {"seq": 3, "text": "a\r\n"},
+            {"seq": 4, "text": "prompt-two\r\n"},
+        ]
+        fake.scripted_log = []
+        fake.scripted_next = 1
+        fake.race_texts = ["raced-five\r\n", "raced-six\r\n"]
+        act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
+            "project_id": "1",
+            "title": "Race forensics task",
+            "priority": "2",
+        })
+        race_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
+                         if t["title"] == "Race forensics task"][0])
+
+        def race_seqs():
+            conn = db()
+            try:
+                return [r[0] for r in conn.execute(
+                    "select seq from session_raw_log where session_id in"
+                    " (select id from sessions where task_id = ?)"
+                    " order by seq", (race_task,))]
+            finally:
+                conn.close()
+
+        check(wait_for(lambda: race_seqs() == [1, 2, 3, 4, 5, 6] or False,
+                       timeout=40, what="race chunks all stored") is not None,
+              "the snapshot race loses nothing: complete 1..6, no gaps (%s)"
+              % race_seqs())
+        time.sleep(7)
+        check(race_seqs() == [1, 2, 3, 4, 5, 6],
+              "race chunks stored exactly once")
+        fake.hold_state = False
+        fake.state = "idle"
+        fake.scripted_log = None
+        fake.scripted_corpus = None
+        check(wait_task_done(auth, race_task, timeout=60) is not None,
+              "race forensics task completes")
+
         # The daemon vanishing mid-task: three refused polls → offline,
         # task requeued with a bot comment, UI reflects.
         act(auth, slot_public(auth, "/tasks", "tasks")["create"], {