Never show ideas for a failed ideas job, however it failed
CI / ci (push) Successful in 5m34s
CI / ci (pull_request) Successful in 5m26s

A failed ideas_from_ingredients job could still show ideas through
GET /api/v1/jobs/{id}: a job failed at boot as interrupted by restart
keeps its last partial result, and when the write clearing the ideas
itself failed the runner recorded internal_error over the uncleared
result. Neither path can be fixed by a write.

The jobs package gains an opt-in per-kind hook, Kind.FailedResult,
which Runner.Get applies to a failed job's stored result on every read.
The ideas kind registers withoutIdeas, which empties the ideas and keeps
the rest of the result. Kinds that do not call FailedResult are
unchanged. The write-side clear stays as a tidy-up of what is stored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 19:36:04 -04:00
co-authored by Claude Opus 5.5
parent 4146f31275
commit 9ed6c305cb
5 changed files with 273 additions and 58 deletions
+19 -2
View File
@@ -99,6 +99,9 @@ func NewService(grounder *recipes.Grounder, generate Generator, runner *jobs.Run
if err := kind.ServiceOnly(); err != nil {
return nil, err
}
if err := kind.FailedResult(withoutIdeas); err != nil {
return nil, err
}
s.job = kind
return s, nil
}
@@ -192,8 +195,10 @@ func IsQuick(method string, minutes *int) bool {
// other grounding failure fails the job, as does ideas_from_ingredients
// failing, with no idea made up instead. The result is published at each
// stage and again as each idea is kept, so it fills as the ideas ground.
// Before the job fails, its result is published again with no ideas, so a
// failed job never shows ideas kept before the failure.
// Before the job fails, its result is published again with no ideas, to
// tidy the stored result. That write is not what keeps a failed job from
// showing ideas, since it may fail and a job interrupted by restart never
// makes it: withoutIdeas, applied whenever a failed job is read, is.
func (s *Service) run(ctx context.Context, p FromIngredientsJob, publish func(Result) error) (Result, error) {
res, err := s.fill(ctx, p, publish)
if err != nil {
@@ -316,6 +321,18 @@ func (s *Service) ground(ctx context.Context, ideas []skills.Idea, kept func(i i
return failed
}
// withoutIdeas is a failed job's stored result as the job shows it: with no
// ideas, however the job failed, so a failed job never shows ideas kept
// before the failure.
func withoutIdeas(stored json.RawMessage) (json.RawMessage, error) {
var res Result
if err := json.Unmarshal(stored, &res); err != nil {
return nil, fmt.Errorf("ideas: decode failed job's result: %w", err)
}
res.Ideas = []Idea{}
return res.PublicJSON()
}
// ideaOf is idea as grounded by gr.
func ideaOf(idea skills.Idea, gr recipes.Grounded) Idea {
return Idea{