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.
251 lines
7.2 KiB
Go
251 lines
7.2 KiB
Go
package lifecycle
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
sprites "github.com/superfly/sprites-go"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/kandev/kandev/internal/scriptengine"
|
|
spritesutil "github.com/kandev/kandev/internal/sprites"
|
|
)
|
|
|
|
func (r *SpritesExecutor) reconnectSprite(ctx context.Context, client *sprites.Client, name string) (*sprites.Sprite, error) {
|
|
stepCtx, cancel := context.WithTimeout(ctx, spriteStepTimeout)
|
|
defer cancel()
|
|
sprite, err := client.GetSprite(stepCtx, name)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to reconnect sprite %q: %w", name, spritesutil.WrapNotFound(err))
|
|
}
|
|
return sprite, nil
|
|
}
|
|
|
|
func (r *SpritesExecutor) StopInstance(ctx context.Context, instance *ExecutorInstance, _ bool) error {
|
|
if instance == nil {
|
|
return nil
|
|
}
|
|
spriteName := getMetadataString(instance.Metadata, "sprite_name")
|
|
if spriteName == "" {
|
|
return nil
|
|
}
|
|
|
|
// Always tear down the local proxy session — it's tied to the agentctl
|
|
// instance we just stopped and would point at a stale TCP connection
|
|
// after the agent process exits.
|
|
r.mu.Lock()
|
|
if proxy, ok := r.proxies[instance.InstanceID]; ok {
|
|
r.closeProxySession(proxy)
|
|
delete(r.proxies, instance.InstanceID)
|
|
}
|
|
r.mu.Unlock()
|
|
|
|
// Plain "stop the agent" runs (e.g. user clicks Stop, then later wants to
|
|
// resume) must NOT destroy the cloud sandbox: the user's working tree,
|
|
// installed deps, and any in-progress files live there. Only destroy the
|
|
// sandbox for explicit terminal lifecycle events (task/session deleted or
|
|
// archived). Resume then re-attaches the same sandbox in seconds.
|
|
if !shouldRunExecutorCleanup(instance.StopReason) {
|
|
r.logger.Info("preserving sprite sandbox after agent stop",
|
|
zap.String("sprite_name", spriteName),
|
|
zap.String("instance_id", instance.InstanceID),
|
|
zap.String("stop_reason", instance.StopReason))
|
|
return nil
|
|
}
|
|
|
|
r.mu.RLock()
|
|
token := r.tokens[instance.InstanceID]
|
|
r.mu.RUnlock()
|
|
if token == "" {
|
|
token = r.resolveTokenFromMetadata(ctx, instance)
|
|
}
|
|
if token == "" {
|
|
r.logger.Warn("no cached API token for sprite instance, cannot destroy",
|
|
zap.String("instance_id", instance.InstanceID))
|
|
return nil
|
|
}
|
|
client := sprites.New(token)
|
|
sprite := client.Sprite(spriteName)
|
|
r.runTerminalCleanupScript(ctx, sprite, instance)
|
|
if err := sprite.Destroy(); err != nil {
|
|
r.logger.Warn("failed to destroy sprite",
|
|
zap.String("sprite_name", spriteName),
|
|
zap.Error(err))
|
|
return fmt.Errorf("failed to destroy sprite: %w", err)
|
|
}
|
|
|
|
r.mu.Lock()
|
|
delete(r.tokens, instance.InstanceID)
|
|
r.mu.Unlock()
|
|
|
|
r.logger.Info("sprite destroyed", zap.String("sprite_name", spriteName),
|
|
zap.String("stop_reason", instance.StopReason))
|
|
return nil
|
|
}
|
|
|
|
func (r *SpritesExecutor) runTerminalCleanupScript(ctx context.Context, sprite *sprites.Sprite, instance *ExecutorInstance) {
|
|
if sprite == nil || instance == nil {
|
|
return
|
|
}
|
|
if !shouldRunExecutorCleanup(instance.StopReason) {
|
|
return
|
|
}
|
|
script := strings.TrimSpace(getMetadataString(instance.Metadata, MetadataKeyCleanupScript))
|
|
if script == "" {
|
|
return
|
|
}
|
|
|
|
resolver := scriptengine.NewResolver().
|
|
WithProvider(scriptengine.WorkspaceProvider(spritesWorkspacePath)).
|
|
WithProvider(scriptengine.AgentctlProvider(r.agentctlPort, spritesWorkspacePath)).
|
|
WithProvider(scriptengine.GitIdentityProvider(instance.Metadata)).
|
|
WithProvider(scriptengine.RepositoryProvider(
|
|
instance.Metadata,
|
|
nil,
|
|
getGitRemoteURL,
|
|
injectGitHubTokenIntoCloneURL,
|
|
))
|
|
resolved := resolver.Resolve(script)
|
|
if strings.TrimSpace(resolved) == "" {
|
|
return
|
|
}
|
|
|
|
stepCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
|
defer cancel()
|
|
|
|
out, err := sprite.CommandContext(stepCtx, "sh", "-c", resolved).CombinedOutput()
|
|
if err != nil {
|
|
r.logger.Warn("cleanup script failed in sprite",
|
|
zap.String("instance_id", instance.InstanceID),
|
|
zap.String("reason", instance.StopReason),
|
|
zap.String("output", strings.TrimSpace(lastLines(string(out), spriteOutputMaxLines))),
|
|
zap.Error(err))
|
|
return
|
|
}
|
|
r.logger.Debug("cleanup script completed in sprite",
|
|
zap.String("instance_id", instance.InstanceID),
|
|
zap.String("reason", instance.StopReason))
|
|
}
|
|
|
|
func shouldRunExecutorCleanup(reason string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(reason)) {
|
|
case "task archived", "task deleted", "session archived", "session deleted":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (r *SpritesExecutor) GetRemoteStatus(ctx context.Context, instance *ExecutorInstance) (*RemoteStatus, error) {
|
|
if instance == nil {
|
|
return nil, fmt.Errorf("instance is nil")
|
|
}
|
|
spriteName := strings.TrimSpace(getMetadataString(instance.Metadata, "sprite_name"))
|
|
if spriteName == "" {
|
|
return &RemoteStatus{
|
|
RuntimeName: string(r.Name()),
|
|
State: "unknown",
|
|
LastCheckedAt: time.Now().UTC(),
|
|
}, nil
|
|
}
|
|
|
|
r.mu.RLock()
|
|
token := r.tokens[instance.InstanceID]
|
|
r.mu.RUnlock()
|
|
if token == "" {
|
|
token = r.resolveTokenFromMetadata(ctx, instance)
|
|
}
|
|
if token == "" {
|
|
return &RemoteStatus{
|
|
RuntimeName: string(r.Name()),
|
|
RemoteName: spriteName,
|
|
State: "unknown",
|
|
LastCheckedAt: time.Now().UTC(),
|
|
}, nil
|
|
}
|
|
|
|
stepCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
|
defer cancel()
|
|
|
|
client := sprites.New(token, sprites.WithDisableControl())
|
|
sprite, err := client.GetSprite(stepCtx, spriteName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &RemoteStatus{
|
|
RuntimeName: string(r.Name()),
|
|
RemoteName: spriteName,
|
|
State: spritesutil.NormalizeSpriteStatus(sprite.Status),
|
|
CreatedAt: nonZeroTimePtr(sprite.CreatedAt),
|
|
LastCheckedAt: time.Now().UTC(),
|
|
}, nil
|
|
}
|
|
|
|
// resolveTokenFromMetadata resolves the Sprites API token from the secret store
|
|
// using the secret ID persisted in metadata. This handles the post-restart case
|
|
// where the in-memory token cache is empty. On success, the token is cached.
|
|
func (r *SpritesExecutor) resolveTokenFromMetadata(ctx context.Context, instance *ExecutorInstance) string {
|
|
if r.secretStore == nil || instance == nil {
|
|
return ""
|
|
}
|
|
secretID := getMetadataString(instance.Metadata, "env_secret_id_SPRITES_API_TOKEN")
|
|
if secretID == "" {
|
|
return ""
|
|
}
|
|
revealed, err := r.secretStore.Reveal(ctx, secretID)
|
|
if err != nil || revealed == "" {
|
|
return ""
|
|
}
|
|
r.mu.Lock()
|
|
r.tokens[instance.InstanceID] = revealed
|
|
r.mu.Unlock()
|
|
return revealed
|
|
}
|
|
|
|
func nonZeroTimePtr(t time.Time) *time.Time {
|
|
if t.IsZero() {
|
|
return nil
|
|
}
|
|
return &t
|
|
}
|
|
|
|
func (r *SpritesExecutor) cleanupOnFailure(_ context.Context, sprite *sprites.Sprite, instanceID string, destroySprite bool) {
|
|
if sprite == nil {
|
|
return
|
|
}
|
|
r.logger.Warn("cleaning up sprite after failure", zap.String("instance_id", instanceID))
|
|
|
|
r.mu.Lock()
|
|
if proxy, ok := r.proxies[instanceID]; ok {
|
|
r.closeProxySession(proxy)
|
|
delete(r.proxies, instanceID)
|
|
}
|
|
delete(r.tokens, instanceID)
|
|
r.mu.Unlock()
|
|
|
|
if !destroySprite {
|
|
r.logger.Info("preserving sprite during cleanup (reconnect flow)",
|
|
zap.String("instance_id", instanceID))
|
|
return
|
|
}
|
|
|
|
if err := sprite.Destroy(); err != nil {
|
|
r.logger.Warn("failed to destroy sprite during cleanup", zap.Error(err))
|
|
}
|
|
}
|
|
|
|
func (r *SpritesExecutor) closeProxySession(proxy *SpritesProxySession) {
|
|
if proxy == nil {
|
|
return
|
|
}
|
|
if proxy.cancel != nil {
|
|
proxy.cancel()
|
|
}
|
|
if proxy.proxySession != nil {
|
|
_ = proxy.proxySession.Close()
|
|
}
|
|
}
|