relay-flow 0.2.1-alpha → 0.2.3-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +193 -25
- package/cmd/relay-flow/beads_composition_test.go +451 -0
- package/cmd/relay-flow/commands_test.go +79 -1
- package/cmd/relay-flow/main.go +50 -1
- package/cmd/relay-flow/scenario_test.go +48 -12
- package/cmd/relay-flow/serve.go +16 -2
- package/examples/beads-workflow.yaml +74 -0
- package/examples/default-story-workflow.yaml +6 -6
- package/go.mod +2 -1
- package/go.sum +2 -0
- package/internal/config/config.go +13 -2
- package/internal/config/merge_test.go +18 -0
- package/internal/execution/goworkflows/activities.go +124 -56
- package/internal/execution/goworkflows/end_feedback_test.go +124 -0
- package/internal/execution/goworkflows/engine.go +20 -0
- package/internal/execution/goworkflows/engine_test.go +33 -15
- package/internal/execution/goworkflows/fakes_test.go +84 -10
- package/internal/execution/goworkflows/interpreter.go +84 -30
- package/internal/execution/goworkflows/mailbox_test.go +85 -0
- package/internal/execution/goworkflows/node_runtime_test.go +30 -5
- package/internal/execution/goworkflows/projection.go +52 -11
- package/internal/execution/goworkflows/recovery_test.go +131 -2
- package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
- package/internal/harness/contract_test.go +10 -0
- package/internal/harness/factory.go +25 -3
- package/internal/harness/harness.go +30 -4
- package/internal/harness/opencode/opencode.go +125 -10
- package/internal/harness/opencode/opencode_test.go +238 -0
- package/internal/harness/opencode/repo_setup.go +401 -0
- package/internal/harness/plugin_selection_test.go +5 -5
- package/internal/identity/identity.go +28 -1
- package/internal/identity/identity_test.go +40 -0
- package/internal/recover/recover.go +11 -6
- package/internal/repo/service.go +10 -0
- package/internal/repo/service_test.go +51 -2
- package/internal/run/manager.go +193 -25
- package/internal/run/run.go +30 -10
- package/internal/run/run_manager_test.go +100 -0
- package/internal/server/api_test.go +54 -0
- package/internal/server/client.go +11 -0
- package/internal/server/fixture_test.go +18 -0
- package/internal/server/server.go +16 -0
- package/internal/task/beads/bdcli/bdcli.go +323 -0
- package/internal/task/beads/bdcli/bdcli_test.go +297 -0
- package/internal/task/beads/bdcli/testdata/array.json +1 -0
- package/internal/task/beads/bdcli/testdata/children.json +1 -0
- package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
- package/internal/task/beads/bdcli/testdata/commented.json +1 -0
- package/internal/task/beads/bdcli/testdata/comments.json +1 -0
- package/internal/task/beads/bdcli/testdata/created.json +1 -0
- package/internal/task/beads/bdcli/testdata/empty.json +1 -0
- package/internal/task/beads/bdcli/testdata/object.json +1 -0
- package/internal/task/beads/bdcli/testdata/ready.json +1 -0
- package/internal/task/beads/bdcli/testdata/show.json +1 -0
- package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
- package/internal/task/beads/bdcli/testdata/updated.json +1 -0
- package/internal/task/beads/beads.go +856 -0
- package/internal/task/beads/beads_test.go +609 -0
- package/internal/task/beads/comments_test.go +242 -0
- package/internal/task/beads/config_compatibility_test.go +163 -0
- package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
- package/internal/task/beads/repo_composition_test.go +232 -0
- package/internal/task/beads/runtime_config_test.go +81 -0
- package/internal/task/beads/status_compatibility_test.go +276 -0
- package/internal/task/beads/status_test.go +257 -0
- package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
- package/internal/task/beads/validation_test.go +110 -0
- package/internal/task/contract_test.go +10 -0
- package/internal/task/factory.go +37 -4
- package/internal/task/jira/auth.go +27 -1
- package/internal/task/jira/auth_test.go +54 -1
- package/internal/task/jira/filters_test.go +60 -0
- package/internal/task/jira/helpers_test.go +3 -0
- package/internal/task/jira/jira.go +187 -29
- package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
- package/internal/task/jira/rest/adf.go +119 -82
- package/internal/task/jira/rest/adf_test.go +60 -0
- package/internal/task/jira/rest/client_test.go +1 -1
- package/internal/task/jira/templates_test.go +118 -0
- package/internal/task/jira/transition_defaults_test.go +47 -2
- package/internal/task/task.go +40 -0
- package/internal/workflow/report_test.go +45 -0
- package/package.json +1 -1
|
@@ -266,6 +266,91 @@ func TestSummaryCurrentFeedbackSelectedNextOnly(t *testing.T) {
|
|
|
266
266
|
}
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
+
func TestTaskSystemTemplatesRenderMailboxAndSplitOneReport(t *testing.T) {
|
|
270
|
+
log := newEventLog()
|
|
271
|
+
sys := newFakeTaskSystem(log)
|
|
272
|
+
seen := map[task.TextKind][]task.TextData{}
|
|
273
|
+
sys.renderText = func(kind task.TextKind, data task.TextData) (string, error) {
|
|
274
|
+
seen[kind] = append(seen[kind], data)
|
|
275
|
+
switch kind {
|
|
276
|
+
case task.TextMailboxDescription:
|
|
277
|
+
return "custom mailbox " + data.Node + " work=" + data.NodeDescription, nil
|
|
278
|
+
case task.TextSummaryComment:
|
|
279
|
+
return "custom summary node=" + data.Node + " mailbox=" + data.Mailbox + "\n" + data.SummaryReport, nil
|
|
280
|
+
case task.TextFeedbackComment:
|
|
281
|
+
return "custom feedback source=" + data.SourceNode + " target=" + data.TargetNode + " mailbox=" + data.Mailbox + "\n" + data.FeedbackReport, nil
|
|
282
|
+
default:
|
|
283
|
+
return "", nil
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
engine := newEngine(t, goworkflows.Dependencies{
|
|
287
|
+
Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log), TaskSystem: "custom-task",
|
|
288
|
+
})
|
|
289
|
+
rid, _ := startRun(engine, threeNodeWorkflow())
|
|
290
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
291
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
292
|
+
return r.CurrentNode == "exploration"
|
|
293
|
+
})
|
|
294
|
+
if got := sys.specs[1].Description; !strings.Contains(got, "custom mailbox exploration work=explore the code") || !strings.Contains(got, "Required report format:") {
|
|
295
|
+
t.Fatalf("rendered mailbox description = %q", got)
|
|
296
|
+
}
|
|
297
|
+
var explorationData task.TextData
|
|
298
|
+
for _, data := range seen[task.TextMailboxDescription] {
|
|
299
|
+
if data.Node == "exploration" {
|
|
300
|
+
explorationData = data
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
for name, got := range map[string]string{
|
|
304
|
+
"runID": explorationData.RunID,
|
|
305
|
+
"ticket": explorationData.Ticket, "workflow": explorationData.Workflow,
|
|
306
|
+
"repo": explorationData.Repo, "node": explorationData.Node,
|
|
307
|
+
"nodeType": explorationData.NodeType, "agent": explorationData.Agent,
|
|
308
|
+
"nodeDescription": explorationData.NodeDescription, "mailbox": explorationData.Mailbox,
|
|
309
|
+
} {
|
|
310
|
+
if got == "" {
|
|
311
|
+
t.Fatalf("mailbox template value %s was empty: %+v", name, explorationData)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
for name, got := range map[string]string{"nextSteps": explorationData.NextSteps, "successRoutes": explorationData.SuccessRoutes, "failureRoutes": explorationData.FailureRoutes} {
|
|
315
|
+
if !strings.Contains(got, "coding") && name != "failureRoutes" {
|
|
316
|
+
t.Fatalf("mailbox template %s = %q", name, got)
|
|
317
|
+
}
|
|
318
|
+
if name == "failureRoutes" && !strings.Contains(got, "exploration") {
|
|
319
|
+
t.Fatalf("mailbox template failureRoutes = %q", got)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
report := successReport("coding")
|
|
323
|
+
report.Feedback = workflow.Feedback{ReasonForNextStep: "reviewed", RequiredActions: "implement", RelevantContext: "ctx", ExpectedResult: "done"}
|
|
324
|
+
if _, err := engine.SubmitReport(context.Background(), reportRequest(rid, "exploration", report)); err != nil {
|
|
325
|
+
t.Fatal(err)
|
|
326
|
+
}
|
|
327
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
328
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
329
|
+
return r.CurrentNode == "coding"
|
|
330
|
+
})
|
|
331
|
+
summary := sys.commentBodies("PAY-101-exploration")
|
|
332
|
+
feedback := sys.commentBodies("PAY-101-coding")
|
|
333
|
+
if len(summary) != 1 || !strings.Contains(summary[0].Body, "custom summary node=exploration mailbox=PAY-101-exploration") || !strings.Contains(summary[0].Body, "COMPLETED:") {
|
|
334
|
+
t.Fatalf("summary comments = %+v", summary)
|
|
335
|
+
}
|
|
336
|
+
if len(feedback) != 1 || !strings.Contains(feedback[0].Body, "source=exploration target=coding mailbox=PAY-101-coding") || !strings.Contains(feedback[0].Body, "REQUIRED ACTIONS:") {
|
|
337
|
+
t.Fatalf("feedback comments = %+v", feedback)
|
|
338
|
+
}
|
|
339
|
+
if len(seen[task.TextSummaryComment]) != 1 || seen[task.TextSummaryComment][0].SummaryReport == "" {
|
|
340
|
+
t.Fatalf("summary template data = %+v", seen[task.TextSummaryComment])
|
|
341
|
+
}
|
|
342
|
+
if len(seen[task.TextFeedbackComment]) != 1 {
|
|
343
|
+
t.Fatalf("feedback template data = %+v", seen[task.TextFeedbackComment])
|
|
344
|
+
}
|
|
345
|
+
feedbackData := seen[task.TextFeedbackComment][0]
|
|
346
|
+
if feedbackData.SourceNode != "exploration" || feedbackData.TargetNode != "coding" || feedbackData.Mailbox != "PAY-101-coding" || feedbackData.FeedbackReport == "" {
|
|
347
|
+
t.Fatalf("feedback template data = %+v", feedbackData)
|
|
348
|
+
}
|
|
349
|
+
if len(sys.commentBodies("PAY-101-review")) != 0 {
|
|
350
|
+
t.Fatal("one report sent feedback to an unselected mailbox")
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
269
354
|
// 3.28: end/mailbox behavior, manual status not routing, HITL lifecycle.
|
|
270
355
|
|
|
271
356
|
func TestManualMailboxStatusDoesNotRouteGraph(t *testing.T) {
|
|
@@ -5,6 +5,7 @@ import (
|
|
|
5
5
|
"database/sql"
|
|
6
6
|
"errors"
|
|
7
7
|
"path/filepath"
|
|
8
|
+
"reflect"
|
|
8
9
|
"testing"
|
|
9
10
|
"time"
|
|
10
11
|
|
|
@@ -222,15 +223,18 @@ func TestEnsureNodeRuntimeUsesDirectIDsAndFallsBackFresh(t *testing.T) {
|
|
|
222
223
|
if rt.TerminalID == "" || rt.TerminalID == "dead-term" || rt.SessionID != "dead-session" {
|
|
223
224
|
t.Fatalf("failed direct IDs not replaced atomically: %+v", rt)
|
|
224
225
|
}
|
|
225
|
-
if fr.findCalls !=
|
|
226
|
+
if fr.findCalls != 2 || fh.buildCalls != 1 || fr.createCalls != 1 {
|
|
226
227
|
t.Fatalf("stored-ID replacement calls: find=%d build=%d create=%d", fr.findCalls, fh.buildCalls, fr.createCalls)
|
|
227
228
|
}
|
|
228
|
-
if len(fr.findIDs) !=
|
|
229
|
-
t.Fatalf("FindTerminal IDs = %v, want [dead-term]", fr.findIDs)
|
|
229
|
+
if len(fr.findIDs) != 2 || fr.findIDs[0] != "dead-term" || fr.findIDs[1] != "dead-term" {
|
|
230
|
+
t.Fatalf("FindTerminal IDs = %v, want [dead-term dead-term]", fr.findIDs)
|
|
230
231
|
}
|
|
231
232
|
if len(fh.resumeIDs) != 1 || fh.resumeIDs[0] != "dead-session" {
|
|
232
233
|
t.Fatalf("BuildCommand ResumeIDs = %v, want [dead-session]", fh.resumeIDs)
|
|
233
234
|
}
|
|
235
|
+
if !reflect.DeepEqual(fh.rendered, []harness.PromptKind{harness.PromptInitial}) {
|
|
236
|
+
t.Fatalf("dead-terminal rendered prompts = %v, want initial", fh.rendered)
|
|
237
|
+
}
|
|
234
238
|
for _, prompt := range fh.prompts {
|
|
235
239
|
if prompt != "work" {
|
|
236
240
|
t.Fatalf("same-visit relaunch prompt = %q, want standard prompt only", prompt)
|
|
@@ -261,7 +265,7 @@ func TestEnsureNodeRuntimeInitialLaunchAppendsCustomInstructions(t *testing.T) {
|
|
|
261
265
|
if err := a.EnsureNodeRuntime(ctx, nw, "", spec, NodeRuntime{}); err != nil {
|
|
262
266
|
t.Fatal(err)
|
|
263
267
|
}
|
|
264
|
-
if len(fh.prompts) != 1 || fh.prompts[0] != "
|
|
268
|
+
if len(fh.prompts) != 1 || fh.prompts[0] != "work\n\ncustom instructions" {
|
|
265
269
|
t.Fatalf("initial prompt = %q", fh.prompts)
|
|
266
270
|
}
|
|
267
271
|
if len(fr.statuses) != 1 || fr.statuses[0] != runner.WorkspaceStatusInReview {
|
|
@@ -298,7 +302,7 @@ func TestEnsureNodeRuntimeSendFailureClosesLiveTerminal(t *testing.T) {
|
|
|
298
302
|
if fr.closeCalls != 1 || fr.closedIDs[0] != "live-old" {
|
|
299
303
|
t.Fatalf("old live terminal not closed before replacement: %+v", fr.closedIDs)
|
|
300
304
|
}
|
|
301
|
-
if len(fr.sentTexts) != 1 || fr.sentTexts[0] != "
|
|
305
|
+
if len(fr.sentTexts) != 1 || fr.sentTexts[0] != "feedback\n\nRead the latest review feedback." {
|
|
302
306
|
t.Fatalf("live revisit prompt = %q", fr.sentTexts)
|
|
303
307
|
}
|
|
304
308
|
if len(fh.prompts) != 1 || fh.prompts[0] != "work\n\nRead the latest review feedback." {
|
|
@@ -311,6 +315,9 @@ func TestEnsureNodeRuntimeSendFailureClosesLiveTerminal(t *testing.T) {
|
|
|
311
315
|
if len(fh.resumeIDs) != 1 || fh.resumeIDs[0] != "session-old" {
|
|
312
316
|
t.Fatalf("replacement ResumeIDs = %v, want [session-old]", fh.resumeIDs)
|
|
313
317
|
}
|
|
318
|
+
if !reflect.DeepEqual(fh.rendered, []harness.PromptKind{harness.PromptFeedback, harness.PromptInitial}) {
|
|
319
|
+
t.Fatalf("rendered prompts = %v, want feedback then replacement initial", fh.rendered)
|
|
320
|
+
}
|
|
314
321
|
}
|
|
315
322
|
|
|
316
323
|
func TestEnsureNodeRuntimeSameVisitSendsNothing(t *testing.T) {
|
|
@@ -338,6 +345,10 @@ func TestEnsureNodeRuntimeSameVisitSendsNothing(t *testing.T) {
|
|
|
338
345
|
if len(fr.sentTexts) != 0 || fr.createCalls != 0 {
|
|
339
346
|
t.Fatalf("same visit sent=%q creates=%d", fr.sentTexts, fr.createCalls)
|
|
340
347
|
}
|
|
348
|
+
fh := a.Harness.(*runtimeTestHarness)
|
|
349
|
+
if len(fh.rendered) != 0 || fh.buildCalls != 0 {
|
|
350
|
+
t.Fatalf("same visit rendered=%v buildCalls=%d, want silence", fh.rendered, fh.buildCalls)
|
|
351
|
+
}
|
|
341
352
|
}
|
|
342
353
|
|
|
343
354
|
func TestEnsureNodeRuntimeRejectsStaleVisitWithoutLaunch(t *testing.T) {
|
|
@@ -487,8 +498,22 @@ type runtimeTestHarness struct {
|
|
|
487
498
|
buildCalls int
|
|
488
499
|
prompts []string
|
|
489
500
|
resumeIDs []string
|
|
501
|
+
rendered []harness.PromptKind
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
func (h *runtimeTestHarness) RenderPrompt(kind harness.PromptKind, _ harness.PromptData, nudge string) (string, error) {
|
|
505
|
+
h.rendered = append(h.rendered, kind)
|
|
506
|
+
prompt := "work"
|
|
507
|
+
if kind == harness.PromptFeedback {
|
|
508
|
+
prompt = "feedback"
|
|
509
|
+
}
|
|
510
|
+
if nudge != "" {
|
|
511
|
+
prompt += "\n\n" + nudge
|
|
512
|
+
}
|
|
513
|
+
return prompt, nil
|
|
490
514
|
}
|
|
491
515
|
|
|
516
|
+
func (*runtimeTestHarness) SetupRepo(context.Context, string) error { return nil }
|
|
492
517
|
func (*runtimeTestHarness) ValidateAgent(context.Context, string, string) error { return nil }
|
|
493
518
|
func (*runtimeTestHarness) FindSession(context.Context, string, string) (harness.Session, bool, error) {
|
|
494
519
|
return harness.Session{}, false, nil
|
|
@@ -34,6 +34,8 @@ type NodeRuntime struct {
|
|
|
34
34
|
const relayRunsSchema = `
|
|
35
35
|
CREATE TABLE IF NOT EXISTS relay_runs (
|
|
36
36
|
id TEXT PRIMARY KEY,
|
|
37
|
+
logical_run_id TEXT,
|
|
38
|
+
attempt_id INTEGER,
|
|
37
39
|
repo TEXT NOT NULL,
|
|
38
40
|
workflow TEXT NOT NULL,
|
|
39
41
|
ticket_id TEXT NOT NULL,
|
|
@@ -86,9 +88,11 @@ func (p *RunProjection) migrate() error {
|
|
|
86
88
|
return err
|
|
87
89
|
}
|
|
88
90
|
for name, definition := range map[string]string{
|
|
89
|
-
"
|
|
90
|
-
"
|
|
91
|
-
"
|
|
91
|
+
"logical_run_id": "TEXT",
|
|
92
|
+
"attempt_id": "INTEGER",
|
|
93
|
+
"retry_error": "TEXT",
|
|
94
|
+
"retry_attempt": "INTEGER",
|
|
95
|
+
"next_retry_at": "DATETIME",
|
|
92
96
|
} {
|
|
93
97
|
var count int
|
|
94
98
|
if err := p.DB.QueryRow(`SELECT COUNT(1) FROM pragma_table_info('relay_runs') WHERE name = ?`, name).Scan(&count); err != nil {
|
|
@@ -100,6 +104,15 @@ func (p *RunProjection) migrate() error {
|
|
|
100
104
|
}
|
|
101
105
|
}
|
|
102
106
|
}
|
|
107
|
+
// Rows created before attempt identities were introduced represent the
|
|
108
|
+
// original attempt. Backfill the stable logical ID and attempt number so
|
|
109
|
+
// restart allocation remains numeric and never reuses attempt 1.
|
|
110
|
+
if _, err := p.DB.Exec(`UPDATE relay_runs SET logical_run_id = id WHERE COALESCE(logical_run_id, '') = ''`); err != nil {
|
|
111
|
+
return err
|
|
112
|
+
}
|
|
113
|
+
if _, err := p.DB.Exec(`UPDATE relay_runs SET attempt_id = 1 WHERE attempt_id IS NULL OR attempt_id = 0`); err != nil {
|
|
114
|
+
return err
|
|
115
|
+
}
|
|
103
116
|
return nil
|
|
104
117
|
}
|
|
105
118
|
|
|
@@ -110,11 +123,19 @@ var errNodeRuntimeNotFound = errors.New("node runtime not found")
|
|
|
110
123
|
func IsNotFound(err error) bool { return errors.Is(err, errRunNotFound) }
|
|
111
124
|
|
|
112
125
|
func (p *RunProjection) insertStart(ctx context.Context, s run.Start, now time.Time) error {
|
|
126
|
+
logicalID := s.LogicalID
|
|
127
|
+
if logicalID == "" {
|
|
128
|
+
logicalID = s.ID
|
|
129
|
+
}
|
|
130
|
+
attemptID := s.AttemptID
|
|
131
|
+
if attemptID == 0 {
|
|
132
|
+
attemptID = 1
|
|
133
|
+
}
|
|
113
134
|
_, err := p.DB.ExecContext(ctx, `
|
|
114
|
-
INSERT INTO relay_runs (id, repo, workflow, ticket_id, ticket_key, state, started_at, updated_at)
|
|
115
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
135
|
+
INSERT INTO relay_runs (id, logical_run_id, attempt_id, repo, workflow, ticket_id, ticket_key, state, started_at, updated_at)
|
|
136
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
116
137
|
ON CONFLICT(id) DO NOTHING`,
|
|
117
|
-
string(s.ID), s.Repo, s.Workflow.Name, s.Ticket.ID, s.Ticket.Key,
|
|
138
|
+
string(s.ID), string(logicalID), int64(attemptID), s.Repo, s.Workflow.Name, s.Ticket.ID, s.Ticket.Key,
|
|
118
139
|
string(run.StateStarting), now, now)
|
|
119
140
|
return err
|
|
120
141
|
}
|
|
@@ -360,7 +381,7 @@ func nullableString(value string) any {
|
|
|
360
381
|
|
|
361
382
|
func (p *RunProjection) get(ctx context.Context, id run.ID) (run.Run, error) {
|
|
362
383
|
row := p.DB.QueryRowContext(ctx, `
|
|
363
|
-
SELECT id, repo, workflow, ticket_id, ticket_key, state, current_node, current_node_visit_id, last_error,
|
|
384
|
+
SELECT id, logical_run_id, attempt_id, repo, workflow, ticket_id, ticket_key, state, current_node, current_node_visit_id, last_error,
|
|
364
385
|
retry_error, retry_attempt, next_retry_at, started_at, updated_at, finished_at
|
|
365
386
|
FROM relay_runs WHERE id = ?`, string(id))
|
|
366
387
|
return scanRun(row)
|
|
@@ -368,9 +389,17 @@ func (p *RunProjection) get(ctx context.Context, id run.ID) (run.Run, error) {
|
|
|
368
389
|
|
|
369
390
|
func (p *RunProjection) findByTicket(ctx context.Context, ticket string) (run.Run, error) {
|
|
370
391
|
row := p.DB.QueryRowContext(ctx, `
|
|
371
|
-
SELECT id, repo, workflow, ticket_id, ticket_key, state, current_node, current_node_visit_id, last_error,
|
|
392
|
+
SELECT id, logical_run_id, attempt_id, repo, workflow, ticket_id, ticket_key, state, current_node, current_node_visit_id, last_error,
|
|
372
393
|
retry_error, retry_attempt, next_retry_at, started_at, updated_at, finished_at
|
|
373
|
-
FROM relay_runs WHERE ticket_key = ? ORDER BY started_at DESC LIMIT 1`, ticket)
|
|
394
|
+
FROM relay_runs WHERE ticket_key = ? ORDER BY started_at DESC, attempt_id DESC LIMIT 1`, ticket)
|
|
395
|
+
return scanRun(row)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
func (p *RunProjection) findByLogicalID(ctx context.Context, logicalID run.ID) (run.Run, error) {
|
|
399
|
+
row := p.DB.QueryRowContext(ctx, `
|
|
400
|
+
SELECT id, logical_run_id, attempt_id, repo, workflow, ticket_id, ticket_key, state, current_node, current_node_visit_id, last_error,
|
|
401
|
+
retry_error, retry_attempt, next_retry_at, started_at, updated_at, finished_at
|
|
402
|
+
FROM relay_runs WHERE logical_run_id = ? ORDER BY started_at DESC, attempt_id DESC LIMIT 1`, string(logicalID))
|
|
374
403
|
return scanRun(row)
|
|
375
404
|
}
|
|
376
405
|
|
|
@@ -380,11 +409,13 @@ type rowScanner interface {
|
|
|
380
409
|
|
|
381
410
|
func scanRun(row rowScanner) (run.Run, error) {
|
|
382
411
|
var r run.Run
|
|
412
|
+
var logicalID sql.NullString
|
|
413
|
+
var attemptNumber sql.NullInt64
|
|
383
414
|
var node, visit, lastErr, retryErr sql.NullString
|
|
384
415
|
var retryAttempt sql.NullInt64
|
|
385
416
|
var nextRetry, finished sql.NullTime
|
|
386
417
|
var started, updated time.Time
|
|
387
|
-
err := row.Scan(&r.ID, &r.Repo, &r.Workflow, &r.Ticket.ID, &r.Ticket.Key, &r.State,
|
|
418
|
+
err := row.Scan(&r.ID, &logicalID, &attemptNumber, &r.Repo, &r.Workflow, &r.Ticket.ID, &r.Ticket.Key, &r.State,
|
|
388
419
|
&node, &visit, &lastErr, &retryErr, &retryAttempt, &nextRetry, &started, &updated, &finished)
|
|
389
420
|
if errors.Is(err, sql.ErrNoRows) {
|
|
390
421
|
return run.Run{}, errRunNotFound
|
|
@@ -392,6 +423,16 @@ func scanRun(row rowScanner) (run.Run, error) {
|
|
|
392
423
|
if err != nil {
|
|
393
424
|
return run.Run{}, err
|
|
394
425
|
}
|
|
426
|
+
if logicalID.Valid && logicalID.String != "" {
|
|
427
|
+
r.LogicalID = run.ID(logicalID.String)
|
|
428
|
+
} else {
|
|
429
|
+
r.LogicalID = r.ID
|
|
430
|
+
}
|
|
431
|
+
if attemptNumber.Valid && attemptNumber.Int64 > 0 {
|
|
432
|
+
r.AttemptID = run.AttemptID(attemptNumber.Int64)
|
|
433
|
+
} else {
|
|
434
|
+
r.AttemptID = 1
|
|
435
|
+
}
|
|
395
436
|
r.CurrentNode = node.String
|
|
396
437
|
r.CurrentNodeVisitID = run.NodeVisitID(visit.String)
|
|
397
438
|
r.LastError = lastErr.String
|
|
@@ -410,7 +451,7 @@ func scanRun(row rowScanner) (run.Run, error) {
|
|
|
410
451
|
}
|
|
411
452
|
|
|
412
453
|
func (p *RunProjection) list(ctx context.Context, f run.Filter) ([]run.Run, error) {
|
|
413
|
-
q := `SELECT id, repo, workflow, ticket_id, ticket_key, state, current_node, current_node_visit_id, last_error, retry_error, retry_attempt, next_retry_at, started_at, updated_at, finished_at FROM relay_runs WHERE 1=1`
|
|
454
|
+
q := `SELECT id, logical_run_id, attempt_id, repo, workflow, ticket_id, ticket_key, state, current_node, current_node_visit_id, last_error, retry_error, retry_attempt, next_retry_at, started_at, updated_at, finished_at FROM relay_runs WHERE 1=1`
|
|
414
455
|
var args []any
|
|
415
456
|
if f.Repo != "" {
|
|
416
457
|
q += ` AND repo = ?`
|
|
@@ -5,6 +5,7 @@ import (
|
|
|
5
5
|
"database/sql"
|
|
6
6
|
"os"
|
|
7
7
|
"path/filepath"
|
|
8
|
+
"strings"
|
|
8
9
|
"testing"
|
|
9
10
|
"time"
|
|
10
11
|
|
|
@@ -219,6 +220,131 @@ func TestCancelRun(t *testing.T) {
|
|
|
219
220
|
}
|
|
220
221
|
}
|
|
221
222
|
|
|
223
|
+
func TestExplicitRestartCreatesFreshAttemptFromStart(t *testing.T) {
|
|
224
|
+
log := newEventLog()
|
|
225
|
+
sys := newFakeTaskSystem(log)
|
|
226
|
+
fr := newFakeRunner(log)
|
|
227
|
+
fh := newFakeHarness(log)
|
|
228
|
+
repos := repoRegistryWith("payments", sys)
|
|
229
|
+
wf := linearWorkflow(false)
|
|
230
|
+
workflows := &workflow.Registry{}
|
|
231
|
+
workflows.Replace(&wf)
|
|
232
|
+
engine := newEngine(t, goworkflows.Dependencies{
|
|
233
|
+
Repos: repos, Runner: fr, Harness: fh,
|
|
234
|
+
})
|
|
235
|
+
oldID, err := startRun(engine, wf)
|
|
236
|
+
if err != nil {
|
|
237
|
+
t.Fatal(err)
|
|
238
|
+
}
|
|
239
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
240
|
+
r, _ := engine.GetRun(context.Background(), oldID)
|
|
241
|
+
return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
|
|
242
|
+
})
|
|
243
|
+
if err := engine.CancelRun(context.Background(), oldID, "operator requested restart"); err != nil {
|
|
244
|
+
t.Fatal(err)
|
|
245
|
+
}
|
|
246
|
+
waitFor(t, 30*time.Second, func() bool {
|
|
247
|
+
r, _ := engine.GetRun(context.Background(), oldID)
|
|
248
|
+
return r.State == run.StateCanceled
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
manager := &run.RunManager{
|
|
252
|
+
Executor: engine, Runs: engine, Repos: repos, Workflows: workflows,
|
|
253
|
+
}
|
|
254
|
+
fresh, err := manager.RestartByTicket(context.Background(), "PAY-101")
|
|
255
|
+
if err != nil {
|
|
256
|
+
t.Fatalf("RestartByTicket failed: %v", err)
|
|
257
|
+
}
|
|
258
|
+
if fresh.ID == oldID || fresh.LogicalID != oldID || fresh.AttemptID != 2 {
|
|
259
|
+
t.Fatalf("fresh attempt = %+v, want logical=%q attempt=2 and a new ID", fresh, oldID)
|
|
260
|
+
}
|
|
261
|
+
oldAck, err := engine.SubmitReport(context.Background(), reportRequest(oldID, "coding", successReport("end")))
|
|
262
|
+
if err != nil || !oldAck.Accepted || !oldAck.Duplicate {
|
|
263
|
+
t.Fatalf("stale old-attempt report ack=%+v err=%v, want accepted duplicate", oldAck, err)
|
|
264
|
+
}
|
|
265
|
+
if got := string(fresh.ID); got != string(oldID)+"~attempt~2" {
|
|
266
|
+
t.Fatalf("fresh execution ID = %q, want numeric attempt suffix", got)
|
|
267
|
+
}
|
|
268
|
+
waitFor(t, 30*time.Second, func() bool {
|
|
269
|
+
r, _ := engine.GetRun(context.Background(), fresh.ID)
|
|
270
|
+
return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
current, err := engine.GetRun(context.Background(), fresh.ID)
|
|
274
|
+
if err != nil {
|
|
275
|
+
t.Fatal(err)
|
|
276
|
+
}
|
|
277
|
+
latest, err := engine.FindRunByTicket(context.Background(), "PAY-101")
|
|
278
|
+
if err != nil {
|
|
279
|
+
t.Fatal(err)
|
|
280
|
+
}
|
|
281
|
+
if latest.ID != fresh.ID || latest.AttemptID != 2 || latest.LogicalID != oldID {
|
|
282
|
+
t.Fatalf("ticket lookup = %+v, want latest fresh attempt %q", latest, fresh.ID)
|
|
283
|
+
}
|
|
284
|
+
if current.State != run.StateWaiting && current.State != run.StateRunning {
|
|
285
|
+
t.Fatalf("fresh attempt state = %q, want active node state", current.State)
|
|
286
|
+
}
|
|
287
|
+
if got := log.count("prepareRestart:PAY-101"); got != 1 {
|
|
288
|
+
t.Fatalf("restart preparation calls = %d, want 1", got)
|
|
289
|
+
}
|
|
290
|
+
if len(fr.envs) != 1 {
|
|
291
|
+
t.Fatalf("restart created a second ticket environment: %d", len(fr.envs))
|
|
292
|
+
}
|
|
293
|
+
if fr.liveTerminals() != 1 {
|
|
294
|
+
t.Fatalf("restart left %d live terminals, want one fresh node terminal", fr.liveTerminals())
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
func TestRestartStatusConflictIsVisibleAndRecovers(t *testing.T) {
|
|
299
|
+
log := newEventLog()
|
|
300
|
+
sys := newFakeTaskSystem(log)
|
|
301
|
+
fr := newFakeRunner(log)
|
|
302
|
+
repos := repoRegistryWith("payments", sys)
|
|
303
|
+
wf := linearWorkflow(false)
|
|
304
|
+
workflows := &workflow.Registry{}
|
|
305
|
+
workflows.Replace(&wf)
|
|
306
|
+
engine := newEngine(t, goworkflows.Dependencies{Repos: repos, Runner: fr, Harness: newFakeHarness(log)})
|
|
307
|
+
oldID, err := startRun(engine, wf)
|
|
308
|
+
if err != nil {
|
|
309
|
+
t.Fatal(err)
|
|
310
|
+
}
|
|
311
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
312
|
+
r, _ := engine.GetRun(context.Background(), oldID)
|
|
313
|
+
return r.CurrentNode == "coding"
|
|
314
|
+
})
|
|
315
|
+
if err := engine.CancelRun(context.Background(), oldID, "operator requested restart"); err != nil {
|
|
316
|
+
t.Fatal(err)
|
|
317
|
+
}
|
|
318
|
+
waitFor(t, 30*time.Second, func() bool {
|
|
319
|
+
r, _ := engine.GetRun(context.Background(), oldID)
|
|
320
|
+
return r.State == run.StateCanceled
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
sys.setStartConflict(true)
|
|
324
|
+
manager := &run.RunManager{Executor: engine, Runs: engine, Repos: repos, Workflows: workflows}
|
|
325
|
+
fresh, err := manager.RestartByTicket(context.Background(), "PAY-101")
|
|
326
|
+
if err != nil {
|
|
327
|
+
t.Fatal(err)
|
|
328
|
+
}
|
|
329
|
+
waitFor(t, 30*time.Second, func() bool {
|
|
330
|
+
r, _ := engine.GetRun(context.Background(), fresh.ID)
|
|
331
|
+
return r.State == run.StateBlocked
|
|
332
|
+
})
|
|
333
|
+
blocked, err := engine.GetRun(context.Background(), fresh.ID)
|
|
334
|
+
if err != nil {
|
|
335
|
+
t.Fatal(err)
|
|
336
|
+
}
|
|
337
|
+
if !strings.Contains(blocked.LastError, "Move ticket PAY-101 to an allowed active start status") {
|
|
338
|
+
t.Fatalf("blocked LastError = %q, want actionable start-status guidance", blocked.LastError)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
sys.setStartConflict(false)
|
|
342
|
+
waitFor(t, 60*time.Second, func() bool {
|
|
343
|
+
r, _ := engine.GetRun(context.Background(), fresh.ID)
|
|
344
|
+
return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
|
|
345
|
+
})
|
|
346
|
+
}
|
|
347
|
+
|
|
222
348
|
func TestCancelDuringRunningActivity(t *testing.T) {
|
|
223
349
|
// Cancellation cannot interrupt an already-running activity; it waits
|
|
224
350
|
// for it to return, then runs cancellation cleanup.
|
|
@@ -444,6 +570,9 @@ func TestConflictMarksBlockedThenRecovers(t *testing.T) {
|
|
|
444
570
|
if r.LastError == "" {
|
|
445
571
|
t.Fatal("blocked run exposes no conflict error in LastError")
|
|
446
572
|
}
|
|
573
|
+
if !strings.Contains(r.LastError, "Restore the task-system state required for node coding") {
|
|
574
|
+
t.Fatalf("blocked LastError = %q, want actionable node-state guidance", r.LastError)
|
|
575
|
+
}
|
|
447
576
|
if sys.mailboxStatusOf("PAY-101-coding") == "Done" {
|
|
448
577
|
t.Fatal("mailbox completed while state was incompatible; no blind overwrite allowed")
|
|
449
578
|
}
|
|
@@ -759,8 +888,8 @@ func recoverTickets(ctx context.Context, engine *goworkflows.Engine, sys *fakeTa
|
|
|
759
888
|
return err
|
|
760
889
|
}
|
|
761
890
|
rm := &run.RunManager{Executor: engine, Runs: engine}
|
|
762
|
-
specsFor := func(w *workflow.Workflow
|
|
763
|
-
return goworkflows.
|
|
891
|
+
specsFor := func(system task.System, work run.Work, w *workflow.Workflow) ([]task.MailboxSpec, error) {
|
|
892
|
+
return goworkflows.RenderMailboxSpecs(system, work, w)
|
|
764
893
|
}
|
|
765
894
|
return recoverpkg.FromTaskSystem(ctx, reg, fr, rm, specsFor)
|
|
766
895
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
package goworkflows
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"os"
|
|
6
|
+
"testing"
|
|
7
|
+
|
|
8
|
+
"github.com/rajpopat27/relay-flow/internal/run"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
func TestSharedReportContractFixtureRendersCommentValues(t *testing.T) {
|
|
12
|
+
b, err := os.ReadFile("../../../testdata/report-contract.json")
|
|
13
|
+
if err != nil {
|
|
14
|
+
t.Fatal(err)
|
|
15
|
+
}
|
|
16
|
+
var fixtures map[string]struct {
|
|
17
|
+
Envelope run.ReportRequest `json:"envelope"`
|
|
18
|
+
SummaryReport string `json:"summaryReport"`
|
|
19
|
+
FeedbackReport string `json:"feedbackReport"`
|
|
20
|
+
}
|
|
21
|
+
if err := json.Unmarshal(b, &fixtures); err != nil {
|
|
22
|
+
t.Fatal(err)
|
|
23
|
+
}
|
|
24
|
+
fixture := fixtures["work"]
|
|
25
|
+
if got := renderSummaryReport(fixture.Envelope.Report); got != fixture.SummaryReport {
|
|
26
|
+
t.Fatalf("summaryReport mismatch\ngot:\n%s\nwant:\n%s", got, fixture.SummaryReport)
|
|
27
|
+
}
|
|
28
|
+
if got := renderFeedbackReport(fixture.Envelope.Report); got != fixture.FeedbackReport {
|
|
29
|
+
t.Fatalf("feedbackReport mismatch\ngot:\n%s\nwant:\n%s", got, fixture.FeedbackReport)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -32,6 +32,8 @@ func newFakeHarness() *fakeHarness {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
func (f *fakeHarness) SetupRepo(context.Context, string) error { return nil }
|
|
36
|
+
|
|
35
37
|
func (f *fakeHarness) ValidateAgent(_ context.Context, _, agent string) error {
|
|
36
38
|
if !f.agents[agent] {
|
|
37
39
|
return errUnknownAgent
|
|
@@ -44,6 +46,14 @@ func (f *fakeHarness) FindSession(_ context.Context, _, title string) (harness.S
|
|
|
44
46
|
return s, ok, nil
|
|
45
47
|
}
|
|
46
48
|
|
|
49
|
+
func (f *fakeHarness) RenderPrompt(_ harness.PromptKind, data harness.PromptData, nudge string) (string, error) {
|
|
50
|
+
prompt := data.TaskSystem + ":" + data.Ticket + ":" + data.Mailbox
|
|
51
|
+
if nudge != "" {
|
|
52
|
+
prompt += "\n\n" + nudge
|
|
53
|
+
}
|
|
54
|
+
return prompt, nil
|
|
55
|
+
}
|
|
56
|
+
|
|
47
57
|
func (f *fakeHarness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
|
|
48
58
|
// The fake mirrors the required env contract; the real opencode harness
|
|
49
59
|
// builds the executable/args. NEXT_STEPS_JSON carries the legal targets
|
|
@@ -9,8 +9,12 @@ import (
|
|
|
9
9
|
"github.com/rajpopat27/relay-flow/internal/config"
|
|
10
10
|
)
|
|
11
11
|
|
|
12
|
-
// Factory constructs a Harness from root harness config
|
|
13
|
-
|
|
12
|
+
// Factory constructs a Harness from root harness config and supplies the
|
|
13
|
+
// selected harness's init defaults without coupling core to an adapter.
|
|
14
|
+
type Factory struct {
|
|
15
|
+
DefaultConfig func() config.RawValues
|
|
16
|
+
New func(config.RawValues) (Harness, error)
|
|
17
|
+
}
|
|
14
18
|
|
|
15
19
|
var (
|
|
16
20
|
registryMu sync.RWMutex
|
|
@@ -35,7 +39,25 @@ func New(name string, cfg config.RawValues) (Harness, error) {
|
|
|
35
39
|
if !ok {
|
|
36
40
|
return nil, fmt.Errorf("harness: unknown plugin %q (registered: %s)", name, strings.Join(Names(), ", "))
|
|
37
41
|
}
|
|
38
|
-
return f(cfg)
|
|
42
|
+
return f.New(config.Merge(defaultConfig(f), cfg))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Defaults returns a fresh copy of the named harness's root config defaults.
|
|
46
|
+
func Defaults(name string) (config.RawValues, error) {
|
|
47
|
+
registryMu.RLock()
|
|
48
|
+
f, ok := registry[name]
|
|
49
|
+
registryMu.RUnlock()
|
|
50
|
+
if !ok {
|
|
51
|
+
return nil, fmt.Errorf("harness: unknown plugin %q (registered: %s)", name, strings.Join(Names(), ", "))
|
|
52
|
+
}
|
|
53
|
+
return config.Merge(defaultConfig(f)), nil
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func defaultConfig(f Factory) config.RawValues {
|
|
57
|
+
if f.DefaultConfig == nil {
|
|
58
|
+
return nil
|
|
59
|
+
}
|
|
60
|
+
return f.DefaultConfig()
|
|
39
61
|
}
|
|
40
62
|
|
|
41
63
|
// ValidateName returns an error listing registered names when name is not
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// Package harness defines the harness contract:
|
|
2
|
-
// discovery, resume syntax, and launch
|
|
3
|
-
// executes the returned command; the harness
|
|
4
|
-
// state.
|
|
1
|
+
// Package harness defines the harness contract: repository setup, prompt
|
|
2
|
+
// rendering, agent validation, session discovery, resume syntax, and launch
|
|
3
|
+
// command construction. The runner executes the returned command; the harness
|
|
4
|
+
// never manipulates runner state.
|
|
5
5
|
package harness
|
|
6
6
|
|
|
7
7
|
import (
|
|
@@ -17,6 +17,29 @@ type Session struct {
|
|
|
17
17
|
Title string
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
type PromptKind string
|
|
21
|
+
|
|
22
|
+
const (
|
|
23
|
+
PromptInitial PromptKind = "initial"
|
|
24
|
+
PromptFeedback PromptKind = "feedback"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
// PromptData is the task-system-neutral data core supplies to the selected
|
|
28
|
+
// harness. Harness templates, including HITL instructions, are rendered only
|
|
29
|
+
// by the harness.
|
|
30
|
+
type PromptData struct {
|
|
31
|
+
TaskSystem string
|
|
32
|
+
Ticket string
|
|
33
|
+
Workflow string
|
|
34
|
+
Repo string
|
|
35
|
+
Node string
|
|
36
|
+
NodeType workflow.NodeType
|
|
37
|
+
Agent string
|
|
38
|
+
NodeDescription string
|
|
39
|
+
NextSteps string
|
|
40
|
+
Mailbox string
|
|
41
|
+
}
|
|
42
|
+
|
|
20
43
|
type LaunchSpec struct {
|
|
21
44
|
RunID identity.RunID
|
|
22
45
|
NodeVisitID identity.NodeVisitID
|
|
@@ -30,12 +53,15 @@ type LaunchSpec struct {
|
|
|
30
53
|
Title string
|
|
31
54
|
Prompt string
|
|
32
55
|
NudgePrompt string
|
|
56
|
+
PromptData PromptData
|
|
33
57
|
NextSteps []workflow.Route
|
|
34
58
|
ResumeID string
|
|
35
59
|
}
|
|
36
60
|
|
|
37
61
|
type Harness interface {
|
|
62
|
+
SetupRepo(ctx context.Context, repoPath string) error
|
|
38
63
|
ValidateAgent(ctx context.Context, repoPath, agent string) error
|
|
39
64
|
FindSession(ctx context.Context, repoPath, title string) (Session, bool, error)
|
|
65
|
+
RenderPrompt(kind PromptKind, data PromptData, nudgeTemplate string) (string, error)
|
|
40
66
|
BuildCommand(spec LaunchSpec) (runner.Command, error)
|
|
41
67
|
}
|