feat: Phase 8 — nomos packages extracted into internal/nomos/{turngate,retrycap,messagequeue,assent,session}

Mechanical extraction of nomos internal components into plain-Go subpackages
per the hexagonal plan (ADR 0016 §3.1 rule 3):
  turngate/   — per-session turn serialization (plan 2026-08-03 F1)
  retrycap/   — per-turn run retry cap (maxRunRetries=3)
  messagequeue/ — operator-message queue for busy-turn re-entry (F2)
  assent/     — chat-assent detection (isAssent, isTypedConfirmation,
                ExtractPendingApprovals), decoupled from agent via
                []string input instead of persistedCall
  session/    — store (chat sessions, plan execution, DB persistence),
                migration runner + local emitEvent to break adapter
                dependency

internal/migrate/ — shared migration runner extracted from postgres pool,
                    used by both the oikos postgres adapter and session tests.

session package export-rename finishing touches remain; the four smaller
packages compile with passing tests. Depguard rules and ADR-0016 leaf-note
update deferred to a followup. VERSION 0.35.1.
This commit is contained in:
2026-08-16 10:31:45 +02:00
parent 7c9f4ec79f
commit 7a7d390718
22 changed files with 629 additions and 683 deletions

View File

@@ -14,9 +14,9 @@ import (
"syscall"
"time"
"github.com/dtoro/oikos/internal/nomos/session"
"github.com/dtoro/oikos/internal/safego"
"github.com/dtoro/oikos/internal/secrets"
"github.com/jackc/pgx/v5"
)
func main() {
@@ -90,13 +90,13 @@ func main() {
probe.close()
}
st, err := newStore(ctx, databaseURL)
st, err := session.New(ctx, databaseURL)
if err != nil {
slog.Error("nomos: db connect", "error", err)
os.Exit(1)
}
if st != nil {
defer st.close()
defer st.Close()
}
nAgent, err := newAgent(ctx, clientPool, st, agentSlug, openrouterAPIKey)
@@ -138,7 +138,7 @@ func main() {
case <-ctx.Done():
return
case <-ticker.C:
st.cleanupStaleExecutions(ctx, 10*time.Minute)
st.CleanupStaleExecutions(ctx, 10*time.Minute)
}
}
})
@@ -211,7 +211,7 @@ func sseEvent(w http.ResponseWriter, flusher http.Flusher, event agentEvent) {
flusher.Flush()
}
func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *session.Store) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", 405)
return
@@ -238,7 +238,7 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
// abandoned): there's nothing to resume, and running a "report state"
// turn there is just a spare turn the operator never asked for (P2.1).
if req.Message == "" && req.SessionID != "" {
if sess, err := st.getSession(context.Background(), req.SessionID); err == nil {
if sess, err := st.GetSession(context.Background(), req.SessionID); err == nil {
switch sess.Status {
case "done", "failed", "abandoned":
slog.Info("nomos: reconnect skipped — session already terminal", "session", req.SessionID, "status", sess.Status)
@@ -249,7 +249,7 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
slog.Info("nomos: reconnect", "session", req.SessionID)
safego.Go("nomos:reconnect:"+req.SessionID, func() {
base := "[System: the operator's connection was re-established. The task may have progressed in the background.]"
note := st.enrichResumeNote(context.Background(), req.SessionID, base)
note := st.EnrichResumeNote(context.Background(), req.SessionID, base)
a.resumeSession(context.Background(), req.SessionID, note)
})
// Return 202 so the frontend doesn't try to consume an SSE stream
@@ -299,7 +299,7 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
if sessionID == "" {
title := truncate(req.Message, 80)
sess, err := st.createSession(pctx, title)
sess, err := st.CreateSession(pctx, title)
if err != nil {
slog.Error("nomos: create session", "error", err)
sessionID = "ephemeral"
@@ -313,24 +313,24 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
// execute). reopenSession marks the prior plan's steps as
// `replaced` (proposePlan ignores those) and clears outcome/
// summary. Without this, propose_plan refuses the follow-up with
// errPlanInFlight because the prior steps are all `done`. If the
// ErrPlanInFlight because the prior steps are all `done`. If the
// session is still active, reopen is a no-op — the follow-up is
// just a continuation of in-flight work.
st.reopenSession(pctx, sessionID)
st.touchSession(pctx, sessionID)
st.ReopenSession(pctx, sessionID)
st.TouchSession(pctx, sessionID)
}
slog.Info("nomos: chat", "session", sessionID, "message", truncate(req.Message, 100))
userMsg, _ := json.Marshal(map[string]any{"role": "user", "text": req.Message})
st.saveMessage(pctx, sessionID, "user", userMsg)
st.SaveMessage(pctx, sessionID, "user", userMsg)
// If this task has a pending operator question, the incoming message IS the
// answer — close it so the panel clears. No separate resume needed: this
// chat turn is the resume, and the agent sees the question + answer in its
// replayed history.
if qid := st.openQuestionID(pctx, sessionID); qid != "" {
st.answerQuestion(pctx, sessionID, qid, req.Message)
if qid := st.OpenQuestionID(pctx, sessionID); qid != "" {
st.AnswerQuestion(pctx, sessionID, qid, req.Message)
}
writeEvent(agentEvent{Type: "session", Data: sessionID, SessionID: sessionID})
@@ -343,8 +343,8 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
// real turn server-side. This never stacks concurrent turns — the gate still
// guarantees one in-flight turn per session.
const turnWait = 5 * time.Second
if !a.gate.acquire(sessionID, turnWait) {
a.queue.enqueue(sessionID, req.Message)
if !a.gate.Acquire(sessionID, turnWait) {
a.queue.Enqueue(sessionID, req.Message)
slog.Info("nomos: turn already active, queued operator message", "session", sessionID)
writeEvent(agentEvent{Type: "queued", Data: sessionID, SessionID: sessionID})
writeEvent(agentEvent{Type: "done", Data: map[string]any{
@@ -354,7 +354,7 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
return
}
defer func() {
a.gate.release(sessionID)
a.gate.Release(sessionID)
// Run any message that was queued while this turn held the gate. In a
// goroutine so the HTTP response finishes without waiting on the next
// turn; the queued turn has no SSE client of its own.
@@ -394,7 +394,7 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
})
}
func handleSessionsList(w http.ResponseWriter, r *http.Request, st *store) {
func handleSessionsList(w http.ResponseWriter, r *http.Request, st *session.Store) {
if st == nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"sessions": []any{}})
@@ -425,7 +425,7 @@ func handleSessionsList(w http.ResponseWriter, r *http.Request, st *store) {
limit = n
}
}
sessions, err := st.listSessionsFiltered(r.Context(), listFilter{
sessions, err := st.ListSessionsFiltered(r.Context(), ListFilter{
Outcome: q.Get("outcome"),
Status: q.Get("status"),
EntityID: q.Get("entity_id"),
@@ -457,7 +457,7 @@ func handleSessionsList(w http.ResponseWriter, r *http.Request, st *store) {
})
}
func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *agent) {
func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *session.Store, a *agent) {
if st == nil {
http.Error(w, "not found", 404)
return
@@ -485,7 +485,7 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *a
// POST /sessions/{id}/resume — the operator asks the agent to continue.
if len(parts) == 2 && parts[1] == "resume" && r.Method == http.MethodPost {
base := "[System: the operator wants you to continue. Pick up where you left off — execute the next step of the plan, diagnose and fix any failures, or report progress if everything is done.]"
note := st.enrichResumeNote(context.Background(), id, base)
note := st.EnrichResumeNote(context.Background(), id, base)
safego.Go("nomos:resume-session", func() { a.resumeSession(context.Background(), id, note) })
w.WriteHeader(202)
return
@@ -503,7 +503,7 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *a
switch parts[1] {
case "plan":
all := r.URL.Query().Has("all") && r.URL.Query().Get("all") != "0" && r.URL.Query().Get("all") != "false"
steps, err := st.getPlanSteps(r.Context(), id, all)
steps, err := st.GetPlanSteps(r.Context(), id, all)
if err != nil {
http.Error(w, err.Error(), 500)
return
@@ -512,7 +512,7 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *a
json.NewEncoder(w).Encode(map[string]any{"steps": steps})
return
case "questions":
questions, err := st.getQuestions(r.Context(), id)
questions, err := st.GetQuestions(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), 500)
return
@@ -521,7 +521,7 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *a
json.NewEncoder(w).Encode(map[string]any{"questions": questions})
return
case "tool_calls":
calls, err := st.getSessionToolCalls(r.Context(), id)
calls, err := st.GetSessionToolCalls(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), 500)
return
@@ -534,7 +534,7 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *a
switch r.Method {
case http.MethodDelete:
if err := st.deleteSession(r.Context(), id); err != nil {
if err := st.DeleteSession(r.Context(), id); err != nil {
http.Error(w, err.Error(), 500)
return
}
@@ -551,7 +551,7 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *a
// pending_approvals, message_count, tool_call_count, etc. The
// messages field is unchanged. Clients that only read
// `messages` keep working.
sess, err := st.getSession(r.Context(), id)
sess, err := st.GetSession(r.Context(), id)
if err != nil {
if err == pgx.ErrNoRows {
http.Error(w, "session not found", 404)
@@ -560,7 +560,7 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *a
http.Error(w, err.Error(), 500)
return
}
messages, err := st.getMessages(r.Context(), id)
messages, err := st.GetMessages(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), 500)
return
@@ -580,7 +580,7 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *a
// handleAnswerQuestion records the operator's answer to a pinned question and
// resumes the agent in the background with that answer injected. Returns 202 —
// the agent's response lands via the normal message-polling path, not this POST.
func handleAnswerQuestion(w http.ResponseWriter, r *http.Request, st *store, a *agent, sessionID, questionID string) {
func handleAnswerQuestion(w http.ResponseWriter, r *http.Request, st *session.Store, a *agent, sessionID, questionID string) {
var req struct {
Answer string `json:"answer"`
}
@@ -588,15 +588,15 @@ func handleAnswerQuestion(w http.ResponseWriter, r *http.Request, st *store, a *
http.Error(w, "answer is required", 400)
return
}
prompt, _, _ := st.getQuestion(r.Context(), questionID)
if err := st.answerQuestion(r.Context(), sessionID, questionID, req.Answer); err != nil {
prompt, _, _ := st.GetQuestion(r.Context(), questionID)
if err := st.AnswerQuestion(r.Context(), sessionID, questionID, req.Answer); err != nil {
http.Error(w, err.Error(), 500)
return
}
if a != nil {
base := fmt.Sprintf("[System: the operator answered your question %q with: %q. "+
"Continue the task from here — do not re-ask.]", prompt, req.Answer)
note := st.enrichResumeNote(context.Background(), sessionID, base)
note := st.EnrichResumeNote(context.Background(), sessionID, base)
safego.Go("nomos:resume-session", func() { a.resumeSession(context.Background(), sessionID, note) })
}
w.WriteHeader(202)