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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user