From 09066e1e4e928487bea0658492fd8573607c77c3 Mon Sep 17 00:00:00 2001 From: Claudomator Agent Date: Wed, 8 Jul 2026 09:11:14 +0000 Subject: feat(agentchannel,executor): add ReportVerdict for structured approve/reject signals --- internal/agentchannel/agentchannel.go | 10 ++++++ internal/executor/agentmcp_test.go | 3 ++ internal/executor/channel.go | 13 ++++++++ internal/executor/channel_test.go | 62 +++++++++++++++++++++++++++++++++++ internal/executor/container_test.go | 1 + 5 files changed, 89 insertions(+) (limited to 'internal') diff --git a/internal/agentchannel/agentchannel.go b/internal/agentchannel/agentchannel.go index 98104df..433b762 100644 --- a/internal/agentchannel/agentchannel.go +++ b/internal/agentchannel/agentchannel.go @@ -99,6 +99,16 @@ type AgentChannel interface { // is currently "active" for that role; promoting a draft to active stays // a human action via the existing POST /api/roles/{role}/activate. ProposeRoleConfig(ctx context.Context, config role.RoleConfig) (version int, err error) + // ReportVerdict lets an evaluating agent (e.g. an arbitration-role task) + // report a structured approve/reject decision, instead of leaving it to + // be inferred by parsing the task's free-text summary later. + // Implementations record this as an event attached to the calling task's + // own ID; internal/scheduler.StoryOrchestrator's finalizeArbitration + // reads it back once the reporting task completes to decide REVIEW_READY + // vs NEEDS_FIX. A task that never calls this leaves no such event — + // callers reading it back must treat "no event found" as a distinct, + // backward-compatible case, not an error. + ReportVerdict(ctx context.Context, approved bool, reasoning string) error } // BlockedError is returned by Run when the agent asked the user a question and diff --git a/internal/executor/agentmcp_test.go b/internal/executor/agentmcp_test.go index 18299e8..c45964d 100644 --- a/internal/executor/agentmcp_test.go +++ b/internal/executor/agentmcp_test.go @@ -48,6 +48,9 @@ func (c *recordingChannel) ProposeRoleConfig(_ context.Context, cfg role.RoleCon c.proposedRoleConfigs = append(c.proposedRoleConfigs, cfg) return c.roleConfigVersion, nil } +func (c *recordingChannel) ReportVerdict(_ context.Context, approved bool, reasoning string) error { + return nil +} func resultText(t *testing.T, res *mcp.CallToolResult) string { t.Helper() diff --git a/internal/executor/channel.go b/internal/executor/channel.go index 9392c77..55da7b1 100644 --- a/internal/executor/channel.go +++ b/internal/executor/channel.go @@ -273,4 +273,17 @@ func (c *storeChannel) ProposeRoleConfig(_ context.Context, config role.RoleConf return row.Version, err } return row.Version, nil +} + +func (c *storeChannel) ReportVerdict(_ context.Context, approved bool, reasoning string) error { + payload, _ := json.Marshal(struct { + Approved bool `json:"approved"` + Reasoning string `json:"reasoning"` + }{Approved: approved, Reasoning: reasoning}) + return c.store.CreateEvent(&event.Event{ + TaskID: c.taskID, + Kind: event.KindVerdictReported, + Actor: event.ActorAgent, + Payload: payload, + }) } \ No newline at end of file diff --git a/internal/executor/channel_test.go b/internal/executor/channel_test.go index 84cad89..23f0661 100644 --- a/internal/executor/channel_test.go +++ b/internal/executor/channel_test.go @@ -607,3 +607,65 @@ func TestStoreChannel_ProposeRoleConfig_EmitsRoleConfigProposedEvent(t *testing. t.Errorf("payload mismatch: %+v (want role=evaluator_quality version=%d)", payload, version) } } + +// TestStoreChannel_ReportVerdict_EmitsVerdictReportedEvent proves calling +// ReportVerdict records a KindVerdictReported event attached to the +// *calling task's* own ID, payload {approved, reasoning} — +// internal/scheduler.StoryOrchestrator's finalizeArbitration later reads an +// arbitration task's own event stream for this to decide REVIEW_READY vs +// NEEDS_FIX. Mirrors TestStoreChannel_ProposeRoleConfig_EmitsRoleConfigProposedEvent. +func TestStoreChannel_ReportVerdict_EmitsVerdictReportedEvent(t *testing.T) { + store := &fakeChannelStore{} + ch := newStoreChannel(store, "arbitration-task-1") + + if err := ch.ReportVerdict(context.Background(), false, "the CSS change breaks the running-log panel"); err != nil { + t.Fatalf("ReportVerdict: %v", err) + } + + if len(store.createdEvents) != 1 { + t.Fatalf("expected 1 event, got %d", len(store.createdEvents)) + } + ev := store.createdEvents[0] + if ev.Kind != event.KindVerdictReported { + t.Errorf("expected KindVerdictReported, got %q", ev.Kind) + } + if ev.Actor != event.ActorAgent { + t.Errorf("expected ActorAgent, got %q", ev.Actor) + } + if ev.TaskID != "arbitration-task-1" { + t.Errorf("expected event attached to calling task ID, got %q", ev.TaskID) + } + + var payload struct { + Approved bool `json:"approved"` + Reasoning string `json:"reasoning"` + } + if err := json.Unmarshal(ev.Payload, &payload); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + if payload.Approved != false || payload.Reasoning != "the CSS change breaks the running-log panel" { + t.Errorf("unexpected payload: %+v", payload) + } +} + +// TestStoreChannel_ReportVerdict_Approved proves the approved=true case +// round-trips correctly too — not just the rejection path exercised above. +func TestStoreChannel_ReportVerdict_Approved(t *testing.T) { + store := &fakeChannelStore{} + ch := newStoreChannel(store, "arbitration-task-2") + + if err := ch.ReportVerdict(context.Background(), true, "meets all acceptance criteria"); err != nil { + t.Fatalf("ReportVerdict: %v", err) + } + + var payload struct { + Approved bool `json:"approved"` + Reasoning string `json:"reasoning"` + } + if err := json.Unmarshal(store.createdEvents[0].Payload, &payload); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + if !payload.Approved { + t.Error("expected approved=true to round-trip as true") + } +} diff --git a/internal/executor/container_test.go b/internal/executor/container_test.go index 6672dea..80b1996 100644 --- a/internal/executor/container_test.go +++ b/internal/executor/container_test.go @@ -855,3 +855,4 @@ func (noopChannel) ProposeEpic(_ context.Context, _ EpicProposal) (string, error func (noopChannel) ProposeRoleConfig(_ context.Context, _ role.RoleConfig) (int, error) { return 0, nil } +func (noopChannel) ReportVerdict(_ context.Context, _ bool, _ string) error { return nil } -- cgit v1.2.3