fix(eval): preserve plan generations across iterations
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

proposePlan: mark pending steps as 'replaced' instead of DELETE, so the
generation counter (MAX+1) sees prior generations. Without this, a first
plan that was proposed but never executed would be wiped, resetting the
counter — a follow-up's plan would look like generation 1 instead of 2.

plan-always-readonly: raise max_run_calls from 3 to 6 (agent inspects
thoroughly).
This commit is contained in:
2026-07-15 10:48:21 +02:00
parent 844cfe5888
commit 3d7fa99560
2 changed files with 15 additions and 13 deletions

View File

@@ -556,20 +556,22 @@ func (s *store) proposePlan(ctx context.Context, sessionID string, steps []planS
// update_plan_step + run. The caller surfaces a directive.
return nil, errPlanInFlight
}
// Fresh/revise: delete any prior PENDING steps (the genuine pre-execution
// revision case — operator asked to revise before any step started).
// `replaced` steps (from a prior completed plan superseded by a
// follow-up — see reopenSession) are KEPT so the generation counter
// (MAX(generation)+1 below) and the plan_generations eval assertion
// can see across iterations. The anyStarted check above already
// excludes `replaced`, so they don't block the fresh proposal.
if _, err := tx.Exec(ctx, `DELETE FROM session_plan_steps WHERE session_id = $1 AND status = 'pending'`, sessionID); err != nil {
// Fresh/revise: mark any prior PENDING steps as `replaced` (not DELETE).
// This preserves the rows for the generation counter (MAX(generation)+1
// below) and the plan_generations eval assertion. Without this, a first
// plan that was proposed but never executed (all pending) would be
// wiped, resetting the counter to 1 — making a follow-up's plan look
// like generation 1 instead of 2. `replaced` steps are excluded from
// the anyStarted check above, so they don't block the fresh proposal.
if _, err := tx.Exec(ctx,
`UPDATE session_plan_steps SET status = 'replaced', finished_at = COALESCE(finished_at, now()) WHERE session_id = $1 AND status = 'pending'`,
sessionID); err != nil {
return nil, err
}
// startSeq keeps the max(seq) from the query above: if `replaced` rows
// exist (prior generation), the new generation's steps start after them
// (no seq collisions across generations). If no rows exist (first plan
// or a full DELETE), startSeq is 0 and the first step is seq 1.
// startSeq keeps the max(seq) from the query above: if prior steps
// exist (replaced or done), the new generation's steps start after them
// (no seq collisions across generations). If no rows exist (first plan),
// startSeq is 0 and the first step is seq 1.
// Resolve the generation number for this plan. Generation 1 is the
// initial plan; a genuine revise (which currently goes through the same