mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
* feat(server): steer running turns from ordinary comments (MUL-7631) A comment can now name recipients whose running turn should receive it (`steer_agent_ids` on POST /issues/:id/comments). Each named recipient with a running, steer-capable turn gets the comment bound to that turn instead of a follow-up run; any other recipient keeps its normal queued / coalesced / deferred trigger, so losing a race never drops input. - One comment can steer several turns: receipts are keyed by (comment_id, task_id) and returned as `supplements` on comments and timeline entries. The single supplement_* fields mirror the first receipt for older clients. - Completion reconciliation skips a steered comment only for the agent it steered, so the same comment still reaches recipients that were addressed without steering. - Delivery claims match the (comment, run) pair. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(issues): choose per recipient how a message reaches a running agent (MUL-7631) Steering moves from a separate "Add message" box on the run row into the normal reply and comment composers. Recipients keep the existing trigger rules; each recipient's current run on the issue decides what the message can do: - running (and steer-capable): add to the current run (default in that run's own thread), start after this run, stop and start over, or skip - queued: include when it starts, or skip - idle: start on send, or skip Receipts follow the message ("Waiting for Lambda to read it" → "Read by Lambda · after step N"), a failed delivery offers retry or sending it as a new run, and the run's step list marks where it read the message. A draft whose recipient finished meanwhile stays in place and says it will start a new run; a message with files is handled after the run. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(server): steer only the chosen turn, and make a retried steer idempotent (MUL-7631) - Steering names the exact running turns the author chose (`steer_task_ids`). A chosen turn that ended before the send arrives is never swapped for a later turn of the same agent; that recipient keeps its normal trigger. - A steering send carries `client_request_id`. A retry after a lost response returns the original comment (200) instead of creating a second comment and a second delivery; a concurrent twin binding the same request is treated as already steered. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(issues): guard steering sends against stale previews and broad resends (MUL-7631) - "Stop and start over" never stops a run while the trigger preview is still catching up with an edited @mention: the stop is dropped unless the preview answers the submitted text. - Steering sends the chosen turns' task ids and one request id per draft text, reused on retries of the same text. - "Send as a new run" on a failed receipt reaches only that receipt's agent: every other current recipient is suppressed. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(issues): keep the reply box placeholder neutral (MUL-7631) The empty reply box cannot know who a reply goes to: the author may @ someone else or only talk to a teammate. The recipient chip already says what Send will do once there is text. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * test(issues): give steering composer tests room for the preview debounce (MUL-7631) Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(issues): choose the default reply to a running agent (MUL-7631) Preferences > Comments gains "When replying to a running agent": "Add to current run" (default) or "Start after this run". It only changes what a reply does without a per-message choice; the recipient chip still offers both, and replies that never steered by default (another thread, an idle or queued agent) are unaffected. Saved on this device next to the sticky comment bar. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(server): save one comment per send and honor "don't start" on replay (MUL-7631) Idempotency now lives on the comment, not the delivery receipt. A send's client_request_id is stored on the comment behind a partial unique index (migrations 550/551), so a retry, or a concurrent twin, returns the saved comment (200) whether the send steered a running turn or fell back to a normal trigger. Before, a twin could save a second comment, and a send whose turn had ended kept no receipt to find. The agents an author chose not to start are stored on the comment too, and completion replay skips them. "Send as a new run" for one missed recipient, and "Won't start this time", no longer wake the other agent once its current run completes. An edit re-records the choice. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(issues): repeat the original steering request on retry (MUL-7631) Until a send is settled, retrying the same text sends its first steering request unchanged, even after the chosen turn ended. The server then returns the comment the first attempt saved instead of posting it again as new input. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(migrations): fail fast on locks in 549 and 550 (MUL-7631) Both take an ACCESS EXCLUSIVE lock on a table the product touches continuously: comment for every read and write, task_supplement for every daemon claim. Bound lock acquisition and execution, as 483 does for issue, so a long transaction makes the migration fail and retry on the next run instead of queueing all traffic behind it. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(server): steer at most one turn per comment during rollout (MUL-7631) Steering is live, and servers from before per-run receipts claim every receipt of a comment at once. While they still serve daemons during a rollout, a comment bound to two turns could have the second copy marked in flight and never delivered. Until every server claims per (comment, run), a comment steers the first chosen turn only; later recipients keep their normal trigger. A follow-up lifts the limit after this ships. Also covers a steer_task_ids entry that names another issue's turn. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(server): finish a saved send on retry; keep an edit's choices with its text (MUL-7631) A send's comment was saved before its agents were started or steered, and deduplication treated the saved comment as the whole send. A dropped connection cancelled the request between the two, and the retry only returned the comment, so no agent ever received it. - Attempts of one logical send now run one at a time under a transaction advisory lock, held on its own connection: a twin waits for the attempt in progress (up to 30s, then 409) instead of racing it, and a process that dies releases it. - After the comment is saved, dispatch runs on a non-cancelable context, and comment.client_request_dispatched_at (migration 552) records that it finished. A retry that finds a saved but undispatched comment finishes the dispatch with the comment's recorded choices. An edit's "don't start" choices were written by a separate statement after the text, so interleaved edits could leave an older edit's choice. They are now part of UpdateComment, under the same revision check as the text. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(issues): offer one steered recipient per message during rollout (MUL-7631) The server steers at most one turn per comment until #8800, but the composer showed "Add to current run" for every running recipient and the server quietly queued the rest. Now only one recipient steers: the one the author picked, otherwise the first that would by default. The others show "Start after this run", and picking "Add to current run" on one of them moves the steer there. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(server): claim a send before dispatching it; drop the request lock (MUL-7631) The previous recovery marked a send dispatched only after reaching its agents, so a failure after dispatch made a retry reach them all again. It also held a pooled connection per send for an advisory lock while the send needed more from the same pool, which could wait on itself until timeout. A send's comment now holds a dispatch claim instead (comment.client_request_dispatched_at, one conditional update): only the attempt whose claim takes effect publishes the comment and reaches its agents. A failed claim writes nothing else and returns 500 for the client to retry; a retry that finds an unclaimed comment claims and dispatches it; a claimed one is only returned. Concurrent attempts no longer wait on each other, and post-save dispatch keeps a 30s bound of its own. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(server): let one comment steer several running turns (MUL-7631) Lifts the rollout limit from #8760. Every server now claims receipts per (comment, run), so a comment can be bound to each running turn its author chose, and each recipient receives it in its own run. Merge only after #8760 is deployed everywhere. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(issues): steer every running recipient a message addresses (MUL-7631) The client side of lifting the rollout limit: each running recipient can take the message in its current turn again, instead of only one per message. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * refactor(issues): drop send-retry idempotency and two display extras (MUL-7631) A steering send now behaves like any other comment when its response is lost: a retry posts it again. The machinery that tried to make that retry return the saved comment grew with every edge it met, and ordinary comments never had it: - Remove comment.client_request_id, its unique index, and the dispatch claim (migrations 551/552, and 550 reduced to suppressed_agent_ids). - Remove the replay/resume path in CreateComment, the post-save non-cancelable dispatch context, and the client's sticky request id. Steering sends only `steer_task_ids`; each binding uses the comment id as its receipt request id. Two display extras go too, since the UI already shows the same thing: - The "run ended while you were writing" notice: the recipient chip already switches from "Add to current run" to its new action. - "after step N" on a read receipt: the run trace already places the message at the step where it was read. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(issues): space agent names in Chinese copy; neutral recipients header (MUL-7631) - The steer badge joined names with Intl.ListFormat, which gives "Lambda和Orion" in Chinese. Latin names are now spaced from the Chinese joiner ("Lambda 和 Orion"), per the zh copy conventions; the enumeration comma stays unspaced. - The multi-recipient popover was still titled "Will start when sent" and its tooltip said "choose who runs", though each row now picks how that agent handles the message. Title it "Recipients" and reword the tooltip. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(issues): translate the recipient source labels in Chinese (MUL-7631) "assignee" and "@mention" were left in English in zh-Hans; the recipient menu header shows them. Use 负责人 (per the zh conventions) and @提及. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]>
471 lines
18 KiB
Go
471 lines
18 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
"github.com/multica-ai/multica/server/pkg/protocol"
|
|
)
|
|
|
|
func (h *Handler) hydrateTaskSupplementMetadata(ctx context.Context, r *http.Request, workspaceID pgtype.UUID, tasks []db.AgentTaskQueue, resp []AgentTaskResponse) {
|
|
if len(tasks) == 0 || len(tasks) != len(resp) {
|
|
return
|
|
}
|
|
taskIDs := make([]pgtype.UUID, 0, len(tasks))
|
|
for i := range tasks {
|
|
if tasks[i].IssueID.Valid {
|
|
taskIDs = append(taskIDs, tasks[i].ID)
|
|
}
|
|
}
|
|
if len(taskIDs) == 0 {
|
|
return
|
|
}
|
|
rows, err := h.Queries.ListTaskSupplementMetadata(ctx, db.ListTaskSupplementMetadataParams{
|
|
WorkspaceID: workspaceID,
|
|
TaskIds: taskIDs,
|
|
})
|
|
if err != nil {
|
|
return // metadata is optional and must fail closed
|
|
}
|
|
byTask := make(map[string]db.ListTaskSupplementMetadataRow, len(rows))
|
|
for _, row := range rows {
|
|
byTask[uuidToString(row.TaskID)] = row
|
|
}
|
|
|
|
userID := requestUserID(r)
|
|
permission := make(map[string]bool)
|
|
for i, task := range tasks {
|
|
row, ok := byTask[uuidToString(task.ID)]
|
|
if !ok {
|
|
continue
|
|
}
|
|
resp[i].SupplementCapability = row.Capability
|
|
resp[i].SupplementCommentIDs = uuidsToStrings(row.CommentIds)
|
|
if task.Status != "running" || row.Capability != protocol.DaemonCapabilityTaskSupplementV1 || userID == "" {
|
|
continue
|
|
}
|
|
agentID := uuidToString(task.AgentID)
|
|
allowed, checked := permission[agentID]
|
|
if !checked {
|
|
agent, loadErr := h.Queries.GetAgentInWorkspace(ctx, db.GetAgentInWorkspaceParams{
|
|
ID: task.AgentID, WorkspaceID: workspaceID,
|
|
})
|
|
allowed = loadErr == nil && h.canInvokeAgent(ctx, agent, "member", userID, userID, uuidToString(workspaceID))
|
|
permission[agentID] = allowed
|
|
}
|
|
resp[i].CanSupplement = allowed
|
|
}
|
|
}
|
|
|
|
type taskSupplementReceiptResponse struct {
|
|
TaskID string `json:"supplement_task_id"`
|
|
Status string `json:"supplement_status"`
|
|
FailureReason *string `json:"supplement_failure_reason,omitempty"`
|
|
DeliveredAt *string `json:"supplement_delivered_at,omitempty"`
|
|
}
|
|
|
|
func supplementReceipt(s db.TaskSupplement) taskSupplementReceiptResponse {
|
|
return taskSupplementReceiptResponse{
|
|
TaskID: uuidToString(s.TaskID),
|
|
Status: s.Status,
|
|
FailureReason: textToPtr(s.FailureReason),
|
|
DeliveredAt: timestampToPtr(s.DeliveredAt),
|
|
}
|
|
}
|
|
|
|
// CommentSupplementResponse is one delivery receipt of a comment that steered
|
|
// a running turn. A comment can steer several agents' turns at once.
|
|
type CommentSupplementResponse struct {
|
|
TaskID string `json:"task_id"`
|
|
AgentID string `json:"agent_id,omitempty"`
|
|
Status string `json:"status"`
|
|
FailureReason *string `json:"failure_reason,omitempty"`
|
|
DeliveredAt *string `json:"delivered_at,omitempty"`
|
|
}
|
|
|
|
// listCommentSupplements groups every receipt by comment id, oldest first.
|
|
// Receipts are optional metadata: a failed read leaves comments without them.
|
|
func (h *Handler) listCommentSupplements(ctx context.Context, workspaceID pgtype.UUID, commentIDs []pgtype.UUID) map[string][]CommentSupplementResponse {
|
|
out := make(map[string][]CommentSupplementResponse)
|
|
if len(commentIDs) == 0 {
|
|
return out
|
|
}
|
|
rows, err := h.Queries.ListTaskSupplementsByCommentIDs(ctx, db.ListTaskSupplementsByCommentIDsParams{
|
|
WorkspaceID: workspaceID, CommentIds: commentIDs,
|
|
})
|
|
if err != nil {
|
|
return out
|
|
}
|
|
for _, row := range rows {
|
|
id := uuidToString(row.CommentID)
|
|
out[id] = append(out[id], CommentSupplementResponse{
|
|
TaskID: uuidToString(row.TaskID),
|
|
AgentID: uuidToString(row.AgentID),
|
|
Status: row.Status,
|
|
FailureReason: textToPtr(row.FailureReason),
|
|
DeliveredAt: timestampToPtr(row.DeliveredAt),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// applyCommentSupplements sets the receipt list and mirrors its first entry
|
|
// into the single-receipt fields older clients read.
|
|
func applyCommentSupplements(resp *CommentResponse, receipts []CommentSupplementResponse) {
|
|
if len(receipts) == 0 {
|
|
return
|
|
}
|
|
resp.Supplements = receipts
|
|
resp.SupplementTaskID = receipts[0].TaskID
|
|
resp.SupplementStatus = receipts[0].Status
|
|
resp.SupplementFailureReason = receipts[0].FailureReason
|
|
resp.SupplementDeliveredAt = receipts[0].DeliveredAt
|
|
}
|
|
|
|
// steerCommentAgentTriggers binds a member comment to the turn its author
|
|
// chose for each recipient. A bound agent leaves the enqueue list: that turn
|
|
// receives the comment instead of a follow-up run. A chosen turn that has
|
|
// ended, has not started, or cannot take additional input is never swapped
|
|
// for another turn of the same agent; that recipient keeps its normal
|
|
// queued / coalesced / deferred handling, so losing a race never drops or
|
|
// misdirects the comment.
|
|
func (h *Handler) steerCommentAgentTriggers(ctx context.Context, issue db.Issue, comment db.Comment, actorType string, triggers []commentAgentTrigger, steerTaskIDs []pgtype.UUID) ([]commentAgentTrigger, map[string]commentEnqueueResult) {
|
|
if len(steerTaskIDs) == 0 || len(triggers) == 0 || actorType != "member" {
|
|
return triggers, nil
|
|
}
|
|
// Each chosen turn names its agent; only a turn of this issue counts.
|
|
chosen := make(map[string]pgtype.UUID, len(steerTaskIDs))
|
|
for _, taskID := range steerTaskIDs {
|
|
task, err := h.Queries.GetAgentTask(ctx, taskID)
|
|
if err != nil || task.IssueID != issue.ID {
|
|
continue
|
|
}
|
|
chosen[uuidToString(task.AgentID)] = task.ID
|
|
}
|
|
kept := make([]commentAgentTrigger, 0, len(triggers))
|
|
steered := make(map[string]commentEnqueueResult)
|
|
for _, trigger := range triggers {
|
|
agentID := uuidToString(trigger.Agent.ID)
|
|
taskID, ok := chosen[agentID]
|
|
if !ok {
|
|
kept = append(kept, trigger)
|
|
continue
|
|
}
|
|
// The comment is its own request id: it binds to a turn at most once.
|
|
bound, err := h.Queries.BindCommentTaskSupplement(ctx, db.BindCommentTaskSupplementParams{
|
|
TaskID: taskID, IssueID: issue.ID, AgentID: trigger.Agent.ID, WorkspaceID: issue.WorkspaceID,
|
|
CommentID: comment.ID, AuthorID: comment.AuthorID, ClientRequestID: comment.ID,
|
|
})
|
|
if err != nil {
|
|
if !errors.Is(err, pgx.ErrNoRows) {
|
|
slog.Warn("steer comment into running turn failed",
|
|
"issue_id", uuidToString(issue.ID), "comment_id", uuidToString(comment.ID), "task_id", uuidToString(taskID), "error", err)
|
|
}
|
|
kept = append(kept, trigger)
|
|
continue
|
|
}
|
|
steered[agentID] = commentEnqueueResult{status: DispatchSteered, reason: ReasonSteered}
|
|
if h.DaemonTaskSupplement != nil && bound.RuntimeID.Valid {
|
|
h.DaemonTaskSupplement.NotifyTaskSupplementAvailable(uuidToString(bound.RuntimeID), uuidToString(bound.TaskID))
|
|
}
|
|
}
|
|
if len(steered) > 0 {
|
|
h.publishCommentSupplementUpdate(ctx, issue.WorkspaceID, comment.ID)
|
|
}
|
|
return kept, steered
|
|
}
|
|
|
|
func (h *Handler) loadTaskSupplementTarget(w http.ResponseWriter, r *http.Request) (db.Issue, db.AgentTaskQueue, pgtype.UUID, bool) {
|
|
issue, ok := h.loadIssueForUser(w, r, chi.URLParam(r, "id"))
|
|
if !ok {
|
|
return db.Issue{}, db.AgentTaskQueue{}, pgtype.UUID{}, false
|
|
}
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return db.Issue{}, db.AgentTaskQueue{}, pgtype.UUID{}, false
|
|
}
|
|
authorID, ok := parseUUIDOrBadRequest(w, userID, "user id")
|
|
if !ok {
|
|
return db.Issue{}, db.AgentTaskQueue{}, pgtype.UUID{}, false
|
|
}
|
|
taskID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "taskId"), "task id")
|
|
if !ok {
|
|
return db.Issue{}, db.AgentTaskQueue{}, pgtype.UUID{}, false
|
|
}
|
|
task, err := h.Queries.GetAgentTask(r.Context(), taskID)
|
|
if err != nil || uuidToString(task.IssueID) != uuidToString(issue.ID) {
|
|
writeError(w, http.StatusNotFound, "task not found")
|
|
return db.Issue{}, db.AgentTaskQueue{}, pgtype.UUID{}, false
|
|
}
|
|
agent, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
|
|
ID: task.AgentID, WorkspaceID: issue.WorkspaceID,
|
|
})
|
|
if err != nil || !h.canInvokeAgent(r.Context(), agent, "member", userID, userID, uuidToString(issue.WorkspaceID)) {
|
|
writeErrorCode(w, http.StatusForbidden, "invocation_not_allowed", "you cannot run this agent")
|
|
return db.Issue{}, db.AgentTaskQueue{}, pgtype.UUID{}, false
|
|
}
|
|
return issue, task, authorID, true
|
|
}
|
|
|
|
type createTaskSupplementRequest struct {
|
|
Content string `json:"content"`
|
|
ClientRequestID string `json:"client_request_id"`
|
|
}
|
|
|
|
// CreateTaskSupplement adds one ordinary historical comment to one exact run.
|
|
// The SQL statement owns the terminal race: when completion wins, no comment,
|
|
// binding, or coverage marker is created.
|
|
func (h *Handler) CreateTaskSupplement(w http.ResponseWriter, r *http.Request) {
|
|
issue, task, authorID, ok := h.loadTaskSupplementTarget(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req createTaskSupplementRequest
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8*maxCommentContentBytes)).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
req.Content = sanitizeNullBytes(req.Content)
|
|
if len(req.Content) > maxCommentContentBytes {
|
|
writeError(w, http.StatusBadRequest, "content is too long")
|
|
return
|
|
}
|
|
if strings.TrimSpace(req.Content) == "" {
|
|
writeError(w, http.StatusBadRequest, "content is required")
|
|
return
|
|
}
|
|
requestID, ok := parseUUIDOrBadRequest(w, req.ClientRequestID, "client_request_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
lookup := db.GetTaskSupplementByRequestParams{
|
|
TaskID: task.ID, WorkspaceID: issue.WorkspaceID, AuthorID: authorID, ClientRequestID: requestID,
|
|
}
|
|
if existing, err := h.Queries.GetTaskSupplementByRequest(r.Context(), lookup); err == nil {
|
|
h.notifyTaskSupplementAvailable(task)
|
|
h.writeExistingTaskSupplement(w, r, existing)
|
|
return
|
|
} else if !errors.Is(err, pgx.ErrNoRows) {
|
|
writeError(w, http.StatusInternalServerError, "failed to check additional message")
|
|
return
|
|
}
|
|
if task.Status != "running" {
|
|
writeErrorCode(w, http.StatusConflict, "task_supplement_turn_ended", "this run has ended")
|
|
return
|
|
}
|
|
capability, err := h.Queries.GetTaskSupplementCapability(r.Context(), task.ID)
|
|
if errors.Is(err, pgx.ErrNoRows) || capability.Capability != protocol.DaemonCapabilityTaskSupplementV1 {
|
|
writeErrorCode(w, http.StatusPreconditionFailed, "task_supplement_unsupported", "this run does not support additional messages")
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to check additional-message support")
|
|
return
|
|
}
|
|
|
|
created, err := h.Queries.CreateTaskSupplement(r.Context(), db.CreateTaskSupplementParams{
|
|
TaskID: task.ID, IssueID: issue.ID, WorkspaceID: issue.WorkspaceID,
|
|
AuthorID: authorID, Content: req.Content, ClientRequestID: requestID,
|
|
})
|
|
if isUniqueViolation(err) {
|
|
if existing, loadErr := h.Queries.GetTaskSupplementByRequest(r.Context(), lookup); loadErr == nil {
|
|
h.notifyTaskSupplementAvailable(task)
|
|
h.writeExistingTaskSupplement(w, r, existing)
|
|
return
|
|
} else if !errors.Is(loadErr, pgx.ErrNoRows) {
|
|
writeError(w, http.StatusInternalServerError, "failed to check additional message")
|
|
return
|
|
}
|
|
writeError(w, http.StatusConflict, "client_request_id is already in use")
|
|
return
|
|
}
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
writeErrorCode(w, http.StatusConflict, "task_supplement_turn_ended", "this run has ended")
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to create additional message")
|
|
return
|
|
}
|
|
comment := db.Comment{
|
|
ID: created.ID, IssueID: created.IssueID, AuthorType: created.AuthorType,
|
|
AuthorID: created.AuthorID, Content: created.Content, Type: created.Type,
|
|
CreatedAt: created.CreatedAt, UpdatedAt: created.UpdatedAt, ParentID: created.ParentID,
|
|
WorkspaceID: created.WorkspaceID, ResolvedAt: created.ResolvedAt,
|
|
ResolvedByType: created.ResolvedByType, ResolvedByID: created.ResolvedByID,
|
|
SourceTaskID: created.SourceTaskID, QuickActionID: created.QuickActionID,
|
|
ViaPluginID: created.ViaPluginID, Revision: created.Revision,
|
|
RecoverySettledAt: created.RecoverySettledAt, DeletedAt: created.DeletedAt,
|
|
}
|
|
resp := commentToResponse(comment, nil, nil)
|
|
resp.IssueRevision = created.IssueRevision
|
|
applyCommentSupplements(&resp, []CommentSupplementResponse{{
|
|
TaskID: uuidToString(created.SupplementTaskID),
|
|
AgentID: uuidToString(task.AgentID),
|
|
Status: created.SupplementStatus,
|
|
FailureReason: textToPtr(created.SupplementFailureReason),
|
|
DeliveredAt: timestampToPtr(created.SupplementDeliveredAt),
|
|
}})
|
|
h.publish(protocol.EventCommentCreated, uuidToString(issue.WorkspaceID), "member", uuidToString(authorID), map[string]any{
|
|
"comment": resp, "issue_title": issue.Title, "issue_revision": created.IssueRevision,
|
|
})
|
|
h.notifyTaskSupplementAvailable(task)
|
|
writeJSON(w, http.StatusCreated, resp)
|
|
}
|
|
|
|
func (h *Handler) notifyTaskSupplementAvailable(task db.AgentTaskQueue) {
|
|
if h.DaemonTaskSupplement == nil || task.Status != "running" || !task.RuntimeID.Valid {
|
|
return
|
|
}
|
|
h.DaemonTaskSupplement.NotifyTaskSupplementAvailable(uuidToString(task.RuntimeID), uuidToString(task.ID))
|
|
}
|
|
|
|
func (h *Handler) writeExistingTaskSupplement(w http.ResponseWriter, r *http.Request, existing db.TaskSupplement) {
|
|
comment, err := h.Queries.GetCommentInWorkspace(r.Context(), db.GetCommentInWorkspaceParams{
|
|
ID: existing.CommentID, WorkspaceID: existing.WorkspaceID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to load additional message")
|
|
return
|
|
}
|
|
resp := commentToResponse(comment, nil, nil)
|
|
applyCommentSupplements(&resp, h.listCommentSupplements(r.Context(), existing.WorkspaceID, []pgtype.UUID{existing.CommentID})[uuidToString(existing.CommentID)])
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *Handler) RetryTaskSupplement(w http.ResponseWriter, r *http.Request) {
|
|
issue, task, _, ok := h.loadTaskSupplementTarget(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
commentID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "commentId"), "comment id")
|
|
if !ok {
|
|
return
|
|
}
|
|
row, err := h.Queries.RetryTaskSupplement(r.Context(), db.RetryTaskSupplementParams{
|
|
CommentID: commentID, WorkspaceID: issue.WorkspaceID, TaskID: task.ID, IssueID: issue.ID,
|
|
})
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
existing, loadErr := h.Queries.GetTaskSupplementForRun(r.Context(), db.GetTaskSupplementForRunParams{
|
|
CommentID: commentID, TaskID: task.ID, WorkspaceID: issue.WorkspaceID,
|
|
})
|
|
if loadErr == nil && existing.Status == "pending" {
|
|
row = existing // duplicate retry is an idempotent success
|
|
err = nil
|
|
}
|
|
}
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
writeErrorCode(w, http.StatusConflict, "task_supplement_not_retryable", "this message cannot be retried")
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to retry additional message")
|
|
return
|
|
}
|
|
h.publishTaskSupplementUpdate(r, row)
|
|
h.notifyTaskSupplementAvailable(task)
|
|
writeJSON(w, http.StatusOK, supplementReceipt(row))
|
|
}
|
|
|
|
type ackTaskSupplementRequest struct {
|
|
Delivered bool `json:"delivered"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
func (h *Handler) ClaimTaskSupplement(w http.ResponseWriter, r *http.Request) {
|
|
taskID := chi.URLParam(r, "taskId")
|
|
if _, ok := h.requireDaemonTaskAccess(w, r, taskID); !ok {
|
|
return
|
|
}
|
|
row, err := h.Queries.ClaimNextTaskSupplement(r.Context(), parseUUID(taskID))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
writeJSON(w, http.StatusOK, map[string]any{})
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to claim additional message")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"comment_id": uuidToString(row.CommentID), "author_name": row.AuthorName, "content": row.Content,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) AckTaskSupplement(w http.ResponseWriter, r *http.Request) {
|
|
taskID := chi.URLParam(r, "taskId")
|
|
if _, ok := h.requireDaemonTaskAccess(w, r, taskID); !ok {
|
|
return
|
|
}
|
|
commentID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "commentId"), "comment id")
|
|
if !ok {
|
|
return
|
|
}
|
|
var req ackTaskSupplementRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
var row db.TaskSupplement
|
|
var err error
|
|
if req.Delivered {
|
|
row, err = h.Queries.AckTaskSupplementDelivered(r.Context(), db.AckTaskSupplementDeliveredParams{
|
|
TaskID: parseUUID(taskID), CommentID: commentID,
|
|
})
|
|
} else {
|
|
reason := stableTaskSupplementFailureReason(req.Error)
|
|
row, err = h.Queries.AckTaskSupplementFailed(r.Context(), db.AckTaskSupplementFailedParams{
|
|
TaskID: parseUUID(taskID), CommentID: commentID,
|
|
FailureReason: pgtype.Text{String: reason, Valid: true},
|
|
})
|
|
}
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
writeError(w, http.StatusConflict, "additional message is no longer deliverable")
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to acknowledge additional message")
|
|
return
|
|
}
|
|
h.publishTaskSupplementUpdate(r, row)
|
|
writeJSON(w, http.StatusOK, supplementReceipt(row))
|
|
}
|
|
|
|
func stableTaskSupplementFailureReason(reason string) string {
|
|
reason = strings.TrimSpace(sanitizeNullBytes(reason))
|
|
switch reason {
|
|
case protocol.TaskSupplementFailureTurnNotStarted, protocol.TaskSupplementFailureTimeout,
|
|
protocol.TaskSupplementFailureTurnEnded, protocol.TaskSupplementFailureProviderRejected:
|
|
return reason
|
|
default:
|
|
// Provider and Go errors are private daemon diagnostics. Never persist or
|
|
// rebroadcast them to workspace members, and never byte-truncate UTF-8.
|
|
return protocol.TaskSupplementFailureProviderRejected
|
|
}
|
|
}
|
|
|
|
func (h *Handler) publishTaskSupplementUpdate(r *http.Request, row db.TaskSupplement) {
|
|
h.publishCommentSupplementUpdate(r.Context(), row.WorkspaceID, row.CommentID)
|
|
}
|
|
|
|
// publishCommentSupplementUpdate rebroadcasts a comment with every receipt it
|
|
// carries, so a change to one run's delivery never hides another run's.
|
|
func (h *Handler) publishCommentSupplementUpdate(ctx context.Context, workspaceID, commentID pgtype.UUID) {
|
|
comment, err := h.Queries.GetCommentInWorkspace(ctx, db.GetCommentInWorkspaceParams{
|
|
ID: commentID, WorkspaceID: workspaceID,
|
|
})
|
|
if err != nil {
|
|
return
|
|
}
|
|
resp := commentToResponse(comment, nil, nil)
|
|
applyCommentSupplements(&resp, h.listCommentSupplements(ctx, workspaceID, []pgtype.UUID{commentID})[uuidToString(commentID)])
|
|
h.publish(protocol.EventCommentUpdated, uuidToString(workspaceID), "system", "", map[string]any{"comment": resp})
|
|
}
|