Kaynağa Gözat

s1: canonical-on-accept — goals.completion_report is written ONLY by the accept verdict (the winning block's body); submit_completion stops writing it at submission (the pending report lives only in its block), reject/revise leaves the goal row clean, and fail_goal's failure report keeps its goal-side home. Tests pin all four directions incl. the accept-gated goals.plan column.

clanker 1 ay önce
ebeveyn
işleme
4b13a1ea1f
2 değiştirilmiş dosya ile 77 ekleme ve 13 silme
  1. 34 9
      src/Services/GoalService.vala
  2. 43 4
      test/web_stack_test.py

+ 34 - 9
src/Services/GoalService.vala

@@ -415,12 +415,14 @@ namespace ClankerSaddle.Services {
         /**
          * The goal agent submitted its completion report (`saddle
          * done` in goal context): the goal moves to
-         * 'completion_review', the report is stored (goal row + ONE
-         * completion-kind block on the goal-run task) and the run
-         * parks on it. The daemon enforces the branch-push rule
-         * before letting `done` through. Idempotent on terminal goals
-         * (plan 13 B2): a redelivered done push must not resurrect a
-         * finished goal.
+         * 'completion_review' and the report lives ONLY in the pending
+         * completion-kind block on the goal-run task (canonical-on-
+         * accept, plan 16 S1: `goals.completion_report` is written
+         * ONLY by the accept verdict — reject/revise leaves the goal
+         * row clean). The run parks on the block. The daemon enforces
+         * the branch-push rule before letting `done` through.
+         * Idempotent on terminal goals (plan 13 B2): a redelivered
+         * done push must not resurrect a finished goal.
          */
         public async TaskBlock? submit_completion(int64 goal_id, string report) throws Error {
             var goal = yield require_goal(goal_id);
@@ -441,7 +443,6 @@ namespace ClankerSaddle.Services {
                 return null;
             }
             ((!)goal).status = GOAL_COMPLETION_REVIEW;
-            ((!)goal).completion_report = report;
             yield db.update_async<Goal>(((!)goal));
             return raised;
         }
@@ -518,9 +519,11 @@ namespace ClankerSaddle.Services {
          *   becomes goal.plan, status 'active';
          * - plan + feedback → status 'planning' (the agent revises);
          * - completion + {@link NOTICE_REVIEW_APPROVED} → the goal
-         *   completes and the goal-run task closes done;
+         *   completes, the winning block's body becomes
+         *   goal.completion_report (plan 16 S1: the ONLY writer) and
+         *   the goal-run task closes done;
          * - completion + feedback → status 'active' (the agent keeps
-         *   working).
+         *   working; the goal row keeps no report).
          */
         public async void answer_block(int64 block_id, string answer,
                 string? answered_by = null) throws Error {
@@ -568,9 +571,21 @@ namespace ClankerSaddle.Services {
                     // Plan 15 C4: the operator-confirmed hard gate —
                     // refuse while origin is behind (live flags only).
                     yield refuse_unpushed_origin(((!)goal), (!)task);
+                    // Plan 16 S1 (canonical-on-accept): the report
+                    // lands on the goal ONLY here — the winning
+                    // block's body becomes completion_report, so the
+                    // terminal card reads a field that provably exists
+                    // only post-acceptance.
+                    if (((!)block).body != null && ((!)block).body.length > 0) {
+                        ((!)goal).completion_report = ((!)block).body;
+                    }
                     ((!)goal).status = GOAL_COMPLETE;
                     ((!)goal).completed_at = new DateTime.now_local();
                 } else {
+                    // Reject/revise: the agent keeps working and the
+                    // goal row stays clean (the report died with the
+                    // rejected block — submit_completion never wrote
+                    // it there).
                     ((!)goal).status = GOAL_ACTIVE;
                 }
             }
@@ -708,6 +723,16 @@ namespace ClankerSaddle.Services {
             return yield tasks.list_blocks(((!)run).id);
         }
 
+        /**
+         * One block row by id (the verdict actions' kind lookup for
+         * the server-owned wording, plan 16 S2/R4), or null.
+         */
+        public async TaskBlock? get_block(int64 block_id) throws Error {
+            return yield db.query<TaskBlock>()
+                .where(expr("id == $0", elem<int64?>(block_id)))
+                .first_async();
+        }
+
         /**
          * One derived supervision-narrative row (plan 15 C3): the
          * read-time projection of a timestamped fact — a lifecycle

+ 43 - 4
test/web_stack_test.py

@@ -5421,6 +5421,15 @@ def bundle_goals(ctx):
           and revisions[0]["answered"] and "Add documentation please" in revisions[0]["answer"]
           and not revisions[1]["answered"],
           "rejected revisions stay as answered history (historical-blocks data)")
+    # Plan 16 S1 pin: goals.plan is accept-gated — the pending revision
+    # lives only in its block; nothing lands on the goal before accept.
+    conn = db()
+    plan_column = conn.execute(
+        "select plan from goals where id = ?", (goal1,)).fetchone()[0]
+    conn.close()
+    check((plan_column is None or plan_column == "")
+          and (state["plan"] or "") == "",
+          "goals.plan stays empty while the revision is only pending (accept-gated, S1)")
     # Approve: goal.plan = v2, status active, agent resumes and parks.
     fake.goal_script = [("suspend",)]
     pending = state["plan_pending_block"]
@@ -5430,7 +5439,8 @@ def bundle_goals(ctx):
                    timeout=60, what="approved plan activates the goal") is not None,
           "approving the plan activates the goal")
     state = goal_state(goal1)
-    check("# Plan v2" in state["plan"], "the approved plan markdown is stored on the goal")
+    check(state["plan"] == "# Plan v2\n\n1. Add tests\n2. Add docs\n3. Ship",
+          "the approved plan markdown is stored on the goal (the accept verdict's write)")
     # The approval resume carries the plan delta as a context_update
     # turn (the prompt-cache-friendly replacement for the daemon's
     # old system-prompt refresh).
@@ -5707,11 +5717,20 @@ def bundle_goals(ctx):
                    timeout=60, what="goal agent submits its completion") is not None,
           "`saddle done` in goal context enters completion review")
     state = goal_state(goal1)
-    check("# Completion" in state["completion_report"]
+    # Plan 16 S1 (canonical-on-accept): while the review is PENDING the
+    # goal row carries NO report — the document lives only in its
+    # block; completion_report is written solely by the accept verdict.
+    conn = db()
+    pending_report_column = conn.execute(
+        "select completion_report from goals where id = ?", (goal1,)).fetchone()[0]
+    conn.close()
+    check((pending_report_column is None or pending_report_column == "")
+          and (state["completion_report"] or "") == ""
+          and "# Completion" in (state["completion_pending_block"].get("body") or "")
           and state["completion_pending"] is True
           and state["completion_pending_block"] is not None
           and state["completion_pending_block"] is not False,
-          "the completion review state carries the report + pending block")
+          "the pending report lives ONLY in its block (goal row clean, S1)")
     check(task_state(auth, run_task)["status"] == "blocked",
           "the goal-run task parks on the completion review")
     check(state["compare_url"].endswith("/compare/main...goal/flange"),
@@ -5723,6 +5742,12 @@ def bundle_goals(ctx):
     check(wait_for(lambda: goal_state(goal1)["status"] == "active" or False,
                    timeout=30, what="rejected completion reactivates") is not None,
           "rejecting the completion returns the goal to active")
+    conn = db()
+    rejected_report = conn.execute(
+        "select completion_report from goals where id = ?", (goal1,)).fetchone()[0]
+    conn.close()
+    check(rejected_report is None or rejected_report == "",
+          "reject/revise leaves the goal row clean (no report leaked, S1)")
     # Round 2: submit again and accept.
     fake.goal_script = [("done", "# Completion v2\n\nThe flange ships, with docs.")]
     conn = db()
@@ -5806,9 +5831,14 @@ def bundle_goals(ctx):
                    timeout=30, what="accepted completion completes") is not None,
           "accepting the completion completes the goal once origin is current")
     state = goal_state(goal1)
+    conn = db()
+    accepted_report = conn.execute(
+        "select completion_report from goals where id = ?", (goal1,)).fetchone()[0]
+    conn.close()
     check(state["terminal"] is True and state.get("cancel") is None
+          and accepted_report == "# Completion v2\n\nThe flange ships, with docs."
           and "# Completion v2" in state["completion_report"],
-          "a complete goal is terminal with its report stored")
+          "a complete goal is terminal with the WINNING block's body as its report (S1)")
     check(wait_for(lambda: task_state(auth, run_task)["status"] == "done" or False,
                    timeout=30, what="goal run task closes") is not None,
           "approving the completion closes the goal-run task done")
@@ -5959,6 +5989,15 @@ def bundle_goals(ctx):
           "nothing under the failed goal stays dispatchable (no orphan drafts)")
     check(len([e for e in state["events"] if e["kind"] == "goal-failed"]) == 1,
           "the failing goal records exactly one goal-failed event")
+    # Plan 16 S1: the FAILURE report keeps its goal-side home (failure
+    # is terminal — no pending duplication exists, so unlike the
+    # completion report it is written at failure time).
+    conn = db()
+    failure_report = conn.execute(
+        "select completion_report from goals where id = ?", (collapse,)).fetchone()[0]
+    conn.close()
+    check(failure_report == "the foundation crumbled",
+          "fail_goal's failure report keeps its goal-side home (S1)")
     fake.hold_state = False
 
     print("== plan 12: auto-enqueue-after for human tasks ==")