summaryrefslogtreecommitdiff
path: root/internal/scheduler/story_orchestrator.go
blob: 6ad0f86007917a7499c7ed59bd45a0010bb5ee2f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
// This file implements Phase 7b's story orchestrator: a deterministic,
// poll-based watcher that drives a story.Story through
// Builder -> 4 parallel Evaluators -> Arbitration -> a single human
// accept-gate at the *story* level, exactly the way Scheduler (above, in
// scheduler.go) drives a role-typed task through its retry/escalation
// ladder. It lives in this package rather than internal/story for a
// structural reason, not just stylistic taste: internal/storage already
// imports internal/story (storage/story.go), so internal/story cannot
// import internal/storage back — and this orchestrator's Store interface
// needs storage.StoryFilter/*task.Task/etc. internal/scheduler already
// imports both internal/storage and internal/task (and has zero risk of
// internal/story importing scheduler back), so it's a clean home. It also
// matches the design note this phase's task description invited: "fold it
// into internal/scheduler as a sibling to the existing Scheduler".
//
// Mechanism choice: poll-based, not a handleRunResult hook. Every task this
// orchestrator cares about (the story's root Builder task, the 4 Evaluators,
// the Arbitration task) is a top-level task (ParentTaskID == ""), so per
// task.go's state machine the *only* way one of them would ever reach
// COMPLETED on its own is via a human/chatbot POST /api/tasks/{id}/accept
// (internal/api.acceptTask), READY -> COMPLETED — executor.Pool.handleRunResult
// never transitions a top-level task to COMPLETED directly (only READY, or
// BLOCKED if it has subtasks). That's exactly the gap this orchestrator
// closes itself (see autoAccept below): the whole point of the story-level
// ceremony is that a human/chatbot should only ever have to make *one*
// accept decision (the final POST /api/stories/{id}/accept, REVIEW_READY ->
// DONE) — not six (builder + 4 evaluators + arbitration) along the way. So
// on every tick, for the specific Builder/Evaluator/Arbitration tasks it
// already knows are wired into a story's pipeline, this orchestrator
// auto-accepts (READY -> COMPLETED) them itself, using the same
// state-machine-respecting write internal/api's acceptTask uses
// (Store.UpdateTaskState, which wraps storage.DB.UpdateTaskStateBy —
// validates task.ValidTransition and writes the state_change event
// atomically, not a raw/unchecked write). This is narrowly scoped: it only
// ever touches the root task a story actually tracks (st.RootTaskID) and
// that root's structurally-discovered evaluator/arbitration dependents
// (found via ensureEvaluators/ensureArbitration's own role-matching) — never
// a blanket "auto-accept every READY task" sweep. Polling (rather than a
// handleRunResult hook) is still the right mechanism regardless of who does
// the accepting: the transition happens via a *write this orchestrator
// itself performs* on a poll tick, which is inherently poll-driven, not an
// executor-package callback.
package scheduler

import (
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"time"

	"github.com/google/uuid"
	"github.com/thepeterstone/claudomator/internal/event"
	"github.com/thepeterstone/claudomator/internal/storage"
	"github.com/thepeterstone/claudomator/internal/story"
	"github.com/thepeterstone/claudomator/internal/task"
)

// evaluatorRoles is the fixed fan-out of Evaluator roles spawned once a
// story's Builder (root_task_id) task completes. Order here is preserved
// wherever evaluator tasks are collected into a slice (e.g. building the
// Arbitration task's DependsOn), purely for determinism/testability — the
// orchestrator does not otherwise care about ordering.
var evaluatorRoles = []string{
	"evaluator_quality",
	"evaluator_security",
	"evaluator_correctness",
	"evaluator_performance",
}

// arbitrationRole is the role assigned to the single task spawned once all
// Evaluators for a story complete. It depends on all 4 Evaluator tasks and,
// per this phase's documented simplification (see finalizeArbitration),
// always routes the story to REVIEW_READY on completion rather than parsing
// its summary for an approve/reject verdict.
const arbitrationRole = "planner"

// StoryStore is the subset of storage.DB methods StoryOrchestrator needs.
// Defining it as an interface (mirroring executor.Store/scheduler.Store)
// allows tests to supply an in-memory fake with no real SQLite database.
type StoryStore interface {
	ListStories(filter storage.StoryFilter) ([]*story.Story, error)
	UpdateStory(st *story.Story) error
	GetTask(id string) (*task.Task, error)
	ListDependents(taskID string) ([]*task.Task, error)
	CreateTask(t *task.Task) error
	UpdateTaskState(id string, newState task.State) error
	CreateEvent(e *event.Event) error
}

// StoryOrchestrator polls stories with a root_task_id set and drives them
// through the Builder -> Evaluators -> Arbitration -> REVIEW_READY ceremony,
// auto-accepting (READY -> COMPLETED) the Builder/Evaluator/Arbitration tasks
// along the way (see autoAccept) so that a human/chatbot never has to touch
// POST /api/tasks/{id}/accept for any of them. The final REVIEW_READY -> DONE
// transition is the *only* remaining human/chatbot action (see internal/api's
// POST /api/stories/{id}/accept), not something this type does itself.
type StoryOrchestrator struct {
	Store StoryStore
	// Pool reuses the same minimal interface Scheduler depends on
	// (Submit(ctx, *task.Task) error) — satisfied directly by
	// *executor.Pool, declared once in scheduler.go.
	Pool   Pool
	Logger *slog.Logger

	// handledVerdicts dedupes per-evaluator KindEvalVerdict emission within a
	// single running process, keyed by evaluator task ID. Without it, an
	// evaluator that completes while its siblings are still running would
	// get a fresh eval_verdict event on every poll tick until the last
	// sibling finishes. This mirrors Scheduler.handled exactly: an in-memory,
	// per-process guard that resets on restart. That's an accepted
	// simplification here for the same reason it is for Scheduler — this is
	// idempotent bookkeeping on an append-only observability stream, not
	// orchestration state; a restart can produce at most one duplicate
	// eval_verdict event per evaluator, never an infinite loop, because the
	// *structural* idempotency checks (ensureEvaluators/ensureArbitration
	// below, and the story.Status=="VALIDATING" gate in
	// finalizeArbitration) are what actually prevent duplicate task
	// creation and duplicate story-status transitions — the two things that
	// would matter if repeated forever.
	handledVerdicts map[string]bool
}

// DefaultStoryPollInterval is used by Run when pollInterval <= 0.
const DefaultStoryPollInterval = 15 * time.Second

// Run polls all stories with a root_task_id set every pollInterval until ctx
// is cancelled.
func (o *StoryOrchestrator) Run(ctx context.Context, pollInterval time.Duration) {
	if pollInterval <= 0 {
		pollInterval = DefaultStoryPollInterval
	}
	ticker := time.NewTicker(pollInterval)
	defer ticker.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			o.Tick(ctx)
		}
	}
}

// Tick runs a single poll pass over every story. Exported so tests can drive
// it directly without waiting on a ticker.
func (o *StoryOrchestrator) Tick(ctx context.Context) {
	stories, err := o.Store.ListStories(storage.StoryFilter{})
	if err != nil {
		o.logf("story orchestrator: list stories", "error", err)
		return
	}
	for _, st := range stories {
		if st.RootTaskID == "" {
			continue // no execution tree yet — nothing for this orchestrator to do
		}
		if st.Status == "DONE" || st.Status == "CANCELLED" {
			continue // terminal; this orchestrator never revives a story from here
		}
		o.processStory(ctx, st)
	}
}

func (o *StoryOrchestrator) logf(msg string, args ...any) {
	if o.Logger != nil {
		o.Logger.Warn(msg, args...)
	}
}

// processStory advances a single story by at most one stage per tick (it
// returns as soon as it finds a stage that isn't ready to progress yet — the
// next tick picks up where this one left off).
func (o *StoryOrchestrator) processStory(ctx context.Context, st *story.Story) {
	root, err := o.Store.GetTask(st.RootTaskID)
	if err != nil {
		o.logf("story orchestrator: get root task", "storyID", st.ID, "rootTaskID", st.RootTaskID, "error", err)
		return
	}
	root = o.autoAccept(st, root)
	if root.State != task.StateCompleted {
		return // Builder hasn't reached COMPLETED yet (still running, or not yet READY to auto-accept)
	}

	// Stage 1: Builder -> Evaluators (+ story -> VALIDATING).
	evaluators, ok := o.ensureEvaluators(ctx, st, root)
	if !ok {
		return // not all 4 could be found/created yet; retry next tick
	}

	// Auto-accept each evaluator that's reached READY, emit per-evaluator
	// verdicts, and check whether all 4 are done.
	allDone := true
	for i, ev := range evaluators {
		ev = o.autoAccept(st, ev)
		evaluators[i] = ev
		o.maybeEmitVerdict(st, ev)
		if ev.State != task.StateCompleted {
			allDone = false
		}
	}
	if !allDone {
		return
	}

	// Stage 2: Evaluators -> Arbitration.
	arbitration, ok := o.ensureArbitration(ctx, st, evaluators)
	if !ok {
		return
	}
	arbitration = o.autoAccept(st, arbitration)

	// Stage 3: Arbitration -> REVIEW_READY.
	if arbitration.State == task.StateCompleted {
		o.finalizeArbitration(st, arbitration)
	}
}

// autoAccept transitions t from READY to COMPLETED if that's its current
// state, using the same state-machine-respecting write internal/api's
// acceptTask uses (Store.UpdateTaskState wraps storage.DB.UpdateTaskStateBy,
// which validates task.ValidTransition and writes the state_change event
// atomically — not a raw/unchecked write). Returns t unchanged if it wasn't
// READY (including if it's already COMPLETED, or still RUNNING/BLOCKED/etc.)
// or if the update failed.
//
// This is the mechanism that makes the story-level accept-gate
// (POST /api/stories/{id}/accept) the *only* human/chatbot interaction
// required to drive a story from a completed Builder run all the way to
// REVIEW_READY: without it, a human would have to separately call
// POST /api/tasks/{id}/accept on the Builder task, each of the 4 Evaluator
// tasks, and the Arbitration task, since none of those top-level tasks can
// reach COMPLETED any other way (see this file's package doc comment).
// Callers only ever pass tasks they've already established are part of a
// specific story's pipeline — the root task a story tracks via
// st.RootTaskID, or a role-matched dependent discovered by
// ensureEvaluators/ensureArbitration — so this never touches an unrelated
// READY task sitting outside any story's pipeline.
func (o *StoryOrchestrator) autoAccept(st *story.Story, t *task.Task) *task.Task {
	if t.State != task.StateReady {
		return t
	}
	if err := o.Store.UpdateTaskState(t.ID, task.StateCompleted); err != nil {
		o.logf("story orchestrator: auto-accept", "storyID", st.ID, "taskID", t.ID, "error", err)
		return t
	}
	accepted := *t
	accepted.State = task.StateCompleted
	return &accepted
}

// ensureEvaluators returns the 4 Evaluator tasks fanned out from root,
// spawning any missing ones. Idempotency is structural, not a marker on the
// story: it looks at root's actual dependents and checks which of
// evaluatorRoles are already represented, so calling this repeatedly for the
// same story never spawns duplicates (test (b) in the phase description) —
// even across a process restart, unlike a purely in-memory guard would be.
// Returns ok=false if any missing evaluator couldn't be created this tick
// (transient store error); the caller retries on the next tick.
func (o *StoryOrchestrator) ensureEvaluators(ctx context.Context, st *story.Story, root *task.Task) ([]*task.Task, bool) {
	dependents, err := o.Store.ListDependents(root.ID)
	if err != nil {
		o.logf("story orchestrator: list root dependents", "storyID", st.ID, "error", err)
		return nil, false
	}
	found := make(map[string]*task.Task, len(evaluatorRoles))
	for _, d := range dependents {
		for _, r := range evaluatorRoles {
			if d.Agent.Role == r {
				found[r] = d
			}
		}
	}

	spawnedAny := false
	for _, r := range evaluatorRoles {
		if _, ok := found[r]; ok {
			continue
		}
		nt, err := o.spawnRoleTask(ctx, fmt.Sprintf("%s: %s", r, st.Name), r, []string{root.ID}, root,
			fmt.Sprintf("Evaluate the changes made by task %s against the %q dimension for story %q.\n\nStory spec:\n%s", root.ID, r, st.Name, st.Spec))
		if err != nil {
			o.logf("story orchestrator: spawn evaluator", "storyID", st.ID, "role", r, "error", err)
			continue
		}
		found[r] = nt
		spawnedAny = true
	}

	if spawnedAny {
		st.Status = "VALIDATING"
		if err := o.Store.UpdateStory(st); err != nil {
			o.logf("story orchestrator: update story to VALIDATING", "storyID", st.ID, "error", err)
		}
	}

	if len(found) != len(evaluatorRoles) {
		return nil, false
	}
	ordered := make([]*task.Task, len(evaluatorRoles))
	for i, r := range evaluatorRoles {
		ordered[i] = found[r]
	}
	return ordered, true
}

// ensureArbitration returns the single Arbitration task depending on all 4
// evaluators, spawning it if it doesn't exist yet. Idempotency is again
// structural: it looks for an existing "planner"-role dependent of the first
// evaluator task that depends on every evaluator ID, rather than relying on
// story.Status (which a human can freely rewrite via PUT /api/stories/{id}).
func (o *StoryOrchestrator) ensureArbitration(ctx context.Context, st *story.Story, evaluators []*task.Task) (*task.Task, bool) {
	ids := make([]string, len(evaluators))
	for i, ev := range evaluators {
		ids[i] = ev.ID
	}

	dependents, err := o.Store.ListDependents(evaluators[0].ID)
	if err != nil {
		o.logf("story orchestrator: list evaluator dependents", "storyID", st.ID, "error", err)
		return nil, false
	}
	for _, d := range dependents {
		if d.Agent.Role == arbitrationRole && dependsOnAll(d, ids) {
			return d, true
		}
	}

	instructions := fmt.Sprintf(
		"Arbitrate the 4 evaluator verdicts for story %q (task %s). Read each evaluator task's summary/events "+
			"and decide whether the story is ready to ship. Acceptance criteria:\n%s",
		st.Name, st.ID, formatAcceptanceCriteria(st.AcceptanceCriteria))
	nt, err := o.spawnRoleTask(ctx, "Arbitration: "+st.Name, arbitrationRole, ids, evaluators[0], instructions)
	if err != nil {
		o.logf("story orchestrator: spawn arbitration", "storyID", st.ID, "error", err)
		return nil, false
	}
	return nt, true
}

// finalizeArbitration handles the Arbitration task reaching COMPLETED: it
// emits KindArbitrationDecided and moves the story to REVIEW_READY.
//
// Documented simplification (Phase 7b, see CLAUDE.md Design Debt): this does
// NOT parse the arbitration task's summary for an approve/reject verdict —
// it always routes to REVIEW_READY. A human or chatbot who reads the
// arbitration summary and disagrees can manually set the story to NEEDS_FIX
// via the existing PUT /api/stories/{id}. A later phase could close this gap
// by giving the arbitration task a dedicated verdict-reporting tool (e.g. a
// new AgentChannel method) whose structured output this orchestrator could
// trust instead of free-text parsing.
//
// Gated on st.Status == "VALIDATING" so repeated ticks (or a story a human
// already advanced past REVIEW_READY) don't re-emit the event or re-write the
// status — this is the one place in the orchestrator where the story's own
// status field, not a structural dependents check, is the idempotency guard,
// because by this stage there's nothing further to check structurally: the
// Arbitration task is the last task in the chain, so "does a subsequent task
// exist" isn't an available signal.
func (o *StoryOrchestrator) finalizeArbitration(st *story.Story, arbitration *task.Task) {
	if st.Status != "VALIDATING" {
		return
	}

	payload, _ := json.Marshal(struct {
		TaskID  string `json:"task_id"`
		Summary string `json:"summary"`
	}{TaskID: arbitration.ID, Summary: arbitration.Summary})
	if err := o.Store.CreateEvent(&event.Event{
		TaskID:  st.ID,
		Kind:    event.KindArbitrationDecided,
		Actor:   event.ActorSystem,
		Payload: payload,
	}); err != nil {
		o.logf("story orchestrator: emit arbitration_decided", "storyID", st.ID, "error", err)
	}

	st.Status = "REVIEW_READY"
	if err := o.Store.UpdateStory(st); err != nil {
		o.logf("story orchestrator: update story to REVIEW_READY", "storyID", st.ID, "error", err)
	}
}

// maybeEmitVerdict records a KindEvalVerdict event, attached to the story's
// ID (not the evaluator task's ID), the first time a given evaluator task is
// observed COMPLETED. Attaching to the story ID — the same choice
// finalizeArbitration makes for KindArbitrationDecided — means a single
// GET /api/stories/{id}/events call surfaces every verdict for a story,
// rather than requiring a client to separately fetch each evaluator task's
// own event stream and reassemble them; events.task_id has no enforced FK
// (see internal/event's doc comment), so this is exactly the tolerance the
// 7a phase already built in anticipation of this use.
func (o *StoryOrchestrator) maybeEmitVerdict(st *story.Story, ev *task.Task) {
	if ev.State != task.StateCompleted {
		return
	}
	if o.handledVerdicts == nil {
		o.handledVerdicts = make(map[string]bool)
	}
	if o.handledVerdicts[ev.ID] {
		return
	}
	o.handledVerdicts[ev.ID] = true

	payload, _ := json.Marshal(struct {
		TaskID  string `json:"task_id"`
		Role    string `json:"role"`
		Summary string `json:"summary"`
	}{TaskID: ev.ID, Role: ev.Agent.Role, Summary: ev.Summary})
	if err := o.Store.CreateEvent(&event.Event{
		TaskID:  st.ID,
		Kind:    event.KindEvalVerdict,
		Actor:   event.ActorSystem,
		Payload: payload,
	}); err != nil {
		o.logf("story orchestrator: emit eval_verdict", "storyID", st.ID, "taskID", ev.ID, "error", err)
	}
}

// spawnRoleTask creates a new role-typed, top-level task (no ParentTaskID —
// these are DAG siblings via DependsOn, not delegated subtasks; see
// internal/executor.Pool.cascadeFail's doc comment for why that distinction
// matters) and submits it to the pool. Agent.Type/Model are left empty so
// Phase 5's role-based dispatch resolves them from the role's escalation
// ladder on first dispatch (internal/executor.Pool.execute()); if no active
// role_configs row exists for the role, that same code path logs a warning
// and dispatches without role resolution — an accepted degraded mode we
// don't special-case here either.
func (o *StoryOrchestrator) spawnRoleTask(ctx context.Context, name, roleName string, dependsOn []string, template *task.Task, instructions string) (*task.Task, error) {
	nt := &task.Task{
		ID:            uuid.NewString(),
		Name:          name,
		Project:       template.Project,
		RepositoryURL: template.RepositoryURL,
		Agent: task.AgentConfig{
			Role:         roleName,
			Instructions: instructions,
		},
		Priority:  task.PriorityNormal,
		Tags:      []string{"story-orchestrator"},
		DependsOn: dependsOn,
		Retry:     task.RetryConfig{MaxAttempts: 1, Backoff: "exponential"},
		State:     task.StatePending,
	}
	if err := o.Store.CreateTask(nt); err != nil {
		return nil, err
	}
	if err := o.Store.UpdateTaskState(nt.ID, task.StateQueued); err != nil {
		return nil, err
	}
	nt.State = task.StateQueued
	if err := o.Pool.Submit(ctx, nt); err != nil {
		return nil, err
	}
	return nt, nil
}

// dependsOnAll reports whether t.DependsOn contains every ID in ids.
func dependsOnAll(t *task.Task, ids []string) bool {
	have := make(map[string]bool, len(t.DependsOn))
	for _, d := range t.DependsOn {
		have[d] = true
	}
	for _, id := range ids {
		if !have[id] {
			return false
		}
	}
	return true
}

// formatAcceptanceCriteria renders a story's acceptance criteria as a
// markdown bullet list, or a placeholder line if there are none.
func formatAcceptanceCriteria(criteria []string) string {
	if len(criteria) == 0 {
		return "(none specified)"
	}
	out := ""
	for _, c := range criteria {
		out += "- " + c + "\n"
	}
	return out
}