瀏覽代碼

feat: suspended state + per-subtask parent resume — /status 'suspended' parks the task and frees the saddle (suspended_at stamps the parking window), the dispatcher resumes a suspended parent on each terminal subtask event with a coalesced markdown report as /start resume_notice, subtasks array in every /start payload (wire contract)

clanker 1 月之前
父節點
當前提交
0614417826

+ 1 - 0
meson.build

@@ -112,6 +112,7 @@ app_sources = files(
     'src/Data/Migrations/M0004_ProjectNotes.vala',
     'src/Data/Migrations/M0005_RawLog.vala',
     'src/Data/Migrations/M0006_Capabilities.vala',
+    'src/Data/Migrations/M0007_SuspendedAt.vala',
     'src/Services/ProjectService.vala',
     'src/Services/TaskService.vala',
     'src/Services/SaddleService.vala',

+ 3 - 1
src/Data/Entities.vala

@@ -71,6 +71,7 @@ namespace ClankerSaddle.Data {
         public DateTime? created_at { get; set; default = null; }
         public DateTime? started_at { get; set; default = null; }
         public DateTime? completed_at { get; set; default = null; }
+        public DateTime? suspended_at { get; set; default = null; }
 
         public static void entity_mapping(EntityMapperBuilder<TaskEntity> cfg) {
             cfg.table("tasks")
@@ -89,7 +90,8 @@ namespace ClankerSaddle.Data {
                 .column<string>("branch_from", o => o.branch_from, (o, v) => o.branch_from = v)
                 .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v)
                 .column<DateTime?>("started_at", o => o.started_at, (o, v) => o.started_at = v)
-                .column<DateTime?>("completed_at", o => o.completed_at, (o, v) => o.completed_at = v);
+                .column<DateTime?>("completed_at", o => o.completed_at, (o, v) => o.completed_at = v)
+                .column<DateTime?>("suspended_at", o => o.suspended_at, (o, v) => o.suspended_at = v);
         }
     }
 

+ 28 - 0
src/Data/Migrations/M0007_SuspendedAt.vala

@@ -0,0 +1,28 @@
+using InvercargillSql.Migrations;
+
+namespace ClankerSaddle.Data.Migrations {
+
+    /**
+     * Web-stack suspension: a task parked by `saddle suspend` records
+     * when it parked (`suspended_at`), so the dispatcher can resume the
+     * parent only for subtask events that landed after the suspension —
+     * one coalesced resume per parking window.
+     */
+    public class M0007_SuspendedAt : Migration {
+
+        public override string name { get { return "Suspension: tasks.suspended_at"; } }
+        public override uint64 serial { get { return 7; } }
+        public override string migration_namespace { get { return "clanker-saddle"; } }
+
+        public override void up(MigrationBuilder b) {
+            b.alter_table("tasks", (t) => {
+                t.add_column<DateTime?>("suspended_at");
+            });
+        }
+
+        public override void down(MigrationBuilder b) {
+            // SQLite drops the column with the table rebuild machinery;
+            // leaving the nullable column behind is harmless.
+        }
+    }
+}

+ 34 - 0
src/Services/Dispatcher.vala

@@ -142,6 +142,7 @@ namespace ClankerSaddle.Services {
             }
 
             yield resume_answered_tasks(tasks, saddles, projects);
+            yield resume_suspended_parents(tasks, saddles, projects);
 
             foreach (var saddle in yield saddles.list_busy_saddles()) {
                 try {
@@ -196,6 +197,39 @@ namespace ClankerSaddle.Services {
             }
         }
 
+        /**
+         * Resumes suspended parents whose subtasks produced events since
+         * they parked (a subtask completed or failed): a fresh /start on
+         * the saddle holding the parked workspace, carrying the coalesced
+         * markdown status report of every subtask as the resume notice.
+         * Resumes on EACH such event — not only when all subtasks finish.
+         */
+        private async void resume_suspended_parents(TaskService tasks, SaddleService saddles,
+                ProjectService projects) throws Error {
+            foreach (var parent in yield tasks.list_tasks(STATUS_SUSPENDED, null)) {
+                if (!(yield tasks.has_terminal_subtask_since(parent))) {
+                    continue;
+                }
+                var session = yield tasks.open_session_for_task(parent.id);
+                if (session == null) {
+                    continue;
+                }
+                try {
+                    string notice = yield tasks.subtask_status_report(parent.id);
+                    bool resumed = yield saddles.resume_answered_task(parent, (!)session,
+                        tasks, projects, notice);
+                    if (resumed) {
+                        // The parking window is over: a later suspension
+                        // starts a fresh event window.
+                        yield tasks.clear_suspension(parent.id);
+                        print(@"[ClankerSaddle] Resumed suspended task $(parent.id) with a subtask report\n");
+                    }
+                } catch (Error e) {
+                    warning(@"[ClankerSaddle] Resuming suspended task $(parent.id) failed: $(e.message)");
+                }
+            }
+        }
+
         /**
          * Declares a saddle unreachable once its failures accumulated:
          * the task it held returns to the queue (with a bot comment) and

+ 44 - 3
src/Services/SaddleService.vala

@@ -356,7 +356,7 @@ namespace ClankerSaddle.Services {
          * response body.
          */
         public async Json.Object push_task_to_saddle(int64 saddle_id, TaskEntity task, int64 session_id,
-                ProjectService projects) throws Error {
+                ProjectService projects, string? resume_notice = null) throws Error {
             var saddle = yield get_saddle(saddle_id);
             if (saddle == null) {
                 throw new Error(Quark.from_string("clanker-saddle"), 11,
@@ -477,6 +477,35 @@ namespace ClankerSaddle.Services {
             }
             payload.end_array();
 
+            // The wire contract's `subtasks` addition: the task's children
+            // at dispatch time — the daemon's source of truth for its
+            // `saddle done` guard and `saddle suspend` validation.
+            payload.set_member_name("subtasks");
+            payload.begin_array();
+            foreach (var child in yield db.query<TaskEntity>()
+                    .where(expr("parent_task_id == $0", elem<int64?>(task.id)))
+                    .order_by(expr("id"))
+                    .materialise_async()) {
+                payload.begin_object();
+                payload.set_member_name("id");
+                payload.add_int_value(child.id);
+                payload.set_member_name("title");
+                payload.add_string_value(child.title);
+                payload.set_member_name("status");
+                payload.add_string_value(child.status);
+                payload.end_object();
+            }
+            payload.end_array();
+
+            // The wire contract's `resume_notice` addition: on a
+            // workspace-adopting start (a suspended parent's resume), the
+            // daemon injects it as the next user turn instead of the
+            // opening prompt.
+            if (resume_notice != null && resume_notice.length > 0) {
+                payload.set_member_name("resume_notice");
+                payload.add_string_value(resume_notice);
+            }
+
             // 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).
@@ -657,6 +686,13 @@ namespace ClankerSaddle.Services {
                     // dispatcher resumes from once the block is answered.
                     yield update_saddle_status(saddle_id, "idle", null);
                     break;
+                case "suspended":
+                    // `saddle suspend`: the same parking as a block, but
+                    // the web drives resumption — a fresh /start (with a
+                    // resume_notice) once a subtask event lands.
+                    yield tasks.suspend_task(task_id);
+                    yield update_saddle_status(saddle_id, "idle", null);
+                    break;
                 case "working":
                     // Never downgrade a pending review: the agent keeps
                     // working after `saddle review` (it does not block),
@@ -847,7 +883,7 @@ namespace ClankerSaddle.Services {
          * work or offline).
          */
         public async bool resume_answered_task(TaskEntity task, ClankerSaddle.Data.Session session,
-                TaskService tasks, ProjectService projects) throws Error {
+                TaskService tasks, ProjectService projects, string? resume_notice = null) throws Error {
             var saddle = yield get_saddle(session.saddle_id);
             if (saddle == null || (((!)saddle).status == "offline"
                     || (((!)saddle).status != "idle" && ((!)saddle).current_task_id != task.id))) {
@@ -876,9 +912,14 @@ namespace ClankerSaddle.Services {
                 yield tasks.update_task(task.id, null, null, null, STATUS_WORKING);
                 yield update_saddle_status(((!)saddle).id, "busy", task.id);
             } else {
+                // Idle daemon (a parked/blocked/suspended workspace): a
+                // 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.
                 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);
+                yield push_task_to_saddle(((!)saddle).id, task, session.id, projects, resume_notice);
                 yield mark_task_comments_delivered(task.id);
                 yield tasks.update_task(task.id, null, null, null, STATUS_ASSIGNED);
             }

+ 81 - 0
src/Services/TaskService.vala

@@ -693,6 +693,87 @@ namespace ClankerSaddle.Services {
             yield pulse.bump(task_id, "task failed");
         }
 
+        /**
+         * Parks a suspended task (the /status "suspended" state): stores
+         * the status and stamps `suspended_at` once per parking window,
+         * so the dispatcher's resume trigger only sees subtask events
+         * that landed after the suspension.
+         */
+        public async void suspend_task(int64 task_id) throws Error {
+            var task = yield db.query<TaskEntity>()
+                .where(expr("id == $0", elem<int64?>(task_id)))
+                .first_async();
+            if (task == null || ((!)task).status == STATUS_SUSPENDED) {
+                return;
+            }
+            ((!)task).status = STATUS_SUSPENDED;
+            ((!)task).suspended_at = new DateTime.now_local();
+            yield db.update_async<TaskEntity>((!)task);
+            yield pulse.bump(task_id, "task suspended");
+        }
+
+        /** Withdraws the suspension stamp after a successful resume. */
+        public async void clear_suspension(int64 task_id) throws Error {
+            var task = yield db.query<TaskEntity>()
+                .where(expr("id == $0", elem<int64?>(task_id)))
+                .first_async();
+            if (task == null || ((!)task).suspended_at == null) {
+                return;
+            }
+            ((!)task).suspended_at = null;
+            yield db.update_async<TaskEntity>((!)task);
+        }
+
+        /**
+         * True when any child of the (suspended) parent reached a
+         * terminal state after the parent parked — the per-subtask
+         * resume trigger. Resumes on EACH such event, not only when all
+         * subtasks finish.
+         */
+        public async bool has_terminal_subtask_since(TaskEntity parent) throws Error {
+            if (parent.suspended_at == null) {
+                return false;
+            }
+            foreach (var child in yield db.query<TaskEntity>()
+                    .where(expr("parent_task_id == $0", elem<int64?>(parent.id)))
+                    .materialise_async()) {
+                if ((child.status == STATUS_DONE || child.status == STATUS_FAILED)
+                        && child.completed_at != null
+                        && ((!)child.completed_at).compare((!)parent.suspended_at) > 0) {
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        /**
+         * The markdown status report riding a suspended parent's resume:
+         * one line per subtask — terminal ones with their outcome and
+         * summary, open ones with their current state. Several subtask
+         * events landing while the parent was parked coalesce into this
+         * single report.
+         */
+        public async string subtask_status_report(int64 parent_id) throws Error {
+            var report = new StringBuilder();
+            report.append("Subtask update — resuming your suspended session:\n\n");
+            foreach (var child in yield db.query<TaskEntity>()
+                    .where(expr("parent_task_id == $0", elem<int64?>(parent_id)))
+                    .order_by(expr("id"))
+                    .materialise_async()) {
+                if (child.status == STATUS_DONE) {
+                    // append_printf: printf would truncate what's there.
+                    report.append_printf("- #%lld %s — completed: %s\n", child.id, child.title,
+                        child.summary ?? "no summary reported");
+                } else if (child.status == STATUS_FAILED) {
+                    report.append_printf("- #%lld %s — failed: %s\n", child.id, child.title,
+                        child.summary ?? "no summary reported");
+                } else {
+                    report.append_printf("- #%lld %s — %s\n", child.id, child.title, child.status);
+                }
+            }
+            return report.str;
+        }
+
         /**
          * Returns a non-terminal task to the queue: closes its open
          * sessions (outcome 'stopped') and frees the saddle that was

+ 1 - 0
src/main.vala

@@ -32,6 +32,7 @@ int main(string[] args) {
         db.add_migration<ClankerSaddle.Data.Migrations.M0004_ProjectNotes>();
         db.add_migration<ClankerSaddle.Data.Migrations.M0005_RawLog>();
         db.add_migration<ClankerSaddle.Data.Migrations.M0006_Capabilities>();
+        db.add_migration<ClankerSaddle.Data.Migrations.M0007_SuspendedAt>();
         db.add_entity<Project>(Project.entity_mapping);
         db.add_entity<ProjectLink>(ProjectLink.entity_mapping);
         db.add_entity<TaskEntity>(TaskEntity.entity_mapping);

+ 3 - 0
src/pages/task_detail.html

@@ -229,6 +229,9 @@
                     <p stm-if="task.task_branch.length > 0">Branch: <strong stm-text="task.task_branch">branch</strong>
                         <span class="muted" stm-if="task.branch_from.length > 0"> from <span stm-text="task.branch_from">base</span></span></p>
                     <p stm-if="task.working_branch.length > 0" class="muted">Working branch: <span stm-text="task.working_branch">branch</span></p>
+                    <p stm-if="task.status == 'suspended'" class="muted">Parked — waiting on
+                        subtasks (<span stm-text="task.subtask_open">0</span> open). The task
+                        resumes with a status report whenever one finishes.</p>
                     <form class="inline">
                         <input type="hidden" name="task_id" stm-attribute.value="task.id">
                         <button type="button" stm-if="task.status == 'failed'" class="primary"

二進制
test/__pycache__/web_stack_test.cpython-314.pyc


+ 78 - 2
test/web_stack_test.py

@@ -209,6 +209,7 @@ class FakeSaddle:
         self.fail_next = False          # next /start drives the task to failed
         self.start_counts = {}          # task id -> /start count
         self.resumed_pending = False    # crash-resume flag on the next /status
+        self.suspend_after_start = False  # next task parks as suspended
         self.pending_output = []        # entries drained by the next /output poll
         self.output_seq = 100
 
@@ -294,8 +295,14 @@ class FakeSaddle:
                         self.daemon.state = "failed"
                         self.daemon.summary = "bash -n rejected the script (simulated)"
                     elif self.daemon.state == "working" and self.daemon.status_polls_after_start >= 2:
-                        self.daemon.state = "done"
-                        self.daemon.summary = "fake daemon finished"
+                        if self.daemon.suspend_after_start:
+                            # `saddle suspend`: park until a fresh /start
+                            # resumes the workspace (one-shot flag).
+                            self.daemon.state = "suspended"
+                            self.daemon.suspend_after_start = False
+                        else:
+                            self.daemon.state = "done"
+                            self.daemon.summary = "fake daemon finished"
                 self._send(200, response)
             elif self.path.startswith("/output"):
                 with self.daemon.lock:
@@ -1125,6 +1132,75 @@ def main():
         check(task_state(auth, rejected_task)["status"] == "done"
               and "fake daemon finished" in (task_state(auth, rejected_task).get("summary") or ""),
               "rejected entries never fail the task")
+        print("== B7: suspended state + per-subtask resume ==")
+        fake.suspend_after_start = True
+        pushes_before = len(fake.start_payloads)
+        act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
+            "project_id": "1",
+            "title": "Suspend parent",
+            "priority": "2",
+        })
+        parent7 = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
+                       if t["title"] == "Suspend parent"][0])
+        check(wait_for(lambda: task_state(auth, parent7).get("status") == "suspended"
+                       or False, timeout=60, what="parent parks as suspended") is not None,
+              "daemon 'suspended' state parks the task")
+        detail = task_state(auth, parent7)
+        check(detail.get("display") == "waiting on subtasks",
+              "suspended task displays as waiting on subtasks")
+        check(any(s["status"] == "idle"
+                  for s in slot_public(auth, "/saddles", "saddles")["saddles"]),
+              "suspension frees the saddle")
+        first_push = fake.start_payloads[pushes_before]
+        check(first_push.get("subtasks") == [],
+              "the /start payload carries the subtasks array (empty here)")
+
+        # Two subtask events land while parked (staged, not dispatched):
+        # both must coalesce into one resume with a full report.
+        conn = db()
+        conn.execute("insert into tasks (project_id, title, status, priority, parent_task_id,"
+                     " summary, completed_at, created_at) values (1, 'Child one', 'done', 5, ?,"
+                     " 'built and verified the flange', strftime('%s','now','+1 second'),"
+                     " strftime('%s','now'))", (parent7,))
+        conn.execute("insert into tasks (project_id, title, status, priority, parent_task_id,"
+                     " summary, completed_at, created_at) values (1, 'Child two', 'failed', 5, ?,"
+                     " 'the harness refused to start', strftime('%s','now','+1 second'),"
+                     " strftime('%s','now'))", (parent7,))
+        conn.commit()
+        conn.close()
+
+        def parent_push_count():
+            return len([p for p in fake.start_payloads
+                        if int(p.get("task", {}).get("id") or 0) == parent7])
+
+        check(wait_for(lambda: parent_push_count() >= 2 or False,
+                       timeout=45, what="suspended parent resumes") is not None,
+              "subtask events resume the suspended parent")
+        parent_pushes = [p for p in fake.start_payloads
+                         if int(p.get("task", {}).get("id") or 0) == parent7]
+        check(len(parent_pushes) >= 2,
+              "the parent's resume is a fresh /start for the same task")
+        resume_push = parent_pushes[-1]
+        notice = resume_push.get("resume_notice") or ""
+        check("Child one" in notice and "completed" in notice
+              and "built and verified the flange" in notice,
+              "resume notice reports the finished subtask with its summary")
+        check("Child two" in notice and "failed" in notice
+              and "the harness refused to start" in 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(wait_task_done(auth, parent7, timeout=60) is not None,
+              "resumed parent completes")
+        detail = task_state(auth, parent7)
+        check(any("restored the session context" in p["summary"]
+                  for p in detail.get("progress", [])),
+              "resumed flag reported on the first status after the restore")
+
+
+
     finally:
         app.terminate()
         try: