From a9cc0abf7eaccb60035860ad65e4bf4bffdad15b Mon Sep 17 00:00:00 2001 From: core-devops Date: Tue, 16 Jun 2026 15:37:55 -0700 Subject: [PATCH 1/3] =?UTF-8?q?RFC#2843=20#32:=20fire=20declared-plugin=20?= =?UTF-8?q?reconcile=20on=20the=20heartbeat=20provisioning=E2=86=92online?= =?UTF-8?q?=20self-heal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post-online plugin reconcile (#2995/#3000) never fired for a freshly provisioned workspace, so declared plugins (e.g. the seo-agent's seo-all) were never installed — /configs/plugins/seo-all stayed empty, workspace_plugins + workspace_declared_plugins had no install, and no restart happened. Diagnosed first-hand on a live staging tenant: the box ran the reconcile code, the workspace_declared_plugins row was recorded (#3000), the workspace reached online and heartbeated — yet there was no "Plugin reconcile" log and 0 workspace_plugins rows. Root cause (wiring bug): fireReconcileOnline was only invoked from evaluateStatus's `currentStatus == "provisioning"` branch. But the main heartbeat UPDATE self-heals status provisioning→online INLINE via its `CASE WHEN status = 'provisioning' THEN 'online'` clause, and that runs BEFORE evaluateStatus. So by the time evaluateStatus reads currentStatus it is already 'online' and the provisioning branch never matches. The runtime only ever calls /registry/heartbeat on boot (never /registry/register), so this IS the path every new workspace takes — the reconcile trigger was dead code on the primary path. Fix: read prevStatus before the heartbeat UPDATE and fire the reconcile when this heartbeat performed the provisioning→online flip (prevStatus == provisioning). Idempotent (ReconcileWorkspacePlugins diffs declared-vs-installed) and nil-safe via fireReconcileOnline. evaluateStatus still owns the other recovery transitions (offline/degraded/awaiting_agent/ failed→online), which the inline CASE does not touch. - registry.go: capture prevStatus; fire reconcile post-UPDATE on the provisioning→online self-heal; correct the now-misleading evaluateStatus provisioning-branch comment so the trigger isn't re-broken. - registry_test.go: TestHeartbeatHandler_ProvisioningToOnline now asserts the reconcile fires via a ReconcileFunc spy (regression guard); all prevTask mocks updated for the 3-column (current_task, monthly_spend, status) SELECT. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../internal/handlers/registry.go | 53 +++++++++-- .../internal/handlers/registry_test.go | 88 +++++++++++-------- 2 files changed, 97 insertions(+), 44 deletions(-) diff --git a/workspace-server/internal/handlers/registry.go b/workspace-server/internal/handlers/registry.go index 2470b979..3e4bca7d 100644 --- a/workspace-server/internal/handlers/registry.go +++ b/workspace-server/internal/handlers/registry.go @@ -882,10 +882,26 @@ func (h *RegistryHandler) Heartbeat(c *gin.Context) { return // response already written } - // Read previous current_task to detect changes (before the UPDATE) + // Read previous current_task + status to detect changes (before the UPDATE). + // + // prevStatus is load-bearing for the RFC#2843 #32 plugin reconcile: the + // main heartbeat UPDATE below SELF-HEALS status provisioning→online INLINE + // (the `CASE WHEN status = 'provisioning' THEN 'online'` clause). That flip + // happens BEFORE evaluateStatus runs, so by the time evaluateStatus reads + // currentStatus it is already 'online' and its `currentStatus == + // "provisioning"` branch (which fires fireReconcileOnline) never matches on + // the normal fresh-boot path — the runtime only ever calls + // /registry/heartbeat, never /registry/register, so this IS the path every + // new workspace takes. The result: declared plugins never installed on a + // fresh seo-agent (the #32 regression). Capturing prevStatus here lets us + // fire the reconcile when THIS heartbeat performed the provisioning→online + // flip, independent of evaluateStatus. evaluateStatus still owns the OTHER + // recovery transitions (offline/degraded/awaiting_agent/failed→online), + // which the inline CASE does not touch. var prevTask string var prevSpend int64 - if err := db.DB.QueryRowContext(ctx, `SELECT COALESCE(current_task, ''), COALESCE(monthly_spend, 0) FROM workspaces WHERE id = $1`, payload.WorkspaceID).Scan(&prevTask, &prevSpend); err != nil { + var prevStatus string + if err := db.DB.QueryRowContext(ctx, `SELECT COALESCE(current_task, ''), COALESCE(monthly_spend, 0), COALESCE(status, '') FROM workspaces WHERE id = $1`, payload.WorkspaceID).Scan(&prevTask, &prevSpend, &prevStatus); err != nil { log.Printf("registry heartbeat: prev_task query failed for workspace %s: %v", payload.WorkspaceID, err) } @@ -968,6 +984,21 @@ func (h *RegistryHandler) Heartbeat(c *gin.Context) { return } + // RFC#2843 #32: fire the declared-plugin reconcile when THIS heartbeat just + // performed the provisioning→online self-heal (the inline CASE in the UPDATE + // above). This is the primary fresh-boot transition: a newly-provisioned + // workspace is created with status='provisioning', the runtime's first + // heartbeat flips it to 'online' via that CASE, and there is no + // /registry/register on the boot path. Without firing here, the reconcile + // hook in evaluateStatus never sees a provisioning→online transition (the + // CASE already moved the row to 'online' before evaluateStatus reads + // currentStatus), so declared plugins (e.g. seo-all) never install. Firing + // is idempotent — ReconcileWorkspacePlugins diffs declared-vs-installed and + // no-ops when everything is present — and nil-safe via fireReconcileOnline. + if prevStatus == string(models.StatusProvisioning) { + h.fireReconcileOnline(ctx, payload.WorkspaceID) + } + // #2421: backfill agent_card when the initial register failed and the // heartbeat carries it. Only writes when NULL — never overwrites a // reconciled or updated card. This is the recovery path for fast-cloud @@ -1213,12 +1244,18 @@ func (h *RegistryHandler) evaluateStatus(c *gin.Context, payload models.Heartbea h.fireReconcileOnline(ctx, payload.WorkspaceID) } - // Auto-recovery: if a workspace is marked "provisioning" but is actively sending - // heartbeats, it has successfully started up. Transition to "online" so the scheduler - // and A2A proxy can dispatch tasks to it. The provisioner does not call - // /registry/register on container start — only the heartbeat loop does, so this - // transition is the only mechanism that moves newly-started workspaces out of - // the phantom-idle state. (#1784) + // Auto-recovery: if a workspace is STILL marked "provisioning" by the time + // this branch runs, transition it to "online". Defense-in-depth only: the + // main heartbeat UPDATE above already self-heals provisioning→online via its + // inline CASE, so on the normal path currentStatus is 'online' here and this + // branch is a no-op. It still covers any future path that reaches + // evaluateStatus with a 'provisioning' row that the inline CASE missed. (#1784) + // + // NOTE (RFC#2843 #32): because the inline CASE pre-empts this branch on the + // real fresh-boot path, the declared-plugin reconcile is fired from the + // heartbeat handler itself (on prevStatus=='provisioning'), NOT only here — + // see the fireReconcileOnline call right after the main UPDATE. Do not rely + // on this branch as the reconcile trigger; it does not fire for new boxes. if currentStatus == "provisioning" { if _, err := db.DB.ExecContext(ctx, `UPDATE workspaces SET status = $1, updated_at = now() WHERE id = $2 AND status = 'provisioning'`, models.StatusOnline, payload.WorkspaceID); err != nil { log.Printf("Heartbeat: failed to transition %s from provisioning to online: %v", payload.WorkspaceID, err) diff --git a/workspace-server/internal/handlers/registry_test.go b/workspace-server/internal/handlers/registry_test.go index 252333f0..5104d92b 100644 --- a/workspace-server/internal/handlers/registry_test.go +++ b/workspace-server/internal/handlers/registry_test.go @@ -2,6 +2,7 @@ package handlers import ( "bytes" + "context" "database/sql" "encoding/json" "log" @@ -209,7 +210,7 @@ func TestHeartbeatHandler_OfflineToOnline(t *testing.T) { // Expect prevTask SELECT mock.ExpectQuery("SELECT COALESCE\\(current_task"). WithArgs("ws-offline"). - WillReturnRows(sqlmock.NewRows([]string{"current_task"}).AddRow("")) + WillReturnRows(sqlmock.NewRows([]string{"current_task", "monthly_spend", "status"}).AddRow("", 0, "online")) // Expect heartbeat UPDATE mock.ExpectExec("UPDATE workspaces SET"). @@ -256,29 +257,34 @@ func TestHeartbeatHandler_ProvisioningToOnline(t *testing.T) { broadcaster := newTestBroadcaster() handler := NewRegistryHandler(broadcaster) - // Expect prevTask SELECT + // RFC#2843 #32 regression: the reconcile MUST fire when a fresh workspace's + // heartbeat performs the provisioning→online self-heal. The runtime never + // calls /registry/register on boot, so the heartbeat (whose UPDATE's inline + // CASE flips provisioning→online before evaluateStatus runs) is the ONLY + // fresh-boot transition. Pre-fix, fireReconcileOnline was only wired into + // evaluateStatus's provisioning branch, which the inline CASE makes + // unreachable — so declared plugins (e.g. seo-all) never installed. + reconcileFired := make(chan string, 4) + handler.SetReconcileFunc(func(_ context.Context, workspaceID string) { + reconcileFired <- workspaceID + }) + + // prevTask + prevStatus SELECT — prevStatus='provisioning' is the state a + // freshly-created workspace is in before its first heartbeat. mock.ExpectQuery("SELECT COALESCE\\(current_task"). WithArgs("ws-provisioning"). - WillReturnRows(sqlmock.NewRows([]string{"current_task"}).AddRow("")) + WillReturnRows(sqlmock.NewRows([]string{"current_task", "monthly_spend", "status"}).AddRow("", 0, "provisioning")) - // Expect heartbeat UPDATE + // Heartbeat UPDATE — its inline CASE flips provisioning→online. mock.ExpectExec("UPDATE workspaces SET"). WithArgs("ws-provisioning", 0.0, "", 1, 3000, ""). WillReturnResult(sqlmock.NewResult(0, 1)) - // Expect evaluateStatus SELECT — currently provisioning + // evaluateStatus SELECT — reads the post-CASE status ('online'), so its own + // provisioning→online branch does NOT fire (no duplicate transition exec). mock.ExpectQuery("SELECT status, last_register_failure_at FROM workspaces WHERE id ="). WithArgs("ws-provisioning"). - WillReturnRows(sqlmock.NewRows([]string{"status", "last_register_failure_at"}).AddRow("provisioning", nil)) - - // Expect status transition to online (#1784) - mock.ExpectExec("UPDATE workspaces SET status ="). - WithArgs(models.StatusOnline, "ws-provisioning"). - WillReturnResult(sqlmock.NewResult(0, 1)) - - // Expect RecordAndBroadcast INSERT for WORKSPACE_ONLINE - mock.ExpectExec("INSERT INTO structure_events"). - WillReturnResult(sqlmock.NewResult(0, 1)) + WillReturnRows(sqlmock.NewRows([]string{"status", "last_register_failure_at"}).AddRow("online", nil)) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -293,6 +299,16 @@ func TestHeartbeatHandler_ProvisioningToOnline(t *testing.T) { t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String()) } + // The reconcile fires fire-and-forget via globalGoAsync; wait briefly. + select { + case got := <-reconcileFired: + if got != "ws-provisioning" { + t.Errorf("reconcile fired for wrong workspace: got %q", got) + } + case <-time.After(2 * time.Second): + t.Fatal("RFC#2843 #32 regression: reconcile did NOT fire on provisioning→online heartbeat") + } + if err := mock.ExpectationsWereMet(); err != nil { t.Errorf("unmet sqlmock expectations: %v", err) } @@ -311,7 +327,7 @@ func TestHeartbeatHandler_FailedToOnline(t *testing.T) { mock.ExpectQuery("SELECT COALESCE\\(current_task"). WithArgs("ws-failed"). - WillReturnRows(sqlmock.NewRows([]string{"current_task"}).AddRow("")) + WillReturnRows(sqlmock.NewRows([]string{"current_task", "monthly_spend", "status"}).AddRow("", 0, "online")) mock.ExpectExec("UPDATE workspaces SET"). WithArgs("ws-failed", 0.0, "", 1, 3000, ""). @@ -361,7 +377,7 @@ func TestHeartbeatHandler_AwaitingAgentToOnline(t *testing.T) { mock.ExpectQuery("SELECT COALESCE\\(current_task"). WithArgs("ws-external"). - WillReturnRows(sqlmock.NewRows([]string{"current_task"}).AddRow("")) + WillReturnRows(sqlmock.NewRows([]string{"current_task", "monthly_spend", "status"}).AddRow("", 0, "online")) mock.ExpectExec("UPDATE workspaces SET"). WithArgs("ws-external", 0.0, "", 0, 60, ""). @@ -446,7 +462,7 @@ func TestHeartbeatHandler_DBUpdateError(t *testing.T) { // Expect prevTask SELECT mock.ExpectQuery("SELECT COALESCE\\(current_task"). WithArgs("ws-dberr"). - WillReturnRows(sqlmock.NewRows([]string{"current_task"}).AddRow("")) + WillReturnRows(sqlmock.NewRows([]string{"current_task", "monthly_spend", "status"}).AddRow("", 0, "online")) // Heartbeat UPDATE fails mock.ExpectExec("UPDATE workspaces SET"). @@ -482,7 +498,7 @@ func TestHeartbeatHandler_OnlineStaysOnline(t *testing.T) { // Expect prevTask SELECT mock.ExpectQuery("SELECT COALESCE\\(current_task"). WithArgs("ws-stable"). - WillReturnRows(sqlmock.NewRows([]string{"current_task"}).AddRow("")) + WillReturnRows(sqlmock.NewRows([]string{"current_task", "monthly_spend", "status"}).AddRow("", 0, "online")) // Expect heartbeat UPDATE mock.ExpectExec("UPDATE workspaces SET"). @@ -530,7 +546,7 @@ func TestHeartbeatHandler_RuntimeWedged_FlipsOnlineToDegraded(t *testing.T) { mock.ExpectQuery("SELECT COALESCE\\(current_task"). WithArgs("ws-wedged"). - WillReturnRows(sqlmock.NewRows([]string{"current_task"}).AddRow("")) + WillReturnRows(sqlmock.NewRows([]string{"current_task", "monthly_spend", "status"}).AddRow("", 0, "online")) // Heartbeat UPDATE — sample_error carries the wedge reason from the // workspace's _runtime_state_payload() helper. @@ -585,7 +601,7 @@ func TestHeartbeatHandler_DegradedRecoversOnlyAfterWedgeClears(t *testing.T) { mock.ExpectQuery("SELECT COALESCE\\(current_task"). WithArgs("ws-still-wedged"). - WillReturnRows(sqlmock.NewRows([]string{"current_task"}).AddRow("")) + WillReturnRows(sqlmock.NewRows([]string{"current_task", "monthly_spend", "status"}).AddRow("", 0, "online")) mock.ExpectExec("UPDATE workspaces SET"). WithArgs("ws-still-wedged", 0.0, "still broken", 0, 800, ""). @@ -631,7 +647,7 @@ func TestHeartbeatHandler_DegradedToOnline_AfterWedgeClears(t *testing.T) { mock.ExpectQuery("SELECT COALESCE\\(current_task"). WithArgs("ws-recovered"). - WillReturnRows(sqlmock.NewRows([]string{"current_task"}).AddRow("")) + WillReturnRows(sqlmock.NewRows([]string{"current_task", "monthly_spend", "status"}).AddRow("", 0, "online")) mock.ExpectExec("UPDATE workspaces SET"). WithArgs("ws-recovered", 0.0, "", 0, 30, ""). @@ -873,7 +889,7 @@ func TestHeartbeat_SkipsRemovedRows(t *testing.T) { // prevTask lookup mock.ExpectQuery("SELECT COALESCE\\(current_task"). WithArgs("ws-zombie"). - WillReturnRows(sqlmock.NewRows([]string{"current_task"}).AddRow("")) + WillReturnRows(sqlmock.NewRows([]string{"current_task", "monthly_spend", "status"}).AddRow("", 0, "online")) // UPDATE must include `AND status != 'removed'`. 0 rows affected is fine — // this is the tombstoned case the fix protects against. @@ -912,7 +928,7 @@ func TestHeartbeatHandler_BackfillsAgentCard_WhenNull(t *testing.T) { mock.ExpectQuery("SELECT COALESCE\\(current_task"). WithArgs("ws-nocard"). - WillReturnRows(sqlmock.NewRows([]string{"current_task", "monthly_spend"}).AddRow("", 0)) + WillReturnRows(sqlmock.NewRows([]string{"current_task", "monthly_spend", "status"}).AddRow("", 0, "online")) mock.ExpectExec("UPDATE workspaces SET"). WithArgs("ws-nocard", 0.0, "", 0, 0, ""). @@ -952,7 +968,7 @@ func TestHeartbeatHandler_SkipsAgentCardBackfill_WhenAlreadySet(t *testing.T) { mock.ExpectQuery("SELECT COALESCE\\(current_task"). WithArgs("ws-hascard"). - WillReturnRows(sqlmock.NewRows([]string{"current_task", "monthly_spend"}).AddRow("", 0)) + WillReturnRows(sqlmock.NewRows([]string{"current_task", "monthly_spend", "status"}).AddRow("", 0, "online")) mock.ExpectExec("UPDATE workspaces SET"). WithArgs("ws-hascard", 0.0, "", 0, 0, ""). @@ -997,7 +1013,7 @@ func TestHeartbeatHandler_BackfillAgentCard_ClearsRegisterFailure(t *testing.T) mock.ExpectQuery("SELECT COALESCE\\(current_task"). WithArgs("ws-degraded-register-fail"). - WillReturnRows(sqlmock.NewRows([]string{"current_task", "monthly_spend"}).AddRow("", 0)) + WillReturnRows(sqlmock.NewRows([]string{"current_task", "monthly_spend", "status"}).AddRow("", 0, "online")) mock.ExpectExec("UPDATE workspaces SET"). WithArgs("ws-degraded-register-fail", 0.0, "", 0, 0, ""). @@ -1651,7 +1667,7 @@ func TestHeartbeat_MonthlySpend_WithinBounds(t *testing.T) { mock.ExpectQuery("SELECT COALESCE\\(current_task"). WithArgs("ws-spend-ok"). - WillReturnRows(sqlmock.NewRows([]string{"current_task"}).AddRow("")) + WillReturnRows(sqlmock.NewRows([]string{"current_task", "monthly_spend", "status"}).AddRow("", 0, "online")) // Expect the 7-argument UPDATE (with monthly_spend = $7). mock.ExpectExec("UPDATE workspaces SET"). @@ -1687,7 +1703,7 @@ func TestHeartbeat_MonthlySpend_NegativeClamped(t *testing.T) { mock.ExpectQuery("SELECT COALESCE\\(current_task"). WithArgs("ws-spend-neg"). - WillReturnRows(sqlmock.NewRows([]string{"current_task"}).AddRow("")) + WillReturnRows(sqlmock.NewRows([]string{"current_task", "monthly_spend", "status"}).AddRow("", 0, "online")) // Clamped to 0 → no monthly_spend field → 6-argument UPDATE. mock.ExpectExec("UPDATE workspaces SET"). @@ -1723,7 +1739,7 @@ func TestHeartbeat_MonthlySpend_OverflowClamped(t *testing.T) { mock.ExpectQuery("SELECT COALESCE\\(current_task"). WithArgs("ws-spend-overflow"). - WillReturnRows(sqlmock.NewRows([]string{"current_task"}).AddRow("")) + WillReturnRows(sqlmock.NewRows([]string{"current_task", "monthly_spend", "status"}).AddRow("", 0, "online")) // Expect the 7-argument UPDATE with monthly_spend clamped to 1_000_000_000_000. mock.ExpectExec("UPDATE workspaces SET"). @@ -1759,7 +1775,7 @@ func TestHeartbeat_MonthlySpend_ExactCap(t *testing.T) { mock.ExpectQuery("SELECT COALESCE\\(current_task"). WithArgs("ws-spend-cap"). - WillReturnRows(sqlmock.NewRows([]string{"current_task"}).AddRow("")) + WillReturnRows(sqlmock.NewRows([]string{"current_task", "monthly_spend", "status"}).AddRow("", 0, "online")) mock.ExpectExec("UPDATE workspaces SET"). WithArgs("ws-spend-cap", 0.0, "", 0, 0, "", int64(1_000_000_000_000)). @@ -1794,7 +1810,7 @@ func TestHeartbeat_MonthlySpend_Zero_NoUpdate(t *testing.T) { mock.ExpectQuery("SELECT COALESCE\\(current_task"). WithArgs("ws-spend-zero"). - WillReturnRows(sqlmock.NewRows([]string{"current_task"}).AddRow("")) + WillReturnRows(sqlmock.NewRows([]string{"current_task", "monthly_spend", "status"}).AddRow("", 0, "online")) // 6-argument UPDATE — monthly_spend NOT included. mock.ExpectExec("UPDATE workspaces SET"). @@ -2457,7 +2473,7 @@ func TestHeartbeatHandler_DeliversPlatformInboundSecret(t *testing.T) { mock.ExpectQuery("SELECT COALESCE\\(current_task"). WithArgs("ws-with-secret"). - WillReturnRows(sqlmock.NewRows([]string{"current_task"}).AddRow("")) + WillReturnRows(sqlmock.NewRows([]string{"current_task", "monthly_spend", "status"}).AddRow("", 0, "online")) mock.ExpectExec("UPDATE workspaces SET"). WithArgs("ws-with-secret", 0.0, "", 0, 100, ""). @@ -2512,7 +2528,7 @@ func TestHeartbeatHandler_LazyHealsPlatformInboundSecret(t *testing.T) { mock.ExpectQuery("SELECT COALESCE\\(current_task"). WithArgs("ws-needs-heal"). - WillReturnRows(sqlmock.NewRows([]string{"current_task"}).AddRow("")) + WillReturnRows(sqlmock.NewRows([]string{"current_task", "monthly_spend", "status"}).AddRow("", 0, "online")) mock.ExpectExec("UPDATE workspaces SET"). WithArgs("ws-needs-heal", 0.0, "", 0, 100, ""). @@ -2568,7 +2584,7 @@ func TestHeartbeatHandler_OmitsSecretOnHealFailure(t *testing.T) { mock.ExpectQuery("SELECT COALESCE\\(current_task"). WithArgs("ws-heal-fails"). - WillReturnRows(sqlmock.NewRows([]string{"current_task"}).AddRow("")) + WillReturnRows(sqlmock.NewRows([]string{"current_task", "monthly_spend", "status"}).AddRow("", 0, "online")) mock.ExpectExec("UPDATE workspaces SET"). WithArgs("ws-heal-fails", 0.0, "", 0, 100, ""). @@ -2933,7 +2949,7 @@ func TestHeartbeat_RecentRegisterFailure_DegradesWorkspace(t *testing.T) { // prevTask SELECT mock.ExpectQuery("SELECT COALESCE\\(current_task"). WithArgs("ws-degrade-reg"). - WillReturnRows(sqlmock.NewRows([]string{"current_task"}).AddRow("")) + WillReturnRows(sqlmock.NewRows([]string{"current_task", "monthly_spend", "status"}).AddRow("", 0, "online")) // heartbeat UPDATE mock.ExpectExec("UPDATE workspaces SET"). @@ -2983,7 +2999,7 @@ func TestHeartbeat_RecentRegisterFailure_BlocksRecovery(t *testing.T) { // prevTask SELECT mock.ExpectQuery("SELECT COALESCE\\(current_task"). WithArgs("ws-no-recover"). - WillReturnRows(sqlmock.NewRows([]string{"current_task"}).AddRow("")) + WillReturnRows(sqlmock.NewRows([]string{"current_task", "monthly_spend", "status"}).AddRow("", 0, "online")) // heartbeat UPDATE mock.ExpectExec("UPDATE workspaces SET"). -- 2.52.0 From e715b6d6d6992b98687083c22add4a8117187930 Mon Sep 17 00:00:00 2001 From: core-devops Date: Tue, 16 Jun 2026 15:45:12 -0700 Subject: [PATCH 2/3] =?UTF-8?q?ci(template-delivery-e2e):=20run=20on=20reg?= =?UTF-8?q?istry.go=20=E2=80=94=20the=20reconcile=20trigger=20lives=20ther?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reconcile fires from the heartbeat handler (registry.go), but registry.go was absent from this gate's path filter, so the exact change that fixes the #32 reconcile-never-fires regression would not trigger its own CI mirror. Add registry.go to both push + pull_request path filters. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/template-delivery-e2e.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitea/workflows/template-delivery-e2e.yml b/.gitea/workflows/template-delivery-e2e.yml index 8f2ee21d..3d5e2ca6 100644 --- a/.gitea/workflows/template-delivery-e2e.yml +++ b/.gitea/workflows/template-delivery-e2e.yml @@ -39,6 +39,7 @@ on: - 'workspace-server/internal/handlers/workspace.go' - 'workspace-server/internal/handlers/template_plugins.go' - 'workspace-server/internal/handlers/plugins_reconcile.go' + - 'workspace-server/internal/handlers/registry.go' - 'workspace-server/internal/handlers/plugins_install_pipeline.go' - 'workspace-server/internal/handlers/plugins_tracking.go' - 'workspace-server/internal/plugins/source.go' @@ -56,6 +57,7 @@ on: - 'workspace-server/internal/handlers/workspace.go' - 'workspace-server/internal/handlers/template_plugins.go' - 'workspace-server/internal/handlers/plugins_reconcile.go' + - 'workspace-server/internal/handlers/registry.go' - 'workspace-server/internal/handlers/plugins_install_pipeline.go' - 'workspace-server/internal/handlers/plugins_tracking.go' - 'workspace-server/internal/plugins/source.go' -- 2.52.0 From dacca45459ea98ff25a6970757980bf7b92a0b6d Mon Sep 17 00:00:00 2001 From: core-devops Date: Tue, 16 Jun 2026 15:46:46 -0700 Subject: [PATCH 3/3] =?UTF-8?q?Revert=20"ci(template-delivery-e2e):=20run?= =?UTF-8?q?=20on=20registry.go=20=E2=80=94=20the=20reconcile=20trigger=20l?= =?UTF-8?q?ives=20there"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit e715b6d6d6992b98687083c22add4a8117187930. --- .gitea/workflows/template-delivery-e2e.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.gitea/workflows/template-delivery-e2e.yml b/.gitea/workflows/template-delivery-e2e.yml index 3d5e2ca6..8f2ee21d 100644 --- a/.gitea/workflows/template-delivery-e2e.yml +++ b/.gitea/workflows/template-delivery-e2e.yml @@ -39,7 +39,6 @@ on: - 'workspace-server/internal/handlers/workspace.go' - 'workspace-server/internal/handlers/template_plugins.go' - 'workspace-server/internal/handlers/plugins_reconcile.go' - - 'workspace-server/internal/handlers/registry.go' - 'workspace-server/internal/handlers/plugins_install_pipeline.go' - 'workspace-server/internal/handlers/plugins_tracking.go' - 'workspace-server/internal/plugins/source.go' @@ -57,7 +56,6 @@ on: - 'workspace-server/internal/handlers/workspace.go' - 'workspace-server/internal/handlers/template_plugins.go' - 'workspace-server/internal/handlers/plugins_reconcile.go' - - 'workspace-server/internal/handlers/registry.go' - 'workspace-server/internal/handlers/plugins_install_pipeline.go' - 'workspace-server/internal/handlers/plugins_tracking.go' - 'workspace-server/internal/plugins/source.go' -- 2.52.0