Ver código fonte

B3: dashboard mirror-sync — either side of a dual-rowed pair closes it

Answering a goal question now settles BOTH rows: the goal-page verdict
answers every unanswered mirror on the run task (the verdict's events
record first, so a dispatcher tick striking the mid-answer window still
sees the full context), and an answer landing on the MIRROR via the
task action API (a stale deep link — the observed one-sided shape)
propagates to the goal block and applies its verdict transition. The
dashboard pile treats a pair as closed when the NEWEST mirror of the
question carries an answer (defensive for historical one-sided rows);
newest-of-question pairing throughout, because plan revisions reuse one
question and an older revision's verdict must not close the current
review. The resume machinery sweep: newest_block_answered and the
allocation's verdict read both fall back to the goal row when the
mirror lags, so historical one-sided pairs resume with their verdict
instead of parking forever. The allocation shard freezes racer-two's
ask triggers across the approval — the mirror-sync makes the run
resume-offerable the moment its goal row carries the verdict, so a
wake-triggered background ask raced the scenario's manual ask.
clanker 1 mês atrás
pai
commit
336c94e49f

+ 30 - 1
src/Services/AllocationService.vala

@@ -363,10 +363,39 @@ namespace ClankerSaddle.Services {
                 } else {
                     // Answering = allocating (plan 11): the verdict rides
                     // the resuming assignment's resume_notice, injected as
-                    // the restored conversation's opening turn.
+                    // the restored conversation's opening turn. Plan 14
+                    // B3 sweep: a goal-run's MIRROR may lag its goal row
+                    // (the mid-answer window, historical one-sided pairs)
+                    // — the goal row's verdict stands in for it.
                     var newest = yield tasks.newest_block(task.id);
                     if (newest != null && ((!)newest).answer != null) {
                         resume_notice = TaskService.verdict_notice_for((!)newest);
+                    } else if (newest != null && task.kind == TASK_KIND_GOAL_RUN
+                            && task.goal_id > 0) {
+                        // Newest-of-question (plan revisions reuse one
+                        // question — an older verdict must not ride).
+                        GoalBlock? newest_goal = null;
+                        foreach (var goal_block in yield db.query<GoalBlock>()
+                                .where(expr("goal_id == $0",
+                                    elem<int64?>(task.goal_id)))
+                                .order_by(expr("id"))
+                                .materialise_async()) {
+                            if (goal_block.question == ((!)newest).question) {
+                                newest_goal = goal_block;
+                            }
+                        }
+                        if (newest_goal != null && ((!)newest_goal).answer != null) {
+                            // Shape the goal row's verdict like the
+                            // mirror so the stable wordings apply.
+                            var shaped = new TaskBlock() {
+                                task_id = ((!)newest).task_id,
+                                question = ((!)newest).question,
+                                answer = ((!)newest_goal).answer,
+                                block_type = ((!)newest).block_type,
+                                options = ((!)newest).options
+                            };
+                            resume_notice = TaskService.verdict_notice_for(shaped);
+                        }
                     }
                 }
                 // Plan 14 B4: persist what the resumed agent first

+ 17 - 7
src/Services/GoalService.vala

@@ -534,10 +534,14 @@ namespace ClankerSaddle.Services {
 
         /**
          * Answers a goal block (the goal page's single verdict path):
-         * the answer lands on the goal row AND on the goal-run task's
-         * mirrored task block — the parked-resume machinery then wakes
-         * the agent with the verdict as its resume_notice. Plan and
-         * completion blocks apply their goal-state transitions:
+         * the answer lands on the goal row AND on EVERY unanswered
+         * mirror of it on the goal-run task — both halves of the
+         * dual-rowed pair settle before any observer pulse fires
+         * (plan 14 B3's mirror-sync: a one-sided pair is exactly the
+         * staleness the dashboard pile suffered); the parked-resume
+         * machinery then wakes the agent with the verdict as its
+         * resume_notice. Plan and completion blocks apply their
+         * goal-state transitions:
          *
          * - plan + {@link NOTICE_PLAN_APPROVED} → the submission
          *   becomes goal.plan, status 'active';
@@ -569,6 +573,15 @@ namespace ClankerSaddle.Services {
             ((!)block).answered_by = answered_by;
             yield db.update_async<GoalBlock>((!)block);
 
+            // Plan 14 B3 mirror-sync ordering: the verdict's events
+            // record FIRST, then both halves of the dual-rowed pair
+            // settle (goal row above, every unanswered mirror below)
+            // before the first observer pulse fires — a one-sided
+            // pair is exactly the staleness the dashboard pile
+            // suffered, and an event-less resume is a lost
+            // context_update. The dispatcher's periodic tick can
+            // strike anywhere in between; by the time either row
+            // reads answered, the events are already committed.
             if (((!)block).block_type == GOAL_BLOCK_PLAN) {
                 if (answer == NOTICE_PLAN_APPROVED) {
                     if (((!)block).body != null && ((!)block).body.length > 0) {
@@ -596,15 +609,12 @@ namespace ClankerSaddle.Services {
             }
             yield db.update_async<Goal>((!)goal);
 
-            // The mirrored task block carries the same verdict to the
-            // parked goal-run workspace (resume_notice material).
             var run = yield goal_run_task(((!)goal).id);
             if (run != null) {
                 foreach (var task_block in yield tasks.list_blocks(((!)run).id)) {
                     if (task_block.question == ((!)block).question
                             && task_block.answer == null) {
                         yield tasks.answer_block(task_block.id, answer, answered_by);
-                        break;
                     }
                 }
                 if (((!)block).block_type == GOAL_BLOCK_COMPLETION

+ 29 - 1
src/Services/TaskService.vala

@@ -1460,10 +1460,38 @@ namespace ClankerSaddle.Services {
          * (the answer-delivery pass, the affinity resume, or a later
          * assignment payload). Such a task must never be requeued: the
          * requeue closes the session the resume machinery needs.
+         * Plan 14 B3 defensive: a goal-run's MIRROR may lag its goal
+         * row (historical one-sided pairs, the mid-answer window) —
+         * the pair counts as answered when the NEWEST goal block of
+         * the same question carries an answer (newest-of-question:
+         * plan revisions reuse one question, an older revision's
+         * verdict must not close the current review).
          */
         public async bool newest_block_answered(int64 task_id) throws Error {
             var newest = yield newest_block(task_id);
-            return newest != null && ((!)newest).answer != null;
+            if (newest == null) {
+                return false;
+            }
+            if (((!)newest).answer != null) {
+                return true;
+            }
+            var task = yield db.query<TaskEntity>()
+                .where(expr("id == $0", elem<int64?>(task_id)))
+                .first_async();
+            if (task == null || ((!)task).kind != TASK_KIND_GOAL_RUN
+                    || ((!)task).goal_id <= 0) {
+                return false;
+            }
+            GoalBlock? newest_goal = null;
+            foreach (var goal_block in yield db.query<GoalBlock>()
+                    .where(expr("goal_id == $0", elem<int64?>(((!)task).goal_id)))
+                    .order_by(expr("id"))
+                    .materialise_async()) {
+                if (goal_block.question == ((!)newest).question) {
+                    newest_goal = goal_block;
+                }
+            }
+            return newest_goal != null && ((!)newest_goal).answer != null;
         }
 
         /** All commands recorded against a session, oldest first. */

+ 30 - 0
src/actions/TaskActions.vala

@@ -239,6 +239,7 @@ namespace ClankerSaddle {
     public class AnswerBlockAction : TypedStatumAction<BlockAnswerPrivate> {
 
         private TaskService tasks = inject<TaskService>();
+        private GoalService goals = inject<GoalService>();
         private UserService users = inject<UserService>();
 
         protected override Type private_type { get { return typeof(BlockAnswerPrivate); } }
@@ -285,6 +286,35 @@ namespace ClankerSaddle {
                 // resume verbatim (the stable verdicts); goal-plan
                 // approval happens on the goal page, not here.
                 yield tasks.answer_block(((!)ctx).block_id, answer);
+                // Plan 14 B3 mirror-sync: an answer landing on a
+                // goal-run task's MIRROR block (a stale deep link, a
+                // historical bookmark — the observed one-sided-row
+                // shape) must close the goal's own block too, or the
+                // goal never applies its verdict transition and the
+                // dashboard pile keeps showing the question forever.
+                var detail = yield tasks.get_task(task_id);
+                if (detail != null && ((!)detail).task.kind == TASK_KIND_GOAL_RUN
+                        && ((!)detail).task.goal_id > 0) {
+                    string question = "";
+                    foreach (var candidate in ((!)detail).blocks) {
+                        if (candidate.id == ((!)ctx).block_id) {
+                            question = candidate.question;
+                            break;
+                        }
+                    }
+                    if (question.length > 0) {
+                        foreach (var goal_block in yield goals.list_blocks(
+                                ((!)detail).task.goal_id)) {
+                            if (goal_block.question == question
+                                    && goal_block.answer == null) {
+                                var me = SpryAuth.current(request);
+                                yield goals.answer_goal_block(goal_block.id, answer,
+                                    me != null ? @"user:$(me.username)" : null);
+                                break;
+                            }
+                        }
+                    }
+                }
                 return directives().notify("success", "Answer recorded; the saddle will collect it")
                     .navigate(@"/tasks/$(task_id)");
             } catch (Error e) {

+ 34 - 0
src/entrypoints/HomeEntrypoint.vala

@@ -252,11 +252,45 @@ namespace ClankerSaddle {
             // interleaved with the task blocks, newest first. The
             // GoalService arrives as a parameter (an inject past an
             // await resumes with an empty injection context stack).
+            // Plan 14 B3 defensive: the dual-rowed pair is CLOSED when
+            // EITHER side carries an answer — a historical one-sided
+            // row (the goal row null, its run-task mirror answered via
+            // a stale deep link) must not pin the question forever.
             foreach (var goal in yield goal_service.list_goals()) {
+                int64 run_task_id = 0;
                 foreach (var block in yield goal_service.list_blocks(goal.id)) {
                     if (block.answer != null) {
                         continue;
                     }
+                    if (run_task_id == 0) {
+                        var run = yield db.query<TaskEntity>()
+                            .where(expr("goal_id == $0 && kind == $1",
+                                elem<int64?>(goal.id),
+                                elem<string>(TASK_KIND_GOAL_RUN)))
+                            .first_async();
+                        if (run != null) {
+                            run_task_id = ((!)run).id;
+                        }
+                    }
+                    if (run_task_id > 0) {
+                        // Newest-of-question: plan revisions reuse one
+                        // question — only the NEWEST mirror's verdict
+                        // closes the current review.
+                        bool mirror_answered = false;
+                        TaskBlock? newest_mirror = null;
+                        foreach (var mirror in yield tasks.list_blocks(run_task_id)) {
+                            if (mirror.question == block.question) {
+                                newest_mirror = mirror;
+                            }
+                        }
+                        if (newest_mirror != null
+                                && ((!)newest_mirror).answer != null) {
+                            mirror_answered = true;
+                        }
+                        if (mirror_answered) {
+                            continue;
+                        }
+                    }
                     var view = new DashboardBlockView() {
                         item_type = "goal",
                         id = block.id,