|
|
@@ -142,7 +142,6 @@ namespace ClankerSaddle.Services {
|
|
|
created_at = new DateTime.now_local()
|
|
|
};
|
|
|
yield db.insert_async<Goal>(goal);
|
|
|
- yield record_event(goal.id, "created", @"Goal created (branch $(trimmed_branch))");
|
|
|
yield pulse.bump(0, "goal created");
|
|
|
return goal;
|
|
|
}
|
|
|
@@ -270,8 +269,6 @@ namespace ClankerSaddle.Services {
|
|
|
null, ((!)goal).base_branch, STATUS_QUEUED,
|
|
|
TASK_KIND_GOAL_RUN, MODE_ORCHESTRATE, CREATOR_GOAL,
|
|
|
((!)goal).id, 0);
|
|
|
- yield record_event(goal_id, "started",
|
|
|
- @"Goal started — the goal agent is planning (run task #$(run.id))", run.id);
|
|
|
yield pulse.bump(run.id, "goal started");
|
|
|
return goal;
|
|
|
}
|
|
|
@@ -290,7 +287,6 @@ namespace ClankerSaddle.Services {
|
|
|
((!)goal).status = GOAL_PAUSED;
|
|
|
((!)goal).paused_at = new DateTime.now_local();
|
|
|
yield db.update_async<Goal>((!)goal);
|
|
|
- yield record_event(goal_id, "paused", "Goal paused — supervision frozen (events queue)");
|
|
|
yield pulse.bump(0, "goal paused");
|
|
|
return goal;
|
|
|
}
|
|
|
@@ -309,7 +305,6 @@ namespace ClankerSaddle.Services {
|
|
|
((!)goal).status = GOAL_ACTIVE;
|
|
|
((!)goal).paused_at = null;
|
|
|
yield db.update_async<Goal>((!)goal);
|
|
|
- yield record_event(goal_id, "resumed", "Goal resumed — queued events coalesce into the next agent wake");
|
|
|
yield pulse.bump(0, "goal resumed");
|
|
|
return goal;
|
|
|
}
|
|
|
@@ -347,7 +342,6 @@ namespace ClankerSaddle.Services {
|
|
|
yield tasks.cancel_task(task.id);
|
|
|
}
|
|
|
}
|
|
|
- yield record_event(goal_id, "cancelled", "Goal cancelled — its tasks were cancelled with it");
|
|
|
yield pulse.bump(0, "goal cancelled");
|
|
|
}
|
|
|
|
|
|
@@ -384,8 +378,6 @@ namespace ClankerSaddle.Services {
|
|
|
}
|
|
|
((!)goal).status = GOAL_PLAN_REVIEW;
|
|
|
yield db.update_async<Goal>(((!)goal));
|
|
|
- yield record_event(goal_id, "plan-submitted",
|
|
|
- @"Plan revision #$(((!)raised).id) submitted for review");
|
|
|
return raised;
|
|
|
}
|
|
|
|
|
|
@@ -404,8 +396,6 @@ namespace ClankerSaddle.Services {
|
|
|
if (raised == null) {
|
|
|
return null;
|
|
|
}
|
|
|
- yield record_event(goal_id, "block-raised",
|
|
|
- @"$(((!)raised).block_type) raised: $(question)");
|
|
|
return raised;
|
|
|
}
|
|
|
|
|
|
@@ -437,8 +427,6 @@ namespace ClankerSaddle.Services {
|
|
|
((!)goal).status = GOAL_COMPLETION_REVIEW;
|
|
|
((!)goal).completion_report = report;
|
|
|
yield db.update_async<Goal>(((!)goal));
|
|
|
- yield record_event(goal_id, "completion-submitted",
|
|
|
- "Completion report submitted for review");
|
|
|
return raised;
|
|
|
}
|
|
|
|
|
|
@@ -490,11 +478,7 @@ namespace ClankerSaddle.Services {
|
|
|
}
|
|
|
cascaded++;
|
|
|
}
|
|
|
- yield record_event(goal_id, "goal-failed",
|
|
|
- report != null && report.length > 0 ? report : "The goal agent failed the goal");
|
|
|
if (cascaded > 0) {
|
|
|
- yield record_event(goal_id, "cascade",
|
|
|
- @"Goal failure cancelled $(cascaded) outstanding task(s)");
|
|
|
}
|
|
|
push.notify_global(PushService.goal_terminal_notification(
|
|
|
((!)goal).title, goal_id, false));
|
|
|
@@ -560,23 +544,15 @@ namespace ClankerSaddle.Services {
|
|
|
((!)goal).plan = ((!)block).body;
|
|
|
}
|
|
|
((!)goal).status = GOAL_ACTIVE;
|
|
|
- yield record_event(((!)goal).id, "plan-approved",
|
|
|
- @"Plan revision #$(((!)block).id) approved — the goal is active");
|
|
|
} else {
|
|
|
((!)goal).status = GOAL_PLANNING;
|
|
|
- yield record_event(((!)goal).id, "plan-rejected",
|
|
|
- @"Plan revision #$(((!)block).id) rejected with feedback — the agent revises");
|
|
|
}
|
|
|
} else if (((!)block).block_type == GOAL_BLOCK_COMPLETION) {
|
|
|
if (answer == NOTICE_REVIEW_APPROVED) {
|
|
|
((!)goal).status = GOAL_COMPLETE;
|
|
|
((!)goal).completed_at = new DateTime.now_local();
|
|
|
- yield record_event(((!)goal).id, "goal-approved",
|
|
|
- "Completion report approved — the goal is complete");
|
|
|
} else {
|
|
|
((!)goal).status = GOAL_ACTIVE;
|
|
|
- yield record_event(((!)goal).id, "completion-rejected",
|
|
|
- "Completion report rejected with feedback — the agent continues");
|
|
|
}
|
|
|
}
|
|
|
yield db.update_async<Goal>(((!)goal));
|
|
|
@@ -686,31 +662,206 @@ namespace ClankerSaddle.Services {
|
|
|
return yield tasks.list_blocks(((!)run).id);
|
|
|
}
|
|
|
|
|
|
- /** A goal's narrative events, oldest first. */
|
|
|
- public async ImmutableLot<GoalEvent> list_events(int64 goal_id) throws Error {
|
|
|
- return yield db.query<GoalEvent>()
|
|
|
- .where(expr("goal_id == $0", elem<int64?>(goal_id)))
|
|
|
- .order_by(expr("id"))
|
|
|
- .materialise_async();
|
|
|
+ /**
|
|
|
+ * One derived supervision-narrative row (plan 15 C3): the
|
|
|
+ * read-time projection of a timestamped fact — a lifecycle
|
|
|
+ * stamp, a run-task block or comment, a worker task's
|
|
|
+ * created/terminal transition. `kind` keeps the old
|
|
|
+ * goal_events vocabulary; system rows (kind "system") come
|
|
|
+ * from the run thread's harness-authored comments.
|
|
|
+ */
|
|
|
+ public class NarrativeRow : Object {
|
|
|
+ public string kind { get; set; default = ""; }
|
|
|
+ public string text { get; set; default = ""; }
|
|
|
+ public int64 task_id { get; set; }
|
|
|
+ public DateTime? at { get; set; default = null; }
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Records one supervision-narrative event. Called by the
|
|
|
- * lifecycle flows, the goal agent's task operations and the
|
|
|
- * task-terminal hook — every goal_events row is also a
|
|
|
- * potential resume signal (see {@link resume_due}).
|
|
|
+ * The supervision narrative (plan 15 C3): a DERIVED read-time
|
|
|
+ * view — nothing is written on ingest any more. The rows come
|
|
|
+ * from the goal's lifecycle stamps (created/started/paused/
|
|
|
+ * terminal), the goal-run task's blocks (plan submissions and
|
|
|
+ * verdicts, questions/RFCs, the completion review) and its
|
|
|
+ * comment thread (agent/user rows as footnote comments,
|
|
|
+ * harness-authored rows as system events), and the goal's
|
|
|
+ * tasks' created and terminal transitions. Timestamp-less
|
|
|
+ * bookkeeping (task-enqueued, task-deleted, resumed) never
|
|
|
+ * returns; the goal-failure CASCADE derives from the
|
|
|
+ * cancelled tasks surviving under a failed goal. Newest
|
|
|
+ * first.
|
|
|
*/
|
|
|
- public async GoalEvent record_event(int64 goal_id, string kind, string text,
|
|
|
- int64 task_id = 0, int64 ref_task_id = 0) throws Error {
|
|
|
- var event = new GoalEvent() {
|
|
|
- goal_id = goal_id,
|
|
|
- kind = kind,
|
|
|
- text = text,
|
|
|
- task_id = task_id,
|
|
|
- created_at = new DateTime.now_local()
|
|
|
- };
|
|
|
- yield db.insert_async<GoalEvent>(event);
|
|
|
- return event;
|
|
|
+ public async ImmutableLot<NarrativeRow> narrative(int64 goal_id) throws Error {
|
|
|
+ var goal = yield get_goal(goal_id);
|
|
|
+ if (goal == null) {
|
|
|
+ return new Vector<NarrativeRow>().to_immutable_buffer();
|
|
|
+ }
|
|
|
+ var rows = new Vector<NarrativeRow>();
|
|
|
+
|
|
|
+ // Lifecycle stamps.
|
|
|
+ if (((!)goal).created_at != null) {
|
|
|
+ rows.add(new NarrativeRow() {
|
|
|
+ kind = "created", at = ((!)goal).created_at,
|
|
|
+ text = @"Goal created (branch $(((!)goal).branch_name))" });
|
|
|
+ }
|
|
|
+ if (((!)goal).started_at != null) {
|
|
|
+ rows.add(new NarrativeRow() {
|
|
|
+ kind = "started", at = ((!)goal).started_at,
|
|
|
+ text = "Goal started — the goal agent is planning" });
|
|
|
+ }
|
|
|
+ if (((!)goal).paused_at != null && ((!)goal).status == GOAL_PAUSED) {
|
|
|
+ rows.add(new NarrativeRow() {
|
|
|
+ kind = "paused", at = ((!)goal).paused_at,
|
|
|
+ text = "Goal paused — supervision frozen (terminal transitions queue)" });
|
|
|
+ }
|
|
|
+ if (((!)goal).completed_at != null) {
|
|
|
+ if (((!)goal).status == GOAL_CANCELLED) {
|
|
|
+ rows.add(new NarrativeRow() {
|
|
|
+ kind = "cancelled", at = ((!)goal).completed_at,
|
|
|
+ text = "Goal cancelled — its tasks were cancelled with it" });
|
|
|
+ } else if (((!)goal).status == GOAL_FAILED) {
|
|
|
+ rows.add(new NarrativeRow() {
|
|
|
+ kind = "goal-failed", at = ((!)goal).completed_at,
|
|
|
+ text = (((!)goal).completion_report ?? "").length > 0
|
|
|
+ ? (!)(((!)goal).completion_report)
|
|
|
+ : "The goal agent failed the goal" });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // The run task's blocks: the plan/completion reviews with
|
|
|
+ // their verdicts, the questions/RFCs.
|
|
|
+ var run = yield goal_run_task(goal_id);
|
|
|
+ if (run != null) {
|
|
|
+ foreach (var block in yield tasks.list_blocks(((!)run).id)) {
|
|
|
+ if (block.created_at != null) {
|
|
|
+ if (block.block_type == GOAL_BLOCK_PLAN) {
|
|
|
+ rows.add(new NarrativeRow() {
|
|
|
+ kind = "plan-submitted", at = block.created_at,
|
|
|
+ text = @"Plan revision #$(block.id) submitted for review" });
|
|
|
+ } else if (block.block_type == GOAL_BLOCK_COMPLETION) {
|
|
|
+ rows.add(new NarrativeRow() {
|
|
|
+ kind = "completion-submitted", at = block.created_at,
|
|
|
+ text = "Completion report submitted for review" });
|
|
|
+ } else {
|
|
|
+ rows.add(new NarrativeRow() {
|
|
|
+ kind = "block-raised", at = block.created_at,
|
|
|
+ text = @"$(block.block_type) raised: $(block.question)" });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (block.answered_at != null) {
|
|
|
+ if (block.block_type == GOAL_BLOCK_PLAN) {
|
|
|
+ bool approved = block.answer == NOTICE_PLAN_APPROVED;
|
|
|
+ rows.add(new NarrativeRow() {
|
|
|
+ kind = approved ? "plan-approved" : "plan-rejected",
|
|
|
+ at = block.answered_at,
|
|
|
+ text = approved
|
|
|
+ ? @"Plan revision #$(block.id) approved — the goal is active"
|
|
|
+ : @"Plan revision #$(block.id) rejected with feedback — the agent revises" });
|
|
|
+ } else if (block.block_type == GOAL_BLOCK_COMPLETION) {
|
|
|
+ bool approved = block.answer == NOTICE_REVIEW_APPROVED;
|
|
|
+ rows.add(new NarrativeRow() {
|
|
|
+ kind = approved ? "goal-approved" : "completion-rejected",
|
|
|
+ at = block.answered_at,
|
|
|
+ text = approved
|
|
|
+ ? "Completion report approved — the goal is complete"
|
|
|
+ : "Completion report rejected with feedback — the agent continues" });
|
|
|
+ } else {
|
|
|
+ rows.add(new NarrativeRow() {
|
|
|
+ kind = "block-answered", at = block.answered_at,
|
|
|
+ text = @"$(block.block_type) answered" });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // The ONE comment thread: agent/user rows as footnote
|
|
|
+ // comments, harness-authored rows as system events.
|
|
|
+ foreach (var comment in yield tasks.list_comments(((!)run).id)) {
|
|
|
+ if (comment.created_at == null) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ rows.add(new NarrativeRow() {
|
|
|
+ kind = comment.author == COMMENT_AUTHOR_SYSTEM
|
|
|
+ ? "system" : "comment",
|
|
|
+ at = comment.created_at,
|
|
|
+ text = comment.body });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // The goal's tasks: created + terminal transitions. The
|
|
|
+ // failure cascade derives from the cancelled tasks
|
|
|
+ // surviving under a failed goal (deleted drafts left no
|
|
|
+ // rows; the count is the surviving record).
|
|
|
+ int64 cascaded = 0;
|
|
|
+ foreach (var task in yield db.query<TaskEntity>()
|
|
|
+ .where(expr("goal_id == $0 && kind == $1",
|
|
|
+ elem<int64?>(goal_id), elem(TASK_KIND_TASK)))
|
|
|
+ .order_by(expr("id"))
|
|
|
+ .materialise_async()) {
|
|
|
+ if (task.created_at != null) {
|
|
|
+ rows.add(new NarrativeRow() {
|
|
|
+ kind = "task-created", at = task.created_at, task_id = task.id,
|
|
|
+ text = @"Task #$(task.id) $(task.title) created by the goal agent ($(task.mode))" });
|
|
|
+ }
|
|
|
+ if (task.completed_at != null) {
|
|
|
+ if (task.status == STATUS_DONE) {
|
|
|
+ rows.add(new NarrativeRow() {
|
|
|
+ kind = "task-done", at = task.completed_at, task_id = task.id,
|
|
|
+ text = @"Task #$(task.id) $(task.title) — completed$(summary_lead(task))" });
|
|
|
+ } else if (task.status == STATUS_FAILED) {
|
|
|
+ rows.add(new NarrativeRow() {
|
|
|
+ kind = "task-failed", at = task.completed_at, task_id = task.id,
|
|
|
+ text = @"Task #$(task.id) $(task.title) — failed$(summary_lead(task))" });
|
|
|
+ if (((!)goal).status == GOAL_FAILED
|
|
|
+ && (task.summary ?? "") == "Cancelled by user") {
|
|
|
+ cascaded++;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (((!)goal).status == GOAL_FAILED && cascaded > 0) {
|
|
|
+ rows.add(new NarrativeRow() {
|
|
|
+ kind = "cascade", at = ((!)goal).completed_at,
|
|
|
+ text = @"Goal failure cancelled $(cascaded) outstanding task(s)" });
|
|
|
+ }
|
|
|
+
|
|
|
+ // Newest first, stable for equal timestamps (insertion
|
|
|
+ // sort — the narratives are small).
|
|
|
+ var array = rows.to_array();
|
|
|
+ for (var i = 1; i < array.length; i++) {
|
|
|
+ var key = array[i];
|
|
|
+ var j = i - 1;
|
|
|
+ while (j >= 0 && narrative_after(key, array[j])) {
|
|
|
+ array[j + 1] = array[j];
|
|
|
+ j--;
|
|
|
+ }
|
|
|
+ array[j + 1] = key;
|
|
|
+ }
|
|
|
+ var newest_first = new Vector<NarrativeRow>();
|
|
|
+ foreach (var row in array) {
|
|
|
+ newest_first.add(row);
|
|
|
+ }
|
|
|
+ return newest_first.to_immutable_buffer();
|
|
|
+ }
|
|
|
+
|
|
|
+ /** True when row `a` sorts after (newer than) row `b`. */
|
|
|
+ private static bool narrative_after(NarrativeRow a, NarrativeRow b) {
|
|
|
+ if (a.at == null) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ if (b.at == null) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ return ((!)a.at).compare((!)b.at) > 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** The task's summary as a narrative lead-in (": <first line>"). */
|
|
|
+ private static string summary_lead(TaskEntity task) {
|
|
|
+ if (task.summary == null || ((!)task.summary).length == 0) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ string summary = (!)task.summary;
|
|
|
+ int cut = summary.index_of("\n");
|
|
|
+ string lead = cut >= 0 ? summary.substring(0, cut) : summary;
|
|
|
+ return @": $(lead)";
|
|
|
}
|
|
|
|
|
|
// ------------------------------------------------------------------
|
|
|
@@ -720,10 +871,14 @@ namespace ClankerSaddle.Services {
|
|
|
/**
|
|
|
* True when the goal agent should be resumed: its run task is
|
|
|
* parked (suspended), the goal is active (paused goals freeze
|
|
|
- * supervision; review states park on blocks instead), and at
|
|
|
- * least one task event has waited out the coalescing window
|
|
|
- * (the OLDEST unconsumed event is old enough — bursts collapse
|
|
|
- * into one resume).
|
|
|
+ * supervision — transitions newer than the watermark simply
|
|
|
+ * QUEUE until the goal resumes; review states park on blocks
|
|
|
+ * instead), and at least one owned task's TERMINAL transition
|
|
|
+ * (completed_at newer than {@link Goal.last_terminal_watermark})
|
|
|
+ * has waited out the coalescing window — the OLDEST such
|
|
|
+ * transition ages the window, so bursts collapse into one
|
|
|
+ * resume. Plan 15 C3: the watermark replaced the goal_events
|
|
|
+ * id cursor; the transitions read straight off the tasks.
|
|
|
*/
|
|
|
public async bool resume_due(TaskEntity run_task) throws Error {
|
|
|
if (run_task.kind != TASK_KIND_GOAL_RUN || run_task.status != STATUS_SUSPENDED) {
|
|
|
@@ -733,23 +888,25 @@ namespace ClankerSaddle.Services {
|
|
|
if (goal == null || ((!)goal).status != GOAL_ACTIVE) {
|
|
|
return false;
|
|
|
}
|
|
|
+ DateTime? watermark = ((!)goal).last_terminal_watermark;
|
|
|
DateTime? oldest = null;
|
|
|
- // ONLY owned-task terminal events resume the parked agent —
|
|
|
- // the agent's own task-created/task-enqueued narrative (and
|
|
|
- // human comments) must never wake it; the first terminal
|
|
|
- // event ages the window and bursts coalesce behind it.
|
|
|
- foreach (var event in yield db.query<GoalEvent>()
|
|
|
- .where(expr("goal_id == $0 && id > $1",
|
|
|
- elem<int64?>(run_task.goal_id),
|
|
|
- elem<int64?>(((!)goal).last_seen_event_id)))
|
|
|
+ // ONLY owned-task terminal transitions resume the parked
|
|
|
+ // agent — the agent's own task-created narrative (and
|
|
|
+ // human comments) must never wake it.
|
|
|
+ foreach (var task in yield db.query<TaskEntity>()
|
|
|
+ .where(expr("goal_id == $0 && kind == $1",
|
|
|
+ elem<int64?>(run_task.goal_id), elem(TASK_KIND_TASK)))
|
|
|
.order_by(expr("id"))
|
|
|
.materialise_async()) {
|
|
|
- if (event.kind != "task-done" && event.kind != "task-failed") {
|
|
|
+ if (task.completed_at == null) {
|
|
|
continue;
|
|
|
}
|
|
|
- if (event.created_at != null) {
|
|
|
- oldest = ((!)event).created_at;
|
|
|
- break;
|
|
|
+ if (watermark != null
|
|
|
+ && ((!)task.completed_at).compare((!)watermark) <= 0) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (oldest == null || ((!)task.completed_at).compare((!)oldest) < 0) {
|
|
|
+ oldest = task.completed_at;
|
|
|
}
|
|
|
}
|
|
|
if (oldest == null) {
|
|
|
@@ -764,8 +921,11 @@ namespace ClankerSaddle.Services {
|
|
|
* assignment's `resume_notice`): the current status of ALL of
|
|
|
* the goal's tasks plus the full completion reports of the
|
|
|
* tasks that finished since the agent parked. Consumes the
|
|
|
- * pending events (the watermark advances), so the window
|
|
|
- * starts fresh for the next park.
|
|
|
+ * pending transitions — the terminal watermark advances past
|
|
|
+ * every transition visible now (and past the clock, so a
|
|
|
+ * same-second park/consume pair cannot re-fire) — so the
|
|
|
+ * window starts fresh for the next park. Plan 15 C3: the
|
|
|
+ * watermark replaced the goal_events id cursor.
|
|
|
*/
|
|
|
public async string take_resume_notice(int64 goal_id) throws Error {
|
|
|
var goal = yield require_goal(goal_id);
|
|
|
@@ -778,7 +938,6 @@ namespace ClankerSaddle.Services {
|
|
|
var report = new StringBuilder();
|
|
|
report.append("Goal update — resuming your parked session:\n\n");
|
|
|
report.append("## Task statuses\n");
|
|
|
- int64 newest_event = ((!)goal).last_seen_event_id;
|
|
|
var finished = new Vector<TaskEntity>();
|
|
|
foreach (var task in yield db.query<TaskEntity>()
|
|
|
.where(expr("goal_id == $0 && kind == $1",
|
|
|
@@ -810,17 +969,18 @@ namespace ClankerSaddle.Services {
|
|
|
.append(" prune worktrees, create or enqueue follow-ups as needed, then")
|
|
|
.append(" `saddle suspend` again when you are parked.\n");
|
|
|
|
|
|
- foreach (var event in yield db.query<GoalEvent>()
|
|
|
+ DateTime consumed = new DateTime.now_local();
|
|
|
+ foreach (var task in yield db.query<TaskEntity>()
|
|
|
.where(expr("goal_id == $0", elem<int64?>(goal_id)))
|
|
|
- .order_by_desc(expr("id"))
|
|
|
- .limit(1)
|
|
|
+ .order_by(expr("id"))
|
|
|
.materialise_async()) {
|
|
|
- newest_event = event.id;
|
|
|
- }
|
|
|
- if (newest_event != ((!)goal).last_seen_event_id) {
|
|
|
- ((!)goal).last_seen_event_id = newest_event;
|
|
|
- yield db.update_async<Goal>((!)goal);
|
|
|
+ if (task.completed_at != null
|
|
|
+ && ((!)task.completed_at).compare(consumed) > 0) {
|
|
|
+ consumed = (!)task.completed_at;
|
|
|
+ }
|
|
|
}
|
|
|
+ ((!)goal).last_terminal_watermark = consumed;
|
|
|
+ yield db.update_async<Goal>(((!)goal));
|
|
|
return report.str;
|
|
|
}
|
|
|
|