diff --git a/internal/governance/job_failures.go b/internal/governance/job_failures.go
index cc9aaae..553bf7b 100644
--- a/internal/governance/job_failures.go
+++ b/internal/governance/job_failures.go
@@ -18,6 +18,8 @@ type JobFailure struct {
TargetKBID string `json:"target_kb_id,omitempty"`
State string `json:"state"`
CreatedAt time.Time `json:"created_at"`
+ DocName string `json:"doc_name,omitempty"`
+ LastError string `json:"last_error,omitempty"`
}
type JobFailureFilter struct {
diff --git a/internal/platform/postgres/filename_filters.go b/internal/platform/postgres/filename_filters.go
index e78fdff..a1837ee 100644
--- a/internal/platform/postgres/filename_filters.go
+++ b/internal/platform/postgres/filename_filters.go
@@ -158,6 +158,14 @@ WHERE intent.source_connection_id=$1 AND filename_is_filtered(intent.source_conn
AND job.payload->>'node_id'=intent.node_id AND job.state IN ('pending','retry','failed')`, connectionID, now); err != nil {
return fmt.Errorf("cancel filename-filtered fetch jobs: %w", err)
}
+ // A cancelled or later-succeeded job is no longer waiting on a human:
+ // retire its dead-letter projection so the DLQ lane shows only real work.
+ if _, err := tx.Exec(ctx, `UPDATE job_failures f SET state='requeued',resolved_at=$2,resolution_actor_ref='system:filename-filter',resolution_reason='任务已被格式白名单取消'
+FROM jobs job
+WHERE job.id=f.job_id AND f.state='open' AND job.type='artifact.fetch'
+ AND job.payload->>'source_connection_id'=$1 AND job.state='cancelled'`, connectionID, now); err != nil {
+ return fmt.Errorf("retire filename-filtered dead letters: %w", err)
+ }
if _, err := tx.Exec(ctx, `UPDATE catalog_import_intents intent SET fetch_state='filtered',last_error='',updated_at=$2
WHERE intent.source_connection_id=$1 AND filename_is_filtered(intent.source_connection_id,intent.relative_path,intent.name)
AND intent.fetch_state<>'ready'`, connectionID, now); err != nil {
diff --git a/internal/platform/postgres/job_failures.go b/internal/platform/postgres/job_failures.go
index 9fa2e20..f194162 100644
--- a/internal/platform/postgres/job_failures.go
+++ b/internal/platform/postgres/job_failures.go
@@ -15,10 +15,20 @@ const listJobFailuresSQL = `
SELECT jf.id, jf.job_id, jf.job_type, jf.publication_id, jf.error_code,
jf.attempt, jf.max_attempts_at_failure, jf.business_change_set_id,
jf.business_action, jf.business_target_kb_id, jf.state, jf.created_at,
- COALESCE(siv.source_item_id,'')
+ COALESCE(siv.source_item_id,''),
+ COALESCE(NULLIF(item.title,''), NULLIF(ci.name,''), ''),
+ COALESCE(NULLIF(jf.last_error,''), j.last_error, '')
FROM job_failures jf
LEFT JOIN test_publications p ON p.id = jf.publication_id
LEFT JOIN source_item_versions siv ON siv.id = p.source_item_version_id
+LEFT JOIN source_items item ON item.id = siv.source_item_id
+LEFT JOIN jobs j ON j.id = jf.job_id
+LEFT JOIN LATERAL (
+ SELECT intent.name FROM catalog_import_intents intent
+ WHERE intent.source_connection_id = j.payload->>'source_connection_id'
+ AND intent.node_id = j.payload->>'node_id'
+ LIMIT 1
+) ci ON TRUE
WHERE ($1 = '' OR jf.state = $1)
ORDER BY jf.created_at DESC, jf.id DESC
LIMIT $2 OFFSET $3
@@ -60,7 +70,7 @@ func (s *Store) ListJobFailures(ctx context.Context, filter governance.JobFailur
var item governance.JobFailure
if err := rows.Scan(&item.ID, &item.JobID, &item.JobType, &item.PublicationID, &item.ErrorCode,
&item.Attempt, &item.MaxAttempts, &item.ChangeSetID, &item.Action, &item.TargetKBID,
- &item.State, &item.CreatedAt, &item.SourceItemID); err != nil {
+ &item.State, &item.CreatedAt, &item.SourceItemID, &item.DocName, &item.LastError); err != nil {
return governance.JobFailurePage{}, fmt.Errorf("scan job failure: %w", err)
}
items = append(items, item)
diff --git a/internal/platform/postgres/queue_sql.go b/internal/platform/postgres/queue_sql.go
index 27a46bd..010140d 100644
--- a/internal/platform/postgres/queue_sql.go
+++ b/internal/platform/postgres/queue_sql.go
@@ -64,6 +64,16 @@ WHERE id = $1
AND state = 'running'
`
+// retireOpenFailuresSQL keeps the dead-letter lane honest: once the job
+// itself succeeded, its terminal-attempt projection is no longer waiting on
+// a human (a later automatic retry recovered it).
+const retireOpenFailuresSQL = `
+UPDATE job_failures
+SET state = 'requeued', resolved_at = $2, resolution_actor_ref = 'system:queue',
+ resolution_reason = '任务后续重试成功'
+WHERE job_id = $1 AND state = 'open'
+`
+
const heartbeatJobSQL = `
UPDATE jobs
SET lease_until = $4,
diff --git a/internal/platform/postgres/store.go b/internal/platform/postgres/store.go
index eff3694..19b99fc 100644
--- a/internal/platform/postgres/store.go
+++ b/internal/platform/postgres/store.go
@@ -87,13 +87,24 @@ func (s *Store) List(ctx context.Context, limit int) ([]jobs.Job, error) {
}
func (s *Store) Complete(ctx context.Context, id, owner string, now time.Time) error {
- tag, err := s.db.Exec(ctx, completeJobSQL, id, owner, now)
+ tx, err := s.db.Begin(ctx)
+ if err != nil {
+ return fmt.Errorf("complete job: %w", err)
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ tag, err := tx.Exec(ctx, completeJobSQL, id, owner, now)
if err != nil {
return fmt.Errorf("complete job: %w", err)
}
if tag.RowsAffected() != 1 {
return ErrLeaseLost
}
+ if _, err := tx.Exec(ctx, retireOpenFailuresSQL, id, now); err != nil {
+ return fmt.Errorf("complete job: retire failures: %w", err)
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return fmt.Errorf("complete job: %w", err)
+ }
return nil
}
diff --git a/internal/workflows/testweknora/upload.go b/internal/workflows/testweknora/upload.go
index 960e2d5..f142319 100644
--- a/internal/workflows/testweknora/upload.go
+++ b/internal/workflows/testweknora/upload.go
@@ -134,15 +134,28 @@ func (handler *UploadHandler) Handle(ctx context.Context, job jobs.Job) error {
return jobs.Permanent(jobs.ErrCodeArtifactInvalid, ErrUploadArtifactInvalid)
}
if uploadErr != nil {
- if !errors.Is(uploadErr, weknora.ErrConflict) && !weknora.IsRetryable(uploadErr) {
- return ErrUploadRemoteFailed
- }
- knowledge, err = handler.recoverAmbiguousUpload(ctx, candidate)
- if err != nil {
- if errors.Is(err, ErrUploadRemoteIdentity) {
- return handler.recordTerminalUploadFailure(ctx, candidate.ID, "upload_identity_ambiguous")
+ if errors.Is(uploadErr, weknora.ErrConflict) && knowledge.ID != "" {
+ // WeKnora dedupes identical file bytes across publications: when the
+ // same document was already uploaded under a sibling publication (the
+ // same file living in two source folders), the conflict response names
+ // the existing item. Adopt it instead of dead-lettering forever.
+ if verified, verifyErr := handler.client.GetKnowledge(ctx, knowledge.ID); verifyErr == nil &&
+ verified.ID == knowledge.ID && verified.KnowledgeBaseID == candidate.TestKBID &&
+ shaMatches(verified, candidate.SHA256) {
+ knowledge = verified
+ } else {
+ return ErrUploadRemoteFailed
+ }
+ } else if !errors.Is(uploadErr, weknora.ErrConflict) && !weknora.IsRetryable(uploadErr) {
+ return ErrUploadRemoteFailed
+ } else {
+ knowledge, err = handler.recoverAmbiguousUpload(ctx, candidate)
+ if err != nil {
+ if errors.Is(err, ErrUploadRemoteIdentity) {
+ return handler.recordTerminalUploadFailure(ctx, candidate.ID, "upload_identity_ambiguous")
+ }
+ return err
}
- return err
}
}
if knowledge.ID == "" || knowledge.KnowledgeBaseID != candidate.TestKBID {
@@ -201,6 +214,15 @@ func metadataString(metadata map[string]any, key string) (string, bool) {
return value, ok
}
+// shaMatches reports whether a remote item carries our content hash, either in
+// its metadata (uploads through governance) or as its file hash.
+func shaMatches(item weknora.Knowledge, sha string) bool {
+ if value, ok := metadataString(item.Metadata, "content_sha256"); ok && value == sha {
+ return true
+ }
+ return sha != "" && strings.EqualFold(item.FileHash, sha)
+}
+
func (handler *UploadHandler) recordTerminalUploadFailure(ctx context.Context, publicationID, code string) error {
if err := handler.store.RecordUploadFailure(ctx, publicationID, code, handler.now()); err != nil {
return ErrUploadStateConflict
diff --git a/internal/workflows/testweknora/upload_test.go b/internal/workflows/testweknora/upload_test.go
index 422f1ec..3ace041 100644
--- a/internal/workflows/testweknora/upload_test.go
+++ b/internal/workflows/testweknora/upload_test.go
@@ -352,3 +352,53 @@ func (uploadClientStub) ListKnowledge(context.Context, string, int, int) ([]wekn
func (uploadClientStub) GetKnowledge(context.Context, string) (weknora.Knowledge, error) {
return weknora.Knowledge{}, errors.New("unexpected get")
}
+
+func TestUploadHandlerAdoptsSameSHAItemOnDuplicateConflict(t *testing.T) {
+ root := t.TempDir()
+ if err := os.Mkdir(filepath.Join(root, "objects"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ body := []byte("duplicated across two folders\n")
+ if err := os.WriteFile(filepath.Join(root, "objects", "artifact.md"), body, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ digest := sha256.Sum256(body)
+ sha := hex.EncodeToString(digest[:])
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodPost:
+ _, _ = io.Copy(io.Discard, r.Body)
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusConflict)
+ _, _ = w.Write([]byte(`{"success":false,"code":"duplicate_file","message":"file exists","data":{"id":"knowledge-sibling","knowledge_base_id":"kb-1","file_hash":"` + sha + `"}}`))
+ case r.Method == http.MethodGet && r.URL.Path == "/api/v1/knowledge/knowledge-sibling":
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"success":true,"data":{"id":"knowledge-sibling","knowledge_base_id":"kb-1","file_hash":"` + sha + `","metadata":{"publication_id":"publication-other","content_sha256":"` + sha + `"}}}`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+ client, err := weknora.NewClient(weknora.ClientConfig{BaseURL: server.URL, APIKey: "test-key", Timeout: time.Second})
+ if err != nil {
+ t.Fatal(err)
+ }
+ store := &uploadStoreStub{candidate: postgresstore.PublicationForUpload{
+ ID: "publication-1", TestKBID: "kb-1", IngestState: governance.IngestPending,
+ ProcessingState: governance.ProcessingPending, SHA256: sha, SizeBytes: int64(len(body)),
+ ValidatedFormat: "md", LocalSpoolRef: "objects/artifact.md", Title: "Safe title",
+ }}
+ handler, err := NewUploadHandler(UploadHandlerConfig{
+ Store: store, Client: client, SpoolRoot: root, MaxUploadBytes: 50 * 1024 * 1024,
+ FirstPollDelay: time.Second, Now: func() time.Time { return time.Now() },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := handler.Handle(context.Background(), jobs.Job{Payload: []byte(`{"publication_id":"publication-1"}`)}); err != nil {
+ t.Fatalf("Handle() error = %v, want duplicate adoption", err)
+ }
+ if store.recordedKnowledgeID != "knowledge-sibling" {
+ t.Fatalf("knowledge=%q", store.recordedKnowledgeID)
+ }
+}
diff --git a/web/src/WeKnoraMonitor.test.tsx b/web/src/WeKnoraMonitor.test.tsx
index 63188ed..5383dcc 100644
--- a/web/src/WeKnoraMonitor.test.tsx
+++ b/web/src/WeKnoraMonitor.test.tsx
@@ -37,7 +37,10 @@ describe('WeKnoraMonitorView',()=>{
})
it('shows inbox lanes for the knowledge-base diff view',()=>{
const attention=renderToStaticMarkup(
{batchMessage}
: null} {attentionItems.length ? attentionItems.map(actionTask) : emptyLane('没有失败或停滞的任务。')} -{failure.publication_id} : null}{failure.attempt}/{failure.max_attempts_at_failure} 次 · {new Date(failure.created_at).toLocaleString('zh-CN', { hour12: false })}{failure.publication_id ? : null}{failure.publication_id} : null}{failure.attempt}/{failure.max_attempts_at_failure} 次 · {new Date(failure.created_at).toLocaleString('zh-CN', { hour12: false })}{failureHint(failure)}{failure.publication_id ? : null}