Files
Agent-System/internal/taskrun/finalize.go
T
weichen 76ddf4dd35 feat(ctx): surface dialogue conflict confirmation cards to triggering user
Capture trigger_user HITL expectations from dialogue adjudication (previously
dropped at the bizdev call site), project them as chat confirmation cards via
taskrun OnTerminal (exactly-once), arm the session PendingExpectation, and
handle confirm_context_fact hits through the supplement-info blackboard path.
Decline (纠正) guides the user to state the correct fact.
2026-09-25 21:01:37 +00:00

582 lines
18 KiB
Go

package taskrun
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"time"
"chat-agent-system/internal/app/uow"
"chat-agent-system/internal/artifactvault"
"chat-agent-system/internal/bizdev"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/events"
"chat-agent-system/internal/projectworkflow"
"chat-agent-system/internal/workspace"
"github.com/jackc/pgx/v5"
)
const (
StatusPending = "pending"
StatusRunning = "running"
StatusFinalizing = "finalizing"
StatusCompleted = "completed"
StatusFailed = "failed"
StatusCancelled = "cancelled"
StatusFinalizingFailed = "finalizing_failed"
StatusQualityFailed = "quality_failed"
StatusInterrupted = "interrupted"
StatusPendingAuthorConfirmation = "pending_author_confirmation"
StatusBlockedPendingGovernance = "blocked_pending_governance"
EventCompleted = "taskrun.completed"
EventCancelled = "taskrun.cancelled"
)
var (
ErrTerminalConflict = errors.New("taskrun: terminal status already set")
ErrCleanupBlocked = errors.New("taskrun: cleanup blocked until verified receipts match the frozen manifest")
)
var openStatuses = map[string]struct{}{
StatusPending: {},
StatusRunning: {},
StatusFinalizing: {},
StatusInterrupted: {},
StatusBlockedPendingGovernance: {},
}
var terminalStatuses = map[string]struct{}{
StatusCompleted: {},
StatusFailed: {},
StatusCancelled: {},
StatusQualityFailed: {},
StatusPendingAuthorConfirmation: {},
StatusFinalizingFailed: {},
}
// Commands is the unique finalize/cancel command. Server is a thin adapter.
type Commands struct {
UoW uow.UnitOfWork
Ctx *ctxmgr.ContextManager
PW *projectworkflow.Service
Fav *artifactvault.Ingest
Env string
StorageIdentity string
// OnTerminal runs after the terminal projection has been applied exactly
// once (CAS-guarded). Optional; used by the server composition to project
// extra chat artifacts such as dialogue-context conflict confirmation
// cards. Hook errors are best-effort and must not fail finalize.
OnTerminal func(ctx context.Context, record *RunRecord, result *bizdev.TaskRunResult, status string)
mu sync.Mutex
mem map[string]*FinalizeResult
}
// FinalizeResult is the durable outcome of a terminal command.
type FinalizeResult struct {
Status string
Manifest *RunManifest
Applied bool
}
type VerifiedReceipt struct {
LogicalID string
Digest string
StorageIdentity string
ArtifactUUID string
Verified bool
}
func (c *Commands) Finalize(ctx context.Context, record *RunRecord, result *bizdev.TaskRunResult) (string, error) {
return c.FinalizeWithEvents(ctx, record, result, nil, 0)
}
// FinalizeWithEvents 与 Finalize 相同,额外把 phase 事件折叠进消息 metadata.task_phase_snapshot。
func (c *Commands) FinalizeWithEvents(ctx context.Context, record *RunRecord, result *bizdev.TaskRunResult, events []StoredEvent, durationMs int64) (string, error) {
if record == nil || result == nil {
return StatusFailed, fmt.Errorf("finalize: missing record or result")
}
status := result.TerminalStatus()
if _, err := ResolveBackend(result.BackendUsed, ""); err != nil && strings.TrimSpace(result.BackendUsed) != "" {
return StatusFailed, err
}
out, err := c.apply(ctx, record, result, status, events, durationMs)
if err != nil {
if errors.Is(err, ErrTerminalConflict) && out != nil && out.Status != "" {
return out.Status, nil
}
return StatusFinalizingFailed, err
}
return out.Status, nil
}
func (c *Commands) Cancel(ctx context.Context, record *RunRecord) (string, error) {
if record == nil {
return StatusCancelled, fmt.Errorf("cancel: missing record")
}
result := &bizdev.TaskRunResult{Success: false, ErrorReason: "cancelled", TaskKey: record.TaskKey, WorkDir: record.WorkspaceID}
out, err := c.apply(ctx, record, result, StatusCancelled, nil, 0)
if err != nil {
if errors.Is(err, ErrTerminalConflict) && out != nil {
return out.Status, nil
}
return StatusCancelled, err
}
return out.Status, nil
}
func (c *Commands) apply(ctx context.Context, record *RunRecord, result *bizdev.TaskRunResult, want string, events []StoredEvent, durationMs int64) (*FinalizeResult, error) {
if c != nil && c.UoW != nil {
var out *FinalizeResult
err := c.UoW.Within(ctx, func(scope uow.TxScope) error {
ctx = uow.WithScope(ctx, scope)
got, err := c.applyLocked(ctx, record, result, want, events, durationMs)
out = got
return err
})
return out, err
}
return c.applyLocked(ctx, record, result, want, events, durationMs)
}
func (c *Commands) applyLocked(ctx context.Context, record *RunRecord, result *bizdev.TaskRunResult, want string, events []StoredEvent, durationMs int64) (*FinalizeResult, error) {
if err := rejectSensitiveText(result.MarkdownBody, result.ErrorReason); err != nil {
return nil, err
}
workspaceID := workspace.OpaqueID(result.WorkDir, record.WorkspaceID)
manifest, err := buildRunManifest(record, result, workspaceID, c.storageID(), want)
if err != nil {
return nil, err
}
existing, applied, err := c.casTerminal(ctx, record.RunID, want)
if err != nil {
return existing, err
}
if !applied {
if existing != nil {
return existing, nil
}
return &FinalizeResult{Status: want, Manifest: manifest, Applied: false}, nil
}
if err := c.persistManifest(ctx, record.RunID, manifest); err != nil {
return nil, err
}
if err := c.projectWorkflow(ctx, record, result, want); err != nil {
return nil, err
}
if err := c.finalizeAssistant(ctx, record, result, want, workspaceID, manifest, events, durationMs); err != nil {
return nil, err
}
if err := c.appendTerminalEvent(ctx, record, result, want, manifest); err != nil {
return nil, err
}
if err := c.stageControlledDoc(ctx, record, result, want, manifest); err != nil {
return nil, err
}
out := &FinalizeResult{Status: want, Manifest: manifest, Applied: true}
c.remember(record.RunID, out)
if c.OnTerminal != nil {
c.OnTerminal(ctx, record, result, want)
}
return out, nil
}
func (c *Commands) storageID() string {
if c != nil && strings.TrimSpace(c.StorageIdentity) != "" {
return c.StorageIdentity
}
env := "dev"
if c != nil && c.Env != "" {
env = c.Env
}
return "env:" + env
}
func (c *Commands) casTerminal(ctx context.Context, runID, want string) (*FinalizeResult, bool, error) {
if scope, ok := uow.ScopeFrom(ctx); ok {
tx, err := uow.AsPG(scope)
if err != nil {
return nil, false, err
}
return casPG(ctx, tx, runID, want)
}
return c.casMemory(runID, want)
}
func casPG(ctx context.Context, tx pgx.Tx, runID, want string) (*FinalizeResult, bool, error) {
var current string
err := tx.QueryRow(ctx, `SELECT status FROM task_runs WHERE run_id=$1 FOR UPDATE`, runID).Scan(&current)
if errors.Is(err, pgx.ErrNoRows) {
return nil, true, nil
}
if err != nil {
return nil, false, err
}
if current == want {
man, _ := loadManifestTX(ctx, tx, runID)
return &FinalizeResult{Status: current, Manifest: man, Applied: false}, false, nil
}
if _, open := openStatuses[current]; !open {
man, _ := loadManifestTX(ctx, tx, runID)
return &FinalizeResult{Status: current, Manifest: man, Applied: false}, false, ErrTerminalConflict
}
tag, err := tx.Exec(ctx, `
UPDATE task_runs SET status=$2, updated_at=now()
WHERE run_id=$1 AND status=$3
`, runID, want, current)
if err != nil {
return nil, false, err
}
if tag.RowsAffected() != 1 {
return nil, false, ErrTerminalConflict
}
return nil, true, nil
}
func (c *Commands) casMemory(runID, want string) (*FinalizeResult, bool, error) {
if c == nil {
return nil, true, nil
}
c.mu.Lock()
defer c.mu.Unlock()
if c.mem == nil {
c.mem = map[string]*FinalizeResult{}
}
if snap, ok := c.mem[runID]; ok {
if snap.Status == want {
cpy := *snap
cpy.Applied = false
return &cpy, false, nil
}
cpy := *snap
cpy.Applied = false
return &cpy, false, ErrTerminalConflict
}
c.mem[runID] = &FinalizeResult{Status: want, Applied: true}
return nil, true, nil
}
func (c *Commands) remember(runID string, out *FinalizeResult) {
if c == nil || out == nil || runID == "" {
return
}
c.mu.Lock()
defer c.mu.Unlock()
if c.mem == nil {
c.mem = map[string]*FinalizeResult{}
}
cpy := *out
c.mem[runID] = &cpy
}
func (c *Commands) persistManifest(ctx context.Context, runID string, man *RunManifest) error {
scope, ok := uow.ScopeFrom(ctx)
if !ok {
return nil
}
tx, err := uow.AsPG(scope)
if err != nil {
return err
}
members, err := json.Marshal(man.Members)
if err != nil {
return err
}
var existingSHA string
err = tx.QueryRow(ctx, `SELECT sha256 FROM task_run_manifests WHERE run_id=$1`, runID).Scan(&existingSHA)
if err == nil {
if strings.TrimSpace(existingSHA) != man.SHA256 {
return uow.ErrDigestConflict
}
return nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return err
}
_, err = tx.Exec(ctx, `
INSERT INTO task_run_manifests (run_id, canonical_bytes, sha256, workspace_id, storage_identity, members)
VALUES ($1,$2,$3,$4,$5,$6::jsonb)
ON CONFLICT (run_id) DO NOTHING
`, runID, man.CanonicalBytes, man.SHA256, man.WorkspaceID, man.StorageIdentity, members)
if err != nil {
// Unit tests without a durable task_runs row skip FK persistence.
if strings.Contains(strings.ToLower(err.Error()), "foreign key") {
return nil
}
return err
}
return nil
}
func loadManifestTX(ctx context.Context, tx pgx.Tx, runID string) (*RunManifest, error) {
var man RunManifest
var members []byte
err := tx.QueryRow(ctx, `
SELECT run_id, canonical_bytes, sha256, workspace_id, storage_identity, members
FROM task_run_manifests WHERE run_id=$1
`, runID).Scan(&man.RunID, &man.CanonicalBytes, &man.SHA256, &man.WorkspaceID, &man.StorageIdentity, &members)
if err != nil {
return nil, err
}
_ = json.Unmarshal(members, &man.Members)
man.SchemaVersion = ManifestSchemaVersion
return &man, nil
}
func (c *Commands) projectWorkflow(ctx context.Context, record *RunRecord, result *bizdev.TaskRunResult, status string) error {
if c == nil || c.PW == nil || record.ProjectID <= 0 || record.RunID == "" {
return nil
}
docID := ""
title := result.ArtifactTitle
if result.DocRecord != nil {
docID = result.DocRecord.DocID
}
if docID == "" && record.TaskKey != "" {
docID = fmt.Sprintf("doc_proj%d_%s", record.ProjectID, record.TaskKey)
}
if title == "" && record.TaskKey != "" {
title = record.TaskKey
}
if err := c.PW.UpdateWorkflowRun(ctx, record.RunID, status, docID, title, ""); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "not found") {
return nil
}
return err
}
return nil
}
func (c *Commands) finalizeAssistant(ctx context.Context, record *RunRecord, result *bizdev.TaskRunResult, status, workspaceID string, man *RunManifest, events []StoredEvent, durationMs int64) error {
if c == nil || c.Ctx == nil || record.AssistantMsgID <= 0 {
return nil
}
summary := result.CompletionSummary()
if status == StatusCancelled {
summary = "任务已取消。"
if result.ErrorReason != "" && result.ErrorReason != "cancelled" {
summary += "\n\n" + result.ErrorReason
}
}
meta := StripSecrets(map[string]any{
"run_id": record.RunID,
"workspace_id": workspaceID,
"task_status": status,
"task_key": record.TaskKey,
})
if record.ProjectID > 0 {
meta["project_id"] = record.ProjectID
}
if man != nil {
meta["run_manifest_sha256"] = man.SHA256
meta["storage_identity"] = man.StorageIdentity
}
if art := result.ArtifactMetadata(); art != nil {
if workspaceID != "" {
art["workspace_id"] = workspaceID
}
if result.DocRecord != nil && result.DocRecord.ContentSHA256 != "" {
art["sha256"] = result.DocRecord.ContentSHA256
}
meta["artifact"] = StripSecrets(art)
} else if result.DocRecord != nil && result.DocRecord.DocID != "" {
artifact := map[string]any{
"doc_id": result.DocRecord.DocID,
"title": result.ArtifactTitle,
"task_key": record.TaskKey,
"workspace_id": workspaceID,
}
if result.DocRecord.ContentSHA256 != "" {
artifact["sha256"] = result.DocRecord.ContentSHA256
}
if result.QualityReport != nil {
artifact["quality_passed"] = result.QualityReport.Passed
artifact["quality_score"] = result.QualityReport.Score
if len(result.QualityReport.Violations) > 0 {
artifact["quality_violations"] = result.QualityReport.Violations
}
}
if result.AwaitsAgentConfirmation() {
artifact["pending_agent_candidate_id"] = result.PendingAgentCandidateID
if result.PendingChangeReportID > 0 {
artifact["pending_change_report_id"] = result.PendingChangeReportID
}
if result.PendingRiskLevel != "" {
artifact["pending_risk_level"] = result.PendingRiskLevel
}
artifact["awaiting_author_confirmation"] = true
}
meta["artifact"] = artifact
}
title := strings.TrimSpace(result.ArtifactTitle)
if title == "" && result.DocRecord != nil {
title = strings.TrimSpace(result.DocRecord.Title)
}
if snap := BuildPhaseSnapshot(events, durationMs, status, title); snap != nil {
meta["task_phase_snapshot"] = snap
}
return c.Ctx.FinalizeAssistantTaskMessage(ctx, record.AssistantMsgID, summary, meta, status)
}
func (c *Commands) appendTerminalEvent(ctx context.Context, record *RunRecord, result *bizdev.TaskRunResult, status string, man *RunManifest) error {
if _, ok := uow.ScopeFrom(ctx); !ok {
return nil
}
name := EventCompleted
if status == StatusCancelled {
name = EventCancelled
}
body, err := json.Marshal(map[string]any{
"run_id": record.RunID,
"status": status,
"workspace_id": man.WorkspaceID,
"storage_identity": man.StorageIdentity,
"manifest_sha256": man.SHA256,
"task_key": record.TaskKey,
"doc_id": docIDOf(result),
})
if err != nil {
return err
}
envName := "dev"
if c != nil && c.Env != "" {
envName = c.Env
}
env := events.Envelope{
EventID: fmt.Sprintf("taskrun:%s:%s", record.RunID, status),
EventName: name,
SchemaVersion: events.EnvelopeSchema,
Environment: envName,
ProjectID: record.ProjectID,
AggregateType: "task_run",
AggregateID: record.RunID,
AggregateVersion: 1,
ActorRef: fmt.Sprintf("user:%d", record.UserID),
IdempotencyKey: fmt.Sprintf("finalize:%s:%s", record.RunID, status),
PayloadDigest: events.Digest(body),
Producer: "taskrun",
OccurredAt: time.Now().UTC(),
Payload: body,
}
return (events.PGSink{}).Append(ctx, env, []string{events.ConsumerNotify})
}
func (c *Commands) stageControlledDoc(ctx context.Context, record *RunRecord, result *bizdev.TaskRunResult, status string, man *RunManifest) error {
if c == nil || c.Fav == nil {
return nil
}
if status != StatusCompleted {
return nil
}
if result.DocRecord == nil || result.DocRecord.DocID == "" {
return nil
}
payload := []byte(result.MarkdownBody)
if len(payload) == 0 {
payload = man.CanonicalBytes
}
if len(payload) == 0 {
return nil
}
snapID := int64(result.DocRecord.Version)
if snapID <= 0 {
snapID = 1
}
eventID := fmt.Sprintf("taskrun:%s:%s", record.RunID, status)
return c.Fav.StageAgentCommit(ctx, record.ProjectID, result.DocRecord.DocID, eventID, snapID, payload)
}
func buildRunManifest(record *RunRecord, result *bizdev.TaskRunResult, workspaceID, storageID, status string) (*RunManifest, error) {
man := &RunManifest{
SchemaVersion: ManifestSchemaVersion,
RunID: record.RunID,
WorkspaceID: workspaceID,
StorageIdentity: storageID,
}
if result.DocRecord != nil && result.DocRecord.DocID != "" {
digest := strings.ToLower(strings.TrimSpace(result.DocRecord.ContentSHA256))
if digest == "" && result.MarkdownBody != "" {
sum := sha256Hex([]byte(result.MarkdownBody))
digest = sum
}
if digest != "" {
man.Members = append(man.Members, ManifestMember{
LogicalID: result.DocRecord.DocID,
Digest: digest,
Kind: MemberKindDocument,
})
}
}
if record.AssistantMsgID > 0 {
digest := sha256Hex([]byte(result.CompletionSummary()))
if status == StatusCancelled {
digest = sha256Hex([]byte("任务已取消。"))
}
man.Members = append(man.Members, ManifestMember{
LogicalID: fmt.Sprintf("assistant:%d", record.AssistantMsgID),
Digest: digest,
Kind: MemberKindAssistantTurn,
})
}
if err := man.Freeze(); err != nil {
return nil, err
}
return man, nil
}
func sha256Hex(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
func docIDOf(result *bizdev.TaskRunResult) string {
if result != nil && result.DocRecord != nil {
return result.DocRecord.DocID
}
return ""
}
func IsTerminalStatus(status string) bool {
_, ok := terminalStatuses[status]
return ok
}
func IsOpenStatus(status string) bool {
_, ok := openStatuses[status]
return ok
}
// AssertCleanupAllowed requires a frozen manifest and a verified receipt for every retained member.
func (m *RunManifest) AssertCleanupAllowed(receipts []VerifiedReceipt) error {
if m == nil || m.SHA256 == "" || m.StorageIdentity == "" {
return ErrCleanupBlocked
}
byLogical := map[string]VerifiedReceipt{}
for _, r := range receipts {
byLogical[r.LogicalID] = r
}
for _, mem := range m.Members {
if !memberNeedsRetention(mem.Kind) {
continue
}
r, ok := byLogical[mem.LogicalID]
if !ok || !r.Verified {
return ErrCleanupBlocked
}
if r.Digest != mem.Digest || r.StorageIdentity != m.StorageIdentity {
return ErrCleanupBlocked
}
}
return nil
}