206 lines
7.5 KiB
Go
206 lines
7.5 KiB
Go
package runmgr
|
||
|
||
import (
|
||
"agent-system-backend/internal/store"
|
||
core "agent-system-core"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
func (c *runCtx) PublishWorkCheckpoint(candidate core.WorkCheckpointCandidate) (string, error) {
|
||
if c.run.Kind != core.RunSubagent || c.subAgent != materialSubagentID || c.task.cardID == "" {
|
||
return "", fmt.Errorf("checkpoint.scope: material task required")
|
||
}
|
||
if !c.prof.Tools.ToolAllowed("publish_work_checkpoint") || c.prof.Tools.NeedsConfirm("publish_work_checkpoint") {
|
||
return "", fmt.Errorf("checkpoint.policy: denied")
|
||
}
|
||
if err := core.ValidateWorkCheckpoint(candidate); err != nil {
|
||
return "", err
|
||
}
|
||
raw, _ := json.Marshal(candidate)
|
||
sum := sha256.Sum256(raw)
|
||
digest := hex.EncodeToString(sum[:])
|
||
card, err := c.mgr.st.GetCard(c.run.Owner, c.task.cardID)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
s := card.Task
|
||
if s == nil || s.Terminal() || s.TaskID != c.task.taskID || s.AttemptID != c.task.attemptID || s.RunID != c.run.ID || s.OwnerInstanceID != c.task.instanceID || s.ExecutionEpoch != c.task.epoch {
|
||
return "", store.ErrConflict
|
||
}
|
||
witnesses := map[string]store.ArtifactWitness{}
|
||
verifiedSources := make([][]core.TaskSourceRef, len(candidate.Findings))
|
||
for i, f := range candidate.Findings {
|
||
for _, q := range f.Sources {
|
||
src, err := c.sources.validate(q)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
bound := false
|
||
for _, input := range s.Inputs {
|
||
if input.ArtifactID == src.ArtifactID && input.ContentHash == src.ContentHash && input.ParserRevision == src.ParserRevision {
|
||
bound = true
|
||
break
|
||
}
|
||
}
|
||
if !bound {
|
||
return "", fmt.Errorf("checkpoint.source: not in task input revision")
|
||
}
|
||
a, err := c.mgr.st.GetArtifact(c.run.Owner, src.ArtifactID)
|
||
if err != nil {
|
||
return "", fmt.Errorf("checkpoint.source: unavailable")
|
||
}
|
||
if !c.mgr.taskSourceAvailable(c.run.Owner, c.run.ProjectID, src) {
|
||
return "", fmt.Errorf("checkpoint.source: unavailable or changed")
|
||
}
|
||
after, err := c.mgr.st.GetArtifact(c.run.Owner, src.ArtifactID)
|
||
if err != nil || !after.UpdatedAt.Equal(a.UpdatedAt) {
|
||
return "", store.ErrConflict
|
||
}
|
||
witnesses[src.ArtifactID] = store.ArtifactWitness{ArtifactID: src.ArtifactID, UpdatedAt: a.UpdatedAt.Format(time.RFC3339Nano)}
|
||
verifiedSources[i] = append(verifiedSources[i], src)
|
||
}
|
||
}
|
||
normalized, notes, err := normalizeMaterialEvidence(candidate, c.sources)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if normalized.Focus != "" || normalized.Remaining != "" || normalized.NextStep != "" {
|
||
notes = append(notes, "任务状态按工作阶段显示中性说明")
|
||
}
|
||
switch candidate.PhaseID {
|
||
case "reading":
|
||
normalized.Focus, normalized.Remaining, normalized.NextStep = "整理材料中的事实", "仍需核对差异和缺项", "继续核对材料"
|
||
case "crosscheck":
|
||
normalized.Focus, normalized.Remaining, normalized.NextStep = "核对材料中的差异", "仍需整理结论", "结合依据形成结果"
|
||
case "summarizing":
|
||
normalized.Focus, normalized.Remaining, normalized.NextStep = "整理已核对的要点", "仍需形成完整答复", "完成整理结果"
|
||
case "draft-ready":
|
||
normalized.Focus, normalized.Remaining, normalized.NextStep = "形成整理结果", "等待完整答复", "输出最终结果"
|
||
}
|
||
if err := validateMaterialApprovalClaims(normalized); err != nil {
|
||
return "", err
|
||
}
|
||
for i, f := range normalized.Findings {
|
||
for j, q := range f.Sources {
|
||
src, err := c.sources.validate(q)
|
||
if err != nil || src.ID != verifiedSources[i][j].ID || src.ArtifactID != verifiedSources[i][j].ArtifactID {
|
||
return "", fmt.Errorf("checkpoint.source: normalized quote unavailable")
|
||
}
|
||
verifiedSources[i][j] = src
|
||
}
|
||
}
|
||
findings := make([]core.TaskFinding, 0, len(normalized.Findings))
|
||
critical := false
|
||
for i, f := range normalized.Findings {
|
||
next := core.TaskFinding{ID: canonicalFindingID(c.task.attemptID, f.FindingKey), Revision: 1, Text: f.Text, Impact: f.Impact, Kind: f.Kind, Status: "active", Assumptions: f.Assumptions, Sources: verifiedSources[i]}
|
||
if f.Corrects != "" {
|
||
critical = true
|
||
next.Corrects = canonicalFindingID(c.task.attemptID, f.Corrects)
|
||
}
|
||
if f.Kind == "material_record" {
|
||
// A quotation proves what the source says, not an unstated approval
|
||
// or model paraphrase. Keep interpretations in their own finding kinds.
|
||
quotes := make([]string, 0, len(next.Sources))
|
||
seen := map[string]bool{}
|
||
for _, src := range next.Sources {
|
||
line := src.Label + ":「" + src.Quote + "」"
|
||
if !seen[line] {
|
||
quotes = append(quotes, line)
|
||
seen[line] = true
|
||
}
|
||
}
|
||
next.Text = strings.Join(quotes, "\n")
|
||
next.Impact, next.Assumptions = "", ""
|
||
}
|
||
findings = append(findings, next)
|
||
}
|
||
if old, ok := s.CheckpointKeys[candidate.CheckpointID]; ok {
|
||
if old == digest {
|
||
return strings.Join(notes, ";"), nil
|
||
}
|
||
return "", fmt.Errorf("checkpoint.id: payload conflict")
|
||
}
|
||
if !critical && s.CheckpointCount >= 6 {
|
||
return "", fmt.Errorf("checkpoint.limit: six updates already saved; continue to final result")
|
||
}
|
||
next := *s
|
||
next.Findings = append([]core.TaskFinding(nil), s.Findings...)
|
||
next.CheckpointKeys = map[string]string{}
|
||
for k, v := range s.CheckpointKeys {
|
||
next.CheckpointKeys[k] = v
|
||
}
|
||
for _, f := range findings {
|
||
corrected := f.Corrects == ""
|
||
for i := range next.Findings {
|
||
old := &next.Findings[i]
|
||
if old.ID == f.ID && old.Revision >= f.Revision {
|
||
f.Revision = old.Revision + 1
|
||
}
|
||
if old.Status == "active" && (old.ID == f.ID || old.ID == f.Corrects) {
|
||
if f.Corrects != "" {
|
||
old.Status = "corrected"
|
||
corrected = true
|
||
} else {
|
||
old.Status = "superseded"
|
||
}
|
||
}
|
||
}
|
||
if !corrected {
|
||
return "", fmt.Errorf("checkpoint.corrects: active finding not found")
|
||
}
|
||
next.Findings = append(next.Findings, f)
|
||
}
|
||
next.CheckpointKeys[candidate.CheckpointID] = digest
|
||
next.CheckpointSeq++
|
||
if !critical {
|
||
next.CheckpointCount++
|
||
}
|
||
next.Revision++
|
||
next.OutcomeCompleteness = "partial"
|
||
next.DeliveryState = "available"
|
||
next.Focus = normalized.Focus
|
||
next.Remaining = normalized.Remaining
|
||
next.NextStep = normalized.NextStep
|
||
now := time.Now().UTC()
|
||
next.Phase = core.TaskPhase{ID: candidate.PhaseID, Label: map[string]string{"reading": "读取材料", "crosscheck": "核对材料", "summarizing": "整理结论", "draft-ready": "形成阶段成果"}[candidate.PhaseID], ObservedAt: now}
|
||
next.LastActivityAt = now
|
||
next.LastMeaningfulUpdateAt = now
|
||
if err = next.Validate(); err != nil {
|
||
if !critical {
|
||
return "", err
|
||
}
|
||
// Reject an oversized replacement, while immediately withdrawing the known-wrong claim.
|
||
next.Findings = next.Findings[:len(s.Findings)]
|
||
next.Focus = ""
|
||
next.Remaining = "更正说明超过展示上限,原结论已撤回,请核对来源"
|
||
next.NextStep = "结合原件重新核验"
|
||
next.Conclusion = "原结论已撤回,尚无可展示的替代结论"
|
||
next.Phase = core.TaskPhase{ID: "correction", Label: "原结论已撤回", ObservedAt: now}
|
||
for i := range next.Findings {
|
||
if next.Findings[i].Status == "corrected" {
|
||
next.Findings[i].Status = "withdrawn"
|
||
}
|
||
}
|
||
if err = next.Validate(); err != nil {
|
||
return "", err
|
||
}
|
||
}
|
||
proofs := make([]store.ArtifactWitness, 0, len(witnesses))
|
||
for _, w := range witnesses {
|
||
proofs = append(proofs, w)
|
||
}
|
||
if err := c.mgr.cards.CommitTaskSources(c.run.Owner, next, s.Revision, proofs); err != nil {
|
||
return "", err
|
||
}
|
||
return strings.Join(notes, ";"), nil
|
||
}
|
||
|
||
func (c *runCtx) WorkCheckpointAvailable() bool {
|
||
return c.task.cardID != "" && c.subAgent == materialSubagentID && len(c.sources) > 0
|
||
}
|