Просмотр исходного кода

goals: assignment context_update — the resume-time instruction delta

Resumed assignments carry an optional context_update (markdown):
since the run's last round — the goal's plan approved (the approved
markdown rides along), rules created/edited (rules.updated_at) or
attached (rule_projects/task_rules.created_at — both new columns in
the flattened schema), or new project notes of the primary +
co-developed projects. The daemon appends it as a turn instead of
refreshing the system prompt (prompt-cache friendly); absent when
nothing changed. Detached rules and deleted notes stay undetected
(no such timestamps) — the payload's current rules/notes arrays
remain the wholesale truth.
clanker 1 месяц назад
Родитель
Сommit
8d895974b4

+ 8 - 0
README.md

@@ -204,6 +204,14 @@ applies the markdown as the goal's plan; the feedback textarea returns
 the agent to planning to revise. The completion review answers "Review
 the agent to planning to revise. The completion review answers "Review
 approved. Continue with the task." to complete the goal.
 approved. Continue with the task." to complete the goal.
 
 
+Resumed assignments also carry an optional `context_update` (markdown
+string): the delta of the instruction context that changed since the
+run's last round — the goal's plan approved in the meantime (the
+approved markdown rides along), rules created, edited or attached to
+the task/its projects, or new project notes. The daemon appends it as
+a TURN on resume (prompt-cache friendly) instead of refreshing the
+system prompt; the member is absent when nothing changed.
+
 ## Browser notifications (Web Push)
 ## Browser notifications (Web Push)
 
 
 The app speaks the real Web Push protocol (VAPID + RFC 8291 payload
 The app speaks the real Web Push protocol (VAPID + RFC 8291 payload

+ 11 - 3
src/Data/Entities.vala

@@ -283,6 +283,8 @@ namespace ClankerSaddle.Data {
     /**
     /**
      * A reusable instruction the dispatched agent must follow, stored in
      * A reusable instruction the dispatched agent must follow, stored in
      * the `rules` table and attached to projects and/or tasks.
      * the `rules` table and attached to projects and/or tasks.
+     * `updated_at` stamps the last body/name edit (null until edited)
+     * — the resume-time context_update delta keys on it.
      */
      */
     public class Rule : Object {
     public class Rule : Object {
 
 
@@ -290,13 +292,15 @@ namespace ClankerSaddle.Data {
         public string name { get; set; default = ""; }
         public string name { get; set; default = ""; }
         public string body { get; set; default = ""; }
         public string body { get; set; default = ""; }
         public DateTime? created_at { get; set; default = null; }
         public DateTime? created_at { get; set; default = null; }
+        public DateTime? updated_at { get; set; default = null; }
 
 
         public static void entity_mapping(EntityMapperBuilder<Rule> cfg) {
         public static void entity_mapping(EntityMapperBuilder<Rule> cfg) {
             cfg.table("rules")
             cfg.table("rules")
                 .column<int64?>("id", o => o.id, (o, v) => o.id = v)
                 .column<int64?>("id", o => o.id, (o, v) => o.id = v)
                 .column<string>("name", o => o.name, (o, v) => o.name = v)
                 .column<string>("name", o => o.name, (o, v) => o.name = v)
                 .column<string>("body", o => o.body, (o, v) => o.body = v)
                 .column<string>("body", o => o.body, (o, v) => o.body = v)
-                .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v);
+                .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v)
+                .column<DateTime?>("updated_at", o => o.updated_at, (o, v) => o.updated_at = v);
         }
         }
     }
     }
 
 
@@ -306,12 +310,14 @@ namespace ClankerSaddle.Data {
         public int64 id { get; set; }
         public int64 id { get; set; }
         public int64 rule_id { get; set; }
         public int64 rule_id { get; set; }
         public int64 project_id { get; set; }
         public int64 project_id { get; set; }
+        public DateTime? created_at { get; set; default = null; }
 
 
         public static void entity_mapping(EntityMapperBuilder<RuleProject> cfg) {
         public static void entity_mapping(EntityMapperBuilder<RuleProject> cfg) {
             cfg.table("rule_projects")
             cfg.table("rule_projects")
                 .column<int64?>("id", o => o.id, (o, v) => o.id = v)
                 .column<int64?>("id", o => o.id, (o, v) => o.id = v)
                 .column<int64?>("rule_id", o => o.rule_id, (o, v) => o.rule_id = v)
                 .column<int64?>("rule_id", o => o.rule_id, (o, v) => o.rule_id = v)
-                .column<int64?>("project_id", o => o.project_id, (o, v) => o.project_id = v);
+                .column<int64?>("project_id", o => o.project_id, (o, v) => o.project_id = v)
+                .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v);
         }
         }
     }
     }
 
 
@@ -321,12 +327,14 @@ namespace ClankerSaddle.Data {
         public int64 id { get; set; }
         public int64 id { get; set; }
         public int64 rule_id { get; set; }
         public int64 rule_id { get; set; }
         public int64 task_id { get; set; }
         public int64 task_id { get; set; }
+        public DateTime? created_at { get; set; default = null; }
 
 
         public static void entity_mapping(EntityMapperBuilder<TaskRule> cfg) {
         public static void entity_mapping(EntityMapperBuilder<TaskRule> cfg) {
             cfg.table("task_rules")
             cfg.table("task_rules")
                 .column<int64?>("id", o => o.id, (o, v) => o.id = v)
                 .column<int64?>("id", o => o.id, (o, v) => o.id = v)
                 .column<int64?>("rule_id", o => o.rule_id, (o, v) => o.rule_id = v)
                 .column<int64?>("rule_id", o => o.rule_id, (o, v) => o.rule_id = v)
-                .column<int64?>("task_id", o => o.task_id, (o, v) => o.task_id = v);
+                .column<int64?>("task_id", o => o.task_id, (o, v) => o.task_id = v)
+                .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v);
         }
         }
     }
     }
 
 

+ 10 - 0
src/Data/Migrations/M0001_Initial.vala

@@ -348,8 +348,16 @@ namespace ClankerSaddle.Data.Migrations {
                 t.column<string>("body")
                 t.column<string>("body")
                     .not_null();
                     .not_null();
                 t.column<DateTime?>("created_at");
                 t.column<DateTime?>("created_at");
+                // Bumped on edit — the resume-time context_update
+                // delta keys on it (a rule edited while a workspace
+                // was parked must reach the agent as a turn).
+                t.column<DateTime?>("updated_at");
             });
             });
 
 
+            // The attachment timestamps (created_at) feed the same
+            // delta: attaching an existing rule to a task or project
+            // changes the effective instruction set without touching
+            // the rule row.
             b.create_table("rule_projects", (t) => {
             b.create_table("rule_projects", (t) => {
                 t.column<int64?>("id")
                 t.column<int64?>("id")
                     .auto_increment()
                     .auto_increment()
@@ -362,6 +370,7 @@ namespace ClankerSaddle.Data.Migrations {
                     .not_null()
                     .not_null()
                     .references("projects", "id")
                     .references("projects", "id")
                     .indexed();
                     .indexed();
+                t.column<DateTime?>("created_at");
                 t.unique("uq_rule_projects_pair", {"rule_id", "project_id"});
                 t.unique("uq_rule_projects_pair", {"rule_id", "project_id"});
             });
             });
 
 
@@ -377,6 +386,7 @@ namespace ClankerSaddle.Data.Migrations {
                     .not_null()
                     .not_null()
                     .references("tasks", "id")
                     .references("tasks", "id")
                     .indexed();
                     .indexed();
+                t.column<DateTime?>("created_at");
                 t.unique("uq_task_rules_pair", {"rule_id", "task_id"});
                 t.unique("uq_task_rules_pair", {"rule_id", "task_id"});
             });
             });
 
 

+ 221 - 1
src/Services/AllocationService.vala

@@ -74,6 +74,7 @@ namespace ClankerSaddle.Services {
         private TaskService tasks = inject<TaskService>();
         private TaskService tasks = inject<TaskService>();
         private CapabilityService capabilities = inject<CapabilityService>();
         private CapabilityService capabilities = inject<CapabilityService>();
         private GoalService goals = inject<GoalService>();
         private GoalService goals = inject<GoalService>();
+        private RuleService rules = inject<RuleService>();
         private StatePulse pulse = inject<StatePulse>();
         private StatePulse pulse = inject<StatePulse>();
         private PushService push = inject<PushService>();
         private PushService push = inject<PushService>();
         private SaddlePollState poll_state = inject<SaddlePollState>();
         private SaddlePollState poll_state = inject<SaddlePollState>();
@@ -344,8 +345,13 @@ namespace ClankerSaddle.Services {
 
 
             string? resume_notice = null;
             string? resume_notice = null;
             bool? branch_advanced = null;
             bool? branch_advanced = null;
+            string? context_update = null;
             if (claim.parked) {
             if (claim.parked) {
                 branch_advanced = yield tasks.branch_advanced_since(task.id);
                 branch_advanced = yield tasks.branch_advanced_since(task.id);
+                // The instruction-context delta since the run's last
+                // round (plan approval, rules, notes) rides the
+                // resume as a turn — null when nothing changed.
+                context_update = yield context_update_for(task, claim.session_id, projects);
                 if (claim.suspended_resume) {
                 if (claim.suspended_resume) {
                     // Plan 12: the goal agent's batched report — every
                     // Plan 12: the goal agent's batched report — every
                     // plan task's status plus the completion reports
                     // plan task's status plus the completion reports
@@ -367,7 +373,8 @@ namespace ClankerSaddle.Services {
 
 
             poll_state.note_push(claim.session_id);
             poll_state.note_push(claim.session_id);
             var payload = yield saddles.build_assignment_payload(saddle, task,
             var payload = yield saddles.build_assignment_payload(saddle, task,
-                claim.session_id, projects, resume_notice, branch_advanced);
+                claim.session_id, projects, resume_notice, branch_advanced,
+                context_update);
             payload.set_int_member("session_id", claim.session_id);
             payload.set_int_member("session_id", claim.session_id);
 
 
             yield saddles.mark_task_comments_delivered(task.id);
             yield saddles.mark_task_comments_delivered(task.id);
@@ -382,5 +389,218 @@ namespace ClankerSaddle.Services {
             print(@"[ClankerSaddle] Allocated task $(task.id) to saddle '$(saddle.name)' on ask (session $(claim.session_id))\n");
             print(@"[ClankerSaddle] Allocated task $(task.id) to saddle '$(saddle.name)' on ask (session $(claim.session_id))\n");
             return payload;
             return payload;
         }
         }
+
+        // ------------------------------------------------------------------
+        // Resume-time context delta (`context_update`)
+        // ------------------------------------------------------------------
+
+        /** Clip lengths keeping the delta a SHORT markdown summary. */
+        private const int CONTEXT_PLAN_CLIP = 4000;
+        private const int CONTEXT_RULE_CLIP = 800;
+        private const int CONTEXT_NOTE_CLIP = 800;
+
+        /**
+         * The instruction-context delta since the run's last round,
+         * as the resuming assignment's `context_update` markdown — or
+         * null when nothing changed. The watermark is the previous
+         * session's last recorded activity (its newest entry's stamp,
+         * else its start): anything the effective instruction set did
+         * after that lands in the summary. Covered, simplest-correct:
+         *
+         * - the goal's plan approved since the run's last round (the
+         *   approved plan rides along whole — it is the operative
+         *   document the agent must now follow);
+         * - rules in the effective set that were created or edited,
+         *   or whose admitting attachment (to the task or an involved
+         *   project) was created, after the watermark;
+         * - project notes of the primary + co-developed projects
+         *   created after the watermark.
+         *
+         * Detaching a rule or deleting a note is NOT detected (the
+         * rows carry no such timestamps) — the daemon still receives
+         * the current effective `rules`/`project_notes` arrays in the
+         * same payload for anything that rebuilds them wholesale.
+         */
+        private async string? context_update_for(TaskEntity task, int64 session_id,
+                ProjectService projects) throws Error {
+            // The previous round: the newest session before this
+            // claim's fresh one; its last activity is the watermark.
+            DateTime? watermark = null;
+            int64 previous_session = 0;
+            foreach (var session in yield db.query<Session>()
+                    .where(expr("task_id == $0", elem<int64?>(task.id)))
+                    .order_by(expr("id"))
+                    .materialise_async()) {
+                if (session.id < session_id && session.started_at != null) {
+                    previous_session = session.id;
+                    watermark = session.started_at;
+                }
+            }
+            if (previous_session == 0 || watermark == null) {
+                return null;
+            }
+            foreach (var command in yield db.query<SessionCommand>()
+                    .where(expr("session_id == $0", elem<int64?>(previous_session)))
+                    .order_by(expr("id"))
+                    .materialise_async()) {
+                DateTime? at = command.completed_at ?? command.started_at;
+                if (at != null && ((!)at).compare((!)watermark) > 0) {
+                    watermark = at;
+                }
+            }
+
+            var report = new StringBuilder();
+
+            // Plan approval (goal work only): the plan-approved event
+            // after the watermark names the round that missed it.
+            if (task.goal_id > 0) {
+                bool approved = false;
+                foreach (var goal_event in yield db.query<GoalEvent>()
+                        .where(expr("goal_id == $0 && kind == $1",
+                            elem<int64?>(task.goal_id), elem("plan-approved")))
+                        .materialise_async()) {
+                    if (goal_event.created_at != null
+                            && ((!)goal_event.created_at).compare((!)watermark) > 0) {
+                        approved = true;
+                    }
+                }
+                if (approved) {
+                    string plan = "";
+                    foreach (var goal in yield db.query<Goal>()
+                            .where(expr("id == $0", elem<int64?>(task.goal_id)))
+                            .materialise_async()) {
+                        plan = goal.plan ?? "";
+                    }
+                    if (plan.length > 0) {
+                        report.append("\n## Plan approved\n\n");
+                        report.append("The goal's plan was approved since your last round.");
+                        report.append(" The approved plan now reads:\n\n");
+                        report.append(clip_text(plan, CONTEXT_PLAN_CLIP));
+                        report.append("\n");
+                    }
+                }
+            }
+
+            // Rules: anything effective whose row was created/edited,
+            // or whose admitting attachment was created, after the
+            // watermark.
+            var involved = yield co_developed_project_ids(task, projects);
+            var changed_rules = new Vector<Rule>();
+            foreach (var rule in yield rules.effective_rules_for(task, projects,
+                    task.goal_id)) {
+                DateTime? rule_stamp = rule.updated_at ?? rule.created_at;
+                if (rule_stamp != null && ((!)rule_stamp).compare((!)watermark) > 0) {
+                    changed_rules.add(rule);
+                    continue;
+                }
+                if (yield rule_attached_since(rule.id, task.id, involved, (!)watermark)) {
+                    changed_rules.add(rule);
+                }
+            }
+            if (changed_rules.length > 0) {
+                report.append("\n## Rules changed\n\n");
+                report.append("New or updated rules now apply to your work:\n\n");
+                foreach (var rule in changed_rules) {
+                    report.append_printf("- **%s**: %s\n", rule.name,
+                        clip_text(rule.body, CONTEXT_RULE_CLIP));
+                }
+            }
+
+            // Project notes of the primary + co-developed projects.
+            var changed_notes = new Vector<ProjectNote>();
+            var note_project = new Dictionary<string, string>();
+            foreach (var project_id in involved) {
+                var project = yield db.query<Project>()
+                    .where(expr("id == $0", elem<int64?>(int64.parse(project_id))))
+                    .first_async();
+                if (project == null) {
+                    continue;
+                }
+                note_project.set(project_id, ((!)project).name);
+                foreach (var note in yield projects.list_notes(int64.parse(project_id))) {
+                    if (note.created_at != null
+                            && ((!)note.created_at).compare((!)watermark) > 0) {
+                        changed_notes.add(note);
+                    }
+                }
+            }
+            if (changed_notes.length > 0) {
+                report.append("\n## New project notes\n\n");
+                foreach (var note in changed_notes) {
+                    report.append_printf("- **%s**: %s\n",
+                        note_project.get_or_default(note.project_id.to_string()) ?? "",
+                        clip_text(note.body, CONTEXT_NOTE_CLIP));
+                }
+            }
+
+            if (report.len == 0) {
+                return null;
+            }
+            var summary = new StringBuilder();
+            summary.append("Context changes since your last round (your instructions changed while you were away):\n");
+            summary.append(report.str);
+            return summary.str;
+        }
+
+        /**
+         * The primary + co-developed project ids (as id strings — the
+         * container-safe element type) of `task`'s effective context:
+         * through the GOAL's links for goal work, the project's links
+         * otherwise (the note/rule scopes of the assignment payload).
+         */
+        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());
+            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 {
+                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());
+                    }
+                }
+            }
+            return ids;
+        }
+
+        /**
+         * True when one of the rule's admitting attachments — a
+         * `task_rules` row for this task, or a `rule_projects` row
+         * for an involved project — was created after `watermark`.
+         */
+        private async bool rule_attached_since(int64 rule_id, int64 task_id,
+                Vector<string> involved_projects, DateTime watermark) throws Error {
+            foreach (var link in yield db.query<TaskRule>()
+                    .where(expr("rule_id == $0 && task_id == $1",
+                        elem<int64?>(rule_id), elem<int64?>(task_id)))
+                    .materialise_async()) {
+                if (link.created_at != null
+                        && ((!)link.created_at).compare(watermark) > 0) {
+                    return true;
+                }
+            }
+            foreach (var project_id in involved_projects) {
+                foreach (var link in yield db.query<RuleProject>()
+                        .where(expr("rule_id == $0 && project_id == $1",
+                            elem<int64?>(rule_id), elem<int64?>(int64.parse(project_id))))
+                        .materialise_async()) {
+                    if (link.created_at != null
+                            && ((!)link.created_at).compare(watermark) > 0) {
+                        return true;
+                    }
+                }
+            }
+            return false;
+        }
+
+        /** A body clipped to `limit` chars (ellipsis when it cut). */
+        private static string clip_text(string body, int limit) {
+            return body.length > limit ? body.substring(0, limit) + "…" : body;
+        }
     }
     }
 }
 }

+ 21 - 6
src/Services/RuleService.vala

@@ -61,14 +61,18 @@ namespace ClankerSaddle.Services {
 
 
         /**
         /**
          * Saves the editable fields of the rule identified by `id`; null
          * Saves the editable fields of the rule identified by `id`; null
-         * or empty arguments keep their current values. Returns null when
-         * the rule does not exist.
+         * or empty arguments keep their current values. An edit stamps
+         * `updated_at` — the resume-time context_update delta keys on
+         * it, so a rule edited while a workspace was parked reaches
+         * the agent as a turn. Returns null when the rule does not
+         * exist.
          */
          */
         public async Rule? update_rule(int64 id, string? name, string? body) throws Error {
         public async Rule? update_rule(int64 id, string? name, string? body) throws Error {
             var rule = yield get_rule(id);
             var rule = yield get_rule(id);
             if (rule == null) {
             if (rule == null) {
                 return null;
                 return null;
             }
             }
+            bool edited = false;
             if (name != null && name.strip().length > 0) {
             if (name != null && name.strip().length > 0) {
                 var clash = yield db.query<Rule>()
                 var clash = yield db.query<Rule>()
                     .where(expr("name == $0 && id != $1", elem(name.strip()), elem<int64?>(id)))
                     .where(expr("name == $0 && id != $1", elem(name.strip()), elem<int64?>(id)))
@@ -77,10 +81,19 @@ namespace ClankerSaddle.Services {
                     throw new Error(Quark.from_string("clanker-saddle"), 32,
                     throw new Error(Quark.from_string("clanker-saddle"), 32,
                         @"A rule named '$(name)' already exists");
                         @"A rule named '$(name)' already exists");
                 }
                 }
-                ((!)rule).name = name.strip();
+                if (((!)rule).name != name.strip()) {
+                    ((!)rule).name = name.strip();
+                    edited = true;
+                }
             }
             }
             if (body != null && body.length > 0) {
             if (body != null && body.length > 0) {
-                ((!)rule).body = body;
+                if (((!)rule).body != body) {
+                    ((!)rule).body = body;
+                    edited = true;
+                }
+            }
+            if (edited) {
+                ((!)rule).updated_at = new DateTime.now_local();
             }
             }
             yield db.update_async<Rule>((!)rule);
             yield db.update_async<Rule>((!)rule);
             return rule;
             return rule;
@@ -126,7 +139,8 @@ namespace ClankerSaddle.Services {
             }
             }
             yield db.insert_async<RuleProject>(new RuleProject() {
             yield db.insert_async<RuleProject>(new RuleProject() {
                 rule_id = rule_id,
                 rule_id = rule_id,
-                project_id = project_id
+                project_id = project_id,
+                created_at = new DateTime.now_local()
             });
             });
         }
         }
 
 
@@ -179,7 +193,8 @@ namespace ClankerSaddle.Services {
             }
             }
             yield db.insert_async<TaskRule>(new TaskRule() {
             yield db.insert_async<TaskRule>(new TaskRule() {
                 rule_id = rule_id,
                 rule_id = rule_id,
-                task_id = task_id
+                task_id = task_id,
+                created_at = new DateTime.now_local()
             });
             });
         }
         }
 
 

+ 15 - 1
src/Services/SaddleService.vala

@@ -720,7 +720,8 @@ namespace ClankerSaddle.Services {
          */
          */
         public async Json.Object build_assignment_payload(Saddle saddle, TaskEntity task,
         public async Json.Object build_assignment_payload(Saddle saddle, TaskEntity task,
                 int64 session_id, ProjectService projects, string? resume_notice = null,
                 int64 session_id, ProjectService projects, string? resume_notice = null,
-                bool? branch_advanced = null) throws Error {
+                bool? branch_advanced = null,
+                string? context_update = null) throws Error {
             var endpoint = LlmEndpoint.from_environment();
             var endpoint = LlmEndpoint.from_environment();
             if (endpoint == null) {
             if (endpoint == null) {
                 throw new Error(Quark.from_string("clanker-saddle"), 24,
                 throw new Error(Quark.from_string("clanker-saddle"), 24,
@@ -950,6 +951,19 @@ namespace ClankerSaddle.Services {
                 payload.add_boolean_value((!)branch_advanced);
                 payload.add_boolean_value((!)branch_advanced);
             }
             }
 
 
+            // The wire's `context_update` (resume pushes): a short
+            // markdown delta of the instruction context that changed
+            // while the workspace was parked — the goal's plan
+            // approved since the run's last round, rules created,
+            // edited or attached, or new project notes. The daemon
+            // appends it as a TURN (prompt-cache friendly) instead of
+            // refreshing the system prompt; absent when nothing
+            // changed.
+            if (context_update != null && context_update.length > 0) {
+                payload.set_member_name("context_update");
+                payload.add_string_value(context_update);
+            }
+
             // The wire contract's `project_notes` addition: notes of the
             // The wire contract's `project_notes` addition: notes of the
             // primary and co-developed projects, oldest first, capped at
             // primary and co-developed projects, oldest first, capped at
             // 30 entries / 20 000 chars (head kept).
             // 30 entries / 20 000 chars (head kept).

+ 84 - 0
test/web_stack_test.py

@@ -5320,10 +5320,16 @@ def bundle_goals(ctx):
           "the goal page carries the goal-accented identity chrome")
           "the goal page carries the goal-accented identity chrome")
     # Reject with feedback: the agent revises.
     # Reject with feedback: the agent revises.
     fake.goal_script = [("plan", "# Plan v2\n\n1. Add tests\n2. Add docs\n3. Ship")]
     fake.goal_script = [("plan", "# Plan v2\n\n1. Add tests\n2. Add docs\n3. Ship")]
+    reject_resume_index = len(fake.goal_assignments)
     act(auth, pending["answer_action"], {"answer": "Add documentation please"})
     act(auth, pending["answer_action"], {"answer": "Add documentation please"})
     check(wait_for(lambda: goal_state(goal1)["status"] == "plan_review" or False,
     check(wait_for(lambda: goal_state(goal1)["status"] == "plan_review" or False,
                    timeout=60, what="revised plan enters review") is not None,
                    timeout=60, what="revised plan enters review") is not None,
           "feedback sends the agent back to planning; the revision re-enters review")
           "feedback sends the agent back to planning; the revision re-enters review")
+    # The rejection resume carried NO context_update (nothing in the
+    # instruction context had changed).
+    check(all("context_update" not in p
+              for p in fake.goal_assignments[reject_resume_index:]),
+          "a resume without context changes carries no context_update")
     state = goal_state(goal1)
     state = goal_state(goal1)
     revisions = state["plan_revisions"]
     revisions = state["plan_revisions"]
     check(len(revisions) == 2
     check(len(revisions) == 2
@@ -5340,6 +5346,16 @@ def bundle_goals(ctx):
           "approving the plan activates the goal")
           "approving the plan activates the goal")
     state = goal_state(goal1)
     state = goal_state(goal1)
     check("# Plan v2" in state["plan"], "the approved plan markdown is stored on the goal")
     check("# Plan v2" in state["plan"], "the approved plan markdown is stored on the goal")
+    # 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).
+    check(wait_for(lambda: len(fake.goal_assignments) > 0 and
+                   "context_update" in fake.goal_assignments[-1] or False,
+                   timeout=30, what="approval resume carries context_update") is not None,
+          "the plan-approval resume carries a context_update turn")
+    approval_update = fake.goal_assignments[-1]["context_update"]
+    check("Plan approved" in approval_update and "# Plan v2" in approval_update,
+          "the context_update summarises the approved plan markdown")
     check(wait_for(lambda: task_state(auth, run_task)["status"] == "suspended"
     check(wait_for(lambda: task_state(auth, run_task)["status"] == "suspended"
                    or False, timeout=60, what="goal agent parks after approval") is not None,
                    or False, timeout=60, what="goal agent parks after approval") is not None,
           "the resumed goal agent parks (saddle suspend)")
           "the resumed goal agent parks (saddle suspend)")
@@ -5453,6 +5469,8 @@ def bundle_goals(ctx):
           "the resume notice batches ALL plan task statuses")
           "the resume notice batches ALL plan task statuses")
     check("New completion reports" in notice and "fake daemon finished" in notice,
     check("New completion reports" in notice and "fake daemon finished" in notice,
           "the resume notice carries the new completion report")
           "the resume notice carries the new completion report")
+    check("context_update" not in resume_payload,
+          "a later resume with no context changes omits context_update entirely")
     check(resume_payload.get("kind") == "goal_run"
     check(resume_payload.get("kind") == "goal_run"
           and resume_payload.get("mode") == "orchestrate",
           and resume_payload.get("mode") == "orchestrate",
           "the resumed goal agent carries its identity triplet")
           "the resumed goal agent carries its identity triplet")
@@ -5738,6 +5756,72 @@ def bundle_goals(ctx):
     check(wait_task_done(auth, successor, timeout=90) is not None,
     check(wait_task_done(auth, successor, timeout=90) is not None,
           "the auto-enqueued successor completes")
           "the auto-enqueued successor completes")
 
 
+    print("== feedback: context_update carries rule/note deltas on resume ==")
+    # A standalone task parks on a question; while it is parked a rule
+    # is attached to the project and a note lands; the resuming
+    # assignment carries both as the context_update markdown turn.
+    probe_id = create_queued(auth, {"project_id": "1", "title": "Context probe",
+                                    "priority": "4"})
+    check(wait_for(lambda: fake.running_task == probe_id
+                   and task_state(auth, probe_id)["status"] == "working" or False,
+                   timeout=60, what="probe task runs") is not None,
+          "the probe task dispatches")
+    fake.set_state("blocked")
+    fake.push_results("blocked", block={"type": "question",
+                                        "question": "Which database dialect?"})
+    check(wait_for(lambda: task_state(auth, probe_id)["status"] == "blocked" or False,
+                   timeout=60, what="probe task blocks") is not None,
+          "the probe task parks on its question")
+    detail = task_state(auth, probe_id)
+    open_block = [b for b in detail["blocks"] if not b["answered"]][0]
+    # Backdate the parked round's start: stamps are second-resolution
+    # epochs, and the delta compares strictly-later — the park must
+    # span a real interval like an operator's would.
+    conn = db()
+    conn.execute("update sessions set started_at = strftime('%s','now') - 60"
+                 " where id = (select min(id) from sessions where task_id = ?)",
+                 (probe_id,))
+    conn.commit()
+    conn.close()
+    # While parked: a new rule attached to the project + a new note.
+    rules_state = slot_public(auth, "/rules", "rules")
+    act(auth, rules_state["create"], {
+        "name": "Nightly gate", "body": "Every PR must pass the nightly suite before merge."})
+    conn = db()
+    rule_id = conn.execute("select id from rules where name = 'Nightly gate'").fetchone()[0]
+    conn.close()
+    act(auth, slot_public(auth, "/projects/1", "project")["attach_rule"],
+        {"rule_id": str(rule_id)})
+    conn = db()
+    conn.execute("insert into project_notes (project_id, body, created_at)"
+                 " values (1, 'The demo repo moved its CI to nightly builds.',"
+                 " strftime('%s','now'))")
+    conn.commit()
+    check(conn.execute("select count(*) from rule_projects where rule_id = ?",
+                       (rule_id,)).fetchone()[0] == 1,
+          "the rule attaches to the project while the task is parked")
+    conn.close()
+    probe_assignments = len([p for p in fake.start_payloads
+                             if int(p.get("task", {}).get("id") or 0) == probe_id])
+    act(auth, open_block["answer_action"], {"answer": "postgres"})
+    check(wait_for(lambda: len([p for p in fake.start_payloads
+                                if int(p.get("task", {}).get("id") or 0) == probe_id])
+                   > probe_assignments or False, timeout=60,
+                   what="probe task resumes") is not None,
+          "answering the block resumes the probe task")
+    probe_resume = [p for p in fake.start_payloads
+                    if int(p.get("task", {}).get("id") or 0) == probe_id][-1]
+    delta = probe_resume.get("context_update") or ""
+    check("Rules changed" in delta and "Nightly gate" in delta
+          and "nightly suite" in delta,
+          "the resume's context_update summarises the newly attached rule")
+    check("New project notes" in delta and "nightly builds" in delta,
+          "the resume's context_update summarises the new project note")
+    check("Plan approved" not in delta,
+          "standalone resumes carry no plan section")
+    check(wait_task_done(auth, probe_id, timeout=60) is not None,
+          "the probe task completes after its resume")
+
 BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",
 BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",
                  "allocation", "lifecycle", "answers", "push", "goals")
                  "allocation", "lifecycle", "answers", "push", "goals")