Files
oikos/internal/execworker/worker.go
dtoro b98d7c24bf
Some checks are pending
ci / build-test (push) Waiting to run
ci / docker-build (push) Waiting to run
feat: Phase 9 gaps closed — ApprovalService.Decide convergence, execlog fold, execworker poller
- ApprovalService (core/app/approval.go) + ApprovalRepo (postgres adapter) with
  full decide transaction: HMAC token verify, approval flip, execution un-gate,
  session-scoped window keys (+session suffix matching GovernanceStore gate),
  nomos session flip, audit+event on failure abort. httpapi DecideApproval now
  a thin presenter delegating to the service. ListPending payload format fixed
  (json.Unmarshal not raw-wrap).
- execlog folded into postgres adapter: internal/execlog deleted, NewExecutionLog
  / ReadExecutionLog live in the db package, callers updated (mcp, httpapi).
- execworker poller over ExecutionService.DispatchQueued: advisory lock leak
  fixed (defer/recover per execution), correlation_id preserved via Finalize
  event emission (ExecRunRepo.Finalize now emits execution.{status} with
  correlation_id from the row).
- Phase 8 session export-rename completed: Store, New, and all 53 methods
  exported; cmd/nomos/ agent.go fixed to use session.PendingContinuation etc.
- Coverage gates: ExecutionService.Submit 93.1%, PolicyService.Decide 100%.
- Plans index updated, VERSION bumped to 0.36.0.
2026-08-16 12:29:59 +02:00

133 lines
4.2 KiB
Go

// Package execworker processes pending executions as a background daemon.
// This provides a Postgres-backed queue: executions survive restarts, and
// per-execution advisory locks prevent duplicate processing across instances.
//
// Since Phase 4 of the hexagonal refactor, the dispatch logic lives in
// ExecutionService.DispatchQueued (over CommandExecutor + TargetResolver
// ports); this package is a thin poller adapter that claims queued rows and
// delegates to the service.
package execworker
import (
"context"
"log/slog"
"time"
"github.com/dtoro/oikos/internal/adapters/postgres"
"github.com/dtoro/oikos/internal/adapters/remote"
"github.com/dtoro/oikos/internal/adapters/ssh"
"github.com/dtoro/oikos/internal/config"
"github.com/dtoro/oikos/internal/core/app"
"github.com/dtoro/oikos/internal/core/domain"
"github.com/dtoro/oikos/internal/health"
"github.com/google/uuid"
)
// Run starts the execution worker loop. Blocks until ctx is cancelled.
func Run(ctx context.Context, pool *db.Pool, cfg config.Config) {
slog.Info("execworker: starting")
// Build the ExecutionService with the same wiring as cmd/oikos/main.go.
executor := ssh.NewExecutor(ssh.FileSignerSource(), 5*time.Minute)
resolver := remote.NewResolver(pool)
policySvc := app.NewPolicyService(db.NewGovernanceRepo(pool))
execSvc := app.NewExecutionService(policySvc, executor, resolver, db.NewExecRunRepo(pool))
probe := health.New(2 * time.Minute)
probe.Serve(ctx, cfg.HealthListen)
recoverOrphaned(ctx, pool)
probe.Bump()
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
slog.Info("execworker: shutting down")
return
case <-ticker.C:
processPending(ctx, pool, execSvc)
probe.Bump()
}
}
}
// recoverOrphaned marks executions stuck in 'running' as failed.
func recoverOrphaned(ctx context.Context, pool *db.Pool) {
tag, err := pool.Exec(ctx, `UPDATE executions SET status = 'failed', result = '{"error":"worker restarted while execution was running"}'::jsonb, completed_at = now() WHERE status = 'running'`)
if err != nil {
slog.Error("execworker: recover orphaned", "error", err)
return
}
if tag.RowsAffected() > 0 {
slog.Warn("execworker: recovered orphaned executions", "count", tag.RowsAffected())
}
}
// processPending polls for pending executions and dispatches them via
// ExecutionService.DispatchQueued.
func processPending(ctx context.Context, pool *db.Pool, execSvc *app.ExecutionService) {
rows, err := pool.Query(ctx, `
SELECT e.entity_id, COALESCE(t.slug, '') AS target_slug, e.action
FROM executions e
LEFT JOIN entities t ON t.id = e.target_entity_id
WHERE e.status = 'proposed'
ORDER BY e.created_at ASC
LIMIT 10`)
if err != nil {
slog.Error("execworker: query pending", "error", err)
return
}
defer rows.Close()
for rows.Next() {
var execID uuid.UUID
var targetSlug, action string
if err := rows.Scan(&execID, &targetSlug, &action); err != nil {
slog.Error("execworker: scan row", "error", err)
continue
}
// At-most-once: try advisory lock on execution entity_id.
lockKey := hashUUID(execID)
lockConn, err := pool.Acquire(ctx)
if err != nil {
slog.Error("execworker: acquire lock conn", "error", err)
continue
}
var locked bool
if err := lockConn.QueryRow(ctx, "SELECT pg_try_advisory_lock($1)", lockKey).Scan(&locked); err != nil || !locked {
lockConn.Release()
continue
}
// Release the lock on the same connection even if the dispatch
// panics — an un-released advisory lock would permanently orphan the
// execution (every future worker skips it at pg_try_advisory_lock).
func() {
defer func() {
if r := recover(); r != nil {
slog.Error("execworker: panic dispatching execution", "execution", execID, "panic", r)
}
lockConn.Exec(context.WithoutCancel(ctx), "SELECT pg_advisory_unlock($1)", lockKey)
lockConn.Release()
}()
_, dispatchErr := execSvc.DispatchQueued(ctx, domain.UUID(execID.String()), targetSlug, action)
if dispatchErr != nil {
slog.Error("execworker: dispatch failed", "execution", execID, "error", dispatchErr)
}
}()
}
}
func hashUUID(id uuid.UUID) int {
h := 0
for _, b := range id {
h = (h*31 + int(b)) & 0x7fffffff
}
return h
}