Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions backend/internal/adapters/reviewer/claudecode/claudecode.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ func (r *Reviewer) Harness() domain.ReviewerHarness {
}

var _ ports.Reviewer = (*Reviewer)(nil)
var _ ports.ReviewerCanceller = (*Reviewer)(nil)

// reviewerAllowedTools is the read-only tool allowlist the reviewer launches
// with. The reviewer runs headless (no human to approve prompts) but must stay
Expand Down Expand Up @@ -105,3 +106,9 @@ func (r *Reviewer) PreLaunch(ctx context.Context, inv ports.ReviewInvocation) er
func (r *Reviewer) ReviewMessage(_ context.Context, inv ports.ReviewInvocation) (string, error) {
return inv.Prompt, nil
}

// ReviewCancel stops the active Claude Code reviewer turn while preserving the
// terminal pane for inspection.
func (r *Reviewer) ReviewCancel(context.Context) (ports.ReviewCancelSpec, error) {
return ports.ReviewCancelSpec{Mode: ports.ReviewCancelInterrupt, Interrupts: 2}, nil
}
7 changes: 7 additions & 0 deletions backend/internal/adapters/reviewer/codex/codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ func (r *Reviewer) Harness() domain.ReviewerHarness {
}

var _ ports.Reviewer = (*Reviewer)(nil)
var _ ports.ReviewerCanceller = (*Reviewer)(nil)

// ReviewCommand launches the reviewer with an enforced read-only filesystem
// sandbox. Auto approval lets the headless session request the narrowly needed
Expand Down Expand Up @@ -65,6 +66,12 @@ func (r *Reviewer) ReviewMessage(_ context.Context, inv ports.ReviewInvocation)
return inv.Prompt, nil
}

// ReviewCancel stops the active Codex reviewer turn while preserving the
// terminal pane for inspection.
func (r *Reviewer) ReviewCancel(context.Context) (ports.ReviewCancelSpec, error) {
return ports.ReviewCancelSpec{Mode: ports.ReviewCancelInterrupt, Interrupts: 2}, nil
}

func insertBeforePrompt(argv []string, extra ...string) []string {
for i, arg := range argv {
if arg == "--" {
Expand Down
7 changes: 7 additions & 0 deletions backend/internal/adapters/reviewer/opencode/opencode.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ func (r *Reviewer) Harness() domain.ReviewerHarness {
}

var _ ports.Reviewer = (*Reviewer)(nil)
var _ ports.ReviewerCanceller = (*Reviewer)(nil)

// ReviewCommand launches the reviewer with an inline permission policy that
// permits inspection and the two reporting commands while denying edits and
Expand All @@ -54,3 +55,9 @@ func (r *Reviewer) ReviewCommand(ctx context.Context, inv ports.ReviewInvocation
func (r *Reviewer) ReviewMessage(_ context.Context, inv ports.ReviewInvocation) (string, error) {
return inv.Prompt, nil
}

// ReviewCancel stops the active OpenCode reviewer turn while preserving the
// terminal pane for inspection.
func (r *Reviewer) ReviewCancel(context.Context) (ports.ReviewCancelSpec, error) {
return ports.ReviewCancelSpec{Mode: ports.ReviewCancelInterrupt, Interrupts: 2}, nil
}
12 changes: 12 additions & 0 deletions backend/internal/adapters/reviewer/registry_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package reviewer

import (
"context"
"testing"

"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)

// TestRegistryMatchesDomainVocabulary enforces that the shipped reviewer
Expand All @@ -19,6 +21,16 @@ func TestRegistryMatchesDomainVocabulary(t *testing.T) {
if registered[h] {
t.Errorf("reviewer harness %q registered twice", h)
}
canceller, ok := a.(ports.ReviewerCanceller)
if !ok {
t.Errorf("reviewer harness %q does not implement cancellation", h)
} else if spec, err := canceller.ReviewCancel(context.Background()); err != nil {
t.Errorf("reviewer harness %q cancel spec: %v", h, err)
} else if spec.Mode != ports.ReviewCancelInterrupt {
t.Errorf("reviewer harness %q cancel mode = %q, want %q", h, spec.Mode, ports.ReviewCancelInterrupt)
} else if spec.Interrupts != 2 {
t.Errorf("reviewer harness %q cancel interrupts = %d, want 2", h, spec.Interrupts)
}
registered[h] = true
}
for _, h := range domain.AllReviewerHarnesses {
Expand Down
14 changes: 14 additions & 0 deletions backend/internal/adapters/runtime/conpty/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,20 @@ func clientSendMessage(addr, message string) error {
return err
}

func clientSendInput(addr, input string) error {
conn, err := dialHost(addr, dialTimeout)
if err != nil {
return err
}
defer func() { _ = conn.Close() }()
frame, err := EncodeMessage(MsgTerminalInput, []byte(input))
if err != nil {
return err
}
_, err = conn.Write(frame)
return err
}

// clientGetOutput sends MsgGetOutputReq and reads frames until MsgGetOutputRes.
// Returns "" on timeout or connection failure (no error), matching the TS.
// lines <= 0 is handled by the caller (runtime.go rejects it before calling).
Expand Down
9 changes: 9 additions & 0 deletions backend/internal/adapters/runtime/conpty/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,15 @@ func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, m
return clientSendMessage(sess.addr, message)
}

// Interrupt sends Ctrl-C to the PTY without tearing down the terminal host.
func (r *Runtime) Interrupt(ctx context.Context, handle ports.RuntimeHandle) error {
sess := r.resolve(handle.ID)
if sess == nil {
return fmt.Errorf("conpty: session %q not found", handle.ID)
}
return clientSendInput(sess.addr, "\x03")
}

// GetOutput returns the last lines lines from the pty-host ring buffer.
func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) {
if lines <= 0 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
type Runtime interface {
ports.Runtime // Create, Destroy, IsAlive
ports.Attacher
Interrupt(ctx context.Context, handle ports.RuntimeHandle) error
SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error
GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error)
}
Expand Down
6 changes: 6 additions & 0 deletions backend/internal/adapters/runtime/tmux/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ func sendEnterArgs(id string) []string {
return []string{"send-keys", "-t", id, "Enter"}
}

// sendInterruptArgs builds args for `tmux send-keys -t <id> C-c` to interrupt
// the foreground process without killing the terminal session.
func sendInterruptArgs(id string) []string {
return []string{"send-keys", "-t", id, "C-c"}
}

// capturePaneArgs builds args for `tmux capture-pane -t <id> -p -S -<lines>`.
// -p prints to stdout; -S -<n> starts n lines back in history.
func capturePaneArgs(id string, lines int) []string {
Expand Down
13 changes: 13 additions & 0 deletions backend/internal/adapters/runtime/tmux/tmux.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,19 @@ func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, m
return nil
}

// Interrupt sends Ctrl-C to the foreground process without destroying the tmux
// session, keeping the terminal available for inspection and reuse.
func (r *Runtime) Interrupt(ctx context.Context, handle ports.RuntimeHandle) error {
id, err := handleID(handle)
if err != nil {
return err
}
if _, err := r.run(ctx, sendInterruptArgs(id)...); err != nil {
return fmt.Errorf("tmux runtime: interrupt session %s: %w", id, err)
}
return nil
}

// GetOutput returns the last `lines` lines of the session pane's captured
// output.
func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) {
Expand Down
13 changes: 13 additions & 0 deletions backend/internal/adapters/runtime/tmux/tmux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ func TestCommandBuilders(t *testing.T) {
if got, want := sendEnterArgs("sess-1"), []string{"send-keys", "-t", "sess-1", "Enter"}; !reflect.DeepEqual(got, want) {
t.Fatalf("sendEnterArgs = %#v, want %#v", got, want)
}
if got, want := sendInterruptArgs("sess-1"), []string{"send-keys", "-t", "sess-1", "C-c"}; !reflect.DeepEqual(got, want) {
t.Fatalf("sendInterruptArgs = %#v, want %#v", got, want)
}
if got, want := capturePaneArgs("sess-1", 10), []string{"capture-pane", "-t", "sess-1", "-p", "-S", "-10"}; !reflect.DeepEqual(got, want) {
t.Fatalf("capturePaneArgs = %#v, want %#v", got, want)
}
Expand Down Expand Up @@ -480,6 +483,16 @@ func TestSendMessageUsesLiteralFlag(t *testing.T) {
}
}

func TestInterruptSendsCtrlC(t *testing.T) {
r, fr := newTestRuntime(0)
if err := r.Interrupt(context.Background(), ports.RuntimeHandle{ID: "sess-1"}); err != nil {
t.Fatalf("Interrupt: %v", err)
}
if got, want := fr.calls[0].args, sendInterruptArgs("sess-1"); !reflect.DeepEqual(got, want) {
t.Fatalf("interrupt args = %#v, want %#v", got, want)
}
}

// -- GetOutput tests --

func TestGetOutputValidatesLines(t *testing.T) {
Expand Down
1 change: 1 addition & 0 deletions backend/internal/domain/review.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ const (
ReviewRunComplete ReviewRunStatus = "complete"
ReviewRunDelivered ReviewRunStatus = "delivered"
ReviewRunFailed ReviewRunStatus = "failed"
ReviewRunCancelled ReviewRunStatus = "cancelled"
)

// ReviewVerdict is the outcome a reviewer reports. The empty verdict marks a
Expand Down
51 changes: 51 additions & 0 deletions backend/internal/httpd/apispec/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1408,6 +1408,45 @@ paths:
summary: List a worker's code-review runs
tags:
- reviews
/api/v1/sessions/{sessionId}/reviews/cancel:
post:
operationId: cancelReview
parameters:
- description: Session identifier, e.g. project-1.
in: path
name: sessionId
required: true
schema:
description: Session identifier, e.g. project-1.
type: string
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/CancelReviewResponse'
description: OK
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/APIError'
description: Not Found
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/APIError'
description: Unprocessable Entity
"501":
content:
application/json:
schema:
$ref: '#/components/schemas/APIError'
description: Not Implemented
summary: Cancel a running code review
tags:
- reviews
/api/v1/sessions/{sessionId}/reviews/submit:
post:
operationId: submitReview
Expand Down Expand Up @@ -1687,6 +1726,18 @@ components:
- id
- label
type: object
CancelReviewResponse:
properties:
reviewerHandleId:
type: string
reviews:
items:
$ref: '#/components/schemas/PRReviewState'
type: array
required:
- reviewerHandleId
- reviews
type: object
ClaimPRRequest:
properties:
allowTakeover:
Expand Down
12 changes: 12 additions & 0 deletions backend/internal/httpd/apispec/specgen/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ var schemaNames = map[string]string{
"ControllersListReviewsResponse": "ListReviewsResponse",
"ControllersReviewRunResponse": "ReviewRunResponse",
"ControllersTriggerReviewResponse": "TriggerReviewResponse",
"ControllersCancelReviewResponse": "CancelReviewResponse",
"ControllersSubmitReviewItem": "SubmitReviewItem",
"ControllersSubmitReviewInput": "SubmitReviewInput",
// domain review entities
Expand Down Expand Up @@ -480,6 +481,17 @@ func reviewOperations() []operation {
{http.StatusNotImplemented, envelope.APIError{}},
},
},
{
method: http.MethodPost, path: "/api/v1/sessions/{sessionId}/reviews/cancel", id: "cancelReview", tag: "reviews",
summary: "Cancel a running code review",
pathParams: []any{controllers.SessionIDParam{}},
resps: []respUnit{
{http.StatusOK, controllers.CancelReviewResponse{}},
{http.StatusUnprocessableEntity, envelope.APIError{}},
{http.StatusNotFound, envelope.APIError{}},
{http.StatusNotImplemented, envelope.APIError{}},
},
},
{
method: http.MethodPost, path: "/api/v1/sessions/{sessionId}/reviews/submit", id: "submitReview", tag: "reviews",
summary: "Record a reviewer's result for a worker's PR",
Expand Down
28 changes: 28 additions & 0 deletions backend/internal/httpd/controllers/reviews.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ type TriggerReviewResponse struct {
Reviews []reviewcore.PRReviewState `json:"reviews"`
}

// CancelReviewResponse is the body of cancel (200). reviews carries the
// PR-scoped review state after running passes have been stopped.
type CancelReviewResponse struct {
ReviewerHandleID string `json:"reviewerHandleId"`
Reviews []reviewcore.PRReviewState `json:"reviews"`
}

// SubmitReviewItem is one review result in a batched submit request.
type SubmitReviewItem struct {
RunID string `json:"runId" description:"Review run id being completed."`
Expand All @@ -63,6 +70,7 @@ type ReviewsController struct {
func (c *ReviewsController) Register(r chi.Router) {
r.Get("/sessions/{sessionId}/reviews", c.list)
r.Post("/sessions/{sessionId}/reviews/trigger", c.trigger)
r.Post("/sessions/{sessionId}/reviews/cancel", c.cancel)
r.Post("/sessions/{sessionId}/reviews/submit", c.submit)
}

Expand Down Expand Up @@ -109,6 +117,26 @@ func (c *ReviewsController) trigger(w http.ResponseWriter, r *http.Request) {
})
}

func (c *ReviewsController) cancel(w http.ResponseWriter, r *http.Request) {
if c.Svc == nil {
apispec.NotImplemented(w, r, "POST", "/api/v1/sessions/{sessionId}/reviews/cancel")
return
}
res, err := c.Svc.Cancel(r.Context(), sessionID(r))
if err != nil {
writeReviewError(w, r, err)
return
}
reviews := res.Reviews
if reviews == nil {
reviews = []reviewcore.PRReviewState{}
}
envelope.WriteJSON(w, http.StatusOK, CancelReviewResponse{
ReviewerHandleID: res.ReviewerHandleID,
Reviews: reviews,
})
}

func (c *ReviewsController) submit(w http.ResponseWriter, r *http.Request) {
if c.Svc == nil {
apispec.NotImplemented(w, r, "POST", "/api/v1/sessions/{sessionId}/reviews/submit")
Expand Down
29 changes: 29 additions & 0 deletions backend/internal/httpd/controllers/reviews_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ import (

type fakeReviewService struct {
triggerErr error
cancelErr error
trigger reviewcore.TriggerResult
cancel reviewcore.CancelResult
list reviewcore.SessionReviews
submitted []reviewsvc.SubmittedReview
}
Expand All @@ -39,6 +41,13 @@ func (f *fakeReviewService) Submit(context.Context, domain.SessionID, string, do
return domain.ReviewRun{}, nil
}

func (f *fakeReviewService) Cancel(context.Context, domain.SessionID) (reviewcore.CancelResult, error) {
if f.cancelErr != nil {
return reviewcore.CancelResult{}, f.cancelErr
}
return f.cancel, nil
}

func (f *fakeReviewService) SubmitMany(_ context.Context, _ domain.SessionID, reviews []reviewsvc.SubmittedReview) ([]domain.ReviewRun, error) {
f.submitted = append([]reviewsvc.SubmittedReview(nil), reviews...)
runs := make([]domain.ReviewRun, 0, len(reviews))
Expand Down Expand Up @@ -126,6 +135,26 @@ func TestReviewsTriggerIncludesBatchFields(t *testing.T) {
}
}

func TestReviewsCancelIncludesReviewStates(t *testing.T) {
srv := newReviewTestServer(t, &fakeReviewService{cancel: reviewcore.CancelResult{
ReviewerHandleID: "review-mer-1",
Reviews: []reviewcore.PRReviewState{
{PRURL: "https://github.com/o/r/pull/1", PRNumber: 1, TargetSHA: "sha1", Status: reviewcore.ReviewStateNeedsReview},
},
}})

body, status, headers := doRequest(t, srv, "POST", "/api/v1/sessions/mer-1/reviews/cancel", "")
assertJSON(t, headers)
if status != http.StatusOK {
t.Fatalf("status = %d body=%s", status, body)
}
for _, want := range []string{`"reviews"`, `"needs_review"`, `"reviewerHandleId":"review-mer-1"`} {
if !strings.Contains(string(body), want) {
t.Fatalf("body missing %s: %s", want, body)
}
}
}

func TestReviewsSubmitAcceptsBatchedReviews(t *testing.T) {
svc := &fakeReviewService{}
srv := newReviewTestServer(t, svc)
Expand Down
Loading
Loading