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 } }