feat: execute durable client commands through supervisor

This commit is contained in:
2026-09-06 12:18:15 +00:00
parent 3f84d3b2f1
commit 56b15c7f4f
41 changed files with 4272 additions and 66 deletions
+3 -9
View File
@@ -35,15 +35,9 @@ func PersistDispatch(ctx context.Context, store *spool.Store, session Session, d
if err != nil {
return spool.Acceptance{}, err
}
acceptance, err := store.AcceptCommand(ctx, spool.Command{IssueUUID: issue, ImmutableSHA256: immutable, Revision: dispatch.GetCommandRevision(), Phase: uint32(rvboxv1.CommandLifecycle_COMMAND_ACCEPTED), ExecutionSpec: spec}, now)
if err != nil {
return spool.Acceptance{}, err
}
command := spool.Command{IssueUUID: issue, ImmutableSHA256: immutable, Revision: dispatch.GetCommandRevision(), Phase: uint32(rvboxv1.CommandLifecycle_COMMAND_ACCEPTED), ExecutionSpec: spec}
if descriptor := dispatch.GetSpec().GetScript(); descriptor != nil {
_, err = store.BeginScript(ctx, issue, spool.ScriptDescriptor{SizeBytes: descriptor.GetSizeBytes(), SHA256: bytesToDigest(descriptor.GetSha256())})
if err != nil {
return spool.Acceptance{}, err
}
command.Script = &spool.ScriptDescriptor{SizeBytes: descriptor.GetSizeBytes(), SHA256: bytesToDigest(descriptor.GetSha256())}
}
return acceptance, nil
return store.AcceptCommand(ctx, command, now)
}
+375
View File
@@ -0,0 +1,375 @@
package agent
import (
"context"
"errors"
"fmt"
"io"
"sync"
"time"
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
"github.com/rvbox/rvbox/internal/client/spool"
"github.com/rvbox/rvbox/internal/client/supervisor"
"github.com/rvbox/rvbox/internal/domain"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
)
// Executor bridges durable dispatch records to the platform supervisor. It
// owns no network state: a command's process, pipes, and spool writes continue
// after the WebSocket session is replaced.
type Executor struct {
Store *spool.Store
Supervisor supervisor.Supervisor
WorkDir string
Now func() time.Time
Notify func(domain.UUID)
mu sync.Mutex
active map[domain.UUID]supervisor.Process
cancel map[domain.UUID]context.CancelFunc
forced map[domain.UUID]bool
}
type ExecutorOptions struct {
Store *spool.Store
Supervisor supervisor.Supervisor
WorkDir string
Now func() time.Time
Notify func(domain.UUID)
}
func NewExecutor(options ExecutorOptions) (*Executor, error) {
if options.Store == nil || options.Supervisor == nil || options.WorkDir == "" {
return nil, errors.New("executor requires durable store, supervisor, and work directory")
}
if options.Now == nil {
options.Now = func() time.Time { return time.Now().UTC() }
}
return &Executor{Store: options.Store, Supervisor: options.Supervisor, WorkDir: options.WorkDir, Now: options.Now, Notify: options.Notify, active: make(map[domain.UUID]supervisor.Process), cancel: make(map[domain.UUID]context.CancelFunc), forced: make(map[domain.UUID]bool)}, nil
}
// Dispatch is safe to invoke after CommandAccepted has been sent. Script
// commands intentionally wait for ScriptCommit; the server may deliver the
// descriptor and body in separate frames.
func (executor *Executor) Dispatch(ctx context.Context, _ Session, dispatch *rvboxv1.CommandDispatch) error {
if executor == nil || dispatch == nil {
return errors.New("invalid command dispatch")
}
issue, err := domain.ParseUUIDv7(dispatch.GetIssueUuid())
if err != nil {
return err
}
if dispatch.GetSpec().GetScript() != nil {
if _, err := executor.Store.ScriptBody(ctx, issue); errors.Is(err, spool.ErrScriptNotReady) {
return nil
} else if err != nil {
return executor.reject(ctx, issue, dispatch.GetCommandRevision(), err)
}
}
return executor.launch(ctx, issue, dispatch.GetCommandRevision(), dispatch.GetSpec())
}
// ScriptReady launches a script after the durable commit barrier. Repeated
// progress/commit frames are harmless because the active map and spool phase
// make launch at-most-once.
func (executor *Executor) ScriptReady(ctx context.Context, _ Session, issue domain.UUID) error {
command, err := executor.Store.GetCommand(ctx, issue)
if err != nil {
return err
}
var spec rvboxv1.ExecutionSpec
if err := proto.Unmarshal(command.ExecutionSpec, &spec); err != nil {
return executor.reject(ctx, issue, command.Revision, err)
}
if spec.GetScript() == nil {
return nil
}
return executor.launch(ctx, issue, command.Revision, &spec)
}
func (executor *Executor) launch(ctx context.Context, issue domain.UUID, revision uint64, spec *rvboxv1.ExecutionSpec) error {
if spec == nil || revision == 0 {
return executor.reject(ctx, issue, revision, errors.New("missing execution specification"))
}
executor.mu.Lock()
if _, exists := executor.active[issue]; exists {
executor.mu.Unlock()
return nil
}
executor.mu.Unlock()
var scriptBody []byte
if spec.GetScript() != nil {
body, err := executor.Store.ScriptBody(ctx, issue)
if err != nil {
if errors.Is(err, spool.ErrScriptNotReady) {
return nil
}
return executor.reject(ctx, issue, revision, err)
}
scriptBody = body
}
workingDirectory := spec.GetCwd()
if workingDirectory == "" {
workingDirectory = executor.WorkDir
}
if err := executor.Store.SetLaunchPhase(ctx, issue, domain.LaunchPhasePrepared, "", 0); err != nil {
return err
}
// The native Windows supervisor creates/assigns the Job while the child is
// suspended and releases it before returning. Marking authorization before
// that call makes a daemon crash in any of those windows recover as an
// interrupted, non-redispatchable command.
if err := executor.Store.SetLaunchPhase(ctx, issue, domain.LaunchPhaseAuthorized, "pending", 0); err != nil {
return err
}
runContext, cancel := context.WithCancel(context.Background())
process, err := executor.Supervisor.Start(runContext, supervisor.StartSpec{IssueUUID: issue, CommandRevision: revision, Execution: proto.Clone(spec).(*rvboxv1.ExecutionSpec), ScriptBody: scriptBody, WorkingDirectory: workingDirectory, Environment: cloneEnvironment(spec.GetEnvOverrides()), ExecutionProfiles: executionProfileNames(spec.GetExecutionProfiles())})
if err != nil {
cancel()
_ = executor.Store.SetLaunchPhase(context.Background(), issue, domain.LaunchPhaseNone, "", 0)
return executor.reject(ctx, issue, revision, err)
}
identity := process.Identity()
if err := executor.Store.SetLaunchPhase(ctx, issue, domain.LaunchPhaseAuthorized, identity.Context, 0); err != nil {
_, _ = executor.Supervisor.Signal(context.Background(), process, supervisor.SignalKill)
cancel()
return err
}
executor.mu.Lock()
executor.active[issue] = process
executor.cancel[issue] = cancel
executor.mu.Unlock()
if _, err := executor.Store.AppendLifecycle(ctx, issue, uint32(rvboxv1.CommandLifecycle_COMMAND_RUNNING), revision, "process started", executor.Now()); err != nil {
_, _ = executor.Supervisor.Signal(context.Background(), process, supervisor.SignalKill)
cancel()
executor.remove(issue)
return err
}
executor.notify(issue)
go executor.watch(runContext, issue, revision, process, cancel)
return nil
}
func (executor *Executor) watch(ctx context.Context, issue domain.UUID, revision uint64, process supervisor.Process, cancel context.CancelFunc) {
defer cancel()
for {
chunk, err := process.ReadOutput(ctx)
if errors.Is(err, io.EOF) {
break
}
if err != nil {
_ = executor.appendIncomplete(context.Background(), issue, revision, err.Error())
break
}
if len(chunk.Data) == 0 {
continue
}
if _, err := executor.Store.AppendOutput(context.Background(), issue, spool.OutputInput{Stream: chunk.Stream, Raw: chunk.Data, ObservedAt: executor.Now()}); err != nil {
_ = executor.appendIncomplete(context.Background(), issue, revision, err.Error())
_, _ = executor.Supervisor.Signal(context.Background(), process, supervisor.SignalKill)
break
}
executor.notify(issue)
}
status, waitErr := process.Wait(context.Background())
phase := rvboxv1.CommandLifecycle_COMMAND_FAILED
if waitErr == nil && status.Code == 0 {
phase = rvboxv1.CommandLifecycle_COMMAND_SUCCEEDED
}
executor.mu.Lock()
if executor.forced[issue] {
phase = rvboxv1.CommandLifecycle_COMMAND_TERMINATED
}
executor.mu.Unlock()
detail := fmt.Sprintf("exit code %d", status.Code)
if waitErr != nil {
detail = boundedError(waitErr)
}
if err := executor.appendLifecycle(context.Background(), issue, revision, phase, detail); err == nil {
executor.notify(issue)
}
executor.remove(issue)
}
func (executor *Executor) appendLifecycle(ctx context.Context, issue domain.UUID, revision uint64, phase rvboxv1.CommandLifecycle, detail string) error {
_, err := executor.Store.AppendLifecycle(ctx, issue, uint32(phase), revision, detail, executor.Now())
return err
}
func (executor *Executor) reject(ctx context.Context, issue domain.UUID, revision uint64, cause error) error {
if revision == 0 {
return cause
}
if err := executor.appendLifecycle(ctx, issue, revision, rvboxv1.CommandLifecycle_COMMAND_REJECTED, boundedError(cause)); err != nil {
return err
}
executor.notify(issue)
return nil
}
func (executor *Executor) appendIncomplete(ctx context.Context, issue domain.UUID, _ uint64, detail string) error {
payload, err := proto.MarshalOptions{Deterministic: true}.Marshal(&rvboxv1.CommandEvent{IssueUuid: issue.String(), ObservedAt: timestamppb.New(executor.Now()), Payload: &rvboxv1.CommandEvent_OutputIncomplete{OutputIncomplete: &rvboxv1.OutputIncomplete{Reason: boundedError(errors.New(detail))}}})
if err != nil {
return err
}
_, err = executor.Store.AppendEvent(ctx, issue, spool.EventInput{Kind: 11, Compression: 1, RawBytes: uint64(len(payload)), Payload: payload, CreatedAt: executor.Now()})
if err == nil {
executor.notify(issue)
}
return err
}
func (executor *Executor) Stdin(ctx context.Context, _ Session, input *rvboxv1.StdinWrite) error {
if input == nil {
return errors.New("missing stdin request")
}
issue, err := domain.ParseUUIDv7(input.GetIssueUuid())
if err != nil {
return err
}
executor.mu.Lock()
process := executor.active[issue]
executor.mu.Unlock()
if process == nil {
return executor.appendStdinAck(ctx, issue, input.GetWriteSeq(), false, "command is not running")
}
err = process.WriteStdin(ctx, input.GetData(), input.GetAppendNewline())
detail := ""
if err != nil {
detail = boundedError(err)
}
return executor.appendStdinAck(ctx, issue, input.GetWriteSeq(), err == nil, detail)
}
func (executor *Executor) CloseStdin(ctx context.Context, _ Session, input *rvboxv1.CloseStdin) error {
if input == nil {
return errors.New("missing close-stdin request")
}
issue, err := domain.ParseUUIDv7(input.GetIssueUuid())
if err != nil {
return err
}
executor.mu.Lock()
process := executor.active[issue]
executor.mu.Unlock()
if process == nil {
return executor.appendStdinAck(ctx, issue, input.GetWriteSeq(), false, "command is not running")
}
err = process.CloseStdin(ctx)
detail := ""
if err != nil {
detail = boundedError(err)
}
return executor.appendStdinAck(ctx, issue, input.GetWriteSeq(), err == nil, detail)
}
func (executor *Executor) appendStdinAck(ctx context.Context, issue domain.UUID, writeSeq uint64, accepted bool, detail string) error {
payload, err := proto.MarshalOptions{Deterministic: true}.Marshal(&rvboxv1.CommandEvent{IssueUuid: issue.String(), ObservedAt: timestamppb.New(executor.Now()), Payload: &rvboxv1.CommandEvent_StdinAck{StdinAck: &rvboxv1.StdinAcknowledgement{WriteSeq: writeSeq, Detail: detail, StdinClosed: !accepted}}})
if err != nil {
return err
}
_, err = executor.Store.AppendEvent(ctx, issue, spool.EventInput{Kind: 7, Compression: 1, RawBytes: uint64(len(payload)), Payload: payload, CreatedAt: executor.Now(), UseCloseout: false})
if err == nil {
executor.notify(issue)
}
return err
}
func (executor *Executor) Signal(ctx context.Context, _ Session, input *rvboxv1.SignalCommand) error {
if input == nil {
return errors.New("missing signal request")
}
issue, err := domain.ParseUUIDv7(input.GetIssueUuid())
if err != nil {
return err
}
executor.mu.Lock()
process := executor.active[issue]
executor.mu.Unlock()
if process == nil {
return errors.New("command is not running")
}
kind := supervisor.SignalTerm
if input.GetSignal() == rvboxv1.SignalKind_SIGNAL_KILL {
kind = supervisor.SignalKill
} else if input.GetSignal() != rvboxv1.SignalKind_SIGNAL_TERM {
return errors.New("unsupported Windows signal")
}
outcome, err := executor.Supervisor.Signal(ctx, process, kind)
if kind == supervisor.SignalKill || outcome.Escalated {
executor.mu.Lock()
executor.forced[issue] = true
executor.mu.Unlock()
}
return executor.appendSignalResult(ctx, issue, input.GetCommandRevision(), input.GetSignal(), err == nil && outcome.Delivered, outcome, err)
}
func (executor *Executor) appendSignalResult(ctx context.Context, issue domain.UUID, revision uint64, signal rvboxv1.SignalKind, accepted bool, outcome supervisor.SignalOutcome, cause error) error {
detail := outcome.Detail
if cause != nil {
detail = boundedError(cause)
}
payload, err := proto.MarshalOptions{Deterministic: true}.Marshal(&rvboxv1.CommandEvent{IssueUuid: issue.String(), ObservedAt: timestamppb.New(executor.Now()), Payload: &rvboxv1.CommandEvent_SignalResult{SignalResult: &rvboxv1.SignalResult{Signal: signal, Accepted: accepted, GracefulDeliveryAttempted: signal == rvboxv1.SignalKind_SIGNAL_TERM, ForcedTerminationUsed: outcome.Escalated || signal == rvboxv1.SignalKind_SIGNAL_KILL, Detail: detail, CommandRevision: revision}}})
if err != nil {
return err
}
_, err = executor.Store.AppendEvent(ctx, issue, spool.EventInput{Kind: 8, Compression: 1, RawBytes: uint64(len(payload)), Payload: payload, CreatedAt: executor.Now()})
if err == nil {
executor.notify(issue)
}
return err
}
func (executor *Executor) Terminate(ctx context.Context, issue domain.UUID) error {
executor.mu.Lock()
process := executor.active[issue]
executor.forced[issue] = true
executor.mu.Unlock()
if process == nil {
return nil
}
returnError := error(nil)
if _, err := executor.Supervisor.Signal(ctx, process, supervisor.SignalKill); err != nil {
returnError = err
}
return returnError
}
func (executor *Executor) StopAll(ctx context.Context) error {
return executor.Supervisor.StopAll(ctx)
}
func (executor *Executor) remove(issue domain.UUID) {
executor.mu.Lock()
delete(executor.active, issue)
delete(executor.cancel, issue)
delete(executor.forced, issue)
executor.mu.Unlock()
}
func (executor *Executor) notify(issue domain.UUID) {
if executor.Notify != nil {
executor.Notify(issue)
}
}
func cloneEnvironment(input map[string]string) map[string]string {
if input == nil {
return nil
}
result := make(map[string]string, len(input))
for key, value := range input {
result[key] = value
}
return result
}
func executionProfileNames(input []rvboxv1.ExecutionProfile) []string {
result := make([]string, 0, len(input))
for _, profile := range input {
result = append(result, profile.String())
}
return result
}
+135
View File
@@ -0,0 +1,135 @@
//go:build !windows
package agent
import (
"context"
"crypto/sha256"
"path/filepath"
"testing"
"time"
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
"github.com/rvbox/rvbox/internal/client/spool"
clientwindows "github.com/rvbox/rvbox/internal/client/supervisor/windows"
"github.com/rvbox/rvbox/internal/domain"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
)
func TestExecutorRunsDurableCommandAndPublishesTerminalEvents_HP_EXECUTOR_01(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
store, err := spool.Open(ctx, spool.Options{DataDir: filepath.Join(t.TempDir(), "spool"), BusyTimeout: time.Second})
if err != nil {
t.Fatal(err)
}
defer store.Close()
issue, _ := domain.ParseUUIDv7("019c46f1-1d02-7000-8000-0000000000d1")
spec := &rvboxv1.ExecutionSpec{ShellType: rvboxv1.ShellType_SHELL_SH, Source: &rvboxv1.ExecutionSpec_CommandText{CommandText: "printf executor-ok"}}
encoded, err := proto.Marshal(spec)
if err != nil {
t.Fatal(err)
}
requestHash := sha256.Sum256([]byte("executor-request"))
if _, err := store.AcceptCommand(ctx, spool.Command{IssueUUID: issue, ImmutableSHA256: requestHash, Revision: 1, Phase: uint32(rvboxv1.CommandLifecycle_COMMAND_ACCEPTED), ExecutionSpec: encoded}, time.Now().UTC()); err != nil {
t.Fatal(err)
}
notified := make(chan domain.UUID, 16)
supervised, err := clientwindows.NewSupervisor(clientwindows.NativeOptions{MaxOutputChunk: 64})
if err != nil {
t.Fatal(err)
}
executor, err := NewExecutor(ExecutorOptions{Store: store, Supervisor: supervised, WorkDir: t.TempDir(), Notify: func(value domain.UUID) { notified <- value }})
if err != nil {
t.Fatal(err)
}
dispatch := &rvboxv1.CommandDispatch{IssueUuid: issue.String(), CommandRevision: 1, TargetSessionGeneration: 1, IssueTime: timestamppb.Now(), Spec: spec, ImmutableRequestSha256: requestHash[:]}
if err := executor.Dispatch(ctx, Session{Generation: 1}, dispatch); err != nil {
t.Fatal(err)
}
deadline := time.After(5 * time.Second)
for {
command, err := store.GetCommand(ctx, issue)
if err != nil {
t.Fatal(err)
}
if command.Phase == uint32(rvboxv1.CommandLifecycle_COMMAND_SUCCEEDED) {
break
}
select {
case <-deadline:
t.Fatalf("executor did not reach terminal phase: %d", command.Phase)
case <-notified:
}
}
if _, err := store.AssignSendWindow(ctx, issue, 8, 1<<20); err != nil {
t.Fatal(err)
}
events, err := store.PendingEvents(ctx, issue)
if err != nil || len(events) < 3 {
t.Fatalf("durable executor events = %#v, %v", events, err)
}
if _, err := store.Check(ctx); err != nil {
t.Fatal(err)
}
}
func TestExecutorWaitsForScriptCommitBeforeLaunch_HP_EXECUTOR_02(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
store, err := spool.Open(ctx, spool.Options{DataDir: filepath.Join(t.TempDir(), "spool"), BusyTimeout: time.Second})
if err != nil {
t.Fatal(err)
}
defer store.Close()
issue, _ := domain.ParseUUIDv7("019c46f1-1d02-7000-8000-0000000000d2")
body := []byte("printf script-ok")
digest := sha256.Sum256(body)
spec := &rvboxv1.ExecutionSpec{ShellType: rvboxv1.ShellType_SHELL_SH, Source: &rvboxv1.ExecutionSpec_Script{Script: &rvboxv1.ScriptDescriptor{Filename: "ignored.sh", SizeBytes: uint64(len(body)), Sha256: digest[:]}}}
encoded, _ := proto.Marshal(spec)
hash := sha256.Sum256([]byte("script-executor-request"))
if _, err := store.AcceptCommand(ctx, spool.Command{IssueUUID: issue, ImmutableSHA256: hash, Revision: 1, Phase: uint32(rvboxv1.CommandLifecycle_COMMAND_ACCEPTED), ExecutionSpec: encoded, Script: &spool.ScriptDescriptor{SizeBytes: uint64(len(body)), SHA256: digest}}, time.Now().UTC()); err != nil {
t.Fatal(err)
}
supervised, _ := clientwindows.NewSupervisor(clientwindows.NativeOptions{})
executor, err := NewExecutor(ExecutorOptions{Store: store, Supervisor: supervised, WorkDir: t.TempDir()})
if err != nil {
t.Fatal(err)
}
dispatch := &rvboxv1.CommandDispatch{IssueUuid: issue.String(), CommandRevision: 1, TargetSessionGeneration: 1, Spec: spec, ImmutableRequestSha256: hash[:]}
if err := executor.Dispatch(ctx, Session{Generation: 1}, dispatch); err != nil {
t.Fatal(err)
}
command, _ := store.GetCommand(ctx, issue)
if command.Phase != uint32(rvboxv1.CommandLifecycle_COMMAND_ACCEPTED) {
t.Fatalf("script launched before commit, phase=%d", command.Phase)
}
chunkHash := sha256.Sum256(body)
if _, err := store.AppendScriptChunk(ctx, issue, 0, body, chunkHash); err != nil {
t.Fatal(err)
}
if _, err := store.CommitScript(ctx, issue, spool.ScriptDescriptor{SizeBytes: uint64(len(body)), SHA256: digest}); err != nil {
t.Fatal(err)
}
if err := executor.ScriptReady(ctx, Session{Generation: 1}, issue); err != nil {
t.Fatal(err)
}
deadline := time.After(5 * time.Second)
for {
command, err = store.GetCommand(ctx, issue)
if err != nil {
t.Fatal(err)
}
if command.Phase == uint32(rvboxv1.CommandLifecycle_COMMAND_SUCCEEDED) {
return
}
select {
case <-deadline:
t.Fatal("committed script did not reach terminal phase")
case <-time.After(10 * time.Millisecond):
}
}
}
+22 -11
View File
@@ -77,16 +77,27 @@ func Reconcile(ctx context.Context, transport Transport, session Session, snapsh
if err := transport.Write(ctx, encoded); err != nil {
return nil, err
}
received, err := transport.Read(ctx)
if err != nil {
return nil, err
for {
received, err := transport.Read(ctx)
if err != nil {
return nil, err
}
result, err := agentproto.DecodeEnvelope(received, limits, rvboxv1.Platform_PLATFORM_WINDOWS)
if err != nil {
return nil, fmt.Errorf("decode reconciliation response: %w", err)
}
if result.GetSessionId() != session.ID || result.GetSessionGeneration() != session.Generation {
return nil, ErrProtocolHandshake
}
if result.GetReconcileRequest() != nil {
// The request may have been queued before the client's snapshot write
// reached the server. The snapshot is already on the wire; consume the
// advisory target frame and continue waiting for the durable result.
continue
}
if result.GetReconcileResult() == nil {
return nil, ErrProtocolHandshake
}
return proto.Clone(result.GetReconcileResult()).(*rvboxv1.ReconcileResult), nil
}
result, err := agentproto.DecodeEnvelope(received, limits, rvboxv1.Platform_PLATFORM_WINDOWS)
if err != nil {
return nil, fmt.Errorf("decode ReconcileResult: %w", err)
}
if result.GetSessionId() != session.ID || result.GetSessionGeneration() != session.Generation || result.GetReconcileResult() == nil {
return nil, ErrProtocolHandshake
}
return proto.Clone(result.GetReconcileResult()).(*rvboxv1.ReconcileResult), nil
}
+543
View File
@@ -0,0 +1,543 @@
package agent
import (
"context"
"crypto/rand"
"errors"
"fmt"
"math/big"
"time"
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
"github.com/rvbox/rvbox/internal/agentproto"
"github.com/rvbox/rvbox/internal/client/spool"
"github.com/rvbox/rvbox/internal/domain"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
)
// RunnerOptions contains the replaceable network edge and the durable client
// state used by one daemon. The supervisor is deliberately a callback here:
// process execution can outlive a network session and is owned by the caller.
type RunnerOptions struct {
Store *spool.Store
Dial func(context.Context) (Transport, error)
Hello *rvboxv1.ClientHello
Limits agentproto.Limits
Backoff BackoffOptions
Jitter Jitter
Now func() time.Time
OnDispatch func(context.Context, Session, *rvboxv1.CommandDispatch) error
OnStdin func(context.Context, Session, *rvboxv1.StdinWrite) error
OnCloseStdin func(context.Context, Session, *rvboxv1.CloseStdin) error
OnSignal func(context.Context, Session, *rvboxv1.SignalCommand) error
OnScriptReady func(context.Context, Session, domain.UUID) error
OnTerminate func(context.Context, domain.UUID) error
// EventReady wakes the active session after a supervisor worker appends a
// durable event. The network loop remains the sole writer; a reconnect can
// safely ignore a stale notification because replay reads the spool again.
EventReady <-chan domain.UUID
}
// BackoffOptions and Jitter are kept at the agent boundary so callers do not
// need to depend on the internal session-machine implementation.
type BackoffOptions struct {
Initial time.Duration
Maximum time.Duration
StableReset time.Duration
}
func (options BackoffOptions) Validate() error {
if options.Initial <= 0 || options.Maximum < options.Initial || options.StableReset <= 0 {
return errors.New("invalid client reconnect backoff options")
}
return nil
}
type Jitter func(time.Duration) time.Duration
func (options RunnerOptions) validate() error {
if options.Store == nil || options.Dial == nil || options.Hello == nil {
return errors.New("client runner requires store, dialer, and hello")
}
if options.Limits.MaxEnvelopeBytes == 0 {
options.Limits = agentproto.DefaultLimits()
}
if options.Backoff.Initial == 0 {
options.Backoff = BackoffOptions{Initial: time.Second, Maximum: time.Minute, StableReset: time.Minute}
}
if err := options.Backoff.Validate(); err != nil {
return err
}
if options.Jitter == nil {
return errors.New("client runner jitter is required")
}
if options.Now == nil {
return errors.New("client runner clock is required")
}
return nil
}
// Run reconnects until ctx is cancelled. A failed handshake or transport is
// treated as a normal network failure; durable commands and their spool remain
// untouched. The first failure uses the configured initial backoff and a
// successful session resets the exponential history only after StableReset.
func Run(ctx context.Context, options RunnerOptions) error {
if err := options.validate(); err != nil {
return err
}
delay := time.Duration(0)
failures := uint32(0)
for {
if err := waitUntil(ctx, delay); err != nil {
return nil
}
sessionStarted := time.Now()
_ = runOnce(ctx, options)
if ctx.Err() != nil {
return nil
}
if time.Since(sessionStarted) >= options.Backoff.StableReset {
failures = 0
}
if failures < ^uint32(0) {
failures++
}
cap := options.Backoff.Initial
for index := uint32(1); index < failures && cap < options.Backoff.Maximum; index++ {
if cap > options.Backoff.Maximum/2 {
cap = options.Backoff.Maximum
break
}
cap *= 2
}
if cap > options.Backoff.Maximum {
cap = options.Backoff.Maximum
}
delay = options.Jitter(cap)
if delay < 0 {
delay = 0
}
if delay > cap {
delay = cap
}
}
}
func waitUntil(ctx context.Context, delay time.Duration) error {
if delay <= 0 {
return nil
}
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
// RunOnce performs one complete session and returns when the transport is
// lost or a protocol/storage error makes that session unusable.
func RunOnce(ctx context.Context, options RunnerOptions) error {
if err := options.validate(); err != nil {
return err
}
return runOnce(ctx, options)
}
func runOnce(ctx context.Context, options RunnerOptions) (resultErr error) {
transport, err := options.Dial(ctx)
if err != nil {
return err
}
defer func() {
if closeErr := transport.Close(); resultErr == nil && closeErr != nil {
resultErr = closeErr
}
}()
limits := options.Limits
if limits.MaxEnvelopeBytes == 0 {
limits = agentproto.DefaultLimits()
}
session, err := Handshake(ctx, transport, options.Hello, limits)
if err != nil {
return err
}
snapshot, err := options.Store.ReconcileSnapshot(ctx)
if err != nil {
return fmt.Errorf("build client reconciliation snapshot: %w", err)
}
result, err := Reconcile(ctx, transport, session, snapshot, limits)
if err != nil {
return err
}
terminated, err := ApplyReconcileResult(ctx, options.Store, result, options.Now())
if err != nil {
return fmt.Errorf("apply server reconciliation: %w", err)
}
for _, issue := range terminated {
if options.OnTerminate != nil {
if err := options.OnTerminate(ctx, issue); err != nil {
return err
}
}
}
sentEvents := make(map[domain.UUID]uint64)
if err := replayEvents(ctx, transport, options.Store, session, snapshot, limits, sentEvents); err != nil {
return err
}
if err := sendCapacity(ctx, transport, session, options.Hello, snapshot, limits); err != nil {
return err
}
return serveActive(ctx, transport, options, session, sentEvents)
}
func replayEvents(ctx context.Context, transport Transport, store *spool.Store, session Session, snapshot *rvboxv1.ReconcileSnapshot, limits agentproto.Limits, sent map[domain.UUID]uint64) error {
for _, retained := range snapshot.GetRetainedCommands() {
if retained == nil || retained.GetTombstoned() {
continue
}
issue, err := domain.ParseUUIDv7(retained.GetIssueUuid())
if err != nil {
return err
}
if _, err := store.AssignSendWindow(ctx, issue, 128, 1<<20); err != nil {
return err
}
events, err := store.PendingEvents(ctx, issue)
if err != nil {
return err
}
for _, event := range events {
if err := SendStoredEvent(ctx, transport, session, event, limits); err != nil {
return err
}
if event.EventSeq > sent[issue] {
sent[issue] = event.EventSeq
}
}
}
return nil
}
// SendStoredEvent converts the compact local representation back to a
// canonical CommandEvent. Output rows store only their typed OutputChunk to
// avoid duplicating the envelope; metadata rows may store a full event.
func SendStoredEvent(ctx context.Context, transport Transport, session Session, event spool.Event, limits agentproto.Limits) error {
commandEvent, err := commandEventFromStored(event)
if err != nil {
return err
}
commandEvent.IssueUuid = event.IssueUUID.String()
commandEvent.EventSeq = event.EventSeq
commandEvent.ObservedAt = timestamppb.New(event.CreatedAt)
return SendCommandEvent(ctx, transport, session, commandEvent, limits)
}
func commandEventFromStored(event spool.Event) (*rvboxv1.CommandEvent, error) {
if event.EventSeq == 0 || event.IssueUUID == (domain.UUID{}) || event.CreatedAt.IsZero() {
return nil, errors.New("stored event lacks assigned sequence or owner")
}
if event.Kind == spool.EventKindOutput {
var output rvboxv1.OutputChunk
if err := proto.Unmarshal(event.Payload, &output); err != nil {
return nil, fmt.Errorf("decode stored output event: %w", err)
}
return &rvboxv1.CommandEvent{Payload: &rvboxv1.CommandEvent_Output{Output: &output}}, nil
}
if event.Kind == spool.EventKindOutputTruncation {
var marker rvboxv1.OutputTruncation
if err := proto.Unmarshal(event.Payload, &marker); err != nil {
return nil, fmt.Errorf("decode stored truncation event: %w", err)
}
return &rvboxv1.CommandEvent{Payload: &rvboxv1.CommandEvent_OutputTruncation{OutputTruncation: &marker}}, nil
}
var stored rvboxv1.CommandEvent
if err := proto.Unmarshal(event.Payload, &stored); err != nil || stored.Payload == nil {
return nil, fmt.Errorf("decode stored command event: %w", err)
}
return &stored, nil
}
func sendCapacity(ctx context.Context, transport Transport, session Session, hello *rvboxv1.ClientHello, snapshot *rvboxv1.ReconcileSnapshot, limits agentproto.Limits) error {
var running, queued uint32
for _, retained := range snapshot.GetRetainedCommands() {
if retained == nil || retained.GetTombstoned() {
continue
}
switch retained.GetLifecycle() {
case rvboxv1.CommandLifecycle_COMMAND_ACCEPTED, rvboxv1.CommandLifecycle_COMMAND_RUNNING:
running++
case rvboxv1.CommandLifecycle_COMMAND_QUEUED, rvboxv1.CommandLifecycle_COMMAND_DISPATCHED:
queued++
}
}
if running > hello.GetMaxRunningCommands() || queued > hello.GetMaxQueuedCommands() {
return fmt.Errorf("durable client command count exceeds advertised capacity")
}
envelope := &rvboxv1.AgentEnvelope{SessionId: session.ID, SessionGeneration: session.Generation, Payload: &rvboxv1.AgentEnvelope_ClientCapacity{ClientCapacity: &rvboxv1.ClientCapacity{RunningCommands: running, QueuedCommands: queued, MaxRunningCommands: hello.GetMaxRunningCommands(), MaxQueuedCommands: hello.GetMaxQueuedCommands()}}}
if err := agentproto.ValidateEnvelope(envelope, limits, rvboxv1.Platform_PLATFORM_WINDOWS); err != nil {
return err
}
encoded, err := proto.Marshal(envelope)
if err != nil {
return err
}
return transport.Write(ctx, encoded)
}
func serveActive(ctx context.Context, transport Transport, options RunnerOptions, session Session, sent map[domain.UUID]uint64) error {
limits := options.Limits
if limits.MaxEnvelopeBytes == 0 {
limits = agentproto.DefaultLimits()
}
readContext, cancelRead := context.WithCancel(ctx)
defer cancelRead()
frames := make(chan []byte, 1)
readErrors := make(chan error, 1)
go func() {
for {
encoded, err := transport.Read(readContext)
if err != nil {
select {
case readErrors <- err:
case <-readContext.Done():
}
return
}
select {
case frames <- encoded:
case <-readContext.Done():
return
}
}
}()
for {
var encoded []byte
select {
case <-ctx.Done():
return nil
case err := <-readErrors:
return err
case issue := <-options.EventReady:
if issue == (domain.UUID{}) {
continue
}
if err := flushEvents(ctx, transport, options.Store, session, issue, limits, sent); err != nil {
return err
}
continue
case encoded = <-frames:
}
envelope, err := agentproto.DecodeEnvelope(encoded, limits, rvboxv1.Platform_PLATFORM_WINDOWS)
if err != nil {
return err
}
if envelope.GetSessionId() != session.ID || envelope.GetSessionGeneration() != session.Generation {
return ErrProtocolHandshake
}
switch {
case envelope.GetCommandDispatch() != nil:
if err := handleDispatch(ctx, transport, options, session, envelope.GetCommandDispatch(), limits); err != nil {
return err
}
issue, parseErr := domain.ParseUUIDv7(envelope.GetCommandDispatch().GetIssueUuid())
if parseErr == nil {
if err := flushEvents(ctx, transport, options.Store, session, issue, limits, sent); err != nil {
return err
}
}
case envelope.GetEventAck() != nil:
if err := ApplyEventAck(ctx, options.Store, envelope.GetEventAck()); err != nil {
return err
}
case envelope.GetScriptChunk() != nil:
if err := handleScriptChunk(ctx, transport, options.Store, session, envelope.GetScriptChunk(), limits); err != nil {
return err
}
case envelope.GetScriptCommit() != nil:
if err := handleScriptCommit(ctx, transport, options.Store, session, envelope.GetScriptCommit(), limits); err != nil {
return err
}
if options.OnScriptReady != nil {
issue, err := domain.ParseUUIDv7(envelope.GetScriptCommit().GetIssueUuid())
if err != nil {
return err
}
if err := options.OnScriptReady(ctx, session, issue); err != nil {
return err
}
}
case envelope.GetStdinWrite() != nil:
if options.OnStdin != nil {
if err := options.OnStdin(ctx, session, envelope.GetStdinWrite()); err != nil {
return err
}
}
if issue, parseErr := domain.ParseUUIDv7(envelope.GetStdinWrite().GetIssueUuid()); parseErr == nil {
if err := flushEvents(ctx, transport, options.Store, session, issue, limits, sent); err != nil {
return err
}
}
case envelope.GetCloseStdin() != nil:
if options.OnCloseStdin != nil {
if err := options.OnCloseStdin(ctx, session, envelope.GetCloseStdin()); err != nil {
return err
}
}
if issue, parseErr := domain.ParseUUIDv7(envelope.GetCloseStdin().GetIssueUuid()); parseErr == nil {
if err := flushEvents(ctx, transport, options.Store, session, issue, limits, sent); err != nil {
return err
}
}
case envelope.GetSignalCommand() != nil:
if options.OnSignal != nil {
if err := options.OnSignal(ctx, session, envelope.GetSignalCommand()); err != nil {
return err
}
}
if issue, parseErr := domain.ParseUUIDv7(envelope.GetSignalCommand().GetIssueUuid()); parseErr == nil {
if err := flushEvents(ctx, transport, options.Store, session, issue, limits, sent); err != nil {
return err
}
}
case envelope.GetError() != nil:
if envelope.GetError().GetCloseSession() {
return fmt.Errorf("server closed agent session: %s", envelope.GetError().GetError().GetMessage())
}
case envelope.GetReconcileRequest() != nil:
// A request is advisory after the snapshot/result barrier. The next
// reconnect repeats the complete snapshot; never apply partial targets.
default:
return ErrUnexpectedMessage
}
}
}
func flushEvents(ctx context.Context, transport Transport, store *spool.Store, session Session, issue domain.UUID, limits agentproto.Limits, sent map[domain.UUID]uint64) error {
if issue == (domain.UUID{}) {
return nil
}
if _, err := store.AssignSendWindow(ctx, issue, 128, 1<<20); err != nil {
return err
}
events, err := store.PendingEvents(ctx, issue)
if err != nil {
return err
}
for _, event := range events {
if event.EventSeq == 0 || event.EventSeq <= sent[issue] {
continue
}
if err := SendStoredEvent(ctx, transport, session, event, limits); err != nil {
return err
}
sent[issue] = event.EventSeq
}
return nil
}
func handleDispatch(ctx context.Context, transport Transport, options RunnerOptions, session Session, dispatch *rvboxv1.CommandDispatch, limits agentproto.Limits) error {
acceptance, err := PersistDispatch(ctx, options.Store, session, dispatch, options.Now(), limits)
accepted := err == nil
ack := &rvboxv1.CommandAccepted{IssueUuid: dispatch.GetIssueUuid(), CommandRevision: dispatch.GetCommandRevision(), Accepted: accepted}
if err != nil {
ack.Rejection = rejectionForError(err, dispatch.GetIssueUuid())
}
envelope := &rvboxv1.AgentEnvelope{SessionId: session.ID, SessionGeneration: session.Generation, Payload: &rvboxv1.AgentEnvelope_CommandAccepted{CommandAccepted: ack}}
if validateErr := agentproto.ValidateEnvelope(envelope, limits, rvboxv1.Platform_PLATFORM_WINDOWS); validateErr != nil {
return validateErr
}
encoded, marshalErr := proto.Marshal(envelope)
if marshalErr != nil {
return marshalErr
}
if err := transport.Write(ctx, encoded); err != nil {
return err
}
if accepted && options.OnDispatch != nil {
return options.OnDispatch(ctx, session, dispatch)
}
_ = acceptance
return nil
}
func rejectionForError(err error, issue string) *rvboxv1.ControlError {
code := rvboxv1.ControlError_TRANSIENT
if errors.Is(err, spool.ErrCommandConflict) || errors.Is(err, spool.ErrAlreadyExecuted) {
code = rvboxv1.ControlError_CONFLICT
} else if errors.Is(err, agentproto.ErrInvalidExecutionSpec) || errors.Is(err, ErrProtocolHandshake) {
code = rvboxv1.ControlError_INVALID_ARGUMENT
}
return &rvboxv1.ControlError{Code: code, Message: boundedError(err), Retryable: code == rvboxv1.ControlError_TRANSIENT, IssueUuid: issue}
}
func boundedError(err error) string {
if err == nil {
return ""
}
message := err.Error()
if len(message) > 1024 {
message = message[:1024]
}
return message
}
func handleScriptChunk(ctx context.Context, transport Transport, store *spool.Store, session Session, chunk *rvboxv1.ScriptChunk, limits agentproto.Limits) error {
status, err := ApplyScriptChunk(ctx, store, session, chunk, limits)
if err != nil {
return err
}
return appendAndSendScriptStatus(ctx, transport, store, session, chunk.GetIssueUuid(), status, limits)
}
func handleScriptCommit(ctx context.Context, transport Transport, store *spool.Store, session Session, commit *rvboxv1.ScriptCommit, limits agentproto.Limits) error {
status, err := ApplyScriptCommit(ctx, store, session, commit, limits)
if err != nil {
return err
}
return appendAndSendScriptStatus(ctx, transport, store, session, commit.GetIssueUuid(), status, limits)
}
func appendAndSendScriptStatus(ctx context.Context, transport Transport, store *spool.Store, session Session, issueText string, status spool.ScriptStatus, limits agentproto.Limits) error {
issue, err := domain.ParseUUIDv7(issueText)
if err != nil {
return err
}
event := &rvboxv1.CommandEvent{IssueUuid: issueText, ObservedAt: timestamppb.New(time.Now().UTC()), Payload: &rvboxv1.CommandEvent_ScriptStatus{ScriptStatus: &rvboxv1.ScriptUploadStatus{ReceivedBytes: status.ReceivedBytes, Complete: status.Committed}}}
payload, err := proto.MarshalOptions{Deterministic: true}.Marshal(event)
if err != nil {
return err
}
if _, err := store.AppendEvent(ctx, issue, spool.EventInput{Kind: 9, Compression: 1, RawBytes: uint64(len(payload)), Payload: payload, CreatedAt: event.GetObservedAt().AsTime()}); err != nil {
return err
}
assigned, err := store.AssignSendWindow(ctx, issue, 1, 1<<20)
if err != nil {
return err
}
for _, item := range assigned {
if err := SendStoredEvent(ctx, transport, session, item, limits); err != nil {
return err
}
}
return nil
}
// CryptoJitter returns a full-jitter delay without relying on math/rand's
// process-global state. Tests inject a deterministic jitter instead.
func CryptoJitter(capacity time.Duration) time.Duration {
if capacity <= 0 {
return 0
}
value, err := rand.Int(rand.Reader, big.NewInt(int64(capacity)+1))
if err != nil {
return capacity
}
return time.Duration(value.Int64())
}
+139
View File
@@ -0,0 +1,139 @@
package agent
import (
"context"
"errors"
"path/filepath"
"sync"
"testing"
"time"
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
"github.com/rvbox/rvbox/internal/agentproto"
"github.com/rvbox/rvbox/internal/client/spool"
"github.com/rvbox/rvbox/internal/domain"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
)
func TestRunOnceReconcilesReplaysAndAdvertisesCapacity_HP_RUNTIME_01(t *testing.T) {
ctx := context.Background()
store, err := spool.Open(ctx, spool.Options{DataDir: filepath.Join(t.TempDir(), "spool"), BusyTimeout: time.Second})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = store.Close() })
issue, err := domain.ParseUUIDv7("019c46f1-1d02-7000-8000-0000000000b1")
if err != nil {
t.Fatal(err)
}
hash := [32]byte{1}
if _, err := store.AcceptCommand(ctx, spool.Command{IssueUUID: issue, ImmutableSHA256: hash, Revision: 1, Phase: uint32(rvboxv1.CommandLifecycle_COMMAND_ACCEPTED), ExecutionSpec: []byte("spec")}, time.Now().UTC()); err != nil {
t.Fatal(err)
}
eventPayload, err := proto.Marshal(&rvboxv1.CommandEvent{Payload: &rvboxv1.CommandEvent_Lifecycle{Lifecycle: &rvboxv1.LifecycleChange{Lifecycle: rvboxv1.CommandLifecycle_COMMAND_RUNNING, CommandRevision: 1}}})
if err != nil {
t.Fatal(err)
}
if _, err := store.AppendEvent(ctx, issue, spool.EventInput{Kind: 4, Compression: 1, RawBytes: uint64(len(eventPayload)), Payload: eventPayload, CreatedAt: time.Now().UTC()}); err != nil {
t.Fatal(err)
}
if _, err := store.AssignSendWindow(ctx, issue, 1, 1<<20); err != nil {
t.Fatal(err)
}
welcome, _ := proto.Marshal(&rvboxv1.AgentEnvelope{SessionId: "session", SessionGeneration: 1, Payload: &rvboxv1.AgentEnvelope_ServerWelcome{ServerWelcome: &rvboxv1.ServerWelcome{SelectedProtocol: &rvboxv1.ProtocolVersion{Major: 1, Minor: 0}, ServerTime: timestamppb.Now()}}})
reconcileRequest, _ := proto.Marshal(&rvboxv1.AgentEnvelope{SessionId: "session", SessionGeneration: 1, Payload: &rvboxv1.AgentEnvelope_ReconcileRequest{ReconcileRequest: &rvboxv1.ReconcileRequest{}}})
reconcileResult, _ := proto.Marshal(&rvboxv1.AgentEnvelope{SessionId: "session", SessionGeneration: 1, Payload: &rvboxv1.AgentEnvelope_ReconcileResult{ReconcileResult: &rvboxv1.ReconcileResult{}}})
transport := &runnerTransport{reads: [][]byte{welcome, reconcileRequest, reconcileResult}, terminal: errors.New("transport closed")}
hello := &rvboxv1.ClientHello{ClientId: "runner-client", SupportedProtocol: &rvboxv1.ProtocolRange{Major: 1, MinMinor: 0, MaxMinor: 0}, DaemonVersion: "test", Platform: rvboxv1.Platform_PLATFORM_WINDOWS, Architecture: "amd64", DaemonCwd: `C:\`, SupportedShells: []rvboxv1.ShellType{rvboxv1.ShellType_SHELL_POWERSHELL}, ClientInstanceId: store.ClientInstanceID().String(), MaxRunningCommands: 1, MaxQueuedCommands: 1, SentAt: timestamppb.Now()}
err = RunOnce(ctx, RunnerOptions{Store: store, Dial: func(context.Context) (Transport, error) { return transport, nil }, Hello: hello, Limits: agentproto.DefaultLimits(), Backoff: BackoffOptions{Initial: time.Millisecond, Maximum: time.Millisecond, StableReset: time.Second}, Jitter: func(value time.Duration) time.Duration { return 0 }, Now: func() time.Time { return time.Now().UTC() }})
if !errors.Is(err, transport.terminal) {
t.Fatalf("RunOnce error = %v, want transport close", err)
}
transport.mu.Lock()
writes := append([][]byte(nil), transport.writes...)
transport.mu.Unlock()
if len(writes) != 4 {
t.Fatalf("wire write count = %d, want hello/snapshot/event/capacity", len(writes))
}
last, err := agentproto.DecodeEnvelope(writes[len(writes)-1], agentproto.DefaultLimits(), rvboxv1.Platform_PLATFORM_WINDOWS)
if err != nil || last.GetClientCapacity() == nil || last.GetClientCapacity().GetMaxRunningCommands() != 1 {
t.Fatalf("capacity advertisement = %#v, %v", last, err)
}
eventEnvelope, err := agentproto.DecodeEnvelope(writes[2], agentproto.DefaultLimits(), rvboxv1.Platform_PLATFORM_WINDOWS)
if err != nil || eventEnvelope.GetCommandEvent() == nil || eventEnvelope.GetCommandEvent().GetEventSeq() != 1 || eventEnvelope.GetCommandEvent().GetIssueUuid() != issue.String() {
t.Fatalf("replayed event = %#v, %v", eventEnvelope, err)
}
}
func TestRunnerOptionsRejectMissingJitter_HP_RUNTIME_02(t *testing.T) {
if err := (RunnerOptions{}).validate(); err == nil {
t.Fatal("empty runner options unexpectedly validated")
}
}
func TestFlushEventsAssignsAndSendsOnlyUnacknowledgedRows_HP_RUNTIME_03(t *testing.T) {
ctx := context.Background()
store, err := spool.Open(ctx, spool.Options{DataDir: filepath.Join(t.TempDir(), "spool"), BusyTimeout: time.Second})
if err != nil {
t.Fatal(err)
}
defer store.Close()
issue, err := domain.ParseUUIDv7("019c46f1-1d02-7000-8000-0000000000b2")
if err != nil {
t.Fatal(err)
}
hash := [32]byte{2}
if _, err := store.AcceptCommand(ctx, spool.Command{IssueUUID: issue, ImmutableSHA256: hash, Revision: 1, Phase: uint32(rvboxv1.CommandLifecycle_COMMAND_ACCEPTED)}, time.Now().UTC()); err != nil {
t.Fatal(err)
}
payload, err := proto.Marshal(&rvboxv1.CommandEvent{Payload: &rvboxv1.CommandEvent_Lifecycle{Lifecycle: &rvboxv1.LifecycleChange{Lifecycle: rvboxv1.CommandLifecycle_COMMAND_RUNNING, CommandRevision: 1}}})
if err != nil {
t.Fatal(err)
}
if _, err := store.AppendEvent(ctx, issue, spool.EventInput{Kind: 4, Compression: 1, RawBytes: uint64(len(payload)), Payload: payload, CreatedAt: time.Now().UTC()}); err != nil {
t.Fatal(err)
}
transport := &runnerTransport{}
sent := map[domain.UUID]uint64{}
session := Session{ID: "session", Generation: 1}
if err := flushEvents(ctx, transport, store, session, issue, agentproto.DefaultLimits(), sent); err != nil {
t.Fatal(err)
}
if sent[issue] != 1 || len(transport.writes) != 1 {
t.Fatalf("sent cursor/writes = %d/%d", sent[issue], len(transport.writes))
}
if err := flushEvents(ctx, transport, store, session, issue, agentproto.DefaultLimits(), sent); err != nil {
t.Fatal(err)
}
if len(transport.writes) != 1 {
t.Fatalf("already-sent event was duplicated: %d writes", len(transport.writes))
}
}
type runnerTransport struct {
mu sync.Mutex
reads [][]byte
writes [][]byte
terminal error
}
func (transport *runnerTransport) Write(_ context.Context, payload []byte) error {
transport.mu.Lock()
defer transport.mu.Unlock()
transport.writes = append(transport.writes, append([]byte(nil), payload...))
return nil
}
func (transport *runnerTransport) Read(context.Context) ([]byte, error) {
transport.mu.Lock()
defer transport.mu.Unlock()
if len(transport.reads) == 0 {
return nil, transport.terminal
}
payload := transport.reads[0]
transport.reads = transport.reads[1:]
return append([]byte(nil), payload...), nil
}
func (transport *runnerTransport) Close() error { return nil }