fix(agent): let stopped threads start a new task and scope thread state to the latest task

Replace the stored cancel func with a handle so a stale deferred cleanup
can't delete a newer task's cancellation, and clear a thread's cancelled
flag when it submits fresh work instead of refusing to restart. Thread
progress/finished state now derives from the latest user task in the
thread rather than the whole history. Adds coverage for continuing a
stopped thread and for sanitized generation-failure messages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-08 12:06:50 +08:00
parent 7679854b5d
commit d13a4c9a48
4 changed files with 151 additions and 23 deletions
+46 -12
View File
@@ -72,6 +72,10 @@ type DesignService struct {
now func() time.Time
}
type agentTaskCancelHandle struct {
cancel context.CancelFunc
}
type GenerateResult struct {
Project design.Project
Message design.Message
@@ -537,14 +541,7 @@ func (s *DesignService) AgentChat(ctx context.Context, projectID string, req des
if err != nil {
return design.AgentThread{}, err
}
if threadIsCancelled(project.Messages, threadID) || s.isAgentTaskCancelled(project.ID, threadID) {
return design.AgentThread{
ThreadID: threadID,
Status: "cancelled",
ThreadIDType: threadIDType,
Project: project,
}, nil
}
s.clearAgentTaskCancellation(project.ID, threadID)
now := s.now()
actionID := newID()
@@ -1700,7 +1697,7 @@ func (s *DesignService) isUserCancelledGeneration(ctx context.Context, projectID
if getErr != nil {
return false
}
return threadIsCancelled(project.Messages, threadID)
return threadHasCancelledMessage(project.Messages, threadID)
}
func detachedUserContext(ctx context.Context) context.Context {
@@ -1737,13 +1734,16 @@ func (s *DesignService) withAgentTaskCancellation(ctx context.Context, projectID
return ctx, func() {}
}
taskCtx, cancel := context.WithCancel(ctx)
s.agentTaskCancels.Store(key, context.CancelFunc(cancel))
handle := &agentTaskCancelHandle{cancel: cancel}
s.agentTaskCancels.Store(key, handle)
if s.isAgentTaskCancelled(projectID, threadID) {
cancel()
}
return taskCtx, func() {
cancel()
s.agentTaskCancels.Delete(key)
if current, ok := s.agentTaskCancels.Load(key); ok && current == handle {
s.agentTaskCancels.Delete(key)
}
}
}
@@ -1754,7 +1754,9 @@ func (s *DesignService) cancelAgentTask(projectID string, threadID string) {
}
s.cancelledAgentTasks.Store(key, s.now())
if cancel, ok := s.agentTaskCancels.Load(key); ok {
if cancelFunc, ok := cancel.(context.CancelFunc); ok {
if handle, ok := cancel.(*agentTaskCancelHandle); ok && handle != nil {
handle.cancel()
} else if cancelFunc, ok := cancel.(context.CancelFunc); ok {
cancelFunc()
}
}
@@ -1763,6 +1765,14 @@ func (s *DesignService) cancelAgentTask(projectID string, threadID string) {
})
}
func (s *DesignService) clearAgentTaskCancellation(projectID string, threadID string) {
key := agentTaskKey(projectID, threadID)
if key == "" {
return
}
s.cancelledAgentTasks.Delete(key)
}
func (s *DesignService) isAgentTaskCancelled(projectID string, threadID string) bool {
key := agentTaskKey(projectID, threadID)
if key == "" {
@@ -3122,6 +3132,30 @@ func (s *DesignService) applyIncrementalGeneratedNode(ctx context.Context, proje
}
func threadIsCancelled(messages []design.Message, threadID string) bool {
threadID = strings.TrimSpace(threadID)
if threadID == "" {
return false
}
for index := len(messages) - 1; index >= 0; index-- {
message := messages[index]
if strings.TrimSpace(message.ThreadID) != threadID {
continue
}
if strings.EqualFold(strings.TrimSpace(message.Status), "cancelled") {
return true
}
if strings.EqualFold(strings.TrimSpace(message.Role), "user") {
return false
}
switch strings.ToLower(strings.TrimSpace(message.Status)) {
case "running", "streaming", "submitted", "success", "failed":
return false
}
}
return false
}
func threadHasCancelledMessage(messages []design.Message, threadID string) bool {
threadID = strings.TrimSpace(threadID)
if threadID == "" {
return false