From 265f09c1f2f9b7fa2e0831bc40fecf01a70c96f2 Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 19 Sep 2026 09:16:18 +0000 Subject: [PATCH] fix(server): cache health probes, surface SSE marshal errors, fail closed on missing duties Health checks reuse a two-second snapshot and bound the schema query. Task SSE writes an error frame when an event cannot be encoded. ResolveDocTask errors when the frozen plan has no duty row, while explicit none-duties stay zero. Drop the unwired semantic-summary replace env, record the intentional dev versus release model split, document the test/main cutover path, and archive pre-goose SQL. Co-authored-by: Cursor --- .../pre-goose}/2026-09-09-doc-edit-lease.sql | 0 ...026-09-09-uq-context-memory-chunk-hash.sql | 0 ...26-09-10-chat-daily-digests-and-search.sql | 0 .../2026-09-10-chat-messages-day-window.sql | 0 .../2026-09-11-chat-read-positions.sql | 0 .../pre-goose}/2026-09-11-chat-turns.sql | 0 .../2026-09-11-freeform-documents.sql | 0 .../2026-09-11-message-export-policy.sql | 0 ...26-09-11-project-workflows-and-lineage.sql | 0 .../2026-09-11-seed-ten-projects-chat.sql | 0 .../2026-09-11-seed-ten-projects.sql | 0 .../2026-09-11-task-run-lineage.sql | 0 ...09-16-retire-demo-ten-project-sessions.sql | 0 .../2026-09-16-retire-demo-ten-projects.sql | 0 deploy/postgres/archive/pre-goose/README.md | 4 ++ docker-compose.dev.yml | 4 +- docker-compose.main.yml | 1 + docker-compose.test.yml | 1 + internal/events/cutover.go | 6 +++ internal/events/cutover_test.go | 8 +++- internal/server/handlers.go | 38 +++++++++++++++---- internal/server/server.go | 2 + internal/server/task_run_handlers.go | 8 +++- internal/workflow/runtime/orchestrator.go | 5 +++ 24 files changed, 66 insertions(+), 11 deletions(-) rename deploy/postgres/{migrations => archive/pre-goose}/2026-09-09-doc-edit-lease.sql (100%) rename deploy/postgres/{migrations => archive/pre-goose}/2026-09-09-uq-context-memory-chunk-hash.sql (100%) rename deploy/postgres/{migrations => archive/pre-goose}/2026-09-10-chat-daily-digests-and-search.sql (100%) rename deploy/postgres/{migrations => archive/pre-goose}/2026-09-10-chat-messages-day-window.sql (100%) rename deploy/postgres/{migrations => archive/pre-goose}/2026-09-11-chat-read-positions.sql (100%) rename deploy/postgres/{migrations => archive/pre-goose}/2026-09-11-chat-turns.sql (100%) rename deploy/postgres/{migrations => archive/pre-goose}/2026-09-11-freeform-documents.sql (100%) rename deploy/postgres/{migrations => archive/pre-goose}/2026-09-11-message-export-policy.sql (100%) rename deploy/postgres/{migrations => archive/pre-goose}/2026-09-11-project-workflows-and-lineage.sql (100%) rename deploy/postgres/{migrations => archive/pre-goose}/2026-09-11-seed-ten-projects-chat.sql (100%) rename deploy/postgres/{migrations => archive/pre-goose}/2026-09-11-seed-ten-projects.sql (100%) rename deploy/postgres/{migrations => archive/pre-goose}/2026-09-11-task-run-lineage.sql (100%) rename deploy/postgres/{migrations => archive/pre-goose}/2026-09-16-retire-demo-ten-project-sessions.sql (100%) rename deploy/postgres/{migrations => archive/pre-goose}/2026-09-16-retire-demo-ten-projects.sql (100%) create mode 100644 deploy/postgres/archive/pre-goose/README.md diff --git a/deploy/postgres/migrations/2026-09-09-doc-edit-lease.sql b/deploy/postgres/archive/pre-goose/2026-09-09-doc-edit-lease.sql similarity index 100% rename from deploy/postgres/migrations/2026-09-09-doc-edit-lease.sql rename to deploy/postgres/archive/pre-goose/2026-09-09-doc-edit-lease.sql diff --git a/deploy/postgres/migrations/2026-09-09-uq-context-memory-chunk-hash.sql b/deploy/postgres/archive/pre-goose/2026-09-09-uq-context-memory-chunk-hash.sql similarity index 100% rename from deploy/postgres/migrations/2026-09-09-uq-context-memory-chunk-hash.sql rename to deploy/postgres/archive/pre-goose/2026-09-09-uq-context-memory-chunk-hash.sql diff --git a/deploy/postgres/migrations/2026-09-10-chat-daily-digests-and-search.sql b/deploy/postgres/archive/pre-goose/2026-09-10-chat-daily-digests-and-search.sql similarity index 100% rename from deploy/postgres/migrations/2026-09-10-chat-daily-digests-and-search.sql rename to deploy/postgres/archive/pre-goose/2026-09-10-chat-daily-digests-and-search.sql diff --git a/deploy/postgres/migrations/2026-09-10-chat-messages-day-window.sql b/deploy/postgres/archive/pre-goose/2026-09-10-chat-messages-day-window.sql similarity index 100% rename from deploy/postgres/migrations/2026-09-10-chat-messages-day-window.sql rename to deploy/postgres/archive/pre-goose/2026-09-10-chat-messages-day-window.sql diff --git a/deploy/postgres/migrations/2026-09-11-chat-read-positions.sql b/deploy/postgres/archive/pre-goose/2026-09-11-chat-read-positions.sql similarity index 100% rename from deploy/postgres/migrations/2026-09-11-chat-read-positions.sql rename to deploy/postgres/archive/pre-goose/2026-09-11-chat-read-positions.sql diff --git a/deploy/postgres/migrations/2026-09-11-chat-turns.sql b/deploy/postgres/archive/pre-goose/2026-09-11-chat-turns.sql similarity index 100% rename from deploy/postgres/migrations/2026-09-11-chat-turns.sql rename to deploy/postgres/archive/pre-goose/2026-09-11-chat-turns.sql diff --git a/deploy/postgres/migrations/2026-09-11-freeform-documents.sql b/deploy/postgres/archive/pre-goose/2026-09-11-freeform-documents.sql similarity index 100% rename from deploy/postgres/migrations/2026-09-11-freeform-documents.sql rename to deploy/postgres/archive/pre-goose/2026-09-11-freeform-documents.sql diff --git a/deploy/postgres/migrations/2026-09-11-message-export-policy.sql b/deploy/postgres/archive/pre-goose/2026-09-11-message-export-policy.sql similarity index 100% rename from deploy/postgres/migrations/2026-09-11-message-export-policy.sql rename to deploy/postgres/archive/pre-goose/2026-09-11-message-export-policy.sql diff --git a/deploy/postgres/migrations/2026-09-11-project-workflows-and-lineage.sql b/deploy/postgres/archive/pre-goose/2026-09-11-project-workflows-and-lineage.sql similarity index 100% rename from deploy/postgres/migrations/2026-09-11-project-workflows-and-lineage.sql rename to deploy/postgres/archive/pre-goose/2026-09-11-project-workflows-and-lineage.sql diff --git a/deploy/postgres/migrations/2026-09-11-seed-ten-projects-chat.sql b/deploy/postgres/archive/pre-goose/2026-09-11-seed-ten-projects-chat.sql similarity index 100% rename from deploy/postgres/migrations/2026-09-11-seed-ten-projects-chat.sql rename to deploy/postgres/archive/pre-goose/2026-09-11-seed-ten-projects-chat.sql diff --git a/deploy/postgres/migrations/2026-09-11-seed-ten-projects.sql b/deploy/postgres/archive/pre-goose/2026-09-11-seed-ten-projects.sql similarity index 100% rename from deploy/postgres/migrations/2026-09-11-seed-ten-projects.sql rename to deploy/postgres/archive/pre-goose/2026-09-11-seed-ten-projects.sql diff --git a/deploy/postgres/migrations/2026-09-11-task-run-lineage.sql b/deploy/postgres/archive/pre-goose/2026-09-11-task-run-lineage.sql similarity index 100% rename from deploy/postgres/migrations/2026-09-11-task-run-lineage.sql rename to deploy/postgres/archive/pre-goose/2026-09-11-task-run-lineage.sql diff --git a/deploy/postgres/migrations/2026-09-16-retire-demo-ten-project-sessions.sql b/deploy/postgres/archive/pre-goose/2026-09-16-retire-demo-ten-project-sessions.sql similarity index 100% rename from deploy/postgres/migrations/2026-09-16-retire-demo-ten-project-sessions.sql rename to deploy/postgres/archive/pre-goose/2026-09-16-retire-demo-ten-project-sessions.sql diff --git a/deploy/postgres/migrations/2026-09-16-retire-demo-ten-projects.sql b/deploy/postgres/archive/pre-goose/2026-09-16-retire-demo-ten-projects.sql similarity index 100% rename from deploy/postgres/migrations/2026-09-16-retire-demo-ten-projects.sql rename to deploy/postgres/archive/pre-goose/2026-09-16-retire-demo-ten-projects.sql diff --git a/deploy/postgres/archive/pre-goose/README.md b/deploy/postgres/archive/pre-goose/README.md new file mode 100644 index 00000000..ddcc29f9 --- /dev/null +++ b/deploy/postgres/archive/pre-goose/README.md @@ -0,0 +1,4 @@ +# Pre-goose SQL archive + +These files predate Goose and are not applied by env-service or any script. +Live schema is deploy/postgres/goose/. Kept for history only. diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 1d753d58..96bb07fa 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -41,6 +41,7 @@ services: DATA_DIR: /app/data/workspaces MINIMAX_ENDPOINT_URL: ${MINIMAX_ENDPOINT_URL:-https://api.minimaxi.com/v1} MINIMAX_API_KEY: ${MINIMAX_API_KEY:-} + # dev iterates on highspeed; test/main pin MiniMax-M3. Do not align without a promotion decision. MINIMAX_MODEL_ID: ${MINIMAX_MODEL_ID:-MiniMax-M2.7-highspeed} MINIMAX_API_TYPE: ${MINIMAX_API_TYPE:-openai-compatible} MINIMAX_PROVIDER: minimax @@ -114,9 +115,8 @@ services: MULTI_INTENT_U10_MUTATE: ${MULTI_INTENT_U10_MUTATE:-false} MULTI_INTENT_U11_DOCGOV: ${MULTI_INTENT_U11_DOCGOV:-false} MULTI_INTENT_U12_RUN_CONTROL: ${MULTI_INTENT_U12_RUN_CONTROL:-false} - # W2 PU6: shadow semantic summary; REPLACE stays unwired even if env is true + # W2 PU6: shadow semantic summary only. REPLACE stays unwired and is not declared here. SEMANTIC_SUMMARY_SHADOW: ${SEMANTIC_SUMMARY_SHADOW:-false} - SEMANTIC_SUMMARY_REPLACE: ${SEMANTIC_SUMMARY_REPLACE:-false} PI_CHANGE_REPORT_NARRATIVE: ${PI_CHANGE_REPORT_NARRATIVE:-false} # FAV U7: keep degraded read + in-process publisher off until separate approval FAV_DEGRADED_READ: ${FAV_DEGRADED_READ:-false} diff --git a/docker-compose.main.yml b/docker-compose.main.yml index f21fa10c..402d4f32 100644 --- a/docker-compose.main.yml +++ b/docker-compose.main.yml @@ -41,6 +41,7 @@ services: DATA_DIR: /app/data/workspaces MINIMAX_ENDPOINT_URL: ${MINIMAX_ENDPOINT_URL:-https://api.minimaxi.com/v1} MINIMAX_API_KEY: ${MINIMAX_API_KEY:-} + # Release pin. dev uses MiniMax-M2.7-highspeed on purpose. MINIMAX_MODEL_ID: ${MINIMAX_MODEL_ID:-MiniMax-M3} MINIMAX_API_TYPE: ${MINIMAX_API_TYPE:-openai-compatible} MINIMAX_PROVIDER: minimax diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 5538a08b..5fba70ff 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -41,6 +41,7 @@ services: DATA_DIR: /app/data/workspaces MINIMAX_ENDPOINT_URL: ${MINIMAX_ENDPOINT_URL:-https://api.minimaxi.com/v1} MINIMAX_API_KEY: ${MINIMAX_API_KEY:-} + # Release pin. dev uses MiniMax-M2.7-highspeed on purpose. MINIMAX_MODEL_ID: ${MINIMAX_MODEL_ID:-MiniMax-M3} MINIMAX_API_TYPE: ${MINIMAX_API_TYPE:-openai-compatible} MINIMAX_PROVIDER: minimax diff --git a/internal/events/cutover.go b/internal/events/cutover.go index c5078b8a..ce0713cd 100644 --- a/internal/events/cutover.go +++ b/internal/events/cutover.go @@ -12,6 +12,12 @@ import ( const OpsCutoverEnvironment = "dev" +// OpsCutoverPromotionPath is how test and main change consumer modes. +// ApplyOpsCutover stays dev-only. After a dev observation window, copy the +// reviewed mode with an authorized promotion receipt (SQL or migration), first +// on test, then on main. Do not widen ValidateOpsCutover to accept those envs. +const OpsCutoverPromotionPath = "dev-observe -> promotion-receipt on test -> same receipt on main; ApplyOpsCutover rejects non-dev" + // ValidateOpsCutover is the shared-dev gate for cluster mode changes. // test/main, FAV, and unknown consumers are rejected before any write. func ValidateOpsCutover(environment, consumer, mode string) error { diff --git a/internal/events/cutover_test.go b/internal/events/cutover_test.go index 36658e37..e7c16069 100644 --- a/internal/events/cutover_test.go +++ b/internal/events/cutover_test.go @@ -1,6 +1,9 @@ package events -import "testing" +import ( + "strings" + "testing" +) func TestValidateOpsCutoverRejectsFavAndNonDev(t *testing.T) { if err := ValidateOpsCutover("dev", ConsumerNotify, ModeShadow); err != nil { @@ -12,6 +15,9 @@ func TestValidateOpsCutoverRejectsFavAndNonDev(t *testing.T) { if err := ValidateOpsCutover("main", ConsumerNotify, ModeLive); err == nil { t.Fatal("main env must fail") } + if !strings.Contains(OpsCutoverPromotionPath, "test") || !strings.Contains(OpsCutoverPromotionPath, "main") { + t.Fatalf("promotion path must name test and main: %s", OpsCutoverPromotionPath) + } if err := ValidateOpsCutover("dev", ConsumerFAV, ModeOff); err == nil { t.Fatal("fav must fail even at off") } diff --git a/internal/server/handlers.go b/internal/server/handlers.go index f527f1a8..f7d333d7 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -86,27 +86,47 @@ type TaskRunRequestDTO struct { } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { - resp, code := s.buildHealthResponse(false) + resp, code := s.buildHealthResponse(r.Context(), false) writeJSONResponse(w, code, resp) } func (s *Server) handleReadiness(w http.ResponseWriter, r *http.Request) { - resp, code := s.buildHealthResponse(true) + resp, code := s.buildHealthResponse(r.Context(), true) writeJSONResponse(w, code, resp) } -func (s *Server) buildHealthResponse(strict bool) (HealthResponse, int) { +type healthSnapshot struct { + at time.Time + strict bool + resp HealthResponse + code int + ok bool +} + +func (s *Server) buildHealthResponse(ctx context.Context, strict bool) (HealthResponse, int) { + const healthCacheTTL = 2 * time.Second + s.healthMu.Lock() + if s.healthCache.ok && s.healthCache.strict == strict && time.Since(s.healthCache.at) < healthCacheTTL { + resp, code := s.healthCache.resp, s.healthCache.code + s.healthMu.Unlock() + return resp, code + } + s.healthMu.Unlock() + + dbCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + dbReady := s.svc != nil && s.svc.DatabaseReady() schemaVersion := int64(0) if s.svc != nil { if dbReady { - schemaVersion = s.svc.LiveSchemaVersion(context.Background()) + schemaVersion = s.svc.LiveSchemaVersion(dbCtx) } else { schemaVersion = s.svc.SchemaVersion() } } schemaReady := schemaVersion > 0 || (s.cfg != nil && s.cfg.AllowInMemory) - taskStoreReady := taskrun.TablesReady(context.Background(), s.pool) + taskStoreReady := taskrun.TablesReady(dbCtx, s.pool) status := "ok" code := http.StatusOK @@ -122,7 +142,7 @@ func (s *Server) buildHealthResponse(strict bool) (HealthResponse, int) { kbReady := s.kb != nil && s.kb.Online() - return HealthResponse{ + resp := HealthResponse{ Status: status, Env: s.cfg.Env, AgentBackend: s.cfg.AgentBackend, @@ -133,7 +153,11 @@ func (s *Server) buildHealthResponse(strict bool) (HealthResponse, int) { SchemaVersion: schemaVersion, SchemaReady: schemaReady, TaskStoreReady: taskStoreReady, - }, code + } + s.healthMu.Lock() + s.healthCache = healthSnapshot{at: time.Now(), strict: strict, resp: resp, code: code, ok: true} + s.healthMu.Unlock() + return resp, code } func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) { diff --git a/internal/server/server.go b/internal/server/server.go index 399c5cf4..d894483e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -80,6 +80,8 @@ type Server struct { vaultStop chan struct{} reminderStop chan struct{} loginLimiter *loginRateLimiter + healthMu sync.Mutex + healthCache healthSnapshot } // NewFromRuntime attaches the HTTP adapter to an already-wired composition root. diff --git a/internal/server/task_run_handlers.go b/internal/server/task_run_handlers.go index edb08c84..a62eb489 100644 --- a/internal/server/task_run_handlers.go +++ b/internal/server/task_run_handlers.go @@ -433,7 +433,13 @@ func (s *Server) handleTaskRunEvents(w http.ResponseWriter, r *http.Request) { writeStoredEvents := func(events []taskrun.StoredEvent) error { for _, stored := range events { - data, _ := json.Marshal(stored.Event) + data, err := json.Marshal(stored.Event) + if err != nil { + if _, werr := fmt.Fprintf(w, "event: error\ndata: {\"error\":\"event_marshal_failed\",\"seq\":%d}\n\n", stored.Seq); werr != nil { + return werr + } + return fmt.Errorf("marshal stored event seq %d: %w", stored.Seq, err) + } if _, err := fmt.Fprintf(w, "id: %d\n", stored.Seq); err != nil { return err } diff --git a/internal/workflow/runtime/orchestrator.go b/internal/workflow/runtime/orchestrator.go index 7431c17a..43ccc717 100644 --- a/internal/workflow/runtime/orchestrator.go +++ b/internal/workflow/runtime/orchestrator.go @@ -66,6 +66,7 @@ func (o *Orchestrator) ResolveDocTask(ctx context.Context, projectID, docTaskIns res.TemplateVersion = def.TemplateVersion } } + matchedDuty := false for _, stPlan := range plan.Stages { if stPlan.StageKey != st.StageKey { continue @@ -77,8 +78,12 @@ func (o *Orchestrator) ResolveDocTask(ctx context.Context, projectID, docTaskIns res.AuthorUserID = dtPlan.Duties.AuthorUserID res.ReviewerUserID = dtPlan.Duties.ReviewerUserID res.ApproverUserID = dtPlan.Duties.ApproverUserID + matchedDuty = true } } + if !matchedDuty { + return nil, fmt.Errorf("orchestrator: frozen plan has no duties for stage %s task %s", st.StageKey, dt.TaskKey) + } return res, nil } }