fix(tasks): plan panel showed only the latest step, not the full plan

Root cause: proposePlan unconditionally deleted and replaced the whole
session_plan_steps list on every call. The model isn't strictly held to
"call propose_plan once with the full list" — nothing stopped it (and
production evidence + live testing showed it happening) from calling
propose_plan once per step as it worked. Each such call wiped every
already-completed step, so the operator only ever saw the model's latest
single step ("1/1") instead of the real, growing plan.

Fix, two layers:
- store.go: proposePlan now only does a destructive replace when no step
  has left 'pending' yet (a genuine pre-execution revision). Once any step
  has started, a new call APPENDS after the current max seq instead of
  wiping — so the panel accumulates the full history regardless of how the
  model chooses to call the tool. plan.proposed now carries `appended` so
  the frontend knows whether to replace or append.
- workspace.ts: plan.proposed handler respects `appended` (update vs set).
- tasks.go / SOUL.md: strengthened the propose_plan description and task-
  loop guidance to call it ONCE with the complete step list end-to-end,
  using update_plan_step (not re-calling propose_plan) to advance — fixing
  the root behavioral cause, with the store-side append as a safety net
  that holds even if the model still calls it incrementally.

Verified: forced the exact incremental-call pattern (propose_plan with 1
step, mark it running, propose_plan again with 1 more step) — the second
call appended at seq 2 instead of erasing seq 1, and its plan.proposed
event carried appended=true.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 14:07:10 +02:00
parent 991e7d0900
commit 5384499903
4 changed files with 63 additions and 26 deletions

View File

@@ -298,11 +298,21 @@ type planStepInput struct {
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.
// proposePlan sets the task's plan and moves it into executing. Emits
// plan.proposed with the persisted steps (seq + id) so the panel can render
// and later address them by id.
//
// Two modes, chosen by whether any existing step has left 'pending':
// - Fresh/revise (no step started yet): full replace (delete + insert). This
// covers the first call, and a genuine re-plan before any work began.
// - Mid-flight (some step is running/done/failed/…): APPEND the new steps
// after the current max seq instead of wiping. The model is instructed to
// propose the whole plan in one call, but nothing stops it from calling
// propose_plan again per-step as it goes — a destructive replace in that
// case would erase every already-completed step, leaving the operator
// seeing only the most recent single step ("1/1") instead of real
// progress. Appending makes the panel's step history correct regardless
// of how the model chooses to call the tool.
func (s *store) proposePlan(ctx context.Context, sessionID string, steps []planStepInput) ([]map[string]any, error) {
if s == nil || sessionID == "" || sessionID == "ephemeral" {
return nil, nil
@@ -313,24 +323,36 @@ func (s *store) proposePlan(ctx context.Context, sessionID string, steps []planS
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, `DELETE FROM session_plan_steps WHERE session_id = $1`, sessionID); err != nil {
var startSeq int
var anyStarted bool
if err := tx.QueryRow(ctx, `
SELECT COALESCE(max(seq), 0), COALESCE(bool_or(status <> 'pending'), false)
FROM session_plan_steps WHERE session_id = $1`, sessionID).Scan(&startSeq, &anyStarted); err != nil {
return nil, err
}
if !anyStarted {
if _, err := tx.Exec(ctx, `DELETE FROM session_plan_steps WHERE session_id = $1`, sessionID); err != nil {
return nil, err
}
startSeq = 0
}
out := make([]map[string]any, 0, len(steps))
for i, st := range steps {
var targetSlug *string
if st.TargetSlug != "" {
targetSlug = &st.TargetSlug
}
seq := startSeq + i + 1
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 {
sessionID, seq, 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,
"id": id.String(), "seq": seq, "title": st.Title,
"detail": st.Detail, "target_slug": st.TargetSlug,
})
}
@@ -342,8 +364,10 @@ func (s *store) proposePlan(ctx context.Context, sessionID string, steps []planS
return nil, err
}
// Event after commit so subscribers only ever see a persisted plan.
// appended=true tells the panel to add these steps to its existing list
// rather than replace it (mirrors the mid-flight append above).
_ = observability.Event(ctx, sqlcgen.New(s.pool), "plan.proposed", s.taskEntityPtr(ctx, sessionID),
"info", "nomos", sessionID, map[string]any{"steps": out})
"info", "nomos", sessionID, map[string]any{"steps": out, "appended": anyStarted})
return out, nil
}

View File

@@ -33,11 +33,15 @@ func taskToolDefs() []toolDef {
},
{
Name: "propose_plan",
Description: "Lay out the ordered steps you'll take to reach the goal. The " +
"operator sees these in the context panel and watches them progress. " +
"Call this before you start executing (after gathering what you need); " +
"re-call it to revise the plan. As you work, call update_plan_step to " +
"advance each one.",
Description: "Lay out ALL the ordered steps you'll take to reach the goal, in ONE " +
"call, listing every step end-to-end — not just the next one. The operator " +
"sees the full list in the context panel and watches it progress; a plan " +
"with only 1 step looks broken to them even if you intend to add more later. " +
"Call this ONCE, before you start executing (after gathering what you need). " +
"As you work, call update_plan_step (not propose_plan again) to advance each " +
"step. Only re-call propose_plan if the plan itself has fundamentally changed " +
"(e.g. a new approach is needed) — in that case new steps are appended after " +
"whatever already ran, never erasing completed work.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{