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

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
 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)
 
 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
      * 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 {
 
@@ -290,13 +292,15 @@ namespace ClankerSaddle.Data {
         public string name { get; set; default = ""; }
         public string body { get; set; default = ""; }
         public DateTime? created_at { get; set; default = null; }
+        public DateTime? updated_at { get; set; default = null; }
 
         public static void entity_mapping(EntityMapperBuilder<Rule> cfg) {
             cfg.table("rules")
                 .column<int64?>("id", o => o.id, (o, v) => o.id = v)
                 .column<string>("name", o => o.name, (o, v) => o.name = 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 rule_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) {
             cfg.table("rule_projects")
                 .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?>("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 rule_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) {
             cfg.table("task_rules")
                 .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?>("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")
                     .not_null();
                 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) => {
                 t.column<int64?>("id")
                     .auto_increment()
@@ -362,6 +370,7 @@ namespace ClankerSaddle.Data.Migrations {
                     .not_null()
                     .references("projects", "id")
                     .indexed();
+                t.column<DateTime?>("created_at");
                 t.unique("uq_rule_projects_pair", {"rule_id", "project_id"});
             });
 
@@ -377,6 +386,7 @@ namespace ClankerSaddle.Data.Migrations {
                     .not_null()
                     .references("tasks", "id")
                     .indexed();
+                t.column<DateTime?>("created_at");
                 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 CapabilityService capabilities = inject<CapabilityService>();
         private GoalService goals = inject<GoalService>();
+        private RuleService rules = inject<RuleService>();
         private StatePulse pulse = inject<StatePulse>();
         private PushService push = inject<PushService>();
         private SaddlePollState poll_state = inject<SaddlePollState>();
@@ -344,8 +345,13 @@ namespace ClankerSaddle.Services {
 
             string? resume_notice = null;
             bool? branch_advanced = null;
+            string? context_update = null;
             if (claim.parked) {
                 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) {
                     // Plan 12: the goal agent's batched report — every
                     // plan task's status plus the completion reports
@@ -367,7 +373,8 @@ namespace ClankerSaddle.Services {
 
             poll_state.note_push(claim.session_id);
             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);
 
             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");
             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
-         * 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 {
             var rule = yield get_rule(id);
             if (rule == null) {
                 return null;
             }
+            bool edited = false;
             if (name != null && name.strip().length > 0) {
                 var clash = yield db.query<Rule>()
                     .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,
                         @"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) {
-                ((!)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);
             return rule;
@@ -126,7 +139,8 @@ namespace ClankerSaddle.Services {
             }
             yield db.insert_async<RuleProject>(new RuleProject() {
                 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() {
                 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,
                 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();
             if (endpoint == null) {
                 throw new Error(Quark.from_string("clanker-saddle"), 24,
@@ -950,6 +951,19 @@ namespace ClankerSaddle.Services {
                 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
             // primary and co-developed projects, oldest first, capped at
             // 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")
     # Reject with feedback: the agent revises.
     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"})
     check(wait_for(lambda: goal_state(goal1)["status"] == "plan_review" or False,
                    timeout=60, what="revised plan enters review") is not None,
           "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)
     revisions = state["plan_revisions"]
     check(len(revisions) == 2
@@ -5340,6 +5346,16 @@ def bundle_goals(ctx):
           "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")
+    # 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"
                    or False, timeout=60, what="goal agent parks after approval") is not None,
           "the resumed goal agent parks (saddle suspend)")
@@ -5453,6 +5469,8 @@ def bundle_goals(ctx):
           "the resume notice batches ALL plan task statuses")
     check("New completion reports" in notice and "fake daemon finished" in notice,
           "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"
           and resume_payload.get("mode") == "orchestrate",
           "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,
           "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",
                  "allocation", "lifecycle", "answers", "push", "goals")