mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
* fix(daemon): defer issue status rules to the project workflow (MUL-8145) When the claim carries a project workflow, the runtime brief no longer tells the agent to set in_progress first and in_review on delivery. Those statuses may be outside the workflow (the server rejects them), and entering a handoff step reassigns the issue. The brief now points at the per-turn [Project workflow] block, and the workflow brief carries the entry/exit checks in workflow terms. Briefs without a workflow are byte-identical to before. Co-authored-by: multica-agent <[email protected]> * fix(squad): word the owning leader's status moves by the project workflow (MUL-8145) The squad protocol (Agent Identity, which outranks the runtime brief) still told an owning leader to move the parent to in_progress and then in_review, which a workflow may not list and which can hand the parent to another step's handler. When the claim carries a project workflow, responsibility 6 now keeps the ownership grant but moves the parent only through the [Project workflow] commands. The claim resolves the workflow before the briefing to choose the wording. Guest leaders and issues without a workflow are unchanged. Co-authored-by: multica-agent <[email protected]> * refactor(daemon): list no status keys in the brief for a workflow issue (MUL-8145) With a project workflow, [Project workflow] already lists the issue's statuses and the commands that move it, and the built-in keys may not be accepted there. The status command line now keeps only the command and points at that block; other issues learn valid statuses from the error. Co-authored-by: multica-agent <[email protected]> * refactor(workflow): stop forbidding status moves nothing asks for (MUL-8145) Under a project workflow the current runtime brief no longer states the built-in in_progress / in_review moves, so the lines that forbade them (in [Project workflow], the runtime brief and the squad protocol) only named statuses the agent had no reason to use. Drop them, and the exit check the runtime brief already states. Older daemons still state the built-in rules, so the daemon now advertises workflow-status-rules-v1; without it the claim keeps the override lines in [Project workflow] and the squad protocol. The multica-platform skill notes that [Project workflow] supersedes its built-in status arc. Co-authored-by: multica-agent <[email protected]> * refactor(workflow): shorten the step's moves in the workflow brief (MUL-8145) Replace the handoff preamble with a plain "Your moves:" heading, name who each move hands off to on its own line, and drop the "this workflow has no blocked status" explanations from Can't proceed. Co-authored-by: multica-agent <[email protected]> * refactor(workflow): state each step outcome as the status to set (MUL-8145) The moves block read like raw CLI ('Step done → multica issue status MUL-7 x'). Each outcome now says which status to set and that the server assigns the next handler; the CLI form is given once. Co-authored-by: multica-agent <[email protected]> * Revert "refactor(workflow): state each step outcome as the status to set (MUL-8145)" This reverts commit 2d82d2d342a99afe88cc40c48516b7ea8e860d1e. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: multica-eve <[email protected]> Co-authored-by: multica-agent <[email protected]>
395 lines
15 KiB
Go
395 lines
15 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/multica-ai/multica/server/internal/issuestatus"
|
|
"github.com/multica-ai/multica/server/internal/issueworkflow"
|
|
"github.com/multica-ai/multica/server/internal/service"
|
|
"github.com/multica-ai/multica/server/internal/util"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// Project workflows on the issue write paths (MUL-7420).
|
|
//
|
|
// Two rules apply to an issue in a project:
|
|
// - when the project uses a named workflow, its status must be one the
|
|
// workflow lists; a move into such a project keeps an equivalent status
|
|
// instead of failing. Default accepts every status;
|
|
// - entering a column the project hands off, through an ordinary status
|
|
// change of an issue that stays in its project, assigns the issue to that
|
|
// handler and starts its run with the step's instructions. A write that
|
|
// sets the assignee itself keeps its explicit choice. A project move
|
|
// enters the column it lands on only when the caller asks for it.
|
|
//
|
|
// Handoffs reuse assignment wholesale: the resolved handler goes through the
|
|
// same permission gate as a hand-picked assignee, and the run starts through
|
|
// the same WillEnqueueRun predicate, with the step's brief as its handoff note.
|
|
|
|
// workflowHandoff is a handoff an issue write triggers.
|
|
type workflowHandoff struct {
|
|
def issueworkflow.Definition
|
|
step issueworkflow.Step
|
|
project db.Project
|
|
}
|
|
|
|
// applyIssueWorkflow enforces prev's (possibly new) project workflow on one
|
|
// prospective update and may rewrite params: the status of an issue moving
|
|
// into the project, and the assignee when the write enters a handoff step.
|
|
// enterOnMove makes a project move enter the step the issue lands on.
|
|
// run is the calling agent's run on this issue (nil for members): it may only
|
|
// move the issue from the step it works on. It returns the handoff that write
|
|
// triggers, or nil.
|
|
func (h *Handler) applyIssueWorkflow(ctx context.Context, prev db.Issue, params *db.UpdateIssueParams, statusExplicit, assigneeTouched, enterOnMove bool, run *db.AgentTaskQueue) (*workflowHandoff, error) {
|
|
def, project, err := issueworkflow.ForProject(ctx, h.Queries, prev.WorkspaceID, params.ProjectID)
|
|
if err != nil || def == nil {
|
|
return nil, err
|
|
}
|
|
projectMoving := params.ProjectID != prev.ProjectID
|
|
if projectMoving && !statusExplicit && !def.IsDefault && !def.Has(prev.Status) {
|
|
target := issueworkflow.MoveTarget(*def, prev.Status, func(key string) string {
|
|
return issuestatus.Category(ctx, h.Queries, prev.WorkspaceID, key)
|
|
})
|
|
params.Status = pgtype.Text{String: target, Valid: true}
|
|
}
|
|
if params.Status.Valid {
|
|
if err := issueworkflow.CheckStatus(def, params.Status.String); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
// Someone else moved the issue while the run worked: the run's decision was
|
|
// made for a step the issue has left, and applying it would undo that move.
|
|
// Default without handoffs keeps the pre-workflow behavior, so workspaces
|
|
// that never adopt workflows see no change.
|
|
if run != nil && run.WorkflowStep.Valid && run.WorkflowStep.String != prev.Status &&
|
|
(!def.IsDefault || def.HandsOffAnywhere()) &&
|
|
statusExplicit && params.Status.Valid && params.Status.String != prev.Status {
|
|
return nil, h.stepMovedError(ctx, prev, run.WorkflowStep.String)
|
|
}
|
|
if assigneeTouched || prev.TriageState.Valid {
|
|
return nil, nil
|
|
}
|
|
entering := ""
|
|
switch {
|
|
case projectMoving && enterOnMove:
|
|
entering = prev.Status
|
|
if params.Status.Valid {
|
|
entering = params.Status.String
|
|
}
|
|
// A project move is administrative unless the caller opted in, and
|
|
// re-selecting the current status is a no-op: neither hands the issue off.
|
|
case !projectMoving && statusExplicit && params.Status.Valid && params.Status.String != prev.Status:
|
|
entering = params.Status.String
|
|
default:
|
|
return nil, nil
|
|
}
|
|
step, ok := def.Step(entering)
|
|
if !ok || !step.HandsOff() {
|
|
return nil, nil
|
|
}
|
|
assigneeType, assigneeID, ok := issueworkflow.ResolveHandler(step, project, prev.CreatorType, prev.CreatorID)
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
params.AssigneeType = pgtype.Text{String: assigneeType, Valid: true}
|
|
params.AssigneeID = assigneeID
|
|
return &workflowHandoff{def: *def, step: step, project: project}, nil
|
|
}
|
|
|
|
// workflowHandoffPayload describes a handoff on the issue:updated event, so
|
|
// the activity log can record the status change and the handoff as one
|
|
// timeline entry.
|
|
func workflowHandoffPayload(issue db.Issue, hand *workflowHandoff, note string, runStarted bool) map[string]string {
|
|
out := map[string]string{
|
|
"workflow": hand.def.Name,
|
|
"to_type": issue.AssigneeType.String,
|
|
"to_id": uuidToString(issue.AssigneeID),
|
|
"run": strconv.FormatBool(runStarted),
|
|
}
|
|
if runStarted && note != "" {
|
|
out["note"] = note
|
|
}
|
|
return out
|
|
}
|
|
|
|
// writeIssueWorkflowError answers a workflow rejection. It reports false when
|
|
// err is not one, so the caller can fall through to its generic 500.
|
|
func writeIssueWorkflowError(w http.ResponseWriter, err error) bool {
|
|
var notIn *issueworkflow.NotInWorkflowError
|
|
if errors.As(err, ¬In) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]any{
|
|
"error": notIn.Error(),
|
|
"code": "status_not_in_workflow",
|
|
"workflow_name": notIn.WorkflowName,
|
|
"allowed_statuses": notIn.Allowed,
|
|
})
|
|
return true
|
|
}
|
|
var moved *issueworkflow.StepMovedError
|
|
if errors.As(err, &moved) {
|
|
writeJSON(w, http.StatusConflict, map[string]any{
|
|
"error": moved.Error(),
|
|
"code": "workflow_step_moved",
|
|
"step": moved.Step,
|
|
"current_status": moved.Current,
|
|
})
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// agentRunOnIssue returns the calling agent's run when it works on this issue,
|
|
// nil for members and for runs on other issues. The task id is the
|
|
// server-trusted X-Task-ID resolveActor vouched for.
|
|
func (h *Handler) agentRunOnIssue(r *http.Request, actorType string, issue db.Issue) *db.AgentTaskQueue {
|
|
if actorType != "agent" {
|
|
return nil
|
|
}
|
|
taskID, err := util.ParseUUID(r.Header.Get("X-Task-ID"))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
task, err := h.Queries.GetAgentTask(r.Context(), taskID)
|
|
if err != nil || task.IssueID != issue.ID {
|
|
return nil
|
|
}
|
|
return &task
|
|
}
|
|
|
|
// handedToAgent names the agent a write hands the issue to: its new agent
|
|
// assignee, the leader of its new squad assignee, or a workflow step's
|
|
// handler even when that handler already owned the issue. Empty when the
|
|
// write leaves the issue with whoever had it.
|
|
func (h *Handler) handedToAgent(ctx context.Context, prev db.Issue, params db.UpdateIssueParams, handoff bool) string {
|
|
if !params.AssigneeID.Valid {
|
|
return ""
|
|
}
|
|
if !handoff && params.AssigneeType == prev.AssigneeType && params.AssigneeID == prev.AssigneeID {
|
|
return ""
|
|
}
|
|
switch params.AssigneeType.String {
|
|
case "agent":
|
|
return uuidToString(params.AssigneeID)
|
|
case "squad":
|
|
squad, err := h.Queries.GetSquadInWorkspace(ctx, db.GetSquadInWorkspaceParams{ID: params.AssigneeID, WorkspaceID: prev.WorkspaceID})
|
|
if err == nil && squad.LeaderID.Valid {
|
|
return uuidToString(squad.LeaderID)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// advanceRunStep moves a run's workflow step along with a status change the
|
|
// run made itself, so its next change is judged from where it put the issue.
|
|
func (h *Handler) advanceRunStep(ctx context.Context, run *db.AgentTaskQueue, prev, issue db.Issue) {
|
|
if run == nil || !run.WorkflowStep.Valid || prev.Status == issue.Status {
|
|
return
|
|
}
|
|
if err := h.Queries.SetTaskWorkflowStep(ctx, db.SetTaskWorkflowStepParams{
|
|
ID: run.ID,
|
|
WorkflowStep: pgtype.Text{String: issue.Status, Valid: true},
|
|
}); err != nil {
|
|
slog.Warn("workflow: advance run step failed", "task_id", uuidToString(run.ID), "error", err)
|
|
}
|
|
}
|
|
|
|
// stepMovedError explains a refused status change: where the run's step was,
|
|
// where the issue is now, and who moved it.
|
|
func (h *Handler) stepMovedError(ctx context.Context, issue db.Issue, step string) error {
|
|
resolver := issuestatus.NewResolver(issue.WorkspaceID)
|
|
out := &issueworkflow.StepMovedError{
|
|
IssueIdentifier: h.getIssuePrefix(ctx, issue.WorkspaceID) + "-" + strconv.Itoa(int(issue.Number)),
|
|
Step: step,
|
|
StepName: resolver.Name(ctx, h.Queries, step),
|
|
Current: issue.Status,
|
|
CurrentName: resolver.Name(ctx, h.Queries, issue.Status),
|
|
}
|
|
if last, err := h.Queries.GetLastIssueStatusChange(ctx, issue.ID); err == nil && last.ActorID.Valid {
|
|
out.MovedBy = h.actorDisplayName(ctx, issue.WorkspaceID, last.ActorType.String, last.ActorID)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// workflowHandoffBrief renders the brief a handoff's agent or squad will
|
|
// read, for the timeline and the handoff confirmation. Members run nothing,
|
|
// so they get none. The run itself reads the brief its claim renders.
|
|
func (h *Handler) workflowHandoffBrief(ctx context.Context, issue db.Issue, hand *workflowHandoff) string {
|
|
if hand == nil || !issue.AssigneeType.Valid {
|
|
return ""
|
|
}
|
|
if t := issue.AssigneeType.String; t != "agent" && t != "squad" {
|
|
return ""
|
|
}
|
|
in := h.workflowBriefInput(ctx, issue, hand.def, hand.project)
|
|
in.StatusKey = hand.step.StatusKey
|
|
return issueworkflow.Brief(in)
|
|
}
|
|
|
|
// workflowBriefInput describes an issue at its current status under its
|
|
// project's workflow, for the assignee's brief.
|
|
func (h *Handler) workflowBriefInput(ctx context.Context, issue db.Issue, def issueworkflow.Definition, project db.Project) issueworkflow.BriefInput {
|
|
resolver := issuestatus.NewResolver(issue.WorkspaceID)
|
|
return issueworkflow.BriefInput{
|
|
Workflow: def,
|
|
ProjectName: project.Title,
|
|
StatusKey: issue.Status,
|
|
IssueIdentifier: h.getIssuePrefix(ctx, issue.WorkspaceID) + "-" + strconv.Itoa(int(issue.Number)),
|
|
StatusName: func(key string) string {
|
|
return resolver.Name(ctx, h.Queries, key)
|
|
},
|
|
HandlerName: func(step issueworkflow.Step) string {
|
|
if !step.HandsOff() {
|
|
return ""
|
|
}
|
|
return h.stepHandlerName(ctx, issue, project, step)
|
|
},
|
|
}
|
|
}
|
|
|
|
// actorDisplayName names an agent, squad or member for a brief. It falls back
|
|
// to the actor type so a lookup failure never blocks a handoff.
|
|
func (h *Handler) actorDisplayName(ctx context.Context, wsUUID pgtype.UUID, actorType string, id pgtype.UUID) string {
|
|
switch actorType {
|
|
case "agent":
|
|
if agent, err := h.Queries.GetAgentInWorkspace(ctx, db.GetAgentInWorkspaceParams{ID: id, WorkspaceID: wsUUID}); err == nil {
|
|
return agent.Name
|
|
}
|
|
case "squad":
|
|
if squad, err := h.Queries.GetSquadInWorkspace(ctx, db.GetSquadInWorkspaceParams{ID: id, WorkspaceID: wsUUID}); err == nil {
|
|
return squad.Name
|
|
}
|
|
case "member":
|
|
if user, err := h.Queries.GetUser(ctx, id); err == nil {
|
|
return user.Name
|
|
}
|
|
}
|
|
return actorType
|
|
}
|
|
|
|
// stopPreviousAssigneeRuns cancels the active runs of whoever a handoff took
|
|
// the issue from — an agent's runs, or every run made on a squad's behalf —
|
|
// when the caller asked for it. A handoff back to the same handler keeps its
|
|
// run, and the agent making the handoff keeps its own: that run is the one
|
|
// making this write.
|
|
func (h *Handler) stopPreviousAssigneeRuns(ctx context.Context, prev, issue db.Issue, actorType, actorID string) {
|
|
if issue.AssigneeType == prev.AssigneeType && issue.AssigneeID == prev.AssigneeID {
|
|
return
|
|
}
|
|
tasks := h.assigneeActiveRuns(ctx, prev)
|
|
if len(tasks) == 0 {
|
|
return
|
|
}
|
|
actor := service.TaskCancellationActor{Type: actorType}
|
|
if id, err := parseUUIDString(actorID); err == nil {
|
|
actor.ID = id
|
|
actor.Name = h.actorDisplayName(ctx, issue.WorkspaceID, actorType, id)
|
|
}
|
|
for _, task := range tasks {
|
|
if actorType == "agent" && uuidToString(task.AgentID) == actorID {
|
|
continue
|
|
}
|
|
if _, err := h.TaskService.CancelTaskByUser(ctx, task.ID, actor); err != nil {
|
|
slog.Warn("workflow: cancel previous run failed",
|
|
"issue_id", uuidToString(issue.ID), "task_id", uuidToString(task.ID), "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// assigneeActiveRuns lists the active runs of an issue's assignee on it: the
|
|
// agent's own runs, or every run made on a squad's behalf. Members run
|
|
// nothing.
|
|
func (h *Handler) assigneeActiveRuns(ctx context.Context, issue db.Issue) []db.AgentTaskQueue {
|
|
if !issue.AssigneeID.Valid {
|
|
return nil
|
|
}
|
|
assigneeType := issue.AssigneeType.String
|
|
if assigneeType != "agent" && assigneeType != "squad" {
|
|
return nil
|
|
}
|
|
tasks, err := h.Queries.ListActiveTasksByIssue(ctx, issue.ID)
|
|
if err != nil {
|
|
slog.Warn("workflow: list active tasks failed", "issue_id", uuidToString(issue.ID), "error", err)
|
|
return nil
|
|
}
|
|
var out []db.AgentTaskQueue
|
|
for _, task := range tasks {
|
|
if (assigneeType == "agent" && task.AgentID == issue.AssigneeID) ||
|
|
(assigneeType == "squad" && task.SquadID == issue.AssigneeID) {
|
|
out = append(out, task)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// claimProjectWorkflow renders the issue's project workflow for a task claim,
|
|
// or nil when there is nothing to say beyond the runtime's own status rules:
|
|
// no project, or Default with no handoffs. The issue's assignee gets the
|
|
// step's instructions and commands; any other agent working on the issue
|
|
// gets the observer brief, so it does not move a step it does not own.
|
|
//
|
|
// legacyRuntimeRules is true for a daemon that does not advertise
|
|
// protocol.DaemonCapabilityWorkflowStatusRulesV1; see BriefInput.
|
|
func (h *Handler) claimProjectWorkflow(ctx context.Context, issue db.Issue, task *db.AgentTaskQueue, legacyRuntimeRules bool) *TaskProjectWorkflowData {
|
|
def, project, err := issueworkflow.ForProject(ctx, h.Queries, issue.WorkspaceID, issue.ProjectID)
|
|
if err != nil {
|
|
slog.Warn("task claim: failed to load project workflow for brief injection",
|
|
"issue_id", uuidToString(issue.ID), "error", err)
|
|
return nil
|
|
}
|
|
if def == nil || (def.IsDefault && !def.HandsOffAnywhere()) {
|
|
return nil
|
|
}
|
|
in := h.workflowBriefInput(ctx, issue, *def, project)
|
|
in.LegacyRuntimeRules = legacyRuntimeRules
|
|
if !taskRunsForAssignee(issue, task) {
|
|
in.Observer = true
|
|
if issue.AssigneeType.Valid && issue.AssigneeID.Valid {
|
|
in.AssigneeName = h.actorDisplayName(ctx, issue.WorkspaceID, issue.AssigneeType.String, issue.AssigneeID)
|
|
}
|
|
}
|
|
return &TaskProjectWorkflowData{Brief: issueworkflow.Brief(in)}
|
|
}
|
|
|
|
// taskRunsForAssignee reports whether a run works on the issue as its
|
|
// assignee: the assigned agent's own run, or a run made for the assigned
|
|
// squad.
|
|
func taskRunsForAssignee(issue db.Issue, task *db.AgentTaskQueue) bool {
|
|
if task == nil || !issue.AssigneeID.Valid {
|
|
return false
|
|
}
|
|
switch issue.AssigneeType.String {
|
|
case "agent":
|
|
return task.AgentID == issue.AssigneeID
|
|
case "squad":
|
|
return task.SquadID == issue.AssigneeID
|
|
}
|
|
return false
|
|
}
|
|
|
|
// stepHandlerName describes who a step hands the issue to, for briefs.
|
|
func (h *Handler) stepHandlerName(ctx context.Context, issue db.Issue, project db.Project, step issueworkflow.Step) string {
|
|
t, id, ok := issueworkflow.ResolveHandler(step, project, issue.CreatorType, issue.CreatorID)
|
|
if !ok {
|
|
switch step.Handler.Type {
|
|
case issueworkflow.HandlerProjectLead:
|
|
return "the project lead (none set)"
|
|
case issueworkflow.HandlerCreator:
|
|
return "the issue creator"
|
|
}
|
|
return ""
|
|
}
|
|
name := h.actorDisplayName(ctx, issue.WorkspaceID, t, id)
|
|
switch step.Handler.Type {
|
|
case issueworkflow.HandlerProjectLead:
|
|
return name + " (project lead)"
|
|
case issueworkflow.HandlerCreator:
|
|
return name + " (issue creator)"
|
|
}
|
|
return name
|
|
}
|