refactor(*): 平台架构 - 拆分后端领域模块与画布前端组件并补充测试

This commit is contained in:
ddcat666 committed 2026-09-30 10:01:39 +08:00
1 parent e0a2697c60
commit 7f5d87ef1a
186 files changed
+33727 -29905

No files matched your search

@@ -0,0 +1,705 @@
// 画布 Agent 的媒体生成:提交任务、等待结果、处理审批。
//
// 涉及扣费的 generate_media 必须先经用户审批(DecideCloudAgentApproval),
// 审批通过后才真正创建任务;用户直接在节点上生成时审批会被关闭为 superseded_by_node。
package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"math"
"time"
"gorm.io/gorm"
"infinite-canvas/backend/internal/kernel"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
)
// image_layer_split deliberately reuses the canonical media admission path.
// Keeping the alias at this boundary preserves one approval, billing and
// write-back implementation while exposing a task-specific Agent affordance.
func cloudAgentMediaCall(call cloudAgentCall) cloudAgentCall {
if call.Function.Name != "image_layer_split" {
return call
}
var args map[string]any
if err := json.Unmarshal([]byte(call.Function.Arguments), &args); err == nil {
args["mode"] = "image"
if raw, err := json.Marshal(args); err == nil {
call.Function.Arguments = string(raw)
}
}
return call
}
func (s *Service) enqueueCloudAgentTask(run *model.CloudAgentExecution, state *cloudAgentRuntime, req CreateTaskRequest, media *cloudAgentMediaPlan) error {
orders, err := s.repo.BillingOrdersByTaskIDs(run.UserID, state.TaskIDs)
if err != nil {
return err
}
remaining := int64(math.Floor(state.Request.Budget.MaxCredits * float64(CreditScale)))
for _, order := range orders {
remaining -= order.AmountMicrocredits
}
if remaining < 0 {
return s.failCloudAgent(run, state, "Agent 累计预算已耗尽")
}
var prepared *cloudAgentPreparedMedia
approvalID, generationID := "", ""
if media != nil {
prepared = media.Prepared
if prepared == nil && state.Approval != nil {
prepared = state.Approval.Prepared
if state.Approval != nil {
approvalID = state.Approval.ID
}
}
if prepared == nil {
return s.cloudAgentMediaError(run, state, "admission", false, false, creationConflict("缺少已批准的生成准备态,请重新申请审批;未提交任务"))
}
generationID = prepared.GenerationID
refs, refErr := cloudAgentPreparedReferences(s.repo, run.UserID, prepared)
if refErr != nil {
return s.cloudAgentMediaError(run, state, "admission", false, false, refErr)
}
if req.Input == nil {
req.Input = map[string]any{}
}
for _, field := range []string{"referenceImages", "referenceVideos", "referenceAudios"} {
delete(req.Input, field)
}
for field, value := range refs {
req.Input[field] = value
}
}
prepare := &creationTaskPreparation{}
req.admission = &taskAdmission{ID: cloudAgentID(run.UserID, fmt.Sprintf("%s:task:%d", run.ID, len(state.TaskIDs))), MaxCharge: remaining, AgentRunID: run.ID, GenerationID: generationID, ApprovalID: approvalID}
req.creationPrepare = prepare
task, err := s.CreateTask(run.UserID, req)
if err != nil {
if media != nil {
return s.cloudAgentMediaError(run, state, "admission", false, false, err)
}
return s.failCloudAgent(run, state, cloudAgentSafeToolError(err))
}
var input map[string]any
if err = json.Unmarshal([]byte(task.InputJSON), &input); err != nil {
if media != nil {
return s.cloudAgentMediaError(run, state, "admission", false, false, err)
}
return err
}
if media != nil {
requested, _ := req.Input["config"].(map[string]any)
resolved, _ := input["config"].(map[string]any)
if err := validateCloudAgentResolvedMediaOptions(requested, resolved); err != nil {
return s.cloudAgentMediaError(run, state, "admission", false, false, err)
}
}
if err = s.protectTaskSecrets(input); err != nil {
if media != nil {
return s.cloudAgentMediaError(run, state, "admission", false, false, err)
}
return err
}
raw, err := json.Marshal(input)
if err != nil {
if media != nil {
return s.cloudAgentMediaError(run, state, "admission", false, false, err)
}
return err
}
task.InputJSON = string(raw)
// 压力读数只对"模型调用"这一步有意义:媒体任务发的是另一份请求(另一套信封),
// 把读数挂到那份信封上会误导消费方。
var contextPressure *cloudAgentContextPressure
var requestCanonical canonicalAgentRequest
if media == nil {
if req.Operation != cloudAgentContextCompactionOperation {
canonical, ok := canonicalAgentRequestFromInput(input)
if !ok {
return fmt.Errorf("Agent 模型任务缺少可计量的请求信封")
}
requestCanonical = canonical
value := s.cloudAgentContextPressure(canonical, state.Request.Prompt, state.Request)
contextPressure = &value
}
}
if prepare.Order != nil {
task.BillingOrderID = prepare.Order.ID
}
policy, err := s.RuntimePolicy()
if err != nil {
if media != nil {
return s.cloudAgentMediaError(run, state, "admission", false, false, err)
}
return err
}
s.storageMu.Lock()
defer s.storageMu.Unlock()
err = s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, repo *repository.Repository) error {
if media != nil {
canvas, err := repo.CanvasProjectForUser(run.UserID, state.Request.CanvasID)
if err != nil {
return err
}
doc, err := creationDocument(canvas.PayloadJSON)
if err != nil {
return err
}
if err := validateCloudAgentPreparedAdmission(repo, run.UserID, prepared, task, prepare.Order, doc, media.Args); err != nil {
return err
}
if err := createCloudAgentMediaNode(repo, run.UserID, state.Request.CanvasID, media, task, policy, cloudAgentCanvasEventRecorder(run.ID, state)); err != nil {
return err
}
}
if err := createTaskWithStorageQuotaRepository(repo, task, prepare.Order, policy); err != nil {
return err
}
if media != nil && prepared != nil {
if approvalID != "" {
if err := repo.TransferCloudAgentResourceLeases(run.UserID, approvalID, "task:"+task.ID, task.ID, prepared.Quote.ExpiresAt); err != nil {
return err
}
} else {
// Auto execution has no approval owner to transfer from. Attach the
// resource lease directly to the billed task before it can run.
ids := make([]string, 0, len(prepared.ResourceSignatures))
for id := range prepared.ResourceSignatures {
ids = append(ids, id)
}
if err := repo.UpsertCloudAgentResourceLeases(run.UserID, run.ID, "task:"+task.ID, ids, prepared.Quote.ExpiresAt); err != nil {
return err
}
}
}
if media != nil {
// The prepared auto admission is single-use. Once the billed task and
// draft node are committed, a later retry must observe the task state,
// not reuse the old quote or create a second task.
state.AutoPreparedMedia = nil
state.AutoPreparedCallHash = ""
}
state.TaskIDs = append(state.TaskIDs, task.ID)
if media != nil {
state.MediaTaskID = task.ID
state.Generations++
state.VideoSeconds += media.Args.Duration
toolName := "generate_media"
if state.CallIndex >= 0 && state.CallIndex < len(state.Calls) && state.Calls[state.CallIndex].Function.Name != "" {
toolName = state.Calls[state.CallIndex].Function.Name
}
state.event(run.ID, "generation_task_created", map[string]any{"toolName": toolName, "taskId": task.ID, "nodeId": media.Args.NodeID, "title": media.Args.Title, "mode": media.Args.Mode, "canvasId": state.Request.CanvasID, "referenceNodeIds": media.Args.ReferenceNodeIDs, "text": "媒体节点与引用连线已创建,生成任务已提交"})
} else {
state.ActiveTaskID = task.ID
// 压缩调用不是本轮的一步:压完还要用压缩后的上下文继续步进,步数不该被它占掉。
if state.ContextCompaction != nil && req.Operation == cloudAgentContextCompactionOperation {
state.ContextCompaction.Status = "running"
} else {
if contextPressure != nil {
config, _ := input["config"].(map[string]any)
model, channelID := stringValue(config["model"]), stringValue(config["channelId"])
signature := cloudAgentRequestSignature(state, requestCanonical, channelID, model)
cloudAgentExpireTokenAnchorForRequest(run.ID, state, contextPressure.ContextWindowTokens, signature, model, channelID)
cloudAgentRecordMemorySegment(&state.Policy, requestCanonical.SystemPrompt)
payload := cloudAgentContextPressurePayload(*contextPressure, state, requestCanonical)
payload["requestId"] = task.ID
cloudAgentNoteContextWindowResolved(run.ID, state, *contextPressure)
state.event(run.ID, "context_pressure", payload)
state.LastStepTaskID = task.ID
state.LastStepOperation = req.Operation
state.LastStepEstimate = contextPressure.EstimatedInputTokens
state.LastStepSourceBytes = contextPressure.SourceBytes
state.LastStepSignature = signature
state.LastStepModel = model
state.LastStepChannelID = channelID
state.LastStepWindowTokens = contextPressure.ContextWindowTokens
}
state.Step++
}
}
return cloudAgentSave(current, state)
})
if err != nil && media != nil {
// Rollback may have happened after checkpoint edits. Reload before recording
// a tool failure; never turn a stale worker revision into a second result.
latest, readErr := s.repo.CloudAgent(run.UserID, run.ID)
if readErr != nil || latest.Revision != run.Revision {
return err
}
fresh, decodeErr := cloudAgentDecode(latest)
if decodeErr != nil {
return decodeErr
}
return s.cloudAgentMediaError(latest, &fresh, "admission", false, false, err)
}
return err
}
func (s *Service) cloudAgentMediaError(run *model.CloudAgentExecution, state *cloudAgentRuntime, phase string, submitted, terminal bool, err error) error {
return s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error {
if state.CallIndex < 0 || state.CallIndex >= len(state.Calls) {
current.Status = "failed"
state.event(run.ID, "run_failed", map[string]any{"text": "Agent 媒体调用状态无效,本轮已停止"})
return cloudAgentSave(current, state)
}
if phase == "admission" && !submitted {
err = cloudAgentWrapMediaAdmissionError(err)
}
toolName := state.Calls[state.CallIndex].Function.Name
cloudAgentRecordToolResult(current, state, state.Calls[state.CallIndex], map[string]any{"phase": phase, "taskSubmitted": submitted}, err)
// Any admission failure advances the call into the repair path. Do not let
// a prepared quote from the failed attempt leak into the corrected call.
state.AutoPreparedMedia = nil
state.AutoPreparedCallHash = ""
if submitted {
state.MediaTaskID = ""
}
// Only explicitly typed argument errors and a stale canvas snapshot may
// continue into another model turn. Every other pre-submission media
// failure is a real admission boundary failure; continuing would invite
// the model to submit unverified variants of a billed write.
continueAfterAdmissionError := false
if phase == "admission" && !submitted {
var argumentErr *cloudAgentArgumentError
continueAfterAdmissionError = errors.As(err, &argumentErr)
var admissionErr *cloudAgentMediaAdmissionError
if errors.As(err, &admissionErr) && admissionErr.Reason == "snapshot_conflict" {
continueAfterAdmissionError = true
}
}
if terminal || (phase == "admission" && !submitted && !continueAfterAdmissionError) {
current.Status = "failed"
message := "媒体生成准入失败,本轮已停止;请检查模型、能力和预算后由用户明确重试"
reason := "tool_admission_failed"
if terminal {
message = "媒体任务已提交,但结果处理失败;任务不会自动重试"
reason = "media_task_failed"
}
current.FailureMessage = truncateRunes(message, 1000)
cloudAgentDropInterjections(run.ID, "本轮已结束:"+truncateRunes(message, 120), state)
state.event(run.ID, "run_failed", map[string]any{"text": message, "reason": reason, "toolName": toolName})
}
return cloudAgentSave(current, state)
})
}
func (s *Service) advanceCloudAgentMedia(run *model.CloudAgentExecution, state *cloudAgentRuntime, call cloudAgentCall) error {
if state.MediaTaskID != "" {
task, err := s.repo.TaskForUser(run.UserID, state.MediaTaskID)
if errors.Is(err, gorm.ErrRecordNotFound) {
return s.cloudAgentMediaError(run, state, "completion", true, true, BadAuthRequest("媒体任务不存在或已失去归属,结果未回写画布"))
}
if err != nil {
return err
}
if task.Status == model.TaskStatusQueued || task.Status == model.TaskStatusRunning {
return nil
}
policy, err := s.RuntimePolicy()
if err != nil {
return err
}
var target struct {
NodeID string `json:"nodeId"`
}
if err := json.Unmarshal([]byte(call.Function.Arguments), &target); err != nil || validateCloudAgentID(target.NodeID, "生成节点ID", 80) != nil {
return s.cloudAgentMediaError(run, state, "completion", true, true, BadAuthRequest("已提交媒体任务的目标节点记录无效,未回写画布"))
}
s.storageMu.Lock()
defer s.storageMu.Unlock()
return s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, repo *repository.Repository) error {
before, readErr := repo.CanvasProjectForUser(run.UserID, state.Request.CanvasID)
nodeID, writeErr := completeCloudAgentMediaNode(repo, run.UserID, state.Request.CanvasID, target.NodeID, task, policy)
if writeErr != nil {
var appErr *AppError
if !errors.Is(writeErr, gorm.ErrRecordNotFound) && !(errors.As(writeErr, &appErr) && (appErr.Status == 400 || appErr.Status == 409)) {
return writeErr // Retry persistence, never resubmit the billed generation.
}
}
// A deleted canvas/node must still checkpoint the tool failure. Only
// a matched node can have changed and require an atomic canvas delta.
if nodeID != "" {
if readErr != nil {
return readErr
}
if err := emitCloudAgentCanvasChange(repo, run.ID, state, cloudAgentMutationInput{UserID: run.UserID, CanvasID: state.Request.CanvasID, BeforeJSON: before.PayloadJSON, Operation: "generate_media_complete"}); err != nil {
return err
}
}
result := map[string]any{"phase": "completion", "taskSubmitted": true, "taskId": task.ID, "nodeId": nodeID, "targetNodeId": target.NodeID, "status": task.Status}
var toolErr error
generationMessage := ""
writebackMessage, writebackReason := "", ""
if task.Status != model.TaskStatusSucceeded {
generationMessage = truncateRunes(cloudAgentSafeMediaTaskError(task), 90)
if !cloudAgentSafeUserMessage(generationMessage) {
generationMessage = "媒体任务未成功"
}
result["generationError"] = generationMessage
toolErr = BadAuthRequest("媒体任务未成功:" + generationMessage + ";请在任务中心查看任务详情,不会自动重试收费生成")
result["summary"] = "媒体任务未成功;任务记录保留在任务中心"
}
if writeErr != nil {
writebackMessage = truncateRunes(cloudAgentSafeToolError(writeErr), 60)
writebackReason = "canvas_writeback_failed"
var writeback *cloudAgentMediaWritebackError
if errors.As(writeErr, &writeback) {
writebackReason = writeback.reason
}
result["writebackError"], result["writebackReason"] = writebackMessage, writebackReason
message := "媒体任务已成功,但画布回写未完成:" + writebackMessage
if generationMessage != "" {
message = "媒体任务未成功:" + generationMessage + ";任务状态也未回写画布:" + writebackMessage
}
toolErr = BadAuthRequest(message + "。请在任务中心查看详情,不会自动重试收费生成")
result["summary"] = "画布回写未完成;任务记录保留在任务中心"
}
if task.Status == model.TaskStatusSucceeded && writeErr == nil {
// Completion writes the generated asset into the canvas. Invalidate
// the same-run snapshot cache just like ordinary canvas writes.
cloudAgentInvalidateReadCache(state)
result["summary"] = "生成结果已回写画布节点"
}
if writeErr != nil {
recordTaskWritebackDiagnostic(task, target.NodeID, "failed", writebackReason, writeErr)
} else {
recordTaskWritebackDiagnostic(task, target.NodeID, "succeeded", "", nil)
}
if err := repo.UpdateTaskExecutionDiagnostic(run.UserID, task.ID, task.ExecutionDiagnosticJSON); err != nil {
return err
}
// A billed generation failure is a tool result, not a dead run: the
// model must still be able to tell the user what happened. Only a
// canvas write that cannot land is terminal for the whole turn.
if writeErr != nil {
if current.Status != "cancelled" {
current.Status = "failed"
}
current.FailureMessage = cloudAgentSafeToolError(toolErr)
state.event(run.ID, "run_failed", map[string]any{
"text": current.FailureMessage, "taskId": task.ID, "nodeId": target.NodeID,
"reason": result["writebackReason"], "generationStatus": task.Status,
"generationError": generationMessage, "writebackError": result["writebackError"],
})
}
cloudAgentToolResult(run.ID, state, call, result, toolErr)
state.MediaTaskID = ""
return cloudAgentSave(current, state)
})
}
req, plan, err := s.prepareCloudAgentMedia(run, state, call)
if err != nil {
return s.cloudAgentMediaError(run, state, "admission", false, false, err)
}
return s.enqueueCloudAgentTask(run, state, req, plan)
}
func (s *Service) DecideCloudAgentApproval(userID, id, approvalID, decision, reason string, mediaSettings ...*CloudAgentMediaSettings) error {
// Resource leases are a protection mechanism only. Expiry cleanup is safe to
// run on the control-plane path and never changes the approval decision.
if err := s.repo.ReleaseExpiredCloudAgentResourceLeases(time.Now().UTC()); err != nil {
return err
}
var settings *CloudAgentMediaSettings
if len(mediaSettings) > 1 {
return BadAuthRequest("只能提交一组生成参数")
}
if len(mediaSettings) == 1 {
settings = mediaSettings[0]
}
if settings != nil && decision != "approve" {
return BadAuthRequest("仅批准生成时可修改生成参数")
}
if decision != "approve" && decision != "reject" {
return BadAuthRequest("无效审批决定")
}
if len(reason) > 2000 {
return BadAuthRequest("审批理由过长")
}
if _, _, err := s.cloudAgentTask(userID, id); err != nil {
return err
}
run, err := s.repo.CloudAgent(userID, id)
if err != nil {
return err
}
state, err := cloudAgentDecodeForExecution(run)
if err != nil {
return err
}
if previous, ok := state.Decisions[approvalID]; ok {
if previous == decision && (settings == nil || state.DecisionSettings[approvalID] == creationHash(settings)) {
return nil
}
return cloudAgentDecisionConflict(previous)
}
if run.Status != "waiting_approval" || state.Approval == nil || state.Approval.ID != approvalID {
return creationConflict("审批不存在或已过期")
}
// 只在记录审批决定的这次写入期间持锁:随后的工具执行(advanceCloudAgentTool)
// 会自己获取 storageMu,持锁到函数返回会造成自锁。旧 Pi 会话退出前仍可能
// 写入最后一个事件,因此审批决定采用重读后 CAS 重试。
for attempt := 0; attempt < 8; attempt++ {
if attempt > 0 {
time.Sleep(time.Duration(attempt) * 20 * time.Millisecond)
run, err = s.repo.CloudAgent(userID, id)
if err != nil {
return err
}
state, err = cloudAgentDecodeForExecution(run)
if err != nil {
return err
}
if previous, ok := state.Decisions[approvalID]; ok {
if previous == decision && (settings == nil || state.DecisionSettings[approvalID] == creationHash(settings)) {
return nil
}
return cloudAgentDecisionConflict(previous)
}
if run.Status != "waiting_approval" || state.Approval == nil || state.Approval.ID != approvalID {
return creationConflict("审批不存在或已过期")
}
}
s.storageMu.Lock()
err = s.repo.MutateCloudAgent(userID, id, run.Revision, func(current *model.CloudAgentExecution, repo *repository.Repository) error {
if settings != nil {
if err := s.updateCloudAgentMediaApproval(repo, run, &state, *settings); err != nil {
return err
}
}
state.Approval.Decision = decision
state.Approval.Reason = reason
state.Decisions[approvalID] = decision
if settings != nil {
if state.DecisionSettings == nil {
state.DecisionSettings = map[string]string{}
}
state.DecisionSettings[approvalID] = creationHash(settings)
}
if decision == "approve" && state.Approval.Prepared != nil {
if state.DecisionPreparedHashes == nil {
state.DecisionPreparedHashes = map[string]string{}
}
state.DecisionPreparedHashes[approvalID] = state.Approval.Prepared.Hash
}
if decision == "reject" {
// Rejection is a user control-plane decision, not a failed tool
// invocation. Make it terminal before the scheduler can advance the
// pending call; no tool result, canvas mutation, generation task or
// follow-up model request may be produced from this decision.
current.Status = "rejected"
current.FailureMessage = ""
state.Approval = nil
state.event(id, "approval_decided", map[string]any{
"approvalId": approvalID,
"decision": decision,
"reason": reason,
"text": "已拒绝本次生成,草稿节点仍保留在画布中;未提交任务、未产生扣费。你可以继续编辑后重新申请。",
})
if err := repo.ReleaseCloudAgentResourceLeases(userID, approvalID); err != nil {
return err
}
return cloudAgentSave(current, &state)
}
current.Status = "running"
payload := map[string]any{"approvalId": approvalID, "decision": decision, "arguments": json.RawMessage(state.Approval.Call.Function.Arguments), "preview": state.Approval.Preview, "modelName": state.Approval.ModelName}
if state.Approval.Prepared != nil {
payload["preparedHash"] = state.Approval.Prepared.Hash
payload["generationId"] = state.Approval.Prepared.GenerationID
}
state.event(id, "approval_decided", payload)
return cloudAgentSave(current, &state)
})
s.storageMu.Unlock()
if !errors.Is(err, repository.ErrCreationConflict) {
break
}
}
if err != nil || decision != "approve" {
return err
}
// Approval only releases the paused tool. Execute it once in the Go business
// executor, then give the durable result back to Pi; Go never asks a model
// what to do next.
var mediaTaskID string
for attempt := 0; attempt < 8; attempt++ {
if attempt > 0 {
time.Sleep(time.Duration(attempt) * 20 * time.Millisecond)
}
latest, err := s.repo.CloudAgent(userID, id)
if err != nil {
return err
}
state, err = cloudAgentDecode(latest)
if err != nil {
return err
}
err = s.advanceCloudAgentTool(latest, &state)
if !errors.Is(err, repository.ErrCreationConflict) {
if err != nil {
return err
}
mediaTaskID = state.MediaTaskID
break
}
if attempt == 7 {
return err
}
}
if mediaTaskID == "" {
return s.resumeCloudAgentAfterApproval(userID, id, &state)
}
// 媒体生成可能要几分钟:审批请求立即返回,等待与回写在后台完成后再恢复运行。
s.startApprovedCloudAgentMediaWaiter(userID, id, mediaTaskID)
return nil
}
func (s *Service) finishApprovedCloudAgentMedia(ctx context.Context, userID, id, mediaTaskID string) error {
mediaTask, err := s.waitCloudAgentTask(ctx, mediaTaskID)
if err != nil && ctx.Err() != nil {
// 服务停机或等待被取消:不能继续回写和恢复运行时,否则正在排空的进程会重新拉起 Agent。
// MediaTaskID 仍保留在运行状态里,重启后 recoverCloudAgentPiRunners 会重新挂上等待。
return ctx.Err()
}
if mediaTask == nil {
// 失败的任务也要回写(记录工具失败并释放 MediaTaskID);只有读不到任务才中止。
if mediaTask, err = s.repo.Task(mediaTaskID); err != nil {
return err
}
}
if err = s.settleCloudAgentMedia(userID, id); err != nil {
return err
}
latest, err := s.repo.CloudAgent(userID, id)
if err != nil {
return err
}
state, err := cloudAgentDecode(latest)
if err != nil {
return err
}
if mediaTask.Status != model.TaskStatusSucceeded {
state.PiResumePrompt = "用户已批准该操作,但媒体任务未成功完成。请根据工具结果告知用户,不要重复提交该操作。"
}
return s.resumeCloudAgentAfterApproval(userID, id, &state)
}
func (s *Service) resumeCloudAgentAfterApproval(userID, id string, state *cloudAgentRuntime) error {
if state.PiResumePrompt == "" {
state.PiResumePrompt = "用户已批准刚才等待审批的操作。业务执行器已执行一次;请根据最新工具结果继续,不要重复调用该操作。"
}
if err := s.saveCloudAgentPiResumePrompt(userID, id, state.PiResumePrompt); err != nil {
return err
}
s.resumeCloudAgentPi(userID, id)
return nil
}
// saveCloudAgentPiResumePrompt 只合并恢复提示词,不覆盖其它并发写入的字段。
func (s *Service) saveCloudAgentPiResumePrompt(userID, id, prompt string) error {
for attempt := 0; attempt < 8; attempt++ {
run, err := s.repo.CloudAgent(userID, id)
if err != nil {
return err
}
err = s.repo.MutateCloudAgent(userID, id, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error {
fresh, err := cloudAgentDecode(current)
if err != nil {
return err
}
fresh.PiResumePrompt = prompt
return cloudAgentSave(current, &fresh)
})
if !errors.Is(err, repository.ErrCreationConflict) {
return err
}
}
return repository.ErrCreationConflict
}
func (s *Service) CancelCloudAgent(ctx context.Context, userID, id string) error {
s.stopCloudAgentPi(id)
// Cancellation is a control-plane operation. It must remain available even
// when the user-facing runtime blob is damaged, so authenticate/authorize
// from the task row first instead of calling CloudAgentRun up front.
task, err := s.repo.TaskForUser(userID, id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return kernel.NotFound("Agent 运行不存在")
}
return err
}
if task.Operation != cloudAgentOperation {
return kernel.NotFound("Agent 运行不存在")
}
run, err := s.repo.CloudAgent(userID, id)
if errors.Is(err, gorm.ErrRecordNotFound) {
// Legacy root tasks may not have an execution row yet. The normal read
// path validates the signed/deterministic task identity before creating it.
if _, err = s.CloudAgentRun(userID, id); err != nil {
return err
}
run, err = s.repo.CloudAgent(userID, id)
}
if err != nil {
return err
}
if run.Status == "completed" || (run.Status == "failed" && !run.CleanupPending) {
return nil
}
// 取消是用户操作,不能因为运行时正在并发写事件就失败:冲突时重读最新 revision 重试。
for attempt := 0; run.Status != "failed" && attempt < 8; attempt++ {
if attempt > 0 {
time.Sleep(time.Duration(attempt) * 20 * time.Millisecond)
if run, err = s.repo.CloudAgent(userID, id); err != nil {
return err
}
if run.Status == "completed" || (run.Status == "failed" && !run.CleanupPending) {
return nil
}
}
// Persist intent independently of the transcript. Retrying also repairs
// legacy cancelled rows that crashed before cancelling their children.
state, decodeErr := cloudAgentDecode(run)
err = s.repo.MutateCloudAgent(userID, id, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error {
firstCancellation := current.Status != "cancelled"
current.Status = "cancelled"
current.CleanupPending = true
if decodeErr == nil {
current.CanvasID, current.ActiveTaskID, current.MediaTaskID = state.Request.CanvasID, state.ActiveTaskID, state.MediaTaskID
if firstCancellation {
state.event(id, "run_cancelled", map[string]any{"source": "user_request", "activeTaskId": state.ActiveTaskID, "mediaTaskId": state.MediaTaskID, "text": "用户取消接口已接收请求,正在取消关联任务"})
if saveErr := cloudAgentSave(current, &state); saveErr != nil {
// Cancellation must still work if the transcript is oversized.
log.Printf("[cloud-agent] cancellation event unavailable for run %s: checkpoint rejected", id)
}
}
} else {
log.Printf("[cloud-agent] cancellation event unavailable for run %s: invalid transcript", id)
}
return nil
})
if errors.Is(err, repository.ErrCreationConflict) {
continue
}
if err != nil {
return err
}
break
}
if errors.Is(err, repository.ErrCreationConflict) {
return err
}
latest, err := s.repo.CloudAgent(userID, id)
if err != nil {
return err
}
return s.finishCloudAgentCleanup(ctx, latest)
}