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:
@@ -175,7 +175,7 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
|
||||
if strings.TrimSpace(goal) == "" {
|
||||
return "error: set_goal needs a goal", true
|
||||
}
|
||||
if err := a.store.setGoal(ctx, sessionID, goal); err != nil {
|
||||
if err := a.store.SetGoal(ctx, sessionID, goal); err != nil {
|
||||
return fmt.Sprintf("error setting goal: %v", err), true
|
||||
}
|
||||
// P1: the plan window is NOT opened here. Opening it on set_goal
|
||||
@@ -196,7 +196,7 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
|
||||
// goal + summary; if it looks related, search_knowledge or open
|
||||
// the prior session's transcript (GET /sessions/{id}) before
|
||||
// re-planning. See plans/2026-07-20-session-review-ten-sessions.md.
|
||||
prior, _ := a.store.recentPartialSessions(ctx, sessionID, 24*time.Hour)
|
||||
prior, _ := a.store.RecentPartialSessions(ctx, sessionID, 24*time.Hour)
|
||||
if len(prior) > 0 {
|
||||
var b strings.Builder
|
||||
b.WriteString("\n\nNOTE — recent unfinished sessions (last 24h, outcome=partial/failed):")
|
||||
@@ -222,7 +222,7 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
|
||||
|
||||
case "propose_plan":
|
||||
raw, _ := args["steps"].([]any)
|
||||
var steps []planStepInput
|
||||
var steps []PlanStepInput
|
||||
for _, r := range raw {
|
||||
m, ok := r.(map[string]any)
|
||||
if !ok {
|
||||
@@ -234,7 +234,7 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
|
||||
}
|
||||
detail, _ := m["detail"].(string)
|
||||
target, _ := m["target_slug"].(string)
|
||||
steps = append(steps, planStepInput{Title: title, Detail: detail, TargetSlug: target})
|
||||
steps = append(steps, PlanStepInput{Title: title, Detail: detail, TargetSlug: target})
|
||||
}
|
||||
if len(steps) == 0 {
|
||||
return "error: propose_plan needs at least one step with a title", true
|
||||
@@ -263,15 +263,15 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
|
||||
}
|
||||
appendedNote := ""
|
||||
if !hasWritebackStep {
|
||||
steps = append(steps, planStepInput{
|
||||
steps = append(steps, PlanStepInput{
|
||||
Title: "Write back: update_entity_attributes + create_relationship + upsert_knowledge",
|
||||
Detail: "Call update_entity_attributes for every entity you ran against (versions, states, counts, timestamps). Call create_relationship for any edge you discovered. Then upsert_knowledge about the affected entities (pass `about` as an array).",
|
||||
})
|
||||
appendedNote = fmt.Sprintf(" (appended a writeback step — your plan didn't include one; step %d)", len(steps))
|
||||
}
|
||||
persisted, err := a.store.proposePlan(ctx, sessionID, steps)
|
||||
persisted, err := a.store.ProposePlan(ctx, sessionID, steps)
|
||||
if err != nil {
|
||||
if errors.Is(err, errPlanInFlight) {
|
||||
if errors.Is(err, session.ErrPlanInFlight) {
|
||||
// The plan is already in flight — refuse the re-proposal.
|
||||
// The agent must advance the existing plan with
|
||||
// update_plan_step + run. This is the structural fix for
|
||||
@@ -308,8 +308,8 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
|
||||
return "error: update_plan_step needs seq (>=1) and status", true
|
||||
}
|
||||
reason, _ := args["replaced_reason"].(string)
|
||||
if err := a.store.updatePlanStep(ctx, sessionID, seq, status, execID, reason); err != nil {
|
||||
if errors.Is(err, errPlanStepNotFound) {
|
||||
if err := a.store.UpdatePlanStep(ctx, sessionID, seq, status, execID, reason); err != nil {
|
||||
if errors.Is(err, session.ErrPlanStepNotFound) {
|
||||
// The seq doesn't address a step in the CURRENT plan — most
|
||||
// often a stale 1-based number the model carried across a
|
||||
// re-plan, or an out-of-range seq. seq is generation-relative
|
||||
@@ -337,7 +337,7 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
|
||||
if ents := toStringSlice(args["context_entities"]); len(ents) > 0 {
|
||||
qctx["entities"] = ents
|
||||
}
|
||||
if _, err := a.store.askOperator(ctx, sessionID, prompt, qctx); err != nil {
|
||||
if _, err := a.store.AskOperator(ctx, sessionID, prompt, qctx); err != nil {
|
||||
return fmt.Sprintf("error posting question: %v", err), true
|
||||
}
|
||||
return "Question posted to the operator; the task is paused until they answer. " +
|
||||
@@ -372,7 +372,7 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
|
||||
// then retry complete_task. Only blocks `success`; an explicit
|
||||
// `failure` or `partial` is allowed through (the agent is
|
||||
// acknowledging it didn't finish — no reason to force writeback).
|
||||
if outcome == "success" && a.store.hadDiscovery(ctx, sessionID) && !a.store.hadEntityWriteback(ctx, sessionID) {
|
||||
if outcome == "success" && a.store.HadDiscovery(ctx, sessionID) && !a.store.HadEntityWriteback(ctx, sessionID) {
|
||||
return "Refused: this session ran `run` against live targets (discovery) but did not call update_entity_attributes or create_relationship to persist what you learned. The knowledge graph will drift if you complete without writeback. Call update_entity_attributes for each entity you ran against (versions, states, counts, timestamps), and create_relationship for any edge you discovered, then call complete_task again. Outcome is held at 'executing' until you do.", true
|
||||
}
|
||||
// D.2: refuse success when the goal mentions a reachability/uptime
|
||||
@@ -381,20 +381,20 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
|
||||
// return 200 for a terminal page (ttyd) or fallback while the actual
|
||||
// dashboard is still down. Must call ping_service or run a successful
|
||||
// curl before claiming success.
|
||||
if outcome == "success" && a.store.hadDiscovery(ctx, sessionID) {
|
||||
goal := a.store.sessionGoal(ctx, sessionID)
|
||||
if mentionsReachability(goal) && !a.store.hadRecentVerification(ctx, sessionID) {
|
||||
if outcome == "success" && a.store.HadDiscovery(ctx, sessionID) {
|
||||
goal := a.store.SessionGoal(ctx, sessionID)
|
||||
if mentionsReachability(goal) && !a.store.HadRecentVerification(ctx, sessionID) {
|
||||
return "Refused: the goal involves a reachability or uptime check (\"make X reachable\", \"get X up\", etc.), but no ping_service call or successful curl/HTTP request against the target was detected. Caddy can return 200 for a terminal or fallback page while the actual service is still down — you must verify the service itself, not just the proxy. Call ping_service(target) or run a curl against the actual service URL, then call complete_task again. Outcome held until verified.", true
|
||||
}
|
||||
}
|
||||
if err := a.store.completeTask(ctx, sessionID, outcome, summary); err != nil {
|
||||
if errors.Is(err, errTaskAlreadyComplete) {
|
||||
if err := a.store.CompleteTask(ctx, sessionID, outcome, summary); err != nil {
|
||||
if errors.Is(err, session.ErrTaskAlreadyComplete) {
|
||||
return "Task is already complete. Do not call complete_task again. If the operator pointed out a UI/sidebar inconsistency, fix it with update_plan_step (reconcile step states) or summarize the panel in your reply — do not re-execute the work.", true
|
||||
}
|
||||
return fmt.Sprintf("error completing task: %v", err), true
|
||||
}
|
||||
result := fmt.Sprintf("Task marked %s: %s", outcome, summary)
|
||||
if !a.store.hadEntityWriteback(ctx, sessionID) {
|
||||
if !a.store.HadEntityWriteback(ctx, sessionID) {
|
||||
result += "\n\n⚠️ No entity attributes or relationships were updated in this session. Call update_entity_attributes and create_relationship to persist what you learned about entities before the next session starts from scratch."
|
||||
}
|
||||
return result, true
|
||||
@@ -445,7 +445,7 @@ func (a *agent) autoCompleteTrivialTask(ctx context.Context, sessionID, response
|
||||
if summary == "" {
|
||||
summary = "Answered without further action needed."
|
||||
}
|
||||
if err := a.store.completeTask(ctx, sessionID, "success", summary); err != nil {
|
||||
if err := a.store.CompleteTask(ctx, sessionID, "success", summary); err != nil {
|
||||
slog.Error("nomos: auto-complete trivial task failed", "session", sessionID, "error", err)
|
||||
}
|
||||
}
|
||||
@@ -464,7 +464,7 @@ func (a *agent) autoCompleteIfPlanDone(ctx context.Context, sessionID, responseT
|
||||
if a.store == nil || sessionID == "" || sessionID == "ephemeral" {
|
||||
return
|
||||
}
|
||||
sess, err := a.store.getSession(ctx, sessionID)
|
||||
sess, err := a.store.GetSession(ctx, sessionID)
|
||||
if err != nil || sess.Status != "executing" {
|
||||
return
|
||||
}
|
||||
@@ -474,13 +474,13 @@ func (a *agent) autoCompleteIfPlanDone(ctx context.Context, sessionID, responseT
|
||||
// dead task. Confirmed in eval: agent hits P5 approval gate, turn
|
||||
// ends, auto-complete fires incorrectly because the approval-queue
|
||||
// `run` responses were logged as success=true in agent_activity.
|
||||
if a.store.hasPendingApprovals(ctx, sessionID) {
|
||||
if a.store.HasPendingApprovals(ctx, sessionID) {
|
||||
return
|
||||
}
|
||||
discovery := a.store.hadDiscovery(ctx, sessionID)
|
||||
writeback := a.store.hadEntityWriteback(ctx, sessionID)
|
||||
discovery := a.store.HadDiscovery(ctx, sessionID)
|
||||
writeback := a.store.HadEntityWriteback(ctx, sessionID)
|
||||
// (a) all plan steps terminal, OR (b) agent did discovery (ran `run`).
|
||||
shouldComplete := a.store.allPlanStepsTerminal(ctx, sessionID)
|
||||
shouldComplete := a.store.AllPlanStepsTerminal(ctx, sessionID)
|
||||
if !shouldComplete && discovery {
|
||||
shouldComplete = true
|
||||
}
|
||||
@@ -500,7 +500,7 @@ func (a *agent) autoCompleteIfPlanDone(ctx context.Context, sessionID, responseT
|
||||
if summary == "" {
|
||||
summary = "All plan steps completed."
|
||||
}
|
||||
if err := a.store.completeTask(ctx, sessionID, outcome, summary); err != nil {
|
||||
if err := a.store.CompleteTask(ctx, sessionID, outcome, summary); err != nil {
|
||||
slog.Error("nomos: auto-complete plan-done task failed", "session", sessionID, "error", err)
|
||||
} else {
|
||||
slog.Info("nomos: auto-completed task — agent didn't call complete_task", "session", sessionID, "outcome", outcome)
|
||||
|
||||
Reference in New Issue
Block a user