|
|
@@ -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;
|
|
|
+ }
|
|
|
}
|
|
|
}
|