The source headers, LICENSE files, and license metadata had drifted apart. Align the entire project to MIT: - Convert every source-file header to the MIT text across all comment styles (Go, TS, TSX, JS, MJS, SQL, CSS, GraphQL, shell), including SPDX-License-Identifier tags - Set the root and cookie-banner LICENSE files to the MIT text with a "MIT License" title line - Switch the package.json license fields, Docker image label, and cookie-banner README to MIT - Update docs and the genmodels header generator accordingly - Normalize copyright lines to a single format (Copyright (c) <year(s)> Probo Inc <hello@probo.com>.): unify the hello@getprobo.com and hello@probo.inc emails to hello@probo.com and the comma-separated years to a hyphenated range Genuine third-party references are intentionally left untouched: the Lucide icon attributions (Lucide is ISC) and the trivy dependency license allowlist. Signed-off-by: Sacha Al Himdani <sacha@probo.com>
1258 lines
31 KiB
Go
1258 lines
31 KiB
Go
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
// of this software and associated documentation files (the "Software"), to deal
|
|
// in the Software without restriction, including without limitation the rights
|
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
// copies of the Software, and to permit persons to whom the Software is
|
|
// furnished to do so, subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in
|
|
// all copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
// SOFTWARE.
|
|
|
|
package agentrun_test
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.probo.inc/probo/internal/test"
|
|
"go.probo.inc/probo/pkg/agent"
|
|
"go.probo.inc/probo/pkg/agentrun"
|
|
"go.probo.inc/probo/pkg/coredata"
|
|
"go.probo.inc/probo/pkg/llm"
|
|
)
|
|
|
|
func TestWorker_PicksUpAndCompletes(t *testing.T) {
|
|
client := test.PGClient(t)
|
|
ag := newDummyAgent(
|
|
"echo-agent",
|
|
[]*llm.ChatCompletionResponse{
|
|
stopResponse("Done."),
|
|
},
|
|
)
|
|
|
|
run := insertPendingRun(
|
|
t,
|
|
client,
|
|
"echo-agent",
|
|
[]llm.Message{{Role: llm.RoleUser, Parts: []llm.Part{llm.TextPart{Text: "go"}}}},
|
|
)
|
|
|
|
runWorker := newTestWorker(
|
|
client,
|
|
&simpleRegistry{agents: map[string]*agent.Agent{"echo-agent": ag}},
|
|
)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
|
|
go func() { _ = runWorker.Run(ctx) }()
|
|
|
|
require.Eventually(
|
|
t,
|
|
func() bool {
|
|
r, err := tryLoadAgentRun(client, run.ID)
|
|
return err == nil && r.Status == coredata.AgentRunStatusCompleted
|
|
},
|
|
10*time.Second,
|
|
200*time.Millisecond,
|
|
)
|
|
|
|
completed := loadAgentRun(t, client, run.ID)
|
|
assert.Equal(t, coredata.AgentRunStatusCompleted, completed.Status)
|
|
assert.NotNil(t, completed.Result)
|
|
assert.Nil(t, completed.Checkpoint)
|
|
assert.Nil(t, completed.ErrorMessage)
|
|
}
|
|
|
|
func TestWorker_StopAndResume(t *testing.T) {
|
|
client := test.PGClient(t)
|
|
store := coredata.NewPGCheckpointer(client)
|
|
|
|
toolReady := make(chan struct{})
|
|
toolRelease := make(chan struct{})
|
|
|
|
slowTool := agent.FunctionTool[struct{}](
|
|
"slow_work",
|
|
"Does slow work",
|
|
func(_ context.Context, _ struct{}) (agent.ToolResult, error) {
|
|
close(toolReady)
|
|
<-toolRelease
|
|
|
|
return agent.ToolResult{Content: "work done"}, nil
|
|
},
|
|
)
|
|
|
|
ag := newDummyAgent(
|
|
"worker-agent",
|
|
[]*llm.ChatCompletionResponse{
|
|
toolCallResponse(llm.ToolCall{
|
|
ID: "tc_1",
|
|
Function: llm.FunctionCall{Name: "slow_work", Arguments: `{}`},
|
|
}),
|
|
stopResponse("All done after resume."),
|
|
},
|
|
slowTool,
|
|
)
|
|
|
|
run := insertPendingRun(
|
|
t,
|
|
client,
|
|
"worker-agent",
|
|
[]llm.Message{{Role: llm.RoleUser, Parts: []llm.Part{llm.TextPart{Text: "do work"}}}},
|
|
)
|
|
|
|
runWorker := newTestWorker(
|
|
client,
|
|
&simpleRegistry{agents: map[string]*agent.Agent{"worker-agent": ag}},
|
|
)
|
|
|
|
ctx1, cancel1 := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel1()
|
|
|
|
go func() { _ = runWorker.Run(ctx1) }()
|
|
|
|
select {
|
|
case <-toolReady:
|
|
case <-ctx1.Done():
|
|
t.Fatal("timed out waiting for tool to start")
|
|
}
|
|
|
|
cancel1()
|
|
|
|
select {
|
|
case <-runWorker.ShutdownBroadcast():
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("timed out waiting for worker shutdown broadcast")
|
|
}
|
|
|
|
close(toolRelease)
|
|
|
|
// Graceful shutdown must commit the run back to PENDING (with its
|
|
// checkpoint intact) so another worker resumes it. Nothing relies on
|
|
// a lease timeout to requeue it.
|
|
require.Eventually(
|
|
t,
|
|
func() bool {
|
|
r, err := tryLoadAgentRun(client, run.ID)
|
|
|
|
return err == nil &&
|
|
r.Status == coredata.AgentRunStatusPending &&
|
|
r.Checkpoint != nil
|
|
},
|
|
10*time.Second,
|
|
200*time.Millisecond,
|
|
)
|
|
|
|
suspended := loadAgentRun(t, client, run.ID)
|
|
assert.Equal(
|
|
t,
|
|
coredata.AgentRunStatusPending,
|
|
suspended.Status,
|
|
"graceful shutdown must requeue the run as PENDING without manual recovery",
|
|
)
|
|
assert.Nil(t, suspended.Result)
|
|
assert.Nil(t, suspended.ErrorMessage)
|
|
|
|
cp, err := store.Load(context.Background(), run.ID.String())
|
|
require.NoError(t, err)
|
|
require.NotNil(t, cp)
|
|
assert.Equal(t, agent.AgentStatusSuspended, cp.Status)
|
|
|
|
// No manual reset: the run is already PENDING from the graceful
|
|
// shutdown, so a fresh worker must pick it up and resume on its own.
|
|
runWorker2 := newTestWorker(
|
|
client,
|
|
&simpleRegistry{agents: map[string]*agent.Agent{"worker-agent": ag}},
|
|
)
|
|
|
|
ctx2, cancel2 := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel2()
|
|
|
|
go func() { _ = runWorker2.Run(ctx2) }()
|
|
|
|
require.Eventually(
|
|
t,
|
|
func() bool {
|
|
r, err := tryLoadAgentRun(client, run.ID)
|
|
return err == nil && r.Status == coredata.AgentRunStatusCompleted
|
|
},
|
|
10*time.Second,
|
|
200*time.Millisecond,
|
|
)
|
|
|
|
completed := loadAgentRun(t, client, run.ID)
|
|
assert.Equal(t, coredata.AgentRunStatusCompleted, completed.Status)
|
|
assert.NotNil(t, completed.Result)
|
|
assert.Nil(t, completed.Checkpoint)
|
|
assert.Nil(t, completed.ErrorMessage)
|
|
}
|
|
|
|
// TestWorker_AwaitsApprovalDoesNotFail covers the regression where a tool
|
|
// call requiring approval surfaced as InterruptedError and was committed
|
|
// as FAILED. The run must instead park in AWAITING_APPROVAL with its
|
|
// checkpoint (and the pending approvals) preserved, and must not be
|
|
// re-claimed while it rests.
|
|
func TestWorker_AwaitsApprovalDoesNotFail(t *testing.T) {
|
|
client := test.PGClient(t)
|
|
store := coredata.NewPGCheckpointer(client)
|
|
|
|
dangerTool := agent.FunctionTool[struct{}](
|
|
"danger",
|
|
"Performs a dangerous action",
|
|
func(_ context.Context, _ struct{}) (agent.ToolResult, error) {
|
|
return agent.ToolResult{Content: "must not run before approval"}, nil
|
|
},
|
|
)
|
|
|
|
provider := &mockProvider{
|
|
responses: []*llm.ChatCompletionResponse{
|
|
toolCallResponse(llm.ToolCall{
|
|
ID: "tc_danger",
|
|
Function: llm.FunctionCall{Name: "danger", Arguments: `{}`},
|
|
}),
|
|
},
|
|
}
|
|
|
|
ag := agent.New(
|
|
"approval-agent",
|
|
newTestClient(provider),
|
|
agent.WithModel("test-model"),
|
|
agent.WithTools(dangerTool),
|
|
agent.WithApproval(agent.ApprovalConfig{ToolNames: []string{"danger"}}),
|
|
)
|
|
|
|
run := insertPendingRun(
|
|
t,
|
|
client,
|
|
"approval-agent",
|
|
[]llm.Message{{Role: llm.RoleUser, Parts: []llm.Part{llm.TextPart{Text: "do the dangerous thing"}}}},
|
|
)
|
|
|
|
runWorker := newTestWorker(
|
|
client,
|
|
&simpleRegistry{agents: map[string]*agent.Agent{"approval-agent": ag}},
|
|
)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
|
|
go func() { _ = runWorker.Run(ctx) }()
|
|
|
|
require.Eventually(
|
|
t,
|
|
func() bool {
|
|
r, err := tryLoadAgentRun(client, run.ID)
|
|
return err == nil && r.Status == coredata.AgentRunStatusAwaitingApproval
|
|
},
|
|
10*time.Second,
|
|
200*time.Millisecond,
|
|
)
|
|
|
|
awaiting := loadAgentRun(t, client, run.ID)
|
|
assert.Equal(t, coredata.AgentRunStatusAwaitingApproval, awaiting.Status)
|
|
assert.Nil(t, awaiting.Result)
|
|
assert.Nil(t, awaiting.ErrorMessage)
|
|
assert.NotNil(t, awaiting.Checkpoint)
|
|
|
|
cp, err := store.Load(context.Background(), run.ID.String())
|
|
require.NoError(t, err)
|
|
require.NotNil(t, cp)
|
|
assert.Equal(t, agent.AgentStatusAwaitingApproval, cp.Status)
|
|
require.Len(t, cp.PendingApprovals, 1)
|
|
assert.Equal(t, "danger", cp.PendingApprovals[0].Function.Name)
|
|
|
|
// The run must stay parked: only one mock response exists, so a
|
|
// re-claim would error with "no more mock responses" and flip it to
|
|
// FAILED. Confirm it holds AWAITING_APPROVAL.
|
|
time.Sleep(time.Second)
|
|
|
|
stillAwaiting := loadAgentRun(t, client, run.ID)
|
|
assert.Equal(t, coredata.AgentRunStatusAwaitingApproval, stillAwaiting.Status)
|
|
}
|
|
|
|
// TestWorker_ApprovalApprovedResumesAndCompletes is the full happy-path
|
|
// approval cycle: the run parks in AWAITING_APPROVAL, a decision approves
|
|
// the pending tool call via the service, and the same worker resumes from
|
|
// the checkpoint, executes the approved tool, and completes.
|
|
func TestWorker_ApprovalApprovedResumesAndCompletes(t *testing.T) {
|
|
client := test.PGClient(t)
|
|
|
|
var executed atomic.Bool
|
|
|
|
dangerTool := agent.FunctionTool[struct{}](
|
|
"danger",
|
|
"Performs a dangerous action",
|
|
func(_ context.Context, _ struct{}) (agent.ToolResult, error) {
|
|
executed.Store(true)
|
|
|
|
return agent.ToolResult{Content: "danger executed"}, nil
|
|
},
|
|
)
|
|
|
|
provider := &mockProvider{
|
|
responses: []*llm.ChatCompletionResponse{
|
|
toolCallResponse(llm.ToolCall{
|
|
ID: "tc_danger",
|
|
Function: llm.FunctionCall{Name: "danger", Arguments: `{}`},
|
|
}),
|
|
stopResponse("all done"),
|
|
},
|
|
}
|
|
|
|
ag := agent.New(
|
|
"approval-agent",
|
|
newTestClient(provider),
|
|
agent.WithModel("test-model"),
|
|
agent.WithTools(dangerTool),
|
|
agent.WithApproval(agent.ApprovalConfig{ToolNames: []string{"danger"}}),
|
|
)
|
|
|
|
run := insertPendingRun(
|
|
t,
|
|
client,
|
|
"approval-agent",
|
|
[]llm.Message{{Role: llm.RoleUser, Parts: []llm.Part{llm.TextPart{Text: "go"}}}},
|
|
)
|
|
|
|
runWorker := newTestWorker(
|
|
client,
|
|
&simpleRegistry{agents: map[string]*agent.Agent{"approval-agent": ag}},
|
|
)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
|
|
go func() { _ = runWorker.Run(ctx) }()
|
|
|
|
require.Eventually(
|
|
t,
|
|
func() bool {
|
|
r, err := tryLoadAgentRun(client, run.ID)
|
|
return err == nil && r.Status == coredata.AgentRunStatusAwaitingApproval
|
|
},
|
|
10*time.Second,
|
|
200*time.Millisecond,
|
|
)
|
|
|
|
svc := agentrun.NewService(client)
|
|
_, err := svc.SubmitApproval(
|
|
context.Background(),
|
|
coredata.NewNoScope(),
|
|
run.ID,
|
|
map[string]agent.ApprovalResult{"tc_danger": {Approved: true}},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
require.Eventually(
|
|
t,
|
|
func() bool {
|
|
r, err := tryLoadAgentRun(client, run.ID)
|
|
return err == nil && r.Status == coredata.AgentRunStatusCompleted
|
|
},
|
|
10*time.Second,
|
|
200*time.Millisecond,
|
|
)
|
|
|
|
completed := loadAgentRun(t, client, run.ID)
|
|
assert.Equal(t, coredata.AgentRunStatusCompleted, completed.Status)
|
|
assert.NotNil(t, completed.Result)
|
|
assert.Nil(t, completed.Checkpoint)
|
|
assert.Nil(t, completed.ErrorMessage)
|
|
assert.True(t, executed.Load(), "approved tool must execute on resume")
|
|
}
|
|
|
|
// TestWorker_ApprovalDeniedResumesAndCompletes covers the denial path: the
|
|
// run resumes without executing the gated tool and completes, with the
|
|
// denial fed back to the model as the tool result.
|
|
func TestWorker_ApprovalDeniedResumesAndCompletes(t *testing.T) {
|
|
client := test.PGClient(t)
|
|
|
|
var executed atomic.Bool
|
|
|
|
dangerTool := agent.FunctionTool[struct{}](
|
|
"danger",
|
|
"Performs a dangerous action",
|
|
func(_ context.Context, _ struct{}) (agent.ToolResult, error) {
|
|
executed.Store(true)
|
|
|
|
return agent.ToolResult{Content: "danger executed"}, nil
|
|
},
|
|
)
|
|
|
|
provider := &mockProvider{
|
|
responses: []*llm.ChatCompletionResponse{
|
|
toolCallResponse(llm.ToolCall{
|
|
ID: "tc_danger",
|
|
Function: llm.FunctionCall{Name: "danger", Arguments: `{}`},
|
|
}),
|
|
stopResponse("acknowledged the denial"),
|
|
},
|
|
}
|
|
|
|
ag := agent.New(
|
|
"approval-agent",
|
|
newTestClient(provider),
|
|
agent.WithModel("test-model"),
|
|
agent.WithTools(dangerTool),
|
|
agent.WithApproval(agent.ApprovalConfig{ToolNames: []string{"danger"}}),
|
|
)
|
|
|
|
run := insertPendingRun(
|
|
t,
|
|
client,
|
|
"approval-agent",
|
|
[]llm.Message{{Role: llm.RoleUser, Parts: []llm.Part{llm.TextPart{Text: "go"}}}},
|
|
)
|
|
|
|
runWorker := newTestWorker(
|
|
client,
|
|
&simpleRegistry{agents: map[string]*agent.Agent{"approval-agent": ag}},
|
|
)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
|
|
go func() { _ = runWorker.Run(ctx) }()
|
|
|
|
require.Eventually(
|
|
t,
|
|
func() bool {
|
|
r, err := tryLoadAgentRun(client, run.ID)
|
|
return err == nil && r.Status == coredata.AgentRunStatusAwaitingApproval
|
|
},
|
|
10*time.Second,
|
|
200*time.Millisecond,
|
|
)
|
|
|
|
svc := agentrun.NewService(client)
|
|
_, err := svc.SubmitApproval(
|
|
context.Background(),
|
|
coredata.NewNoScope(),
|
|
run.ID,
|
|
map[string]agent.ApprovalResult{"tc_danger": {Approved: false, Message: "denied by reviewer"}},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
require.Eventually(
|
|
t,
|
|
func() bool {
|
|
r, err := tryLoadAgentRun(client, run.ID)
|
|
return err == nil && r.Status == coredata.AgentRunStatusCompleted
|
|
},
|
|
10*time.Second,
|
|
200*time.Millisecond,
|
|
)
|
|
|
|
completed := loadAgentRun(t, client, run.ID)
|
|
assert.Equal(t, coredata.AgentRunStatusCompleted, completed.Status)
|
|
assert.NotNil(t, completed.Result)
|
|
assert.Nil(t, completed.Checkpoint)
|
|
assert.Nil(t, completed.ErrorMessage)
|
|
assert.False(t, executed.Load(), "denied tool must not execute on resume")
|
|
}
|
|
|
|
// TestWorker_StopAndResumeAcrossHandoff exercises tree suspension where the
|
|
// active branch is a handed-off child agent. The checkpoint must record the
|
|
// child as active, and restore must resolve it from the registry so the
|
|
// resumed run continues in that branch and completes.
|
|
func TestWorker_StopAndResumeAcrossHandoff(t *testing.T) {
|
|
client := test.PGClient(t)
|
|
store := coredata.NewPGCheckpointer(client)
|
|
|
|
toolReady := make(chan struct{})
|
|
toolRelease := make(chan struct{})
|
|
|
|
slowTool := agent.FunctionTool[struct{}](
|
|
"slow_work",
|
|
"Does slow work",
|
|
func(_ context.Context, _ struct{}) (agent.ToolResult, error) {
|
|
close(toolReady)
|
|
<-toolRelease
|
|
|
|
return agent.ToolResult{Content: "child work done"}, nil
|
|
},
|
|
)
|
|
|
|
childAgent := newDummyAgent(
|
|
"child-agent",
|
|
[]*llm.ChatCompletionResponse{
|
|
toolCallResponse(llm.ToolCall{
|
|
ID: "tc_child",
|
|
Function: llm.FunctionCall{Name: "slow_work", Arguments: `{}`},
|
|
}),
|
|
stopResponse("child done"),
|
|
},
|
|
slowTool,
|
|
)
|
|
|
|
rootProvider := &mockProvider{
|
|
responses: []*llm.ChatCompletionResponse{
|
|
toolCallResponse(llm.ToolCall{
|
|
ID: "tc_root",
|
|
Function: llm.FunctionCall{Name: "transfer_to_child_agent", Arguments: `{}`},
|
|
}),
|
|
},
|
|
}
|
|
|
|
rootAgent := agent.New(
|
|
"root-agent",
|
|
newTestClient(rootProvider),
|
|
agent.WithModel("test-model"),
|
|
agent.WithHandoffs(childAgent),
|
|
)
|
|
|
|
registry := &simpleRegistry{
|
|
agents: map[string]*agent.Agent{
|
|
"root-agent": rootAgent,
|
|
"child-agent": childAgent,
|
|
},
|
|
}
|
|
|
|
run := insertPendingRun(
|
|
t,
|
|
client,
|
|
"root-agent",
|
|
[]llm.Message{{Role: llm.RoleUser, Parts: []llm.Part{llm.TextPart{Text: "do work"}}}},
|
|
)
|
|
|
|
runWorker := newTestWorker(client, registry)
|
|
|
|
ctx1, cancel1 := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel1()
|
|
|
|
go func() { _ = runWorker.Run(ctx1) }()
|
|
|
|
select {
|
|
case <-toolReady:
|
|
case <-ctx1.Done():
|
|
t.Fatal("timed out waiting for child agent tool to start")
|
|
}
|
|
|
|
cancel1()
|
|
|
|
select {
|
|
case <-runWorker.ShutdownBroadcast():
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("timed out waiting for worker shutdown broadcast")
|
|
}
|
|
|
|
close(toolRelease)
|
|
|
|
require.Eventually(
|
|
t,
|
|
func() bool {
|
|
r, err := tryLoadAgentRun(client, run.ID)
|
|
|
|
return err == nil &&
|
|
r.Status == coredata.AgentRunStatusPending &&
|
|
r.Checkpoint != nil
|
|
},
|
|
10*time.Second,
|
|
200*time.Millisecond,
|
|
)
|
|
|
|
cp, err := store.Load(context.Background(), run.ID.String())
|
|
require.NoError(t, err)
|
|
require.NotNil(t, cp)
|
|
assert.Equal(t, agent.AgentStatusSuspended, cp.Status)
|
|
assert.Equal(
|
|
t,
|
|
"child-agent",
|
|
cp.AgentName,
|
|
"checkpoint must record the handed-off child as the active agent",
|
|
)
|
|
|
|
runWorker2 := newTestWorker(client, registry)
|
|
|
|
ctx2, cancel2 := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel2()
|
|
|
|
go func() { _ = runWorker2.Run(ctx2) }()
|
|
|
|
require.Eventually(
|
|
t,
|
|
func() bool {
|
|
r, err := tryLoadAgentRun(client, run.ID)
|
|
return err == nil && r.Status == coredata.AgentRunStatusCompleted
|
|
},
|
|
10*time.Second,
|
|
200*time.Millisecond,
|
|
)
|
|
|
|
completed := loadAgentRun(t, client, run.ID)
|
|
assert.Equal(t, coredata.AgentRunStatusCompleted, completed.Status)
|
|
assert.NotNil(t, completed.Result)
|
|
assert.Nil(t, completed.Checkpoint)
|
|
assert.Nil(t, completed.ErrorMessage)
|
|
}
|
|
|
|
func TestWorker_StopAndResumeNestedSubAgent(t *testing.T) {
|
|
client := test.PGClient(t)
|
|
store := coredata.NewPGCheckpointer(client)
|
|
|
|
toolReady := make(chan struct{})
|
|
toolRelease := make(chan struct{})
|
|
|
|
var readyOnce sync.Once
|
|
|
|
slowTool := agent.FunctionTool[struct{}](
|
|
"slow_work",
|
|
"Does slow work",
|
|
func(_ context.Context, _ struct{}) (agent.ToolResult, error) {
|
|
readyOnce.Do(func() { close(toolReady) })
|
|
<-toolRelease
|
|
|
|
return agent.ToolResult{Content: "inner work done"}, nil
|
|
},
|
|
)
|
|
|
|
innerAgent := newDummyAgent(
|
|
"inner-agent",
|
|
[]*llm.ChatCompletionResponse{
|
|
toolCallResponse(
|
|
llm.ToolCall{
|
|
ID: "tc_inner",
|
|
Function: llm.FunctionCall{Name: "slow_work", Arguments: `{}`},
|
|
},
|
|
),
|
|
stopResponse("inner done"),
|
|
},
|
|
slowTool,
|
|
)
|
|
|
|
outerAgent := newDummyAgent(
|
|
"outer-agent",
|
|
[]*llm.ChatCompletionResponse{
|
|
toolCallResponse(
|
|
llm.ToolCall{
|
|
ID: "tc_outer",
|
|
Function: llm.FunctionCall{Name: "call_inner", Arguments: `{"input":"delegate"}`},
|
|
},
|
|
),
|
|
stopResponse("outer done"),
|
|
},
|
|
innerAgent.AsTool("call_inner", "Call inner"),
|
|
)
|
|
|
|
registry := &simpleRegistry{
|
|
agents: map[string]*agent.Agent{
|
|
"outer-agent": outerAgent,
|
|
"inner-agent": innerAgent,
|
|
},
|
|
}
|
|
|
|
run := insertPendingRun(
|
|
t,
|
|
client,
|
|
"outer-agent",
|
|
[]llm.Message{{Role: llm.RoleUser, Parts: []llm.Part{llm.TextPart{Text: "do work"}}}},
|
|
)
|
|
|
|
runWorker := newTestWorker(client, registry)
|
|
|
|
ctx1, cancel1 := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel1()
|
|
|
|
go func() { _ = runWorker.Run(ctx1) }()
|
|
|
|
select {
|
|
case <-toolReady:
|
|
case <-ctx1.Done():
|
|
t.Fatal("timed out waiting for nested sub-agent tool to start")
|
|
}
|
|
|
|
cancel1()
|
|
|
|
select {
|
|
case <-runWorker.ShutdownBroadcast():
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("timed out waiting for worker shutdown broadcast")
|
|
}
|
|
|
|
close(toolRelease)
|
|
|
|
require.Eventually(
|
|
t,
|
|
func() bool {
|
|
r, err := tryLoadAgentRun(client, run.ID)
|
|
|
|
return err == nil &&
|
|
r.Status == coredata.AgentRunStatusPending &&
|
|
r.Checkpoint != nil
|
|
},
|
|
10*time.Second,
|
|
200*time.Millisecond,
|
|
)
|
|
|
|
cp, err := store.Load(context.Background(), run.ID.String())
|
|
require.NoError(t, err)
|
|
require.NotNil(t, cp)
|
|
assert.Equal(t, agent.AgentStatusSuspended, cp.Status)
|
|
assert.Equal(t, "outer-agent", cp.AgentName)
|
|
|
|
innerCP, ok := cp.InnerCheckpoints["tc_outer"]
|
|
require.True(t, ok, "expected nested checkpoint for outer tool call")
|
|
require.NotNil(t, innerCP)
|
|
assert.Equal(t, "inner-agent", innerCP.AgentName)
|
|
assert.Equal(t, agent.AgentStatusSuspended, innerCP.Status)
|
|
|
|
runWorker2 := newTestWorker(client, registry)
|
|
|
|
ctx2, cancel2 := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel2()
|
|
|
|
go func() { _ = runWorker2.Run(ctx2) }()
|
|
|
|
require.Eventually(
|
|
t,
|
|
func() bool {
|
|
r, err := tryLoadAgentRun(client, run.ID)
|
|
return err == nil && r.Status == coredata.AgentRunStatusCompleted
|
|
},
|
|
10*time.Second,
|
|
200*time.Millisecond,
|
|
)
|
|
|
|
completed := loadAgentRun(t, client, run.ID)
|
|
assert.Equal(t, coredata.AgentRunStatusCompleted, completed.Status)
|
|
assert.NotNil(t, completed.Result)
|
|
assert.Nil(t, completed.Checkpoint)
|
|
assert.Nil(t, completed.ErrorMessage)
|
|
}
|
|
|
|
func TestWorker_StopAndResumeNestedSubAgentMultiLevel(t *testing.T) {
|
|
client := test.PGClient(t)
|
|
store := coredata.NewPGCheckpointer(client)
|
|
|
|
toolReady := make(chan struct{})
|
|
toolRelease := make(chan struct{})
|
|
|
|
var readyOnce sync.Once
|
|
|
|
slowTool := agent.FunctionTool[struct{}](
|
|
"slow_work",
|
|
"Does slow work",
|
|
func(_ context.Context, _ struct{}) (agent.ToolResult, error) {
|
|
readyOnce.Do(func() { close(toolReady) })
|
|
<-toolRelease
|
|
|
|
return agent.ToolResult{Content: "grandchild work done"}, nil
|
|
},
|
|
)
|
|
|
|
grandchildAgent := newDummyAgent(
|
|
"grandchild-agent",
|
|
[]*llm.ChatCompletionResponse{
|
|
toolCallResponse(
|
|
llm.ToolCall{
|
|
ID: "tc_grandchild",
|
|
Function: llm.FunctionCall{Name: "slow_work", Arguments: `{}`},
|
|
},
|
|
),
|
|
stopResponse("grandchild done"),
|
|
},
|
|
slowTool,
|
|
)
|
|
|
|
childAgent := newDummyAgent(
|
|
"child-agent",
|
|
[]*llm.ChatCompletionResponse{
|
|
toolCallResponse(
|
|
llm.ToolCall{
|
|
ID: "tc_child",
|
|
Function: llm.FunctionCall{Name: "call_grandchild", Arguments: `{"input":"delegate deeper"}`},
|
|
},
|
|
),
|
|
stopResponse("child done"),
|
|
},
|
|
grandchildAgent.AsTool("call_grandchild", "Call grandchild"),
|
|
)
|
|
|
|
outerAgent := newDummyAgent(
|
|
"outer-agent",
|
|
[]*llm.ChatCompletionResponse{
|
|
toolCallResponse(
|
|
llm.ToolCall{
|
|
ID: "tc_outer",
|
|
Function: llm.FunctionCall{Name: "call_child", Arguments: `{"input":"delegate"}`},
|
|
},
|
|
),
|
|
stopResponse("outer done"),
|
|
},
|
|
childAgent.AsTool("call_child", "Call child"),
|
|
)
|
|
|
|
registry := &simpleRegistry{
|
|
agents: map[string]*agent.Agent{
|
|
"outer-agent": outerAgent,
|
|
"child-agent": childAgent,
|
|
"grandchild-agent": grandchildAgent,
|
|
},
|
|
}
|
|
|
|
run := insertPendingRun(
|
|
t,
|
|
client,
|
|
"outer-agent",
|
|
[]llm.Message{{Role: llm.RoleUser, Parts: []llm.Part{llm.TextPart{Text: "do work"}}}},
|
|
)
|
|
|
|
runWorker := newTestWorker(client, registry)
|
|
|
|
ctx1, cancel1 := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel1()
|
|
|
|
go func() { _ = runWorker.Run(ctx1) }()
|
|
|
|
select {
|
|
case <-toolReady:
|
|
case <-ctx1.Done():
|
|
t.Fatal("timed out waiting for grandchild tool to start")
|
|
}
|
|
|
|
cancel1()
|
|
|
|
select {
|
|
case <-runWorker.ShutdownBroadcast():
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("timed out waiting for worker shutdown broadcast")
|
|
}
|
|
|
|
close(toolRelease)
|
|
|
|
require.Eventually(
|
|
t,
|
|
func() bool {
|
|
r, err := tryLoadAgentRun(client, run.ID)
|
|
|
|
return err == nil &&
|
|
r.Status == coredata.AgentRunStatusPending &&
|
|
r.Checkpoint != nil
|
|
},
|
|
10*time.Second,
|
|
200*time.Millisecond,
|
|
)
|
|
|
|
cp, err := store.Load(context.Background(), run.ID.String())
|
|
require.NoError(t, err)
|
|
require.NotNil(t, cp)
|
|
assert.Equal(t, "outer-agent", cp.AgentName)
|
|
|
|
childCP, ok := cp.InnerCheckpoints["tc_outer"]
|
|
require.True(t, ok)
|
|
require.NotNil(t, childCP)
|
|
assert.Equal(t, "child-agent", childCP.AgentName)
|
|
|
|
grandchildCP, ok := childCP.InnerCheckpoints["tc_child"]
|
|
require.True(t, ok)
|
|
require.NotNil(t, grandchildCP)
|
|
assert.Equal(t, "grandchild-agent", grandchildCP.AgentName)
|
|
assert.Equal(t, agent.AgentStatusSuspended, grandchildCP.Status)
|
|
|
|
runWorker2 := newTestWorker(client, registry)
|
|
|
|
ctx2, cancel2 := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel2()
|
|
|
|
go func() { _ = runWorker2.Run(ctx2) }()
|
|
|
|
require.Eventually(
|
|
t,
|
|
func() bool {
|
|
r, err := tryLoadAgentRun(client, run.ID)
|
|
return err == nil && r.Status == coredata.AgentRunStatusCompleted
|
|
},
|
|
10*time.Second,
|
|
200*time.Millisecond,
|
|
)
|
|
|
|
completed := loadAgentRun(t, client, run.ID)
|
|
assert.Equal(t, coredata.AgentRunStatusCompleted, completed.Status)
|
|
assert.NotNil(t, completed.Result)
|
|
assert.Nil(t, completed.Checkpoint)
|
|
assert.Nil(t, completed.ErrorMessage)
|
|
}
|
|
|
|
// TestWorker_ReclaimedRunDoesNotClobberWinner simulates the residual
|
|
// manual-recovery risk now that leasing is gone: a human moves a still
|
|
// in-flight run back to PENDING (resetRunToPending) while worker A is
|
|
// blocked in a tool. Worker B then claims and finishes it. When worker A
|
|
// finally returns, its commit must be discarded because the row is no
|
|
// longer RUNNING. The CommitAgentRunResult `status = 'RUNNING'` guard is
|
|
// the only fence protecting the winner's result.
|
|
func TestWorker_ReclaimedRunDoesNotClobberWinner(t *testing.T) {
|
|
client := test.PGClient(t)
|
|
|
|
toolReady := make(chan struct{})
|
|
toolRelease := make(chan struct{})
|
|
|
|
slowTool := agent.FunctionTool[struct{}](
|
|
"slow_work",
|
|
"Does slow work",
|
|
func(_ context.Context, _ struct{}) (agent.ToolResult, error) {
|
|
close(toolReady)
|
|
<-toolRelease
|
|
|
|
return agent.ToolResult{Content: "work done"}, nil
|
|
},
|
|
)
|
|
|
|
provider := &mockProvider{
|
|
responses: []*llm.ChatCompletionResponse{
|
|
toolCallResponse(llm.ToolCall{
|
|
ID: "tc_1",
|
|
Function: llm.FunctionCall{Name: "slow_work", Arguments: `{}`},
|
|
}),
|
|
stopResponse("winner result"),
|
|
stopResponse("stale result"),
|
|
},
|
|
}
|
|
|
|
ag := agent.New(
|
|
"worker-agent",
|
|
newTestClient(provider),
|
|
agent.WithModel("test-model"),
|
|
agent.WithTools(slowTool),
|
|
)
|
|
|
|
run := insertPendingRun(
|
|
t,
|
|
client,
|
|
"worker-agent",
|
|
[]llm.Message{{Role: llm.RoleUser, Parts: []llm.Part{llm.TextPart{Text: "do work"}}}},
|
|
)
|
|
|
|
runWorkerA := newTestWorker(
|
|
client,
|
|
&simpleRegistry{agents: map[string]*agent.Agent{"worker-agent": ag}},
|
|
agentrun.WithWorkerMaxConcurrency(1),
|
|
)
|
|
|
|
ctxA, cancelA := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancelA()
|
|
|
|
go func() { _ = runWorkerA.Run(ctxA) }()
|
|
|
|
select {
|
|
case <-toolReady:
|
|
case <-ctxA.Done():
|
|
t.Fatal("timed out waiting for first worker tool call")
|
|
}
|
|
|
|
resetRunToPending(t, client, run.ID)
|
|
|
|
runWorkerB := newTestWorker(
|
|
client,
|
|
&simpleRegistry{agents: map[string]*agent.Agent{"worker-agent": ag}},
|
|
agentrun.WithWorkerMaxConcurrency(1),
|
|
)
|
|
|
|
ctxB, cancelB := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancelB()
|
|
|
|
go func() { _ = runWorkerB.Run(ctxB) }()
|
|
|
|
require.Eventually(
|
|
t,
|
|
func() bool {
|
|
r, err := tryLoadAgentRun(client, run.ID)
|
|
return err == nil && r.Status == coredata.AgentRunStatusCompleted
|
|
},
|
|
15*time.Second,
|
|
200*time.Millisecond,
|
|
)
|
|
|
|
winner := loadAgentRun(t, client, run.ID)
|
|
winnerResult := append(json.RawMessage(nil), winner.Result...)
|
|
require.NotNil(t, winnerResult)
|
|
|
|
close(toolRelease)
|
|
|
|
require.Eventually(
|
|
t,
|
|
func() bool {
|
|
provider.mu.Lock()
|
|
defer provider.mu.Unlock()
|
|
|
|
return provider.calls >= 3
|
|
},
|
|
15*time.Second,
|
|
200*time.Millisecond,
|
|
)
|
|
|
|
require.Eventually(
|
|
t,
|
|
func() bool {
|
|
r, err := tryLoadAgentRun(client, run.ID)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
return r.Status == coredata.AgentRunStatusCompleted && string(r.Result) == string(winnerResult)
|
|
},
|
|
10*time.Second,
|
|
200*time.Millisecond,
|
|
)
|
|
}
|
|
|
|
func TestWorker_UnknownAgentFails(t *testing.T) {
|
|
client := test.PGClient(t)
|
|
|
|
run := insertPendingRun(
|
|
t,
|
|
client,
|
|
"missing-agent",
|
|
[]llm.Message{{Role: llm.RoleUser, Parts: []llm.Part{llm.TextPart{Text: "go"}}}},
|
|
)
|
|
|
|
runWorker := newTestWorker(
|
|
client,
|
|
&simpleRegistry{agents: map[string]*agent.Agent{}},
|
|
)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
|
|
go func() { _ = runWorker.Run(ctx) }()
|
|
|
|
require.Eventually(
|
|
t,
|
|
func() bool {
|
|
r, err := tryLoadAgentRun(client, run.ID)
|
|
return err == nil && r.Status == coredata.AgentRunStatusFailed
|
|
},
|
|
10*time.Second,
|
|
200*time.Millisecond,
|
|
)
|
|
|
|
failed := loadAgentRun(t, client, run.ID)
|
|
assert.Equal(t, coredata.AgentRunStatusFailed, failed.Status)
|
|
require.NotNil(t, failed.ErrorMessage)
|
|
assert.Contains(t, *failed.ErrorMessage, "cannot resolve agent")
|
|
}
|
|
|
|
func TestWorker_InvalidInputMessagesFails(t *testing.T) {
|
|
client := test.PGClient(t)
|
|
ag := newDummyAgent(
|
|
"worker-agent",
|
|
[]*llm.ChatCompletionResponse{
|
|
stopResponse("Done."),
|
|
},
|
|
)
|
|
|
|
run := insertPendingRun(
|
|
t,
|
|
client,
|
|
"worker-agent",
|
|
[]llm.Message{{Role: llm.RoleUser, Parts: []llm.Part{llm.TextPart{Text: "go"}}}},
|
|
)
|
|
|
|
overwriteRunInputMessagesRaw(t, client, run.ID, `"invalid-json"`)
|
|
|
|
runWorker := newTestWorker(
|
|
client,
|
|
&simpleRegistry{agents: map[string]*agent.Agent{"worker-agent": ag}},
|
|
)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
|
|
go func() { _ = runWorker.Run(ctx) }()
|
|
|
|
require.Eventually(
|
|
t,
|
|
func() bool {
|
|
r, err := tryLoadAgentRun(client, run.ID)
|
|
return err == nil && r.Status == coredata.AgentRunStatusFailed
|
|
},
|
|
10*time.Second,
|
|
200*time.Millisecond,
|
|
)
|
|
|
|
failed := loadAgentRun(t, client, run.ID)
|
|
assert.Equal(t, coredata.AgentRunStatusFailed, failed.Status)
|
|
require.NotNil(t, failed.ErrorMessage)
|
|
assert.Contains(t, *failed.ErrorMessage, "cannot unmarshal input messages")
|
|
}
|
|
|
|
func TestWorker_SIGTERM(t *testing.T) {
|
|
if os.Getenv("TEST_SIGTERM_SUBPROCESS") == "1" {
|
|
runSIGTERMSubprocess(t)
|
|
return
|
|
}
|
|
|
|
// Skip when the test database is unreachable so the parent does not
|
|
// wait on a subprocess that skips itself for the same reason and never
|
|
// prints READY.
|
|
test.PGClient(t)
|
|
|
|
cmd := exec.Command(os.Args[0], "-test.run=^TestWorker_SIGTERM$")
|
|
|
|
cmd.Env = append(os.Environ(), "TEST_SIGTERM_SUBPROCESS=1")
|
|
|
|
stdout, err := cmd.StdoutPipe()
|
|
require.NoError(t, err)
|
|
|
|
cmd.Stderr = cmd.Stdout
|
|
|
|
require.NoError(t, cmd.Start())
|
|
|
|
ready := make(chan struct{})
|
|
scanDone := make(chan struct{})
|
|
|
|
var (
|
|
linesMu sync.Mutex
|
|
lines []string
|
|
)
|
|
|
|
snapshotLines := func() string {
|
|
linesMu.Lock()
|
|
defer linesMu.Unlock()
|
|
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
go func() {
|
|
defer close(scanDone)
|
|
|
|
scanner := bufio.NewScanner(stdout)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
|
|
linesMu.Lock()
|
|
|
|
lines = append(lines, line)
|
|
linesMu.Unlock()
|
|
|
|
if line == "READY" {
|
|
close(ready)
|
|
}
|
|
}
|
|
}()
|
|
|
|
select {
|
|
case <-ready:
|
|
case <-time.After(20 * time.Second):
|
|
_ = cmd.Process.Kill()
|
|
|
|
t.Fatalf("subprocess did not become ready for SIGTERM\n%s", snapshotLines())
|
|
}
|
|
|
|
require.NoError(t, cmd.Process.Signal(syscall.SIGTERM))
|
|
|
|
if err := cmd.Wait(); err != nil {
|
|
t.Fatalf("subprocess failed: %v\n%s", err, snapshotLines())
|
|
}
|
|
|
|
<-scanDone
|
|
}
|
|
|
|
func runSIGTERMSubprocess(t *testing.T) {
|
|
client := test.PGClient(t)
|
|
|
|
workStarted := make(chan struct{})
|
|
|
|
ag := newDummyAgent(
|
|
"battle-agent",
|
|
battleTestResponses(),
|
|
makeBattleTools(workStarted)...,
|
|
)
|
|
|
|
run := insertPendingRun(
|
|
t,
|
|
client,
|
|
"battle-agent",
|
|
[]llm.Message{{Role: llm.RoleUser, Parts: []llm.Part{llm.TextPart{Text: "start"}}}},
|
|
)
|
|
|
|
runWorker := newTestWorker(
|
|
client,
|
|
&simpleRegistry{agents: map[string]*agent.Agent{"battle-agent": ag}},
|
|
agentrun.WithWorkerInterval(150*time.Millisecond),
|
|
agentrun.WithWorkerMaxConcurrency(1),
|
|
)
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
go func() {
|
|
_ = runWorker.Run(ctx)
|
|
}()
|
|
|
|
select {
|
|
case <-workStarted:
|
|
case <-time.After(15 * time.Second):
|
|
t.Fatal("tool did not start before SIGTERM")
|
|
}
|
|
|
|
_, _ = fmt.Fprintln(os.Stdout, "READY")
|
|
|
|
select {
|
|
case <-runWorker.ShutdownBroadcast():
|
|
case <-time.After(15 * time.Second):
|
|
t.Fatal("worker did not broadcast shutdown after SIGTERM")
|
|
}
|
|
|
|
time.Sleep(300 * time.Millisecond)
|
|
|
|
// The in-flight run may checkpoint or be recovered later depending on
|
|
// timing, but it must still be queryable after graceful shutdown.
|
|
_, err := tryLoadAgentRun(client, run.ID)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
type workInput struct {
|
|
Step string `json:"step"`
|
|
}
|
|
|
|
func makeBattleTools(workStarted chan<- struct{}) []agent.Tool {
|
|
return []agent.Tool{
|
|
agent.FunctionTool[workInput](
|
|
"do_work",
|
|
"Performs interruptible work",
|
|
func(ctx context.Context, _ workInput) (agent.ToolResult, error) {
|
|
close(workStarted)
|
|
<-ctx.Done()
|
|
|
|
return agent.ToolResult{Content: "interrupted"}, ctx.Err()
|
|
},
|
|
),
|
|
}
|
|
}
|
|
|
|
func battleTestResponses() []*llm.ChatCompletionResponse {
|
|
return []*llm.ChatCompletionResponse{
|
|
toolCallResponse(llm.ToolCall{
|
|
ID: "tc_battle_1",
|
|
Function: llm.FunctionCall{
|
|
Name: "do_work",
|
|
Arguments: `{"step":"one"}`,
|
|
},
|
|
}),
|
|
stopResponse("done"),
|
|
}
|
|
}
|