|
|
@@ -9,6 +9,7 @@ using ClankerSaddle.Data;
|
|
|
namespace ClankerSaddle.Services {
|
|
|
|
|
|
/** Valid task statuses (the `tasks.status` wire values). */
|
|
|
+ public const string STATUS_DRAFT = "draft";
|
|
|
public const string STATUS_QUEUED = "queued";
|
|
|
public const string STATUS_ASSIGNED = "assigned";
|
|
|
public const string STATUS_WORKING = "working";
|
|
|
@@ -22,14 +23,28 @@ namespace ClankerSaddle.Services {
|
|
|
public const string BLOCK_QUESTION = "question";
|
|
|
public const string BLOCK_RFC = "rfc";
|
|
|
public const string BLOCK_REVIEW = "review";
|
|
|
+ public const string BLOCK_PLAN = "plan";
|
|
|
+
|
|
|
+ /**
|
|
|
+ * The stable verdict wordings (plan 11): they are stored as the
|
|
|
+ * block answers AND ride the resuming allocation's
|
|
|
+ * `resume_notice` verbatim, so agent and human always read the
|
|
|
+ * same text.
|
|
|
+ */
|
|
|
+ public const string NOTICE_PLAN_APPROVED = "Plan approved. Proceed with implementation.";
|
|
|
+ public const string NOTICE_PLAN_FEEDBACK = "Plan feedback received:";
|
|
|
+ public const string NOTICE_REVIEW_APPROVED = "Review approved. Continue with the task.";
|
|
|
+ public const string NOTICE_REVIEW_FEEDBACK = "Review feedback received:";
|
|
|
|
|
|
/**
|
|
|
* A task's computed presentation state (stored status + open
|
|
|
* subtasks + unanswered blocks): `review requested` while a review
|
|
|
* block is unanswered (even when the stored status is terminal —
|
|
|
- * the post-mortem's `done`-with-open-review case), `delegating x/y`
|
|
|
+ * the post-mortem's `done`-with-open-review case), `waiting on
|
|
|
+ * subtasks to be enqueued` while a SUSPENDED parent still has
|
|
|
+ * draft children (the human must enqueue them), `delegating x/y`
|
|
|
* while non-terminal subtasks exist, `waiting on subtasks` for the
|
|
|
- * parked suspended state, otherwise the stored status. Pure
|
|
|
+ * parked suspended state otherwise, else the stored status. Pure
|
|
|
* presentation — the stored statuses and the dispatcher are
|
|
|
* untouched.
|
|
|
*/
|
|
|
@@ -39,17 +54,21 @@ namespace ClankerSaddle.Services {
|
|
|
public bool failed { get; set; }
|
|
|
public int64 subtask_total { get; set; }
|
|
|
public int64 subtask_open { get; set; }
|
|
|
+ public int64 subtask_draft { get; set; }
|
|
|
|
|
|
public static TaskDisplay compute(TaskEntity task, bool review_pending,
|
|
|
- int64 subtask_total, int64 subtask_open) {
|
|
|
+ int64 subtask_total, int64 subtask_open, int64 subtask_draft = 0) {
|
|
|
var view = new TaskDisplay() {
|
|
|
terminal = task.status == STATUS_DONE || task.status == STATUS_FAILED,
|
|
|
failed = task.status == STATUS_FAILED,
|
|
|
subtask_total = subtask_total,
|
|
|
- subtask_open = subtask_open
|
|
|
+ subtask_open = subtask_open,
|
|
|
+ subtask_draft = subtask_draft
|
|
|
};
|
|
|
if (review_pending) {
|
|
|
view.status = "review requested";
|
|
|
+ } else if (task.status == STATUS_SUSPENDED && subtask_draft > 0) {
|
|
|
+ view.status = "waiting on subtasks to be enqueued";
|
|
|
} else if (subtask_open > 0) {
|
|
|
view.status = @"delegating $(subtask_total - subtask_open)/$(subtask_total)";
|
|
|
} else if (task.status == STATUS_SUSPENDED) {
|
|
|
@@ -93,19 +112,21 @@ namespace ClankerSaddle.Services {
|
|
|
* Records a subtask's current state for the daemon hosting its
|
|
|
* parent's live or parked session (`POST /task-update
|
|
|
* {kind: "subtask-status"}`, wire contract). The wire carries
|
|
|
- * the four guard-relevant values;
|
|
|
- * intermediate web states (assigned, blocked, review-requested,
|
|
|
- * suspended) all mean "still in flight" and map to `working`.
|
|
|
- * Only the latest state per subtask is kept (queue coalescing).
|
|
|
+ * the four guard-relevant values; intermediate web states
|
|
|
+ * (assigned, blocked, review-requested, suspended) all mean
|
|
|
+ * "still in flight" and map to `working`. A draft subtask maps
|
|
|
+ * to `queued` — not yet started, but still pending for the
|
|
|
+ * parent's done/suspend guard. Only the latest state per
|
|
|
+ * subtask is kept (queue coalescing).
|
|
|
*/
|
|
|
private void note_subtask_event(TaskEntity task) {
|
|
|
if (task.parent_task_id <= 0) {
|
|
|
return;
|
|
|
}
|
|
|
string wire_status = STATUS_WORKING;
|
|
|
- if (task.status == STATUS_QUEUED || task.status == STATUS_DONE
|
|
|
- || task.status == STATUS_FAILED) {
|
|
|
- wire_status = task.status;
|
|
|
+ if (task.status == STATUS_DRAFT || task.status == STATUS_QUEUED
|
|
|
+ || task.status == STATUS_DONE || task.status == STATUS_FAILED) {
|
|
|
+ wire_status = task.status == STATUS_DRAFT ? STATUS_QUEUED : task.status;
|
|
|
}
|
|
|
subtask_events.enqueue(new SubtaskStatusEvent() {
|
|
|
parent_task_id = task.parent_task_id,
|
|
|
@@ -117,15 +138,21 @@ namespace ClankerSaddle.Services {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Inserts a task (status 'queued'). `parent_task_id`,
|
|
|
+ * Inserts a task (status 'draft' — only the human Enqueue
|
|
|
+ * action queues it, and no agent path exists). `parent_task_id`,
|
|
|
* `description`, `task_branch` and `branch_from` may be null;
|
|
|
* `branch_from` prefills from the project's default branch and
|
|
|
* `task_branch` is inherited from the parent task when unset;
|
|
|
- * priority defaults to 5 (1 is highest).
|
|
|
+ * priority defaults to 5 (1 is highest). `plan_auto_approve`
|
|
|
+ * answers this task's own plan blocks instantly web-side;
|
|
|
+ * `auto_enqueue_subtasks` enqueues subtasks created under it;
|
|
|
+ * `auto_approve` covers descendants' review and plan blocks.
|
|
|
*/
|
|
|
public async TaskEntity create_task(int64 project_id, string title, string? description,
|
|
|
- int64 priority, bool is_design_task, int64? parent_task_id,
|
|
|
- string? task_branch = null, string? branch_from = null) throws Error {
|
|
|
+ int64 priority, int64? parent_task_id,
|
|
|
+ string? task_branch = null, string? branch_from = null,
|
|
|
+ bool plan_auto_approve = false, bool auto_enqueue_subtasks = false,
|
|
|
+ bool auto_approve = false, string status = STATUS_DRAFT) throws Error {
|
|
|
string resolved_branch_from = branch_from ?? "";
|
|
|
if (resolved_branch_from.length == 0) {
|
|
|
var project = yield db.query<Project>()
|
|
|
@@ -151,10 +178,12 @@ namespace ClankerSaddle.Services {
|
|
|
title = title,
|
|
|
description = description != null && description.length > 0 ? description : null,
|
|
|
priority = priority.clamp(1, 9),
|
|
|
- is_design_task = is_design_task,
|
|
|
- status = STATUS_QUEUED,
|
|
|
+ status = status,
|
|
|
task_branch = resolved_task_branch,
|
|
|
branch_from = resolved_branch_from.length > 0 ? resolved_branch_from : null,
|
|
|
+ plan_auto_approve = plan_auto_approve,
|
|
|
+ auto_enqueue_subtasks = auto_enqueue_subtasks,
|
|
|
+ auto_approve = auto_approve ? 1 : 0,
|
|
|
created_at = new DateTime.now_local()
|
|
|
};
|
|
|
yield db.insert_async<TaskEntity>(task);
|
|
|
@@ -229,9 +258,12 @@ namespace ClankerSaddle.Services {
|
|
|
}
|
|
|
|
|
|
// Subtask counters per parent (stringly keyed: the Invercargill
|
|
|
- // collections cannot key on value types).
|
|
|
+ // collections cannot key on value types). Draft children count
|
|
|
+ // both as open (not terminal) and as drafts (the suspended
|
|
|
+ // parent's "waiting on subtasks to be enqueued" wording).
|
|
|
var open_by_parent = new Dictionary<string, string>();
|
|
|
var total_by_parent = new Dictionary<string, string>();
|
|
|
+ var draft_by_parent = new Dictionary<string, string>();
|
|
|
foreach (var child in yield db.query<TaskEntity>()
|
|
|
.where(expr("parent_task_id > 0"))
|
|
|
.materialise_async()) {
|
|
|
@@ -245,6 +277,10 @@ namespace ClankerSaddle.Services {
|
|
|
open_by_parent.set(parent,
|
|
|
(int64.parse(open_by_parent.get_or_default(parent) ?? "0") + 1).to_string());
|
|
|
}
|
|
|
+ if (child.status == STATUS_DRAFT) {
|
|
|
+ draft_by_parent.set(parent,
|
|
|
+ (int64.parse(draft_by_parent.get_or_default(parent) ?? "0") + 1).to_string());
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
var map = new Dictionary<string, TaskDisplay>();
|
|
|
@@ -253,7 +289,8 @@ namespace ClankerSaddle.Services {
|
|
|
map.set(key, TaskDisplay.compute(task,
|
|
|
review_pending.has(key),
|
|
|
int64.parse(total_by_parent.get_or_default(key) ?? "0"),
|
|
|
- int64.parse(open_by_parent.get_or_default(key) ?? "0")));
|
|
|
+ int64.parse(open_by_parent.get_or_default(key) ?? "0"),
|
|
|
+ int64.parse(draft_by_parent.get_or_default(key) ?? "0")));
|
|
|
}
|
|
|
return map;
|
|
|
}
|
|
|
@@ -266,6 +303,7 @@ namespace ClankerSaddle.Services {
|
|
|
bool review_pending = false;
|
|
|
int64 subtask_total = 0;
|
|
|
int64 subtask_open = 0;
|
|
|
+ int64 subtask_draft = 0;
|
|
|
foreach (var block in detail.blocks) {
|
|
|
if (block.block_type == BLOCK_REVIEW && block.answered_at == null) {
|
|
|
review_pending = true;
|
|
|
@@ -276,8 +314,12 @@ namespace ClankerSaddle.Services {
|
|
|
if (child.status != STATUS_DONE && child.status != STATUS_FAILED) {
|
|
|
subtask_open++;
|
|
|
}
|
|
|
+ if (child.status == STATUS_DRAFT) {
|
|
|
+ subtask_draft++;
|
|
|
+ }
|
|
|
}
|
|
|
- return TaskDisplay.compute(detail.task, review_pending, subtask_total, subtask_open);
|
|
|
+ return TaskDisplay.compute(detail.task, review_pending, subtask_total,
|
|
|
+ subtask_open, subtask_draft);
|
|
|
}
|
|
|
|
|
|
public async ImmutableLot<TaskEntity> list_tasks(string? status, int64? project_id) throws Error {
|
|
|
@@ -304,12 +346,22 @@ namespace ClankerSaddle.Services {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Saves the editable fields of the task identified by `id`; null
|
|
|
- * arguments keep their current values. Returns null when the task
|
|
|
- * does not exist. The pulse only fires when a value actually
|
|
|
- * moved, so periodic status refreshes do not spam live pages.
|
|
|
- */ public async TaskEntity? update_task(int64 id, string? title, string? description,
|
|
|
- int64? priority, string? status) throws Error {
|
|
|
+ * Saves the human-editable fields of the task identified by `id`
|
|
|
+ * under the draft-lifecycle edit matrix: in DRAFT all fields
|
|
|
+ * except status are editable (title, description, priority,
|
|
|
+ * branch fields and the three task options); in non-draft
|
|
|
+ * non-terminal states only priority applies; in terminal
|
|
|
+ * states nothing does. Fields not editable in the current
|
|
|
+ * state are silently left unchanged (null arguments always
|
|
|
+ * mean "leave unchanged"; an empty branch field clears it).
|
|
|
+ * Returns null when the task does not exist. The pulse only
|
|
|
+ * fires when a value actually moved, so periodic status
|
|
|
+ * refreshes do not spam live pages.
|
|
|
+ */
|
|
|
+ public async TaskEntity? edit_task(int64 id, string? title, string? description,
|
|
|
+ int64? priority, string? task_branch, string? branch_from,
|
|
|
+ bool? plan_auto_approve, bool? auto_enqueue_subtasks,
|
|
|
+ bool? auto_approve) throws Error {
|
|
|
var task = yield db.query<TaskEntity>()
|
|
|
.where(expr("id == $0", elem<int64?>(id)))
|
|
|
.first_async();
|
|
|
@@ -317,39 +369,208 @@ namespace ClankerSaddle.Services {
|
|
|
return null;
|
|
|
}
|
|
|
bool changed = false;
|
|
|
- if (title != null && title.length > 0 && ((!)task).title != title) {
|
|
|
- ((!)task).title = title;
|
|
|
- changed = true;
|
|
|
- }
|
|
|
- if (description != null) {
|
|
|
- string? next_description = description.length > 0 ? description : null;
|
|
|
- if (((!)task).description != next_description) {
|
|
|
- ((!)task).description = next_description;
|
|
|
+ bool draft = ((!)task).status == STATUS_DRAFT;
|
|
|
+ if (draft) {
|
|
|
+ if (title != null && title.length > 0 && ((!)task).title != title) {
|
|
|
+ ((!)task).title = title;
|
|
|
+ changed = true;
|
|
|
+ }
|
|
|
+ if (description != null) {
|
|
|
+ string? next_description = description.length > 0 ? description : null;
|
|
|
+ if (((!)task).description != next_description) {
|
|
|
+ ((!)task).description = next_description;
|
|
|
+ changed = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (task_branch != null) {
|
|
|
+ string? next_branch = task_branch.length > 0 ? task_branch : null;
|
|
|
+ if (((!)task).task_branch != next_branch) {
|
|
|
+ ((!)task).task_branch = next_branch;
|
|
|
+ changed = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (branch_from != null) {
|
|
|
+ string? next_from = branch_from.length > 0 ? branch_from : null;
|
|
|
+ if (((!)task).branch_from != next_from) {
|
|
|
+ ((!)task).branch_from = next_from;
|
|
|
+ changed = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (plan_auto_approve != null && ((!)task).plan_auto_approve != (!)plan_auto_approve) {
|
|
|
+ ((!)task).plan_auto_approve = (!)plan_auto_approve;
|
|
|
+ changed = true;
|
|
|
+ }
|
|
|
+ if (auto_enqueue_subtasks != null
|
|
|
+ && ((!)task).auto_enqueue_subtasks != (!)auto_enqueue_subtasks) {
|
|
|
+ ((!)task).auto_enqueue_subtasks = (!)auto_enqueue_subtasks;
|
|
|
+ changed = true;
|
|
|
+ }
|
|
|
+ if (auto_approve != null
|
|
|
+ && (((!)task).auto_approve != 0) != (!)auto_approve) {
|
|
|
+ ((!)task).auto_approve = (!)auto_approve ? 1 : 0;
|
|
|
changed = true;
|
|
|
}
|
|
|
}
|
|
|
- if (priority != null) {
|
|
|
+ // Priority stays editable in every non-terminal state.
|
|
|
+ if ((((!)task).status != STATUS_DONE && ((!)task).status != STATUS_FAILED)
|
|
|
+ && priority != null) {
|
|
|
int64 clamped = ((!)priority).clamp(1, 9);
|
|
|
if (((!)task).priority != clamped) {
|
|
|
((!)task).priority = clamped;
|
|
|
changed = true;
|
|
|
}
|
|
|
}
|
|
|
- if (status != null && status.length > 0 && ((!)task).status != status) {
|
|
|
+ yield db.update_async<TaskEntity>(task);
|
|
|
+ if (changed) {
|
|
|
+ yield pulse.bump(id, "task saved");
|
|
|
+ }
|
|
|
+ return task;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * The internal status-transition path (daemon pushes, park and
|
|
|
+ * result machinery) — never reachable from a human form.
|
|
|
+ * Stamps `completed_at` on done and lets the subtask's parent
|
|
|
+ * daemon hear the move. Returns null when the task does not
|
|
|
+ * exist.
|
|
|
+ */
|
|
|
+ public async TaskEntity? set_status(int64 id, string status) throws Error {
|
|
|
+ var task = yield db.query<TaskEntity>()
|
|
|
+ .where(expr("id == $0", elem<int64?>(id)))
|
|
|
+ .first_async();
|
|
|
+ if (task == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ if (((!)task).status != status) {
|
|
|
((!)task).status = status;
|
|
|
if (status == STATUS_DONE && ((!)task).completed_at == null) {
|
|
|
((!)task).completed_at = new DateTime.now_local();
|
|
|
}
|
|
|
- changed = true;
|
|
|
+ yield db.update_async<TaskEntity>(task);
|
|
|
+ yield pulse.bump(id, @"task $(status)");
|
|
|
+ note_subtask_event(((!)task));
|
|
|
}
|
|
|
- yield db.update_async<TaskEntity>(task);
|
|
|
- if (changed) {
|
|
|
- yield pulse.bump(id, @"task $(status ?? "saved")");
|
|
|
- if (status != null && status.length > 0) {
|
|
|
- note_subtask_event(((!)task));
|
|
|
+ return task;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * The human-only Enqueue action: moves a draft task to the
|
|
|
+ * queue. Refused with an error naming them while any
|
|
|
+ * dependency is still in draft (draft dependencies never
|
|
|
+ * finish, so enqueueing would strand the task); dependencies
|
|
|
+ * in any other non-done state simply hold dispatch back (the
|
|
|
+ * dispatcher's readiness check). There is no agent path to
|
|
|
+ * this — the saddle API only ever reports working/blocked.
|
|
|
+ */
|
|
|
+ public async void enqueue_task(int64 id) throws Error {
|
|
|
+ var task = yield db.query<TaskEntity>()
|
|
|
+ .where(expr("id == $0", elem<int64?>(id)))
|
|
|
+ .first_async();
|
|
|
+ if (task == null) {
|
|
|
+ throw new Error(Quark.from_string("clanker-saddle"), 50,
|
|
|
+ @"No task exists with id $(id)");
|
|
|
+ }
|
|
|
+ if (((!)task).status != STATUS_DRAFT) {
|
|
|
+ throw new Error(Quark.from_string("clanker-saddle"), 51,
|
|
|
+ "Only draft tasks can be enqueued");
|
|
|
+ }
|
|
|
+
|
|
|
+ var blockers = new Vector<string>();
|
|
|
+ foreach (var dep in yield db.query<TaskDependency>()
|
|
|
+ .where(expr("task_id == $0", elem<int64?>(id)))
|
|
|
+ .materialise_async()) {
|
|
|
+ var other = yield db.query<TaskEntity>()
|
|
|
+ .where(expr("id == $0", elem<int64?>(dep.depends_on_task_id)))
|
|
|
+ .first_async();
|
|
|
+ if (other != null && ((!)other).status == STATUS_DRAFT) {
|
|
|
+ blockers.add(@"#$(dep.depends_on_task_id) $(((!)other).title)");
|
|
|
}
|
|
|
}
|
|
|
- return task;
|
|
|
+ if (blockers.length > 0) {
|
|
|
+ throw new Error(Quark.from_string("clanker-saddle"), 52,
|
|
|
+ "Cannot enqueue while dependencies are still in draft: "
|
|
|
+ + string.joinv(", ", blockers.to_array()));
|
|
|
+ }
|
|
|
+
|
|
|
+ ((!)task).status = STATUS_QUEUED;
|
|
|
+ yield db.update_async<TaskEntity>((!)task);
|
|
|
+ yield pulse.bump(id, "task enqueued");
|
|
|
+ note_subtask_event(((!)task));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * The human-only Delete action: removes a DRAFT task with no
|
|
|
+ * dependents. Dependents — tasks holding a dependency edge to
|
|
|
+ * this one, or child subtasks — block the delete with an error
|
|
|
+ * naming every one of them (they must be deleted first).
|
|
|
+ * Attached rows (comments, blocks, progress, rules,
|
|
|
+ * capabilities, the task's own dependency edges) die with the
|
|
|
+ * task.
|
|
|
+ */
|
|
|
+ public async void delete_task(int64 id) throws Error {
|
|
|
+ var task = yield db.query<TaskEntity>()
|
|
|
+ .where(expr("id == $0", elem<int64?>(id)))
|
|
|
+ .first_async();
|
|
|
+ if (task == null) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (((!)task).status != STATUS_DRAFT) {
|
|
|
+ throw new Error(Quark.from_string("clanker-saddle"), 53,
|
|
|
+ "Only draft tasks can be deleted (cancel or retry handles the rest)");
|
|
|
+ }
|
|
|
+
|
|
|
+ var dependents = new Vector<string>();
|
|
|
+ foreach (var dep in yield db.query<TaskDependency>()
|
|
|
+ .where(expr("depends_on_task_id == $0", elem<int64?>(id)))
|
|
|
+ .materialise_async()) {
|
|
|
+ var other = yield db.query<TaskEntity>()
|
|
|
+ .where(expr("id == $0", elem<int64?>(dep.task_id)))
|
|
|
+ .first_async();
|
|
|
+ dependents.add(@"#$(dep.task_id) $(((!)other).title)");
|
|
|
+ }
|
|
|
+ foreach (var child in yield db.query<TaskEntity>()
|
|
|
+ .where(expr("parent_task_id == $0", elem<int64?>(id)))
|
|
|
+ .materialise_async()) {
|
|
|
+ dependents.add(@"#$(child.id) $(child.title) (subtask)");
|
|
|
+ }
|
|
|
+ if (dependents.length > 0) {
|
|
|
+ throw new Error(Quark.from_string("clanker-saddle"), 54,
|
|
|
+ "Cannot delete: other tasks depend on this one — "
|
|
|
+ + string.joinv(", ", dependents.to_array()));
|
|
|
+ }
|
|
|
+
|
|
|
+ foreach (var link in yield db.query<TaskRule>()
|
|
|
+ .where(expr("task_id == $0", elem<int64?>(id)))
|
|
|
+ .materialise_async()) {
|
|
|
+ yield db.delete_async<TaskRule>(link);
|
|
|
+ }
|
|
|
+ foreach (var link in yield db.query<TaskCapability>()
|
|
|
+ .where(expr("task_id == $0", elem<int64?>(id)))
|
|
|
+ .materialise_async()) {
|
|
|
+ yield db.delete_async<TaskCapability>(link);
|
|
|
+ }
|
|
|
+ foreach (var row in yield db.query<TaskProgress>()
|
|
|
+ .where(expr("task_id == $0", elem<int64?>(id)))
|
|
|
+ .materialise_async()) {
|
|
|
+ yield db.delete_async<TaskProgress>(row);
|
|
|
+ }
|
|
|
+ foreach (var row in yield db.query<TaskComment>()
|
|
|
+ .where(expr("task_id == $0", elem<int64?>(id)))
|
|
|
+ .materialise_async()) {
|
|
|
+ yield db.delete_async<TaskComment>(row);
|
|
|
+ }
|
|
|
+ foreach (var row in yield db.query<TaskBlock>()
|
|
|
+ .where(expr("task_id == $0", elem<int64?>(id)))
|
|
|
+ .materialise_async()) {
|
|
|
+ yield db.delete_async<TaskBlock>(row);
|
|
|
+ }
|
|
|
+ foreach (var row in yield db.query<TaskDependency>()
|
|
|
+ .where(expr("task_id == $0", elem<int64?>(id)))
|
|
|
+ .materialise_async()) {
|
|
|
+ yield db.delete_async<TaskDependency>(row);
|
|
|
+ }
|
|
|
+ yield db.delete_async<TaskEntity>((!)task);
|
|
|
+ yield pulse.bump(id, "task deleted");
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -393,31 +614,6 @@ namespace ClankerSaddle.Services {
|
|
|
.materialise_async();
|
|
|
}
|
|
|
|
|
|
- /**
|
|
|
- * Saves the task's branch fields: `task_branch` is the branch the
|
|
|
- * agent works on, `branch_from` the base it is cut from. Null
|
|
|
- * arguments keep their current values. Returns null when the task
|
|
|
- * does not exist.
|
|
|
- */
|
|
|
- public async TaskEntity? set_branch_fields(int64 id, string? task_branch,
|
|
|
- string? branch_from) throws Error {
|
|
|
- var task = yield db.query<TaskEntity>()
|
|
|
- .where(expr("id == $0", elem<int64?>(id)))
|
|
|
- .first_async();
|
|
|
- if (task == null) {
|
|
|
- return null;
|
|
|
- }
|
|
|
- if (task_branch != null && task_branch.length > 0) {
|
|
|
- ((!)task).task_branch = task_branch;
|
|
|
- }
|
|
|
- if (branch_from != null && branch_from.length > 0) {
|
|
|
- ((!)task).branch_from = branch_from;
|
|
|
- }
|
|
|
- yield db.update_async<TaskEntity>((!)task);
|
|
|
- yield pulse.bump(id, "branch fields saved");
|
|
|
- return task;
|
|
|
- }
|
|
|
-
|
|
|
/** Appends a comment; `author` is 'bot' or 'user:<name>'. */
|
|
|
public async TaskComment add_comment(int64 task_id, string author, string body,
|
|
|
string? quote = null) throws Error {
|
|
|
@@ -713,29 +909,117 @@ namespace ClankerSaddle.Services {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Auto-answers a just-raised review block when its task is
|
|
|
- * covered by an ancestor's auto-approve toggle: answer text
|
|
|
- * `Auto-approved (subtask auto-approve is on)`, answered-by
|
|
|
- * 'auto', a timeline entry on the session — and the verdict
|
|
|
- * reaches the daemon through the same delivery path as a human
|
|
|
- * answer. Questions and RFC blocks are never touched; returns
|
|
|
- * true when the block was auto-answered.
|
|
|
+ * Auto-answers a just-raised block web-side so neither agent nor
|
|
|
+ * daemon knows a human was not there:
|
|
|
+ *
|
|
|
+ * - REVIEW blocks covered by a STRICT ancestor's subtask
|
|
|
+ * auto-approve toggle — answer text `Auto-approved (subtask
|
|
|
+ * auto-approve is on)`, answered-by 'auto'.
|
|
|
+ * - PLAN blocks covered by the task's own "auto-approve plan"
|
|
|
+ * option OR a strict ancestor's subtask auto-approve toggle
|
|
|
+ * (the transitive toggle also covers subtask plans) — answer
|
|
|
+ * text {@link NOTICE_PLAN_APPROVED}, the plan markdown applied
|
|
|
+ * to task.plan, answered-by 'auto'.
|
|
|
+ *
|
|
|
+ * A timeline entry lands on the session either way — and the
|
|
|
+ * verdict reaches the daemon through the same path as a human
|
|
|
+ * answer: the allocation resume's `resume_notice`. Questions
|
|
|
+ * and RFC blocks are never touched; returns true when the
|
|
|
+ * block was auto-answered.
|
|
|
*/
|
|
|
public async bool auto_answer_if_covered(TaskBlock block) throws Error {
|
|
|
- if (block.block_type != BLOCK_REVIEW || block.answered_at != null) {
|
|
|
+ if (block.answered_at != null) {
|
|
|
return false;
|
|
|
}
|
|
|
- if (!(yield is_auto_approve_covered(block.task_id))) {
|
|
|
- return false;
|
|
|
+ if (block.block_type == BLOCK_REVIEW) {
|
|
|
+ if (!(yield is_auto_approve_covered(block.task_id))) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ yield answer_block(block.id, "Auto-approved (subtask auto-approve is on)", "auto");
|
|
|
+ yield log_session_entry(block.task_id, "auto-approve",
|
|
|
+ "review auto-approved", block.body ?? block.question);
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ if (block.block_type == BLOCK_PLAN) {
|
|
|
+ var task = yield db.query<TaskEntity>()
|
|
|
+ .where(expr("id == $0", elem<int64?>(block.task_id)))
|
|
|
+ .first_async();
|
|
|
+ bool covered = task != null && ((!)task).plan_auto_approve;
|
|
|
+ if (!covered) {
|
|
|
+ covered = yield is_auto_approve_covered(block.task_id);
|
|
|
+ }
|
|
|
+ if (!covered) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ yield answer_block(block.id, NOTICE_PLAN_APPROVED, "auto");
|
|
|
+ if (block.body != null && ((!)block.body).length > 0) {
|
|
|
+ yield update_plan(block.task_id, (!)block.body);
|
|
|
+ }
|
|
|
+ yield log_session_entry(block.task_id, "auto-approve",
|
|
|
+ "plan auto-approved", block.body ?? block.question);
|
|
|
+ return true;
|
|
|
}
|
|
|
- yield answer_block(block.id, "Auto-approved (subtask auto-approve is on)", "auto");
|
|
|
- yield log_session_entry(block.task_id, "auto-approve",
|
|
|
- "review auto-approved", block.body ?? block.question);
|
|
|
- return true;
|
|
|
+ return false;
|
|
|
}
|
|
|
|
|
|
- /** Creates a child task under `parent_task_id` (status 'queued'). */
|
|
|
- public async TaskEntity create_subtask(int64 parent_task_id, string title,
|
|
|
+ /**
|
|
|
+ * The resume_notice wording for an answered block (the plan-11
|
|
|
+ * stable verdicts): review and plan answers are already stored
|
|
|
+ * in the stable wording and pass through verbatim; question
|
|
|
+ * replies are formatted the way the daemon used to format
|
|
|
+ * delivered answers ("Human selected: …" for option matches,
|
|
|
+ * "Human answered (other): …" otherwise); RFC/plain answers
|
|
|
+ * carry the "Human answer: …" prefix.
|
|
|
+ */
|
|
|
+ public static string verdict_notice_for(TaskBlock block) {
|
|
|
+ string answer = block.answer ?? "";
|
|
|
+ if (block.block_type == BLOCK_REVIEW || block.block_type == BLOCK_PLAN) {
|
|
|
+ return answer;
|
|
|
+ }
|
|
|
+ if (block.block_type == BLOCK_QUESTION) {
|
|
|
+ foreach (var option in parse_block_options(block.options)) {
|
|
|
+ if (option == answer) {
|
|
|
+ return "Human selected: " + answer;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return "Human answered (other): " + answer;
|
|
|
+ }
|
|
|
+ return "Human answer: " + answer;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** The stored options JSON (a string array) back into a Vala array. */
|
|
|
+ public static string[] parse_block_options(string? options_json) {
|
|
|
+ if (options_json == null || ((!)options_json).length == 0) {
|
|
|
+ return new string[0];
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ var parser = new Json.Parser();
|
|
|
+ parser.load_from_data((!)options_json);
|
|
|
+ var root = parser.get_root();
|
|
|
+ if (root == null || root.get_node_type() != Json.NodeType.ARRAY) {
|
|
|
+ return new string[0];
|
|
|
+ }
|
|
|
+ var values = new Vector<string>();
|
|
|
+ foreach (var element in root.get_array().get_elements()) {
|
|
|
+ if (element.get_node_type() == Json.NodeType.VALUE
|
|
|
+ && element.get_string() != null) {
|
|
|
+ values.add(element.get_string());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return values.to_array();
|
|
|
+ } catch (Error e) {
|
|
|
+ return new string[0];
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Creates a child task under `parent_task_id` in draft (the
|
|
|
+ * row-level builder shared by the human form, the entry-log
|
|
|
+ * fallback and the synchronous callback). The child always
|
|
|
+ * inherits the parent's auto-enqueue value as its own setting
|
|
|
+ * for ITS children at creation time.
|
|
|
+ */
|
|
|
+ private async TaskEntity create_subtask_draft(int64 parent_task_id, string title,
|
|
|
string? description) throws Error {
|
|
|
var parent = yield db.query<TaskEntity>()
|
|
|
.where(expr("id == $0", elem<int64?>(parent_task_id)))
|
|
|
@@ -745,8 +1029,26 @@ namespace ClankerSaddle.Services {
|
|
|
@"No task exists with id $(parent_task_id)");
|
|
|
}
|
|
|
return yield create_task(((!)parent).project_id, title, description,
|
|
|
- ((!)parent).priority, false, parent_task_id,
|
|
|
- ((!)parent).task_branch, ((!)parent).branch_from);
|
|
|
+ ((!)parent).priority, parent_task_id,
|
|
|
+ ((!)parent).task_branch, ((!)parent).branch_from,
|
|
|
+ false, ((!)parent).auto_enqueue_subtasks, false, STATUS_DRAFT);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Creates a subtask applying the auto-enqueue rule: a subtask
|
|
|
+ * created under T enqueues immediately when T carries the
|
|
|
+ * "Auto-enqueue agent-created subtasks" option, otherwise it
|
|
|
+ * lands in draft — and either way the child inherits T's value
|
|
|
+ * as its own setting for its children at creation time.
|
|
|
+ */
|
|
|
+ public async TaskEntity create_subtask(int64 parent_task_id, string title,
|
|
|
+ string? description) throws Error {
|
|
|
+ var child = yield create_subtask_draft(parent_task_id, title, description);
|
|
|
+ if (child.auto_enqueue_subtasks) {
|
|
|
+ yield enqueue_task(child.id);
|
|
|
+ child.status = STATUS_QUEUED;
|
|
|
+ }
|
|
|
+ return child;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -778,10 +1080,14 @@ namespace ClankerSaddle.Services {
|
|
|
* be an ancestor of the new subtask — depending on your own
|
|
|
* ancestor would deadlock the parent/child wait (the parent
|
|
|
* waits for its subtasks while the subtask waits for the
|
|
|
- * parent's chain).
|
|
|
+ * parent's chain). With `enqueue` false the row stays in draft
|
|
|
+ * so the caller can attach capabilities before queueing it (the
|
|
|
+ * callback's race-free ordering); with it true the parent's
|
|
|
+ * auto-enqueue setting decides.
|
|
|
*/
|
|
|
public async TaskEntity create_subtask_with_dependencies(int64 parent_task_id,
|
|
|
- string title, string? description, int64[] depends_on) throws Error {
|
|
|
+ string title, string? description, int64[] depends_on,
|
|
|
+ bool enqueue = true) throws Error {
|
|
|
foreach (int64 dependency in depends_on) {
|
|
|
var dep = yield db.query<TaskEntity>()
|
|
|
.where(expr("id == $0", elem<int64?>(dependency)))
|
|
|
@@ -806,10 +1112,16 @@ namespace ClankerSaddle.Services {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- var task = yield create_subtask(parent_task_id, title, description);
|
|
|
+ var task = yield create_subtask_draft(parent_task_id, title, description);
|
|
|
foreach (int64 dependency in depends_on) {
|
|
|
yield add_dependency(task.id, dependency);
|
|
|
}
|
|
|
+ if (enqueue && task.auto_enqueue_subtasks) {
|
|
|
+ // The parent's setting was inherited onto the row at
|
|
|
+ // creation; applying it here enqueues immediately.
|
|
|
+ yield enqueue_task(task.id);
|
|
|
+ task.status = STATUS_QUEUED;
|
|
|
+ }
|
|
|
return task;
|
|
|
}
|
|
|
|
|
|
@@ -847,7 +1159,8 @@ namespace ClankerSaddle.Services {
|
|
|
/**
|
|
|
* Cancels a task: status 'failed' with a cancellation summary. Any
|
|
|
* open session is closed with outcome 'stopped' and its saddle
|
|
|
- * freed.
|
|
|
+ * freed. Draft tasks are refused — they are deleted, not
|
|
|
+ * cancelled (nothing ever ran).
|
|
|
*/
|
|
|
public async void cancel_task(int64 task_id) throws Error {
|
|
|
var task = yield db.query<TaskEntity>()
|
|
|
@@ -857,6 +1170,10 @@ namespace ClankerSaddle.Services {
|
|
|
throw new Error(Quark.from_string("clanker-saddle"), 9,
|
|
|
@"No task exists with id $(task_id)");
|
|
|
}
|
|
|
+ if (((!)task).status == STATUS_DRAFT) {
|
|
|
+ throw new Error(Quark.from_string("clanker-saddle"), 55,
|
|
|
+ "Draft tasks are deleted, not cancelled");
|
|
|
+ }
|
|
|
|
|
|
((!)task).status = STATUS_FAILED;
|
|
|
((!)task).completed_at = new DateTime.now_local();
|