feat(tasks): phase 4 — structured plan steps (set_goal/propose_plan/update_plan_step)
Gives a task a legible, live-advancing plan via three more nomos-local tools: - set_goal(goal): records the task goal, status → planning, emits goal.set. - propose_plan(steps[]): persists ordered steps (clean replace for v1 — a revision starts a new list), status → executing, emits plan.proposed with the persisted steps (id+seq) so the panel can address them. - update_plan_step(seq, status, execution_id?): advances a step, stamping started_at/finished_at, emits plan.step.started/finished. Anchors the event to the step's target entity when it has one. Belt-and-suspenders: when an execution linked to a step reaches a terminal state, the api auto-closes the step (closePlanStepForExecution in emitExecutionEvent) and emits plan.step.finished — so the board stays honest even if the agent forgets to close a step it started. Verified end-to-end: a goal-driven task fired goal.set → plan.proposed → 2× step.started/finished → task.status on the SSE stream; both steps persisted done with start/finish timestamps; status progressed planning→executing→done. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -265,6 +265,138 @@ func (s *store) deleteSession(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// taskEntityPtr returns the task entity id for a session, or nil — used as the
|
||||
// entity_id on task-scoped events so they anchor to the task in the graph.
|
||||
func (s *store) taskEntityPtr(ctx context.Context, sessionID string) *uuid.UUID {
|
||||
var id uuid.UUID
|
||||
if err := s.pool.QueryRow(ctx,
|
||||
`SELECT entity_id FROM agent_sessions WHERE id = $1`, sessionID).Scan(&id); err != nil || id == uuid.Nil {
|
||||
return nil
|
||||
}
|
||||
return &id
|
||||
}
|
||||
|
||||
// setGoal records the task's goal and moves it into planning. Emits goal.set.
|
||||
func (s *store) setGoal(ctx context.Context, sessionID, goal string) error {
|
||||
if s == nil || sessionID == "" || sessionID == "ephemeral" {
|
||||
return nil
|
||||
}
|
||||
if _, err := s.pool.Exec(ctx,
|
||||
`UPDATE agent_sessions SET goal = $2, status = 'planning', last_active_at = now() WHERE id = $1`,
|
||||
sessionID, goal); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = observability.Event(ctx, sqlcgen.New(s.pool), "goal.set", s.taskEntityPtr(ctx, sessionID),
|
||||
"info", "nomos", sessionID, map[string]any{"goal": goal})
|
||||
return nil
|
||||
}
|
||||
|
||||
// planStepInput is one step as the agent proposes it.
|
||||
type planStepInput struct {
|
||||
Title string
|
||||
Detail string
|
||||
TargetSlug string
|
||||
}
|
||||
|
||||
// proposePlan replaces the task's plan with a fresh ordered step list and moves
|
||||
// the task into executing. v1 does a clean replace (delete + insert): revising a
|
||||
// plan mid-flight starts a new list rather than versioning the old one. Emits
|
||||
// plan.proposed with the persisted steps (seq + id) so the panel can render and
|
||||
// later address them by id.
|
||||
func (s *store) proposePlan(ctx context.Context, sessionID string, steps []planStepInput) ([]map[string]any, error) {
|
||||
if s == nil || sessionID == "" || sessionID == "ephemeral" {
|
||||
return nil, nil
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM session_plan_steps WHERE session_id = $1`, sessionID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]map[string]any, 0, len(steps))
|
||||
for i, st := range steps {
|
||||
var targetSlug *string
|
||||
if st.TargetSlug != "" {
|
||||
targetSlug = &st.TargetSlug
|
||||
}
|
||||
var id uuid.UUID
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO session_plan_steps (session_id, seq, title, detail, target_slug)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||
sessionID, i+1, st.Title, st.Detail, targetSlug).Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"id": id.String(), "seq": i + 1, "title": st.Title,
|
||||
"detail": st.Detail, "target_slug": st.TargetSlug,
|
||||
})
|
||||
}
|
||||
if _, err := tx.Exec(ctx,
|
||||
`UPDATE agent_sessions SET status = 'executing', last_active_at = now() WHERE id = $1`, sessionID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Event after commit so subscribers only ever see a persisted plan.
|
||||
_ = observability.Event(ctx, sqlcgen.New(s.pool), "plan.proposed", s.taskEntityPtr(ctx, sessionID),
|
||||
"info", "nomos", sessionID, map[string]any{"steps": out})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// updatePlanStep sets a step's status by seq, stamping started_at/finished_at
|
||||
// and linking an execution if given. Emits plan.step.started (running) or
|
||||
// plan.step.finished (terminal) so the panel advances live. The execution link
|
||||
// is also what lets the api auto-close the step when the execution finishes
|
||||
// (see closePlanStepForExecution).
|
||||
func (s *store) updatePlanStep(ctx context.Context, sessionID string, seq int, status, execID string) error {
|
||||
if s == nil || sessionID == "" || sessionID == "ephemeral" {
|
||||
return nil
|
||||
}
|
||||
stamp := ""
|
||||
switch status {
|
||||
case "running":
|
||||
stamp = ", started_at = COALESCE(started_at, now())"
|
||||
case "done", "failed", "skipped", "blocked":
|
||||
stamp = ", finished_at = now()"
|
||||
}
|
||||
var execPtr *uuid.UUID
|
||||
if id, err := uuid.Parse(execID); err == nil {
|
||||
execPtr = &id
|
||||
}
|
||||
var stepID uuid.UUID
|
||||
var targetSlug *string
|
||||
// stamp is a fixed literal from the switch above — never user input.
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
UPDATE session_plan_steps
|
||||
SET status = $3, execution_id = COALESCE($4, execution_id)`+stamp+`
|
||||
WHERE session_id = $1 AND seq = $2
|
||||
RETURNING id, target_slug`, sessionID, seq, status, execPtr).Scan(&stepID, &targetSlug); err != nil {
|
||||
return err
|
||||
}
|
||||
// Anchor the event to the step's target entity when it has one, else the task.
|
||||
entPtr := s.taskEntityPtr(ctx, sessionID)
|
||||
if targetSlug != nil && *targetSlug != "" {
|
||||
var tid uuid.UUID
|
||||
if s.pool.QueryRow(ctx, `SELECT id FROM entities WHERE slug = $1`, *targetSlug).Scan(&tid) == nil {
|
||||
entPtr = &tid
|
||||
}
|
||||
}
|
||||
evType := "plan.step.finished"
|
||||
if status == "running" {
|
||||
evType = "plan.step.started"
|
||||
}
|
||||
data := map[string]any{"step_id": stepID.String(), "seq": seq, "status": status}
|
||||
if execID != "" {
|
||||
data["execution_id"] = execID
|
||||
}
|
||||
_ = observability.Event(ctx, sqlcgen.New(s.pool), evType, entPtr, "info", "nomos", sessionID, data)
|
||||
return nil
|
||||
}
|
||||
|
||||
// completeTask sets a task's terminal state, outcome, and one-line summary,
|
||||
// mirrors the outcome onto the task entity's attributes (so the board/graph
|
||||
// show it), and publishes task.status for the live context panel. outcome is
|
||||
|
||||
Reference in New Issue
Block a user