Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,5 @@ docs/proposals/**
.compozy/tasks/**/.gitkeep
.compozy/tasks/**/.empty
.codex/loop/**
.codex/qa/**
# <<< skeeper ignored specs <<<
1 change: 1 addition & 0 deletions .skeeper.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ namespaces:
- .compozy/tasks/**/.gitkeep
- .compozy/tasks/**/.empty
- .codex/loop/**
- .codex/qa/**
1 change: 0 additions & 1 deletion config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ agent = "general"
provider = "claude"

[limits]
max_sessions = 10
max_concurrent_agents = 20

[permissions]
Expand Down
1 change: 0 additions & 1 deletion internal/api/contract/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,6 @@ type SettingsDefaultsPayload struct {
}

type SettingsLimitsPayload struct {
MaxSessions int `json:"max_sessions"`
MaxConcurrentAgents int `json:"max_concurrent_agents"`
}

Expand Down
2 changes: 1 addition & 1 deletion internal/api/core/agent_spawn.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ func statusForAgentSpawnError(err error) int {
return http.StatusCreated
case errors.Is(err, session.ErrSpawnPermissionDenied):
return http.StatusForbidden
case errors.Is(err, session.ErrSpawnLimitExceeded), errors.Is(err, session.ErrMaxSessionsReached):
case errors.Is(err, session.ErrSpawnLimitExceeded):
return http.StatusConflict
case errors.Is(err, session.ErrSpawnValidation):
return http.StatusUnprocessableEntity
Expand Down
19 changes: 13 additions & 6 deletions internal/api/core/authored_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -660,7 +660,7 @@ func (h *BaseHandlers) GetAgentHeartbeatStatus(c *gin.Context) {
if sessionID != "" {
if _, err := h.requireSessionInWorkspace(
c.Request.Context(),
target.sessionWorkspaceID,
target.storageWorkspaceID(),
sessionID,
); err != nil {
h.respondError(c, statusForWorkspaceScopedResourceError(err), err)
Expand Down Expand Up @@ -725,15 +725,15 @@ func (h *BaseHandlers) WakeAgentHeartbeat(c *gin.Context) {
if sessionID != "" {
if _, err := h.requireSessionInWorkspace(
c.Request.Context(),
target.sessionWorkspaceID,
target.storageWorkspaceID(),
sessionID,
); err != nil {
h.respondError(c, statusForWorkspaceScopedResourceError(err), err)
return
}
}
decision, err := h.HeartbeatWake.Wake(c.Request.Context(), heartbeat.WakeRequest{
WorkspaceID: target.workspaceID,
WorkspaceID: target.storageWorkspaceID(),
AgentName: target.agentName,
SessionID: sessionID,
Source: source,
Expand Down Expand Up @@ -1059,7 +1059,7 @@ func (t authoredAgentTarget) withAgentArtifacts(

func (t authoredAgentTarget) soulAuthoringTarget() soul.AuthoringTarget {
return soul.AuthoringTarget{
WorkspaceID: t.workspaceID,
WorkspaceID: t.storageWorkspaceID(),
WorkspaceRoot: authoredContextSourceRoot(t.workspaceRoot, t.agentPath),
AgentName: t.agentName,
AgentPath: t.agentPath,
Expand All @@ -1070,14 +1070,21 @@ func (t authoredAgentTarget) soulAuthoringTarget() soul.AuthoringTarget {

func (t authoredAgentTarget) heartbeatAuthoringTarget() heartbeat.AuthoringTarget {
return heartbeat.AuthoringTarget{
WorkspaceID: t.workspaceID,
WorkspaceID: t.storageWorkspaceID(),
WorkspaceRoot: authoredContextSourceRoot(t.workspaceRoot, t.agentPath),
AgentName: t.agentName,
AgentPath: t.agentPath,
Config: t.heartbeatConfig,
}
}

func (t authoredAgentTarget) storageWorkspaceID() string {
if id := strings.TrimSpace(t.sessionWorkspaceID); id != "" {
return id
}
return strings.TrimSpace(t.workspaceID)
}
Comment thread
pedronauck marked this conversation as resolved.

func authoredContextSourceRoot(workspaceRoot string, agentPath string) string {
root := strings.TrimSpace(workspaceRoot)
source := strings.TrimSpace(agentPath)
Expand Down Expand Up @@ -1376,7 +1383,7 @@ func (h *BaseHandlers) heartbeatWakeEvents(
return nil, nil
}
events, err := h.HeartbeatWakeEvents.ListHeartbeatWakeEvents(ctx, heartbeat.WakeEventListQuery{
WorkspaceID: target.workspaceID,
WorkspaceID: target.storageWorkspaceID(),
AgentName: target.agentName,
SessionID: strings.TrimSpace(sessionID),
Limit: defaultWakeEventInspectLimit,
Expand Down
219 changes: 219 additions & 0 deletions internal/api/core/authored_context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"

Expand Down Expand Up @@ -164,6 +167,222 @@ func (s *heartbeatWakeSpy) Wake(
}, nil
}

type workspaceIDCaptureSoulAuthoring struct {
putCalls int
last soul.PutRequest
}

func (s *workspaceIDCaptureSoulAuthoring) Validate(context.Context, soul.ValidateRequest) (soul.ValidateResult, error) {
return soul.ValidateResult{}, nil
}

func (s *workspaceIDCaptureSoulAuthoring) Put(
_ context.Context,
req soul.PutRequest,
) (soul.MutationResult, error) {
s.putCalls++
s.last = req
return soul.MutationResult{}, errors.New("captured soul put request")
}

func (s *workspaceIDCaptureSoulAuthoring) Delete(context.Context, soul.DeleteRequest) (soul.MutationResult, error) {
return soul.MutationResult{}, nil
}

func (s *workspaceIDCaptureSoulAuthoring) History(context.Context, soul.HistoryRequest) (soul.HistoryResult, error) {
return soul.HistoryResult{}, nil
}

func (s *workspaceIDCaptureSoulAuthoring) Rollback(context.Context, soul.RollbackRequest) (soul.MutationResult, error) {
return soul.MutationResult{}, nil
}

func TestAuthoredContextUsesRegistryWorkspaceIDForStorageBackedOperations(t *testing.T) {
t.Parallel()

workspaceRoot := t.TempDir()
agentDir := filepath.Join(workspaceRoot, aghconfig.DirName, aghconfig.AgentsDirName, "coder")
if err := os.MkdirAll(agentDir, 0o755); err != nil {
t.Fatalf("MkdirAll(agent dir) error = %v", err)
}
agentBody := []byte("---\nname: coder\nprovider: claude\n---\nReview startup launch work.\n")
if err := os.WriteFile(filepath.Join(agentDir, "AGENT.md"), agentBody, 0o644); err != nil {
t.Fatalf("WriteFile(AGENT.md) error = %v", err)
}

workspaces := testutil.StubWorkspaceService{
ResolveFn: func(ctx context.Context, ref string) (workspacepkg.ResolvedWorkspace, error) {
if err := ctx.Err(); err != nil {
return workspacepkg.ResolvedWorkspace{}, err
}
if strings.TrimSpace(ref) != "ws-stable" {
return workspacepkg.ResolvedWorkspace{}, workspacepkg.ErrWorkspaceNotFound
}
return workspacepkg.ResolvedWorkspace{
Workspace: workspacepkg.Workspace{ID: "ws-registry", RootDir: workspaceRoot, Name: "Ad8 QA"},
WorkspaceID: "ws-stable",
Config: aghconfig.Config{
Agents: aghconfig.AgentsConfig{
Soul: aghconfig.DefaultSoulConfig(),
Heartbeat: aghconfig.DefaultHeartbeatConfig(),
},
},
}, nil
},
}
fixture := newHandlerFixture(t, testutil.StubSessionManager{}, testutil.StubObserver{}, workspaces, nil, nil)
soulAuthoring := &workspaceIDCaptureSoulAuthoring{}
statusSpy := &heartbeatStatusSpy{}
wakeSpy := &heartbeatWakeSpy{}
fixture.Handlers.SoulAuthoring = soulAuthoring
fixture.Handlers.HeartbeatStatus = statusSpy
fixture.Handlers.HeartbeatWake = wakeSpy
fixture.Engine.PUT("/agents/:agent_name/soul", fixture.Handlers.PutAgentSoul)
fixture.Engine.GET("/agents/:name/heartbeat/status", fixture.Handlers.GetAgentHeartbeatStatus)
fixture.Engine.POST("/agents/:name/heartbeat/wake", fixture.Handlers.WakeAgentHeartbeat)

t.Run("Should pass registry workspace id to Soul authoring", func(t *testing.T) {
body := []byte("{\"workspace_id\":\"ws-stable\",\"agent_name\":\"coder\",\"body\":\"# Soul\"}")
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodPut,
"/agents/coder/soul",
bytes.NewReader(body),
)
req.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
fixture.Engine.ServeHTTP(recorder, req)

if soulAuthoring.putCalls != 1 {
t.Fatalf("soul put calls = %d, want 1", soulAuthoring.putCalls)
}
if got, want := soulAuthoring.last.Target.WorkspaceID, "ws-registry"; got != want {
t.Fatalf("Soul target WorkspaceID = %q, want %q", got, want)
}
})

t.Run("Should pass registry workspace id to Heartbeat status", func(t *testing.T) {
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet,
"/agents/coder/heartbeat/status?workspace_id=ws-stable",
nil,
)
recorder := httptest.NewRecorder()
fixture.Engine.ServeHTTP(recorder, req)

if statusSpy.calls != 1 {
t.Fatalf("heartbeat status calls = %d, want 1", statusSpy.calls)
}
if got, want := statusSpy.last.Target.WorkspaceID, "ws-registry"; got != want {
t.Fatalf("Heartbeat status target WorkspaceID = %q, want %q", got, want)
}
})

t.Run("Should pass registry workspace id to Heartbeat wake", func(t *testing.T) {
body := []byte(
"{\"workspace_id\":\"ws-stable\",\"agent_name\":\"coder\",\"source\":\"manual\",\"dry_run\":true}",
)
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost,
"/agents/coder/heartbeat/wake",
bytes.NewReader(body),
)
req.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
fixture.Engine.ServeHTTP(recorder, req)

if wakeSpy.calls != 1 {
t.Fatalf("heartbeat wake calls = %d, want 1", wakeSpy.calls)
}
if got, want := wakeSpy.last.WorkspaceID, "ws-registry"; got != want {
t.Fatalf("Heartbeat wake WorkspaceID = %q, want %q", got, want)
}
})

t.Run("Should use the stable workspace id for session-gated heartbeat fallback checks", func(t *testing.T) {
manager := testutil.StubSessionManager{
StatusFn: func(ctx context.Context, id string) (*session.Info, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if id != "sess-owned" {
t.Fatalf("Status() session id = %q, want sess-owned", id)
}
return &session.Info{ID: id, WorkspaceID: "ws-stable", AgentName: "coder"}, nil
},
}
workspacesWithStableOnly := testutil.StubWorkspaceService{
ResolveFn: func(ctx context.Context, ref string) (workspacepkg.ResolvedWorkspace, error) {
if err := ctx.Err(); err != nil {
return workspacepkg.ResolvedWorkspace{}, err
}
if strings.TrimSpace(ref) != "ws-stable" {
return workspacepkg.ResolvedWorkspace{}, workspacepkg.ErrWorkspaceNotFound
}
return workspacepkg.ResolvedWorkspace{
Workspace: workspacepkg.Workspace{
RootDir: workspaceRoot,
Name: "Ad8 QA",
},
WorkspaceID: "ws-stable",
Config: aghconfig.Config{
Agents: aghconfig.AgentsConfig{Heartbeat: aghconfig.DefaultHeartbeatConfig()},
},
}, nil
},
}
stableFixture := newHandlerFixture(t, manager, testutil.StubObserver{}, workspacesWithStableOnly, nil, nil)
stableStatusSpy := &heartbeatStatusSpy{}
stableWakeSpy := &heartbeatWakeSpy{}
stableFixture.Handlers.HeartbeatStatus = stableStatusSpy
stableFixture.Handlers.HeartbeatWake = stableWakeSpy
stableFixture.Engine.GET("/agents/:name/heartbeat/status", stableFixture.Handlers.GetAgentHeartbeatStatus)
stableFixture.Engine.POST("/agents/:name/heartbeat/wake", stableFixture.Handlers.WakeAgentHeartbeat)

statusReq := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet,
"/agents/coder/heartbeat/status?workspace_id=ws-stable&session_id=sess-owned",
nil,
)
statusRecorder := httptest.NewRecorder()
stableFixture.Engine.ServeHTTP(statusRecorder, statusReq)
if got, want := statusRecorder.Code, http.StatusOK; got != want {
t.Fatalf("heartbeat status code = %d, want %d body=%s", got, want, statusRecorder.Body.String())
}
if stableStatusSpy.calls != 1 {
t.Fatalf("heartbeat status calls = %d, want 1", stableStatusSpy.calls)
}
if got, want := stableStatusSpy.last.Target.WorkspaceID, "ws-stable"; got != want {
t.Fatalf("heartbeat status target WorkspaceID = %q, want %q", got, want)
}

wakeBody := []byte(
"{\"workspace_id\":\"ws-stable\",\"agent_name\":\"coder\",\"session_id\":\"sess-owned\",\"source\":\"manual\",\"dry_run\":true}",
)
wakeReq := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost,
"/agents/coder/heartbeat/wake",
bytes.NewReader(wakeBody),
)
wakeReq.Header.Set("Content-Type", "application/json")
wakeRecorder := httptest.NewRecorder()
stableFixture.Engine.ServeHTTP(wakeRecorder, wakeReq)
if got, want := wakeRecorder.Code, http.StatusConflict; got != want {
t.Fatalf("heartbeat wake code = %d, want %d body=%s", got, want, wakeRecorder.Body.String())
}
if stableWakeSpy.calls != 1 {
t.Fatalf("heartbeat wake calls = %d, want 1", stableWakeSpy.calls)
}
if got, want := stableWakeSpy.last.WorkspaceID, "ws-stable"; got != want {
t.Fatalf("heartbeat wake WorkspaceID = %q, want %q", got, want)
}
})
}

func TestAuthoredContextHeartbeatStatusAndWakeRejectForeignSessionWorkspace(t *testing.T) {
t.Parallel()

Expand Down
21 changes: 18 additions & 3 deletions internal/api/core/conversions.go
Original file line number Diff line number Diff line change
Expand Up @@ -1047,7 +1047,11 @@ func SessionProviderOptionPayloadsFromConfig(cfg *aghconfig.Config) []contract.S
}
}

return sortSessionProviderOptionPayloads(payloadsByName)
defaultProvider := ""
if cfg != nil {
defaultProvider = aghconfig.CanonicalProviderName(cfg.Defaults.Provider)
}
return sortSessionProviderOptionPayloads(payloadsByName, defaultProvider)
}

func sessionProviderOptionPayloadFromConfig(
Expand Down Expand Up @@ -1089,17 +1093,29 @@ func sessionProviderOptionPayloads(names []string) []contract.SessionProviderOpt
}
values[trimmed] = contract.SessionProviderOptionPayload{Name: trimmed}
}
return sortSessionProviderOptionPayloads(values)
return sortSessionProviderOptionPayloads(values, "")
}

func sortSessionProviderOptionPayloads(
values map[string]contract.SessionProviderOptionPayload,
defaultProvider string,
) []contract.SessionProviderOptionPayload {
names := make([]string, 0, len(values))
for name := range values {
names = append(names, name)
}
sort.Strings(names)
defaultProvider = aghconfig.CanonicalProviderName(defaultProvider)
if defaultProvider != "" {
for i, name := range names {
if name != defaultProvider {
continue
}
copy(names[1:i+1], names[:i])
names[0] = defaultProvider
break
}
}
payloads := make([]contract.SessionProviderOptionPayload, 0, len(names))
for _, name := range names {
payloads = append(payloads, values[name])
Expand Down Expand Up @@ -1568,7 +1584,6 @@ func settingsGeneralConfigPayload(value settingspkg.GeneralSettings) contract.Se
Sandbox: strings.TrimSpace(value.Defaults.Sandbox),
},
Limits: contract.SettingsLimitsPayload{
MaxSessions: value.Limits.MaxSessions,
MaxConcurrentAgents: value.Limits.MaxConcurrentAgents,
},
Permissions: contract.SettingsPermissionsPayload{
Expand Down
Loading
Loading