948743600e
Tier 1 of the post-review cleanup. Each item is independently safe; bundling because they're all small, mechanical, and lint-passing. - fix(clone): unify injectTokenIntoURL across Docker and Sprites — Sprites used to ignore GH_TOKEN entirely (a user with only GH_TOKEN set got 401 on Sprites clone but worked on Docker). Promote one `injectGitHubTokenIntoCloneURL` helper, accept both env vars (GITHUB_TOKEN wins, matching Docker), and use the documented `https://x-access-token:T@github.com/` form on both surfaces. - fix(executor-popover): swap the <span tabIndex role="button"> trigger for <Button variant="ghost"> so Enter/Space activation, focus rings on Safari, and disabled semantics come for free. - test(docker): rewrite TestDockerStopInstance_PreservesSessionDirOnPlainStop to drive the real DockerExecutor.StopInstance path with ContainerID="" (skips the docker daemon, exercises the kandev-side cleanup branch). Previous version only iterated shouldRunExecutorCleanup strings — would pass even if StopInstance always cleaned up. Now table-driven over preserve+destructive reasons with on-disk asserts. - refactor(orchestrator): drop dead `_ = arg` parameters from persist* helpers (persistResumeState, persistModelSwitchState, persistLaunchState). Brings signatures from 9→6 args, under the funlen lint cap. - refactor(orchestrator): collapse byte-identical isContainerizedExecutor ≡ isCloneBasedExecutorType predicate into one. - refactor(lifecycle): drop dead getMetadataBool, FileUploader.RunCommand, expandMountSource {home} arm (no production agent ships {home} in Mounts; codex_acp_test asserts it), dockerTaskBranch one-line wrapper.
370 lines
11 KiB
Go
370 lines
11 KiB
Go
package lifecycle
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
sprites "github.com/superfly/sprites-go"
|
|
"go.uber.org/zap"
|
|
|
|
agentctl "github.com/kandev/kandev/internal/agentctl/client"
|
|
"github.com/kandev/kandev/internal/scriptengine"
|
|
)
|
|
|
|
// createSprite creates a new sprite via the API (explicit POST, not lazy).
|
|
func (r *SpritesExecutor) createSprite(ctx context.Context, client *sprites.Client, name string) (*sprites.Sprite, error) {
|
|
stepCtx, cancel := context.WithTimeout(ctx, spriteStepTimeout)
|
|
defer cancel()
|
|
|
|
r.logger.Debug("creating sprite via API", zap.String("sprite", name))
|
|
sprite, err := client.CreateSprite(stepCtx, name, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create sprite: %w", err)
|
|
}
|
|
return sprite, nil
|
|
}
|
|
|
|
func (r *SpritesExecutor) uploadAgentctl(ctx context.Context, sprite *sprites.Sprite) error {
|
|
binaryPath, err := r.agentctlResolver.ResolveLinuxBinary()
|
|
if err != nil {
|
|
return fmt.Errorf("agentctl binary not found: %w", err)
|
|
}
|
|
|
|
data, err := os.ReadFile(binaryPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read agentctl binary: %w", err)
|
|
}
|
|
|
|
stepCtx, cancel := context.WithTimeout(ctx, spriteStepTimeout)
|
|
defer cancel()
|
|
|
|
r.logger.Debug("uploading agentctl binary", zap.Int("size_bytes", len(data)))
|
|
|
|
// Upload via sprites filesystem API (HTTP PUT, much faster than stdin pipe for large binaries).
|
|
if err := r.writeFileWithRetry(stepCtx, sprite, "/usr/local/bin/agentctl", data, 0o755); err != nil {
|
|
return fmt.Errorf("failed to upload agentctl: %w", err)
|
|
}
|
|
|
|
// Verify the binary is present and executable.
|
|
if _, err := sprite.CommandContext(stepCtx, "test", "-x", "/usr/local/bin/agentctl").Output(); err != nil {
|
|
return fmt.Errorf("agentctl verification failed: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// runPrepareScript resolves the prepare script with scriptengine and executes it,
|
|
// streaming stdout/stderr output through the onOutput callback.
|
|
func (r *SpritesExecutor) runPrepareScript(
|
|
ctx context.Context,
|
|
sprite *sprites.Sprite,
|
|
req *ExecutorCreateRequest,
|
|
onOutput func(string),
|
|
) error {
|
|
script := r.resolvePrepareScript(req)
|
|
if script == "" {
|
|
r.logger.Debug("no prepare script configured, skipping")
|
|
return nil
|
|
}
|
|
|
|
stepCtx, cancel := context.WithTimeout(ctx, spriteStepTimeout)
|
|
defer cancel()
|
|
|
|
r.logger.Debug("running prepare script")
|
|
cmd := sprite.CommandContext(stepCtx, "bash", "-c", script)
|
|
cmd.Env = r.buildSpriteEnv(req.Env)
|
|
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get stdout pipe: %w", err)
|
|
}
|
|
stderr, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get stderr pipe: %w", err)
|
|
}
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return fmt.Errorf("failed to start prepare script: %w", err)
|
|
}
|
|
|
|
// Stream stdout and stderr concurrently (io.MultiReader is sequential,
|
|
// which blocks stderr until stdout EOF — bad for git progress output).
|
|
var outputBuf strings.Builder
|
|
var outputMu sync.Mutex
|
|
emitOutput := func(chunk []byte) {
|
|
outputMu.Lock()
|
|
outputBuf.Write(chunk)
|
|
if onOutput != nil {
|
|
onOutput(lastLines(outputBuf.String(), spriteOutputMaxLines))
|
|
}
|
|
outputMu.Unlock()
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
readStream := func(rd io.Reader) {
|
|
defer wg.Done()
|
|
buf := make([]byte, 4096)
|
|
for {
|
|
n, readErr := rd.Read(buf)
|
|
if n > 0 {
|
|
emitOutput(buf[:n])
|
|
}
|
|
if readErr != nil {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
wg.Add(2)
|
|
go readStream(stdout)
|
|
go readStream(stderr)
|
|
|
|
waitErr := cmd.Wait()
|
|
wg.Wait() // ensure all output is collected
|
|
|
|
if waitErr != nil {
|
|
return fmt.Errorf("prepare script failed: %w\n%s", waitErr, lastLines(outputBuf.String(), spriteOutputMaxLines))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// resolvePrepareScript builds the resolved prepare script using scriptengine.
|
|
//
|
|
// The kandev-managed feature-branch checkout is appended as an invariant
|
|
// postlude after the user's script — see executor_docker.go for the same
|
|
// pattern. Profiles created in the UI snapshot the default at create time,
|
|
// so without an unconditional postlude older Sprites profiles would still
|
|
// commit straight onto main.
|
|
func (r *SpritesExecutor) resolvePrepareScript(req *ExecutorCreateRequest) string {
|
|
script := getMetadataString(req.Metadata, MetadataKeySetupScript)
|
|
if script == "" {
|
|
script = DefaultPrepareScript("sprites")
|
|
}
|
|
if script == "" {
|
|
return ""
|
|
}
|
|
script += KandevBranchCheckoutPostlude()
|
|
|
|
installScripts := r.collectAgentInstallScripts(req)
|
|
|
|
resolver := scriptengine.NewResolver().
|
|
WithProvider(scriptengine.WorkspaceProvider(spritesWorkspacePath)).
|
|
WithProvider(scriptengine.AgentctlProvider(r.agentctlPort, spritesWorkspacePath)).
|
|
WithProvider(scriptengine.GitIdentityProvider(req.Metadata)).
|
|
WithProvider(scriptengine.GitHubAuthProvider(req.Env)).
|
|
WithProvider(scriptengine.AgentInstallProvider(installScripts)).
|
|
WithProvider(scriptengine.WorktreeProvider(
|
|
"",
|
|
spritesWorkspacePath,
|
|
getMetadataString(req.Metadata, MetadataKeyWorktreeID),
|
|
getMetadataString(req.Metadata, MetadataKeyWorktreeBranch),
|
|
getMetadataString(req.Metadata, MetadataKeyBaseBranch),
|
|
)).
|
|
WithProvider(scriptengine.RepositoryProvider(
|
|
req.Metadata,
|
|
req.Env,
|
|
getGitRemoteURL,
|
|
injectGitHubTokenIntoCloneURL,
|
|
))
|
|
|
|
return resolver.Resolve(script)
|
|
}
|
|
|
|
// collectAgentInstallScripts extracts agent IDs from the executor profile metadata
|
|
// and the current task's agent config, then collects their install scripts.
|
|
func (r *SpritesExecutor) collectAgentInstallScripts(req *ExecutorCreateRequest) []string {
|
|
agentIDs := map[string]bool{}
|
|
|
|
// Always include the current task's agent.
|
|
if req.AgentConfig != nil {
|
|
agentIDs[req.AgentConfig.ID()] = true
|
|
}
|
|
|
|
// Extract agent IDs from remote_credentials (e.g., "agent:claude-code:files:0").
|
|
if credsJSON, _ := req.Metadata["remote_credentials"].(string); credsJSON != "" {
|
|
var methodIDs []string
|
|
if json.Unmarshal([]byte(credsJSON), &methodIDs) == nil {
|
|
for _, id := range methodIDs {
|
|
if agentID := extractAgentID(id); agentID != "" {
|
|
agentIDs[agentID] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract agent IDs from remote_auth_secrets (e.g., "agent:codex:env:OPENAI_API_KEY").
|
|
if secretsJSON, _ := req.Metadata["remote_auth_secrets"].(string); secretsJSON != "" {
|
|
var secretsMap map[string]string
|
|
if json.Unmarshal([]byte(secretsJSON), &secretsMap) == nil {
|
|
for methodID := range secretsMap {
|
|
if agentID := extractAgentID(methodID); agentID != "" {
|
|
agentIDs[agentID] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Look up install scripts from the agent list.
|
|
var scripts []string
|
|
if r.agentList != nil {
|
|
for _, ag := range r.agentList.ListEnabled() {
|
|
if agentIDs[ag.ID()] {
|
|
scripts = append(scripts, ag.InstallScript())
|
|
}
|
|
}
|
|
}
|
|
return scripts
|
|
}
|
|
|
|
// extractAgentID extracts the agent ID from method IDs like "agent:claude-code:env:TOKEN".
|
|
func extractAgentID(methodID string) string {
|
|
parts := strings.SplitN(methodID, ":", 3)
|
|
if len(parts) >= 2 && parts[0] == "agent" {
|
|
return parts[1]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// createAgentInstance creates a per-instance server on the agentctl control server
|
|
// running inside the sprite. Returns the port of the per-instance server.
|
|
func (r *SpritesExecutor) createAgentInstance(
|
|
ctx context.Context,
|
|
sprite *sprites.Sprite,
|
|
req *ExecutorCreateRequest,
|
|
) (int, error) {
|
|
instanceReq := agentctl.CreateInstanceRequest{
|
|
ID: req.InstanceID,
|
|
WorkspacePath: spritesWorkspacePath,
|
|
SessionID: req.SessionID,
|
|
TaskID: req.TaskID,
|
|
Protocol: req.Protocol,
|
|
AgentType: agentTypeFromReq(req),
|
|
McpServers: req.McpServers,
|
|
McpMode: req.McpMode,
|
|
}
|
|
reqJSON, err := json.Marshal(instanceReq)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to marshal instance request: %w", err)
|
|
}
|
|
|
|
stepCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
defer cancel()
|
|
|
|
cmd := sprite.CommandContext(stepCtx, "sh", "-c",
|
|
fmt.Sprintf("curl -sf -X POST http://localhost:%d/api/v1/instances -H 'Content-Type: application/json' -d @-",
|
|
r.agentctlPort))
|
|
cmd.Stdin = bytes.NewReader(reqJSON)
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to create agent instance: %w", err)
|
|
}
|
|
|
|
var resp agentctl.CreateInstanceResponse
|
|
if err := json.Unmarshal(out, &resp); err != nil {
|
|
return 0, fmt.Errorf("failed to parse instance response: %w (output: %s)", err, string(out))
|
|
}
|
|
|
|
r.logger.Debug("agent instance created inside sprite",
|
|
zap.String("instance_id", resp.ID),
|
|
zap.Int("port", resp.Port))
|
|
|
|
return resp.Port, nil
|
|
}
|
|
|
|
// getExistingInstancePort queries agentctl inside the sprite to check if a
|
|
// previously created instance is still running. Returns its port if found.
|
|
func (r *SpritesExecutor) getExistingInstancePort(
|
|
ctx context.Context,
|
|
sprite *sprites.Sprite,
|
|
instanceID string,
|
|
) (int, error) {
|
|
if instanceID == "" {
|
|
return 0, fmt.Errorf("no instance ID")
|
|
}
|
|
|
|
checkCmd := fmt.Sprintf(
|
|
"curl -sf http://localhost:%d/api/v1/instances/%s",
|
|
r.agentctlPort, instanceID)
|
|
|
|
stepCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
|
|
out, err := sprite.CommandContext(stepCtx, "sh", "-c", checkCmd).Output()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("instance %s not found: %w", instanceID, err)
|
|
}
|
|
|
|
var resp struct {
|
|
Port int `json:"port"`
|
|
}
|
|
if err := json.Unmarshal(out, &resp); err != nil || resp.Port == 0 {
|
|
return 0, fmt.Errorf("failed to parse instance response: %v", err)
|
|
}
|
|
|
|
r.logger.Info("reusing existing agent instance",
|
|
zap.String("instance_id", instanceID),
|
|
zap.Int("port", resp.Port))
|
|
return resp.Port, nil
|
|
}
|
|
|
|
// isAgentSubprocessRunning checks whether the agent subprocess (e.g., Claude Code)
|
|
// is still alive inside an existing agentctl instance by querying the status endpoint.
|
|
func (r *SpritesExecutor) isAgentSubprocessRunning(
|
|
ctx context.Context,
|
|
sprite *sprites.Sprite,
|
|
instancePort int,
|
|
) bool {
|
|
statusCmd := fmt.Sprintf("curl -sf http://localhost:%d/api/v1/status", instancePort)
|
|
stepCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
|
|
out, err := sprite.CommandContext(stepCtx, "sh", "-c", statusCmd).Output()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
var status struct {
|
|
AgentStatus string `json:"agent_status"`
|
|
}
|
|
if json.Unmarshal(out, &status) != nil {
|
|
return false
|
|
}
|
|
|
|
alive := status.AgentStatus == "running" || status.AgentStatus == "ready"
|
|
r.logger.Debug("checked agent subprocess status",
|
|
zap.Int("instance_port", instancePort),
|
|
zap.String("agent_status", status.AgentStatus),
|
|
zap.Bool("alive", alive))
|
|
return alive
|
|
}
|
|
|
|
func agentTypeFromReq(req *ExecutorCreateRequest) string {
|
|
if req.AgentConfig != nil {
|
|
return req.AgentConfig.ID()
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (r *SpritesExecutor) waitForHealth(ctx context.Context, sprite *sprites.Sprite) error {
|
|
deadline := time.Now().Add(spriteHealthTimeout)
|
|
healthURL := fmt.Sprintf("http://localhost:%d/health", r.agentctlPort)
|
|
|
|
for time.Now().Before(deadline) {
|
|
checkCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
out, err := sprite.CommandContext(checkCtx, "curl", "-sf", healthURL).Output()
|
|
cancel()
|
|
|
|
if err == nil && len(out) > 0 {
|
|
r.logger.Debug("agentctl is healthy in sprite")
|
|
return nil
|
|
}
|
|
time.Sleep(spriteHealthRetryWait)
|
|
}
|
|
return fmt.Errorf("agentctl did not become healthy within %v", spriteHealthTimeout)
|
|
}
|