merge: fix/agent-clear-last-error-on-sync-success

Merge agent sticky LastError clear fix into main.
This commit is contained in:
ryan
2026-08-06 13:48:14 +08:00
4 changed files with 168 additions and 0 deletions
+4
View File
@@ -26,6 +26,10 @@ sidebar: false
- 新增 Cloudflare DNS 指向管理:可复用现有 Cloudflare DNS 账号或配置独立 Token,按分组将 ZoneDomain 的单条 A 记录异步同步到边缘节点 IPv4,并支持成员橙云、同步状态与节点 IP 变更联动。
### 修复
- 修复 Agent 在配置已对齐但磁盘校验和不一致时,Pages 等对账成功后仍保留 `LastError` 的问题,避免偶发网络失败被健康事件长期显示为「活动中」且无法自动恢复。
### 改进
- 删除、撤销与未保存离开等确认操作统一改用页面内 AlertDialog,不再使用浏览器原生 `confirm` 弹窗,交互风格与系统其余对话框保持一致。
+2
View File
@@ -166,6 +166,8 @@ func (s *Service) applyIfNeeded(ctx context.Context, mode string, startup bool,
return err
}
slog.Debug("skipping apply because state already records target version/checksum", "version", config.Version, "checksum", config.Checksum)
// Successful reconcile must clear sticky LastError so health events can resolve.
snapshot.LastError = ""
return s.stateStore.Save(snapshot)
}
return s.applyRenderedConfig(ctx, mode, snapshot, currentChecksum, config)
+160
View File
@@ -1804,3 +1804,163 @@ func TestSyncOnceDownloadsPagesDeploymentWithTopLevelFolder(t *testing.T) {
t.Fatalf("unexpected Pages assets/app.js content: %s", string(jsData))
}
}
func TestSyncOnceClearsStickyLastErrorWhenChecksumMatches(t *testing.T) {
const sticky = "pages project 1: fetch latest hash: context deadline exceeded"
client := &fakeClient{
config: protocol.ActiveConfigResponse{
Version: "20260309-201",
Checksum: "checksum-201",
SourceConfigJSON: testSourceConfigJSON("auto", 90),
CreatedAt: time.Now().Format(time.RFC3339),
},
}
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
nodeID, err := stateStore.EnsureNodeID()
if err != nil {
t.Fatalf("EnsureNodeID failed: %v", err)
}
if err = stateStore.Save(&state.Snapshot{
NodeID: nodeID,
CurrentVersion: client.config.Version,
CurrentChecksum: client.config.Checksum,
PagesDeployments: []state.PagesDeployment{},
LastError: sticky,
}); err != nil {
t.Fatalf("failed to seed state: %v", err)
}
manager := &fakeManager{currentChecksum: client.config.Checksum}
service := New(client, manager, stateStore)
if err = service.SyncOnce(context.Background(), &protocol.ActiveConfigMeta{
Version: client.config.Version,
Checksum: client.config.Checksum,
}); err != nil {
t.Fatalf("SyncOnce failed: %v", err)
}
snapshot, err := stateStore.Load()
if err != nil {
t.Fatalf("failed to load state: %v", err)
}
if snapshot.LastError != "" {
t.Fatalf("expected sticky LastError to clear after successful up-to-date sync, got %q", snapshot.LastError)
}
}
func TestSyncOnceClearsStickyLastErrorWhenStateMatchesButDiskChecksumDiffers(t *testing.T) {
// Reproduces the production sticky-alert bug: nginx on-disk checksum no longer
// equals the active config checksum, but agent state already records the target.
// Pages reconcile succeeds, yet older code saved state without clearing LastError.
const sticky = "pages project 1: fetch latest hash: connection reset by peer"
const version = "20260309-202"
const checksum = "checksum-202"
packageBytes := testPagesPackage(t, map[string]string{"index.html": "ok"})
hash := testBytesChecksum(packageBytes)
projectID := uint(1)
client := &fakeClient{
config: protocol.ActiveConfigResponse{
Version: version,
Checksum: checksum,
SourceConfigJSON: testPagesSourceConfigJSON(projectID, projectID, hash),
CreatedAt: time.Now().Format(time.RFC3339),
},
pagesPackages: map[uint][]byte{projectID: packageBytes},
pagesHashes: map[uint]string{projectID: hash},
}
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
nodeID, err := stateStore.EnsureNodeID()
if err != nil {
t.Fatalf("EnsureNodeID failed: %v", err)
}
if err = stateStore.Save(&state.Snapshot{
NodeID: nodeID,
CurrentVersion: version,
CurrentChecksum: checksum,
LastError: sticky,
PagesDeployments: []state.PagesDeployment{{
ProjectID: projectID,
DeploymentID: projectID,
Hash: hash,
}},
}); err != nil {
t.Fatalf("failed to seed state: %v", err)
}
// Disk checksum differs from target → mismatched path; state already matches target.
manager := &fakeManager{currentChecksum: "on-disk-differs-from-target"}
service := New(client, manager, stateStore)
service.SetPagesDir(t.TempDir())
if err = service.SyncOnce(context.Background(), &protocol.ActiveConfigMeta{
Version: version,
Checksum: checksum,
}); err != nil {
t.Fatalf("SyncOnce failed: %v", err)
}
snapshot, err := stateStore.Load()
if err != nil {
t.Fatalf("failed to load state: %v", err)
}
if snapshot.LastError != "" {
t.Fatalf("expected sticky LastError to clear when pages reconcile succeeds, got %q", snapshot.LastError)
}
}
func TestSyncOnceClearsStickyLastErrorAfterStaleHeartbeatFetchesActiveConfig(t *testing.T) {
// Heartbeat meta is stale vs active config; disk checksum mismatches heartbeat.
// After fetch, snapshot already matches active config → applyIfNeeded state-match path.
const sticky = "previous transient pages hash timeout"
const activeVersion = "20260309-203"
const activeChecksum = "checksum-203"
client := &fakeClient{
config: protocol.ActiveConfigResponse{
Version: activeVersion,
Checksum: activeChecksum,
SourceConfigJSON: testSourceConfigJSON("auto", 91),
CreatedAt: time.Now().Format(time.RFC3339),
},
}
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
nodeID, err := stateStore.EnsureNodeID()
if err != nil {
t.Fatalf("EnsureNodeID failed: %v", err)
}
if err = stateStore.Save(&state.Snapshot{
NodeID: nodeID,
CurrentVersion: activeVersion,
CurrentChecksum: activeChecksum,
PagesDeployments: []state.PagesDeployment{},
LastError: sticky,
}); err != nil {
t.Fatalf("failed to seed state: %v", err)
}
// Disk differs from stale heartbeat target so SyncOnce does not take the matching path.
manager := &fakeManager{currentChecksum: "disk-checksum-differs"}
service := New(client, manager, stateStore)
if err = service.SyncOnce(context.Background(), &protocol.ActiveConfigMeta{
Version: "20260309-202",
Checksum: "checksum-stale-heartbeat",
}); err != nil {
t.Fatalf("SyncOnce failed: %v", err)
}
snapshot, err := stateStore.Load()
if err != nil {
t.Fatalf("failed to load state: %v", err)
}
if snapshot.LastError != "" {
t.Fatalf("expected sticky LastError to clear on applyIfNeeded state-match success, got %q", snapshot.LastError)
}
if client.fetchCalls != 1 {
t.Fatalf("expected one config fetch for stale heartbeat target, got %d", client.fetchCalls)
}
if len(manager.applyMainContents) != 0 {
t.Fatal("expected state-match path to skip OpenResty apply")
}
}
+2
View File
@@ -122,6 +122,8 @@ func (s *Service) syncMismatchedChecksum(ctx context.Context, mode string, start
if !reconciled {
slog.Debug("skipping config fetch because state already records target version/checksum", "version", target.Version, "checksum", target.Checksum)
}
// Successful reconcile must clear sticky LastError so health events can resolve.
snapshot.LastError = ""
return s.stateStore.Save(snapshot)
}