Selaa lähdekoodia

Merge branch 'task/8-35' into goal/toto-batch-1

# Conflicts:
#	test/harness.py
saddle-Clanker-Saddle 1 kuukausi sitten
vanhempi
sitoutus
6c8ae5e3b5

+ 6 - 2
src/Data/Migrations/M0001_Initial.vala

@@ -145,10 +145,14 @@ namespace ClankerSaddle.Data.Migrations {
                 t.column<int64?>("id")
                     .auto_increment()
                     .primary_key();
+                // Goal 8 task 4: 0 = a STANDALONE task — no project
+                // row exists, the workspace holds no clones and the
+                // deliverable is the completion report. No FK (0 is
+                // not a project id) and no index (every browse path
+                // filters by goal or lists all).
                 t.column<int64?>("project_id")
                     .not_null()
-                    .references("projects", "id")
-                    .indexed();
+                    .default_value<int64?>(0);
                 t.column<int64?>("goal_id")
                     .references("goals", "id")
                     .indexed();

+ 6 - 2
src/Services/AllocationService.vala

@@ -656,14 +656,18 @@ namespace ClankerSaddle.Services {
         private async Vector<string> co_developed_project_ids(TaskEntity task,
                 ProjectService projects) throws Error {
             var ids = new Vector<string>();
-            ids.add(task.project_id.to_string());
+            // Goal 8 task 4: a standalone task (project 0) involves no
+            // projects — its context update covers rules only.
+            if (task.project_id > 0) {
+                ids.add(task.project_id.to_string());
+            }
             if (task.goal_id > 0) {
                 foreach (var link in yield goals.list_links(task.goal_id)) {
                     if (link.relationship == LINK_CO_DEVELOPED) {
                         ids.add(link.project_id.to_string());
                     }
                 }
-            } else {
+            } else if (task.project_id > 0) {
                 foreach (var link in yield projects.list_links(task.project_id)) {
                     if (link.relationship == LINK_CO_DEVELOPED) {
                         ids.add(link.linked_project_id.to_string());

+ 76 - 1
src/Services/GoalService.vala

@@ -440,6 +440,64 @@ namespace ClankerSaddle.Services {
             return goal;
         }
 
+        /**
+         * Goal 8 task 4 (comment-resurrect): a HUMAN comment on a
+         * terminal goal (complete/failed — cancelled goals stay dead,
+         * a cancellation is a decision, not a stopping point) re-opens
+         * the goal and re-offers its run for another round. The goal
+         * flips back to 'active' (supervision restarts — the same
+         * state a rejected completion review lands in), the run task
+         * returns to 'suspended' so the parked-resume machinery picks
+         * it up, and force_wake is set so resume_due fires without
+         * waiting on the coalescing window. The completion report
+         * STAYS on the goal row: the resuming agent reads it, amends
+         * its work and submits a fresh one. A run task that closed
+         * terminal (the approved-completion case) gets a seeded
+         * parked session row — the parked-resume allocation keys on
+         * the newest session's affine saddle, and a DONE run has none
+         * open. Returns true when the goal was terminal and has been
+         * resurrected; false when it was not terminal (the comment is
+         * just a comment). Throws when the goal has no run task at
+         * all (the comment already failed upstream — nothing to
+         * resurrect onto).
+         */
+        public async bool resurrect_goal(int64 goal_id) throws Error {
+            var goal = yield require_goal(goal_id);
+            if (((!)goal).status != GOAL_COMPLETE && ((!)goal).status != GOAL_FAILED) {
+                return false;
+            }
+            var run = yield goal_run_task(goal_id);
+            if (run == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 88,
+                    "The goal agent has no run task — nothing to resurrect");
+            }
+
+            string from = ((!)goal).status;
+            ((!)goal).status = GOAL_ACTIVE;
+            ((!)goal).completed_at = null;
+            ((!)goal).force_wake = true;
+            yield db.update_async<Goal>((!)goal);
+            yield append_goal_event(goal_id, EVENT_STATE_CHANGE,
+                @"Goal re-opened by comment (was $(from)) — supervision restarts", GOAL_ACTIVE);
+
+            // The run task returns to the parked shape. A terminal run
+            // (the approved completion) additionally seeds the parked
+            // session row the parked-resume claim needs; the summary
+            // stays on the row for the agent to read on resume.
+            string run_from = ((!)run).status;
+            if (((!)run).status == STATUS_DONE || ((!)run).status == STATUS_FAILED) {
+                yield tasks.seed_parked_session(((!)run).id);
+            }
+            ((!)run).status = STATUS_SUSPENDED;
+            ((!)run).suspended_at = new DateTime.now_local();
+            ((!)run).completed_at = null;
+            yield db.update_async<TaskEntity>((!)run);
+            yield tasks.add_system_comment(((!)run).id,
+                @"Goal re-opened by comment — supervision resumes (was $(run_from))");
+            yield pulse.bump(0, "goal re-opened by comment");
+            return true;
+        }
+
 
         /**
          * Cancels a goal: the cascade closes every one of its tasks —
@@ -823,7 +881,16 @@ namespace ClankerSaddle.Services {
                 throw new Error(Quark.from_string("clanker-saddle"), 87,
                     "The goal agent has not started yet — comments open with its run");
             }
-            return yield tasks.add_comment(((!)run).id, author, body, quote);
+            var comment = yield tasks.add_comment(((!)run).id, author, body, quote);
+            // Goal 8 task 4: a comment on a TERMINAL goal resurrects
+            // it — the goal re-opens, the parked run re-offers with the
+            // comment riding the resume (force_wake skips the
+            // coalescing window; the comment itself rides the thread
+            // both as the resuming assignment's comments array and the
+            // wake-noticed resume). Non-terminal goals just keep the
+            // comment.
+            yield resurrect_goal(goal_id);
+            return comment;
         }
 
         /**
@@ -1216,6 +1283,14 @@ namespace ClankerSaddle.Services {
             if (woken) {
                 report.append("The user explicitly force-woke this goal's supervision — you were parked with no pending wake.\n\n");
             }
+            // Goal 8 task 4: a goal resurrected by comment names why —
+            // the fresh human comment (below) is the resumption
+            // instruction; the resubmitted completion report stays on
+            // the goal row for the agent to amend.
+            if (((!)goal).completion_report != null
+                    && ((!)((!)goal).completion_report).length > 0) {
+                report.append("This goal was re-opened by a human comment after its completion report — address the comment, then submit_report again.\n\n");
+            }
             report.append("Goal update — resuming your parked session:\n\n");
             report.append("## Task statuses\n");
             var finished = new Vector<TaskEntity>();

+ 8 - 4
src/Services/RuleService.vala

@@ -257,9 +257,13 @@ namespace ClankerSaddle.Services {
                     ordered.add(rule);
                 }
             }
-            foreach (var rule in yield list_rules_for_project(task.project_id)) {
-                if (seen.add(rule.id.to_string())) {
-                    ordered.add(rule);
+            // Goal 8 task 4: standalone tasks (project 0) carry only
+            // their own task rules — no project exists to expand.
+            if (task.project_id > 0) {
+                foreach (var rule in yield list_rules_for_project(task.project_id)) {
+                    if (seen.add(rule.id.to_string())) {
+                        ordered.add(rule);
+                    }
                 }
             }
             if (goal_id > 0) {
@@ -276,7 +280,7 @@ namespace ClankerSaddle.Services {
                         }
                     }
                 }
-            } else {
+            } else if (task.project_id > 0) {
                 foreach (var link in yield projects.list_links(task.project_id)) {
                     if (link.relationship != LINK_CO_DEVELOPED) {
                         continue;

+ 28 - 13
src/Services/SaddleService.vala

@@ -775,10 +775,18 @@ namespace ClankerSaddle.Services {
                     "No LLM endpoint configured (set SADDLE_LLM_BASE_URL)");
             }
 
-            var project = yield db.query<Project>()
-                .where(expr("id == $0", elem<int64?>(task.project_id)))
-                .first_async();
-            if (project == null) {
+            var project = task.project_id > 0
+                ? yield db.query<Project>()
+                    .where(expr("id == $0", elem<int64?>(task.project_id)))
+                    .first_async()
+                : null;
+            // Goal 8 task 4: project_id 0 is a STANDALONE task — no
+            // project row exists (and none is looked up), the
+            // co-developed/reference arrays ship EMPTY and the daemon
+            // renders the standalone prompt section. A task with a
+            // non-zero project_id whose row is missing is still the
+            // data-integrity error it always was.
+            if (project == null && task.project_id > 0) {
                 throw new Error(Quark.from_string("clanker-saddle"), 12,
                     @"Task $(task.id) has no project $(task.project_id)");
             }
@@ -786,6 +794,8 @@ namespace ClankerSaddle.Services {
             // Plan 12: goal work (the agent's own run and its worker
             // tasks) scopes its co-developed/reference set through the
             // GOAL's links; standalone tasks through the project's.
+            // Standalone tasks (project 0) own no links at all — the
+            // simplest consistent shape: both arrays stay empty.
             Goal? goal = null;
             if (task.goal_id > 0) {
                 goal = yield db.query<Goal>()
@@ -808,7 +818,7 @@ namespace ClankerSaddle.Services {
                         references.add((!)linked);
                     }
                 }
-            } else {
+            } else if (project != null) {
                 foreach (var link in yield projects.list_links(task.project_id)) {
                     var linked = yield db.query<Project>()
                         .where(expr("id == $0", elem<int64?>(link.linked_project_id)))
@@ -835,17 +845,20 @@ namespace ClankerSaddle.Services {
             payload.set_member_name("description");
             payload.add_string_value(task.description ?? "");
             payload.set_member_name("project_name");
-            payload.add_string_value(((!)project).name);
+            payload.add_string_value(project != null ? ((!)project).name : "standalone");
             payload.set_member_name("project_branch");
-            payload.add_string_value(((!)project).default_branch);
+            payload.add_string_value(project != null ? ((!)project).default_branch : "");
             payload.set_member_name("task_branch");
             payload.add_string_value(task.task_branch ?? "");
             payload.set_member_name("branch_from");
-            payload.add_string_value(task.branch_from ?? ((!)project).default_branch);
+            payload.add_string_value(task.branch_from
+                ?? (project != null ? ((!)project).default_branch : ""));
 
             payload.set_member_name("co_developed");
             payload.begin_array();
-            payload.add_value(project_node(((!)project), task.id));
+            if (project != null) {
+                payload.add_value(project_node(((!)project), task.id));
+            }
             foreach (var linked in co_developed) {
                 payload.add_value(project_node(linked, task.id));
             }
@@ -915,7 +928,7 @@ namespace ClankerSaddle.Services {
                 payload.add_string_value(((!)goal).branch_name);
                 payload.set_member_name("base_branch");
                 payload.add_string_value(((!)goal).base_branch
-                    ?? ((!)project).default_branch);
+                    ?? (project != null ? ((!)project).default_branch : ""));
                 payload.set_member_name("brief");
                 payload.add_string_value(((!)goal).brief);
                 payload.set_member_name("plan");
@@ -1016,9 +1029,11 @@ namespace ClankerSaddle.Services {
             // 30 entries / 20 000 chars (head kept).
             var note_project_names = new Dictionary<string, string>();
             var project_notes_rows = new Vector<ClankerSaddle.Data.ProjectNote>();
-            note_project_names.set(((!)project).id.to_string(), ((!)project).name);
-            foreach (var note in yield projects.list_notes(((!)project).id)) {
-                project_notes_rows.add(note);
+            if (project != null) {
+                note_project_names.set(((!)project).id.to_string(), ((!)project).name);
+                foreach (var note in yield projects.list_notes(((!)project).id)) {
+                    project_notes_rows.add(note);
+                }
             }
             foreach (var linked in co_developed) {
                 note_project_names.set(linked.id.to_string(), linked.name);

+ 68 - 1
src/Services/TaskService.vala

@@ -163,8 +163,14 @@ namespace ClankerSaddle.Services {
                 string kind = TASK_KIND_TASK, string mode = "implement",
                 string creator = CREATOR_HUMAN, int64 goal_id = 0,
                 int64 enqueue_after_task_id = 0) throws Error {
+            // Goal 8 task 4: project_id 0 is a STANDALONE task — its
+            // own ephemeral workspace, no project row anywhere.
+            if (project_id < 0) {
+                throw new Error(Quark.from_string("clanker-saddle"), 76,
+                    @"Invalid project id $(project_id)");
+            }
             string resolved_branch_from = branch_from ?? "";
-            if (resolved_branch_from.length == 0) {
+            if (resolved_branch_from.length == 0 && project_id > 0) {
                 var project = yield db.query<Project>()
                     .where(expr("id == $0", elem<int64?>(project_id)))
                     .first_async();
@@ -959,6 +965,10 @@ namespace ClankerSaddle.Services {
                     || ((!)task).task_branch.length == 0) {
                 return "";
             }
+            if (((!)task).project_id <= 0) {
+                // Standalone tasks own no repository — no compare URL.
+                return "";
+            }
             var project = yield db.query<Project>()
                 .where(expr("id == $0", elem<int64?>(((!)task).project_id)))
                 .first_async();
@@ -1642,6 +1652,63 @@ namespace ClankerSaddle.Services {
          * stamp; its keep-parked gate (a task that parked mid-flight)
          * skips the release entirely.
          */
+        /**
+         * Goal 8 task 4 (comment-resurrect): re-queues a TERMINAL
+         * non-goal task (goal_id == 0, status done/failed) for another
+         * round after a human comment. The completion summary and
+         * `completed_at` STAY (the report of the earlier round remains
+         * readable — the daemon's next finish overwrites the summary),
+         * and a fresh parked session row is seeded on the task's last
+         * saddle so the plain-task requeue path (which keys on the
+         * newest session's affine saddle) has a workspace to resume;
+         * no session row at all (a fixture-shaped task) requeues onto
+         * the open queue instead. The resurrection is idempotent from
+         * the caller's side: only a terminal task flips.
+         */
+        public async void requeue_terminal_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) {
+                throw new Error(Quark.from_string("clanker-saddle"), 8,
+                    @"No task exists with id $(task_id)");
+            }
+            if (((!)task).status != STATUS_DONE && ((!)task).status != STATUS_FAILED) {
+                return;
+            }
+            string from = ((!)task).status;
+            ((!)task).status = STATUS_QUEUED;
+            ((!)task).completed_at = null;
+            yield db.update_async<TaskEntity>((!)task);
+            yield append_state_change(task_id, from, STATUS_QUEUED,
+                @"Task re-opened by comment ($(from))", EVENT_ACTOR_USER);
+            yield pulse.bump(task_id, "task re-opened by comment");
+        }
+
+        /**
+         * Seeds a CLOSED 'parked' session row for `task_id` on the
+         * saddle of its newest prior session (the resurrect paths'
+         * resume anchor — the allocation's parked-resume arm needs a
+         * session row to key the affine saddle and the successor row
+         * on). Returns true when a row was seeded; false when the task
+         * has no session history at all.
+         */
+        public async bool seed_parked_session(int64 task_id) throws Error {
+            var newest = yield newest_session_for_task(task_id);
+            if (newest == null) {
+                return false;
+            }
+            var seeded = new Session() {
+                task_id = task_id,
+                saddle_id = ((!)newest).saddle_id,
+                started_at = ((!)newest).started_at ?? new DateTime.now_local(),
+                ended_at = new DateTime.now_local(),
+                outcome = OUTCOME_PARKED
+            };
+            yield db.insert_async<Session>(seeded);
+            return true;
+        }
+
         public async void release_task(int64 task_id, string outcome, bool requeue) throws Error {
             if (requeue) {
                 var task = yield db.query<TaskEntity>()

+ 42 - 3
src/actions/TaskActions.vala

@@ -86,6 +86,12 @@ namespace ClankerSaddle {
         private TaskService tasks = inject<TaskService>();
         private CapabilityService capabilities = inject<CapabilityService>();
         private UserService users = inject<UserService>();
+        private ProjectService projects = inject<ProjectService>();
+
+        /** The project of `id`, or null (standalone validation). */
+        private async ClankerSaddle.Data.Project? tasks_project(int64 id) throws GLib.Error {
+            return yield projects.get_project(id);
+        }
 
         public override async DirectiveBuilder handle() throws GLib.Error {
             var guard = yield SpryAuth.require_login(directives(), request, users);
@@ -95,8 +101,21 @@ namespace ClankerSaddle {
 
             int64 project_id = Forms.integer(request, "project_id", 0);
             string title = Forms.field(request, "title");
-            if (project_id <= 0 || title.length == 0) {
-                return directives().notify("error", "Project and title are required").navigate("/tasks");
+            // Goal 8 task 4: project_id 0 posts a STANDALONE task (the
+            // form's "No project" option) — no repository, its own
+            // ephemeral workspace; negative ids are form corruption.
+            if (project_id < 0) {
+                return directives().notify("error", "Unknown project").navigate("/tasks");
+            }
+            if (title.length == 0) {
+                return directives().notify("error", "Title is required").navigate("/tasks");
+            }
+            if (project_id > 0) {
+                var project = yield tasks_project(project_id);
+                if (project == null) {
+                    return directives().notify("error",
+                            @"No project exists with id $(project_id)").navigate("/tasks");
+                }
             }
 
             // Required capabilities: the create form's multi-select posts
@@ -237,13 +256,33 @@ namespace ClankerSaddle {
 
             var me = SpryAuth.current(request);
             try {
+                var task = yield tasks.get_task(task_id);
+                if (task == null) {
+                    return directives().notify("error", @"No task exists with id $(task_id)")
+                        .navigate("/tasks");
+                }
+                // Goal 8 task 4: comments stay OPEN on terminal tasks,
+                // and a comment on a TERMINAL NON-GOAL task (goal_id
+                // == 0) resurrects it — the task re-queues with the
+                // comment injected (the daemon receives the thread's
+                // comments on start). Goal-owned worker tasks keep
+                // their goal agent as the only voice: the service
+                // refuses below.
+                bool terminal_plain = (((!)task).task.goal_id == 0)
+                    && ((((!)task).task.status == STATUS_DONE)
+                        || (((!)task).task.status == STATUS_FAILED));
                 yield tasks.add_comment(task_id, @"user:$(me.username)", body, quote);
+                if (terminal_plain) {
+                    yield tasks.requeue_terminal_task(task_id);
+                }
                 // Terminals post with a return_to so the live view survives
                 // the comment; every other caller lands on the task page.
                 string return_to = Forms.field(request, "return_to");
                 string destination = return_to.length > 0 && return_to.has_prefix("/")
                     ? return_to : @"/tasks/$(task_id)";
-                return directives().notify("success", "Comment added").navigate(destination);
+                string message = terminal_plain
+                    ? "Comment added — task re-queued for another round" : "Comment added";
+                return directives().notify("success", message).navigate(destination);
             } catch (Error e) {
                 return directives().notify("error", @"Could not add the comment: $(e.message)")
                     .navigate(@"/tasks/$(task_id)");

+ 8 - 0
src/pages/goal_detail.html

@@ -509,10 +509,18 @@
                 <p stm-if="goal.comments.length == 0" class="muted">No comments yet.</p>
                 <p stm-if="goal.run_task_id == 0" class="muted">The goal agent has not started
                     yet — comments open with its run.</p>
+                <!-- Goal 8 task 4: the composer stays OPEN on terminal
+                     goals — a comment there re-opens the goal and
+                     re-offers its agent's run with the comment riding
+                     the resume (cancelled goals stay closed: their
+                     composer keeps the run gate alone). -->
                 <form stm-if="goal.run_task_id > 0" stm-action="goal.comment">
                     <div class="form-row"><label for="goal-comment-body">Add comment</label>
                         <textarea id="goal-comment-body" name="body" rows="3"
                                   placeholder="A timeless note for a future reader — status belongs in progress updates (markdown)"></textarea></div>
+                    <p stm-if="goal.terminal && goal.status != 'cancelled'" class="muted">
+                        This goal is terminal — commenting re-opens it: the agent resumes
+                        with your comment and submits a fresh completion report.</p>
                     <button type="submit" class="primary">Comment</button>
                 </form>
                 <dialog id="quote-comment-dialog">

+ 20 - 2
src/pages/task_detail.html

@@ -63,11 +63,18 @@
         <p stm-if="task != null && !task.exists" class="muted">Task not found.</p>
 
         <div stm-if="task != null && task.exists">
-            <p><a href="/tasks">All tasks</a> · project
+            <!-- Goal 8 task 4: a standalone task (no project) shows
+                 "(standalone)" instead of a project link — its
+                 workspace holds no clones. -->
+            <p stm-if="task.project_id > 0"><a href="/tasks">All tasks</a> · project
                 <a stm-attribute.href="'/projects/' + task.project_id" stm-text="task.project_name">project</a>
                 <span stm-if="task.enqueue_after_task_id > 0"> · auto-enqueues after
                     <a stm-attribute.href="'/tasks/' + task.enqueue_after_task_id"
                        stm-text="'#' + task.enqueue_after_task_id">after</a></span></p>
+            <p stm-if="task.project_id == 0"><a href="/tasks">All tasks</a> ·
+                <strong>(standalone)</strong>
+                <span class="muted">— no project: the agent works in its own ephemeral
+                    workspace and delivers its completion report</span></p>
 
             <!-- Progress feed ABOVE the status card (plan 11 task D):
                  the agent's newest line leads the page, its history
@@ -305,12 +312,23 @@
                 <blockquote stm-if="comment.quote.length > 0" stm-text="comment.quote">quote</blockquote>
             </div>
             <p stm-if="task.comments.length == 0" class="muted">No comments yet.</p>
-            <form stm-action="task.comment">
+            <!-- Goal 8 task 4: comments stay OPEN on terminal tasks.
+                 A terminal NON-GOAL task resurrects on comment (it
+                 re-queues with the comment injected); a terminal
+                 goal-owned worker keeps its goal agent as the only
+                 voice — the hint says so and the service refuses. -->
+            <form stm-if="!(task.terminal && task.goal_id > 0)" stm-action="task.comment">
                 <input type="hidden" name="task_id" stm-attribute.value="task.id">
                 <div class="form-row"><label for="comment-body">Add comment</label>
                     <textarea id="comment-body" name="body" rows="3" placeholder="A comment for the agent"></textarea></div>
+                <p stm-if="task.terminal && task.goal_id == 0" class="muted">
+                    This task is terminal — commenting re-queues it for another round
+                    with your comment injected.</p>
                 <button type="submit" class="primary">Comment</button>
             </form>
+            <p stm-if="task.terminal && task.goal_id > 0" class="muted">
+                Comments are closed — this task belongs to its goal; the goal agent
+                (its goal's page) owns the conversation.</p>
 
             <!-- The highlight-to-comment popover (main.html) fills and opens
                  this dialog; it went missing in a template rework, which broke

+ 6 - 1
src/pages/tasks.html

@@ -92,7 +92,12 @@
             <summary><strong>New task</strong></summary>
             <form stm-action="tasks.create">
                 <div class="form-row"><label for="task-project">Project</label>
-                    <select id="task-project" name="project_id" required>
+                    <!-- Goal 8 task 4: "No project" posts project_id 0 —
+                         a STANDALONE task with its own ephemeral
+                         workspace; the completion report is the
+                         deliverable. -->
+                    <select id="task-project" name="project_id">
+                        <option value="0" selected>No project (standalone)</option>
                         <option stm-for-project-in="tasks.projects" stm-attribute.value="project.id"
                                  stm-text="project.name">project</option>
                     </select></div>

+ 3 - 1
test/bundles/__init__.py

@@ -4,7 +4,8 @@ the modules' star-imported runtime globals are the shard's own). The
 order here is the parallel runner's shard order."""
 
 from . import (allocation, answers, dispatch, goalui, goals, lineage,
-               lifecycle, notes, pages, push, replay, stacks, subtasks, users)
+               lifecycle, notes, pages, push, replay, resurrect, stacks,
+               subtasks, users)
 
 BUNDLES = {
     "pages": pages,
@@ -21,6 +22,7 @@ BUNDLES = {
     "users": users,
     "stacks": stacks,
     "notes": notes,
+    "resurrect": resurrect,
 }
 
 BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",

+ 227 - 0
test/bundles/resurrect.py

@@ -0,0 +1,227 @@
+"""The resurrect bundle (goal 8 task 4): standalone (no-project) tasks
+and comment-resurrected terminal items.
+
+- A task created with project_id 0 dispatches with EMPTY co-developed
+  and reference arrays, the "standalone" project name, and completes on
+  its completion report.
+- A human comment on a TERMINAL goal (complete) re-opens the goal, its
+  run task returns to suspended-offerable, and the resuming assignment
+  carries the comment + the re-open line in its resume notice.
+- A human comment on a TERMINAL plain task re-queues it, the next
+  assignment carries the comment.
+- A terminal goal-owned WORKER task keeps comments closed (the page
+  hides the composer; the service path is the goal's alone).
+"""
+from harness import *  # noqa: F401,F403 — the shared wire helpers
+
+
+def run(ctx):
+    auth, fake, fake_id, fake_token = ctx.auth, ctx.fake, ctx.fake_id, ctx.fake_token
+
+    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"])
+
+    def run_task_of(goal_id):
+        conn = db()
+        try:
+            row = conn.execute(
+                "select id from tasks where goal_id = ? and kind = 'goal_run'",
+                (goal_id,)).fetchone()
+            return int(row[0]) if row else None
+        finally:
+            conn.close()
+
+    print("== standalone task: created with no project, dispatched, done ==")
+    state = slot_public(auth, "/tasks", "tasks")
+    act(auth, state["create"], {
+        "project_id": "0",
+        "title": "Standalone research",
+        "description": "Summarise the fleet's flaky tests.",
+        "priority": "3",
+    })
+    standalone = task_id_of("Standalone research")
+    detail = task_state(auth, standalone)
+    check(detail["status"] == "draft" and int(detail["project_id"]) == 0,
+          "a project_id 0 task creates in draft (project_id %s)" % detail["project_id"])
+    page = http_get_page(auth, "/tasks/%d" % standalone)
+    check("(standalone)" in page and "/projects/0" not in page,
+          "the task page renders the (standalone) marker, not a project link")
+    check("No project (standalone)" in http_get_page(auth, "/tasks"),
+          "the create form offers the No project (standalone) option")
+    enqueue_task(auth, standalone)
+    check(wait_for(lambda: len(fake.start_payloads) > 0 or False,
+                   timeout=30, what="standalone /start push") is not None,
+          "the dispatcher pushes the standalone task to the fake saddle")
+    payload = fake.start_payloads[-1]
+    task_obj = payload.get("task") or {}
+    check(task_obj.get("project_name") == "standalone",
+          "the standalone assignment names the project 'standalone' (%s)"
+          % task_obj.get("project_name"))
+    check(task_obj.get("co_developed") == [] and task_obj.get("reference") == [],
+          "the standalone assignment ships EMPTY co-developed and reference arrays")
+    check(task_obj.get("branch_from", "x") == "",
+          "the standalone assignment carries no branch_from")
+    check(wait_task_done(auth, standalone) is not None,
+          "the standalone task completes on its report")
+
+    print("== terminal plain task: a comment re-queues it ==")
+    plain = create_queued(auth, {
+        "project_id": "1", "title": "Resurrect me",
+        "description": "A plain project task that finishes.", "priority": "4"})
+    check(wait_task_done(auth, plain) is not None, "the plain task completes")
+    detail = task_state(auth, plain)
+    act(auth, detail["comment"], {"body": "Please also cover the retry path."})
+    detail = task_state(auth, plain)
+    check(detail["status"] == "queued",
+          "the comment re-queued the terminal plain task (%s)" % detail["status"])
+    check(any("cover the retry path" in c["body"] for c in detail["comments"]),
+          "the resurrecting comment rides the thread")
+    conn = db()
+    summary_kept = conn.execute(
+        "select summary from tasks where id = ?", (plain,)).fetchone()[0]
+    conn.close()
+    check(summary_kept and len(summary_kept) > 0,
+          "the earlier round's completion summary survives the re-open")
+    check(wait_for(lambda: len(fake.start_payloads) > 1 or False,
+                   timeout=30, what="requeued dispatch") is not None,
+          "the re-queued task dispatches again")
+
+    def comment_reached_agent():
+        for payload in fake.start_payloads:
+            task_obj = payload.get("task") or {}
+            if task_obj.get("id") == plain and any(
+                    "cover the retry path" in c["body"]
+                    for c in task_obj.get("comments", [])):
+                return True
+        return any("cover the retry path" in (c.get("text") or "")
+                   for c in fake.comment_log)
+    # The comment reaches the agent by EITHER channel: inside an
+    # assignment payload, or via the live /task-update push to the
+    # RUNNING daemon (the delivered flag) — the dispatcher tick races
+    # the dispatch, so wait for whichever lands.
+    check(wait_for(comment_reached_agent, timeout=30,
+                   what="comment delivery") is not None,
+          "the second round receives the resurrecting comment (assignment or live push)")
+    check(wait_task_done(auth, plain) is not None, "the second round completes")
+
+    print("== terminal goal: a comment re-opens it and resumes the agent ==")
+    # One behaviour per ROUND: round 1 plans (parks blocked), round 2
+    # (the approved resume) suspends, round 3 (the force-woke resume)
+    # submits the completion report.
+    fake.goal_script = [("plan", "# Resurrect plan\n\n1. Finish quickly"),
+                        ("suspend",),
+                        ("done", "# Completion\n\nThe report stands.")]
+    goal = create_goal({"project_id": "1", "title": "Resurrect goal",
+                        "brief": "Completes, then re-opens on a comment.",
+                        "branch_name": "goal/resurrect"})
+    act(auth, goal_state(goal)["start"], {})
+    check(wait_for(lambda: run_task_of(goal) is not None or False,
+                   timeout=30, what="goal run task appears") is not None,
+          "starting the goal creates its run task")
+    run_task = run_task_of(goal)
+    check(wait_for(lambda: goal_state(goal)["status"] == "plan_review" or False,
+                   timeout=90, what="goal plans") is not None,
+          "the goal reaches plan review")
+    pending = goal_state(goal)["plan_pending_block"]
+    act(auth, pending["answer_action"], {"verdict": "accept"})
+    check(wait_for(lambda: goal_state(goal)["status"] == "active" or False,
+                   timeout=60, what="plan approved") is not None,
+          "approving the plan activates the goal")
+    check(wait_for(lambda: task_state(auth, run_task)["status"] == "suspended" or False,
+                   timeout=60, what="agent parks") is not None,
+          "the agent parks after the plan")
+    # Round 3 needs a wake: force-wake the parked agent so its scripted
+    # done round (the completion report) runs.
+    state = goal_state(goal)
+    act(auth, state["force_wake"], {})
+    # The completion round: the scripted done parks the run for review.
+    check(wait_for(lambda: goal_state(goal)["status"] == "completion_review" or False,
+                   timeout=90, what="completion review opens") is not None,
+          "the scripted done round enters completion review")
+    block = goal_state(goal)["completion_pending_block"]
+    act(auth, block["answer_action"], {"verdict": "accept"})
+    check(wait_for(lambda: goal_state(goal)["status"] == "complete" or False,
+                   timeout=30, what="goal completes") is not None,
+          "accepting the completion completes the goal")
+    check(wait_for(lambda: task_state(auth, run_task)["status"] == "done" or False,
+                   timeout=30, what="run task done") is not None,
+          "the run task closed done with the goal")
+
+    goal_page = http_get_page(auth, "/goals/%d" % goal)
+    check("stm-action=\"goal.comment\"" in goal_page,
+          "the terminal goal page keeps the comment composer open")
+    check("commenting re-opens it" in goal_page,
+          "the composer explains the resurrect semantics on a terminal goal")
+    state = goal_state(goal)
+    act(auth, state["comment"], {"body": "One more sweep of the edge cases, please."})
+    check(wait_for(lambda: goal_state(goal)["status"] == "active" or False,
+                   timeout=30, what="goal re-opens") is not None,
+          "the comment re-opened the completed goal")
+    check(wait_for(lambda: task_state(auth, run_task)["status"] == "suspended" or False,
+                   timeout=30, what="run re-offerable") is not None,
+          "the DONE run task returned to suspended (the parked-resume shape)")
+    conn = db()
+    report_kept = conn.execute(
+        "select completion_report from goals where id = ?", (goal,)).fetchone()[0]
+    seeded = conn.execute(
+        "select count(*) from sessions where task_id = ?", (run_task,)).fetchone()[0]
+    conn.close()
+    check(report_kept and len(report_kept) > 0,
+          "the completion report stays on the re-opened goal")
+    check(seeded >= 1, "the resurrected run carries a session row (the resume anchor)")
+    goal_comments = goal_state(goal)["comments"]
+    check(any("edge cases" in c["body"] for c in goal_comments),
+          "the resurrecting comment rides the goal thread")
+    # force_wake made the parked run offerable at once: the dispatcher
+    # hands it back and the resuming assignment carries the notice.
+    assignments_before = len(fake.goal_assignments)
+    check(wait_for(lambda: len(fake.goal_assignments) > assignments_before or False,
+                   timeout=60, what="resurrected agent resumes") is not None,
+          "the resurrected goal agent resumes on its saddle")
+    resumed = fake.goal_assignments[-1]
+    check((resumed.get("task") or {}).get("id") == run_task,
+          "the resuming assignment is the run task")
+    check("re-opened by a human comment" in (resumed.get("resume_notice") or ""),
+          "the resume notice names the comment-driven re-open")
+    check(any("edge cases" in c["body"]
+              for c in (resumed.get("task") or {}).get("comments", []))
+          or any("edge cases" in (c.get("text") or "") for c in fake.comment_log),
+          "the resuming agent receives the comment (assignment or live push)")
+
+    print("== terminal goal worker: comments stay closed ==")
+    conn = db()
+    worker = conn.execute(
+        "insert into tasks (project_id, goal_id, kind, title, status, priority,"
+        " mode, creator, completed_at, created_at)"
+        " values (1, ?, 'task', 'Dead worker', 'done', 5,"
+        " 'implement', 'goal', strftime('%s','now'), strftime('%s','now'))",
+        (goal,)).lastrowid
+    conn.commit()
+    conn.close()
+    worker_page = http_get_page(auth, "/tasks/%d" % worker)
+    check("Comments are closed" in worker_page,
+          "a terminal goal-owned task page explains the closed comments")
+    # The gate lives in the template (statum hides the branch at render;
+    # the served markup carries no branch text to match — pages.py's
+    # Edit-button precedent): the composer must sit inside the
+    # goal-owned-terminal negation and the closed notice inside the
+    # positive gate.
+    template = open(os.path.join(REPO, "src", "pages", "task_detail.html")).read()
+    composer_at = template.find('<form stm-if="!(task.terminal && task.goal_id > 0)"')
+    notice_at = template.find('<p stm-if="task.terminal && task.goal_id > 0"')
+    check(composer_at > 0 and notice_at > composer_at,
+          "the task template gates the composer on goal-owned terminal tasks")
+    # The service path is the goal's alone: the page-level gate is the
+    # only barrier a human meets, and a direct AddCommentAction post on
+    # the dead worker must NOT resurrect it (the action ignores
+    # goal-owned terminal tasks).
+    state = task_state(auth, worker)
+    if state.get("comment"):
+        act(auth, state["comment"], {"body": "Sneaky resurrection attempt"})
+        check(task_state(auth, worker)["status"] == "done",
+              "a comment on a terminal goal worker does not resurrect it")
+    else:
+        check(True, "a terminal goal worker exposes no comment action at all")

+ 2 - 1
test/harness.py

@@ -1314,7 +1314,8 @@ def start_bundle(name):
 
 BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",
                  "allocation", "lifecycle", "answers", "push", "goals",
-                 "replay", "goalui", "forcewake", "users", "stacks", "notes")
+                 "replay", "goalui", "forcewake", "users", "stacks", "notes",
+                 "resurrect")
 
 
 def run_bundle(name):